Embedded I2C driver layering teaches more than rewriting an SDK

David Krause8 min read
Other ManufacturerOther TopicTechnical Reference
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

A wristband built on an ESP32-C3 with a MAX30102 heart-rate sensor, driven entirely through library calls, has already exercised the I2C and BLE paths; the next gain in low-level skill comes from writing your own MAX30102 device driver on top of a thin I2C bus interface. Cloning the vendor SDK teaches little, because the SDK is already the lowest layer most firmware should touch. Register-level work on the ESP32 family is practical only for the simplest peripherals, so pursue register work on a Cortex-M part and keep the ESP32-C3 for driver architecture, real-time behavior, and power.

Reading a request for lower-level firmware work

Low level means different things to different engineers. Pin down which of the following you want before choosing a resource, because they need different toolchains and different hardware.

Interpretation What the work is Fit for an ESP32-C3 + MAX30102 wristband
Own the device driver Implement the sensor's register map, configuration, and FIFO readout over I2C from the datasheet Best fit. The protocol and the device are the transferable knowledge.
Own the bus driver Program the I2C controller registers directly Low return on ESP32; good on a Cortex-M part with a documented register-level reference manual.
Own the BLE stack Write the link layer and host stack Not realistic. The ESP-IDF radio controller ships precompiled; your work is the host-side API and GATT design.
Call BLE APIs directly Drop the Arduino wrapper and use the ESP-IDF (or Zephyr) BLE API Reasonable step. It differs entirely from writing a stack, so name which one you mean.
Real-time work Deterministic sampling, interrupts, DMA, audio or display pipelines Good next project; it forces timing budgets.
HDL for FPGA or SoC VHDL/Verilog hardware description Different discipline, not firmware programming.

A vendor SDK is a human-friendly wrapper over register access. If the project used the vendor SDK (ESP-IDF) it already sits at the vendor's lowest supported level. Projects built on the Arduino framework sit one wrapper higher, so the first concrete step is moving to ESP-IDF or Zephyr.

Layers between the sensor and the register

Firmware for an I2C sensor stacks in five layers. Each layer has one job and depends only on the layer below it.

  1. Application: heart-rate computation, LED indication, BLE notifications.
  2. Device driver: knows the sensor's register map, its configuration sequence, and its data format. Contains no MCU-specific calls.
  3. Bus interface: exposes configure, read, and write with a device address. Hides the SDK or the operating system.
  4. Vendor SDK / HAL: ESP-IDF on this hardware. Owns clock setup, pin muxing, and peripheral registers.
  5. Registers: the I2C controller itself.

Two transaction shapes cover almost every register-based I2C device:

  • Write: device address with the write bit, then a command part (register pointer, command byte, or memory address, possibly with other parameters), then the data bytes.
  • Read: the same command part first, then a repeated start (or a new start) with the read bit, then the data bytes.

A 24C256 EEPROM uses a 16-bit memory address as its command part; a temperature sensor such as the LM75 uses a pointer register. The command-then-data shape is the same, and this shared shape is the reason a single bus interface can serve every I2C device on the board.

A bus interface that keeps drivers portable

Define the bus as a table of function pointers that carries the command part and the data part separately. A device driver holds a pointer to a bus and never includes an SDK header.

/* i2c_bus.h - no SDK or OS headers here */
typedef struct i2c_bus i2c_bus_t;

typedef struct {
    int (*configure)(i2c_bus_t *bus, uint32_t speed_hz);
    /* write: dev_addr, command bytes, then data bytes in one transaction */
    int (*write)(i2c_bus_t *bus, uint8_t dev_addr,
                 const uint8_t *cmd, size_t cmd_len,
                 const uint8_t *data, size_t data_len);
    /* read: write cmd, repeated start, read data_len bytes */
    int (*read)(i2c_bus_t *bus, uint8_t dev_addr,
                const uint8_t *cmd, size_t cmd_len,
                uint8_t *data, size_t data_len);
} i2c_ops_t;

struct i2c_bus { const i2c_ops_t *ops; void *ctx; };

/* lm75.c / at24c256.c / max30102.c include only i2c_bus.h */

Implement the bus once per platform: an ESP-IDF backend for the ESP32-C3, and a Linux backend over /dev/i2c-N using the ioctl, which carries a write message and a read message in one combined transaction. The device drivers do not change between the two.

Build order from EEPROM to a portable MAX30102 driver

  1. Write a small project that reads and writes an AT24Cxx-family EEPROM (a 24C256, for example) using the vendor SDK. Handle the 16-bit address and the page size given in the datasheet.
  2. Write a second small project with another simple I2C device (AHT20 or LM75) using the vendor SDK, on a different I2C port at a different bus frequency.
  3. Diff the two projects. The configuration should differ only in port and speed. The write path should show a command part before the data, and the read path should show the same command part before the read.
  4. Extract the shared code into the bus interface above with configure, read, and write.
  5. Rebuild the EEPROM and LM75 drivers on top of the bus interface, with no SDK calls left in them.
  6. Port both projects to a Linux ARM SBC. Refine the interface where the ESP-IDF implementation leaked into it (buffer ownership, timeouts, error codes). The end state is 24C256 and LM75 drivers that compile unmodified for an MCU and for the SBC.
  7. Return to the wristband. Write the MAX30102 driver from its datasheet register map on the same bus interface: reset, mode and LED-current configuration, FIFO pointer handling, FIFO burst read. Read the register addresses and the sensor's I2C address from the datasheet rather than from a library header.
  8. Move sample processing (filtering, peak detection) into its own module that accepts raw samples, so it can be tested on a PC with recorded data.

What transfers to later projects is knowledge of the protocol (I2C), the devices (24C256, LM75, MAX30102), the platforms (ESP32-C3, an STM32-class MCU, Linux), and how to architect the code. The first three are the low-level knowledge; the fourth keeps it reusable.

Real-time and power work on the same hardware

Improving one device teaches more than starting a new one each time. Three extensions of the wristband each force hardware-level understanding:

  • Real-time sampling: run the sensor read from an interrupt or a fixed-period task, and budget the I2C transaction time against the sample period. Audio processing or driving a display from a small MCU forces the same discipline.
  • Sleep modes: add sleep between samples and between BLE connection events. Battery life is the usual objection to ESP32 in wearables, and the answer is a measured current profile, not an assumption. Log average current over a full measure-and-notify cycle with a current meter capable of resolving the sleep floor, then find which state dominates.
  • Signal processing: replace library heart-rate code with your own filter and peak detector, and compare against a reference on recorded data.

For actual register-level practice, use a Cortex-M microcontroller: its peripherals are documented at register level, and the tooling and reference material are extensive. On the ESP32 family, direct register access is workable for simple peripherals only, and the radio side is closed.

Failure modes in hand-written I2C drivers

Symptom Cause Fix
No ACK on the address byte Wrong 7-bit versus 8-bit address convention, missing pull-ups, or device not powered Confirm the address format in the datasheet and the bus API; check pull-ups and supply with a scope.
Register reads return stale or wrong bytes Command part and read sent as two separate transactions with a stop between them, on a device that needs a repeated start Send the command and read in one combined transaction.
EEPROM writes intermittently lost Next access issued during the internal write cycle, or a write that crosses a page boundary Poll for ACK after a write, and split writes at the page size from the datasheet.
Firmware hangs with the sensor unplugged Bus calls have no timeout and the error code is ignored Return an error from every bus call and propagate it to the application.
Device driver will not build for the SBC SDK headers or types leaked into the driver Keep SDK includes inside the bus backend only.
Rewriting the whole vendor SDK stalls the project Effort spent duplicating clock, pin, and peripheral init with no new knowledge Read the SDK sources for how it programs the peripheral, then build the abstraction above it.

Verification checks for the driver stack

  1. Bus timing: capture SCL with a logic analyzer or scope. Expected: the frequency matches the value passed to configure on each port, including the different speeds used in the second project.
  2. Address ACK: decode the first byte of each transaction. Expected: the device ACKs its address, and a deliberately wrong address produces a NACK that the driver reports as an error.
  3. Combined read: decode a register read. Expected: command part, repeated start, then read address with the data, and no stop between the command and the read.
  4. Round trip: write a known pattern to the EEPROM, power-cycle, and read it back. Expected: identical bytes, including a write that spans a page boundary.
  5. Portability: build the 24C256 and LM75 driver source files for both the MCU and the Linux SBC. Expected: the driver files are byte-identical in both trees and only the bus backend differs.
  6. Fault handling: disconnect the device during a transfer. Expected: the call returns an error within the timeout and the application keeps running.
  7. Power profile: log current across a full sample-and-notify cycle after adding sleep modes. Expected: a distinct low-current floor between activity bursts, with the average current used to compute battery life for the cell capacity you fit.

FAQ

Can I do register-level programming on the ESP32-C3?

Only for simple peripherals such as GPIO-class blocks; the BLE controller is delivered as a precompiled binary, so there is no register-level path to it. For register-level practice, use a Cortex-M microcontroller with a documented reference manual.

Does writing my own MAX30102 driver teach more than using a library?

Yes, because you learn the register map, FIFO handling, and the I2C transaction shapes from the datasheet. Write it against a bus interface with configure, read, and write so the driver stays independent of ESP-IDF.

Can the same I2C sensor driver run on an MCU and a Linux SBC?

Yes, if the driver calls only a bus interface and never SDK functions. Implement one backend on the vendor SDK and one on /dev/i2c-N with the ioctl, and the device driver source stays unchanged.

Back to blog