Writing Drivers for I2C Chips in Embedded Systems

How to build maintainable I2C chip drivers for sensors, ADCs, EEPROMs, GPIO expanders, and other peripheral devices without leaking bus details into application code.

Part 2 of 3

Key patterns for Page 2

  • Treat conversion time, EEPROM write time, and freshness as part of the driver contract.
  • Prefer ready flags, interrupt state, or ACK polling when the device provides them.
  • Decode raw bytes once, with named byte order, status bits, scaling, and validity rules.
  • Use blocking calls, state machines, callbacks, or RTOS queues deliberately instead of hiding long waits inside innocent function names.

Model the conversion sequence

Many I2C chips do not return a fresh measurement just because a register was read. A sensor may need a start-conversion command, an ADC may need channel selection plus acquisition and conversion time, and a power monitor may update periodically while exposing flags that tell you whether the value is fresh.

The driver should make that sequence visible. A blocking helper can be practical in small firmware, but it should still name the start, wait, read, and decode phases. When a conversion delay is hidden inside a function that looks like a plain register read, watchdog behavior, scheduler latency, and measurement freshness become much harder to review.

Timing diagram showing I2C command write, wait window, status polling, result read, and driver states.
Conversion and write-cycle timing should be explicit, with ready polling and a real timeout instead of a hidden fixed delay.

The snippet below introduces a cooperative read job. It names the start, wait, read, decode, done, and failed states so conversion timing is visible to the scheduler and to reviewers.

The setup above does not start the bus transaction yet. It gives the scheduler a concrete state object that can move one step at a time. That is useful in RTOS tasks, cooperative loops, and low-power firmware where the MCU should sleep between conversion phases.

Poll status instead of guessing delays

Fixed delays are sometimes necessary, but they should not be the first choice when the chip provides a better signal. A ready bit, interrupt pin, status register, or ACK polling loop usually gives better behavior across temperature, voltage, bus speed, and chip variants.

EEPROM write cycles are a good example. After a page write, many EEPROMs NACK their address while the internal write is busy. A fixed maximum delay works, but it slows every write to the worst case. ACK polling finishes as soon as the device is ready, while still enforcing a timeout.

The next snippet shows ACK polling for EEPROM-style write cycles. It treats address NACK as a temporary busy indication, but still returns other errors immediately and enforces a timeout.

The key detail is not the exact polling interval. The key detail is that the code treats an expected busy NACK differently from a real bus failure. Arbitration loss, a stuck bus, or a controller timeout should not be buried under the idea that the chip is simply still busy.

For sensors and ADCs, the same idea often appears as a status register poll. If the datasheet gives both a maximum conversion delay and a ready bit, use the ready bit with the maximum delay as a timeout. That gives the normal path good latency and still prevents an infinite wait when the board is wrong.

Decode raw data in the driver

Raw bytes are not measurements yet. They still carry byte order, sign representation, alignment, scaling, status bits, and sometimes validity flags. If every caller decodes those bytes independently, the project can end up with several slightly different versions of the same measurement.

The driver should return a value in a named unit, or at least return a decoded raw count with clear validity flags. That keeps calibration, filtering, logging, and alarm code from learning the byte layout of the chip.

The following code snippet shows where raw bytes become a temperature sample. It names the byte indexes, owns the endian conversion, and returns milli-Celsius rather than exposing the register layout to callers.

The register address in the example is intentionally small, but in real code it should be a named constant from the datasheet. If the chip packs status flags into the same bytes as the measurement, decode the flags before scaling and return them through the result structure.

Handle block reads deliberately

Multi-byte I2C reads can be safer than several single-byte reads, but only when the chip supports the behavior you are relying on. Some chips auto-increment the register pointer, some latch a coherent snapshot when the first byte is read, and some update high and low bytes independently unless a specific sequence is followed.

That shows up in accelerometers, power monitors, counters, ADC result registers, and almost any measurement wider than one byte. Separate high-byte and low-byte transactions can combine two different samples, while an unsupported block read can repeat one register or wander into reserved space.

Put the block-read rule into the driver profile or into a chip-specific helper. Do not let callers decide whether a burst read is safe.

The block-read snippet below shows a coherent multi-byte sample. It checks that the profile supports auto-increment, reads all axis bytes in one transfer, and decodes each little-endian pair at a named offset.

The profile check may look strict, but it documents the assumption. If a device variant needs a special burst bit or a snapshot command, add a chip-specific path instead of making every caller remember the exception.

Preserve error categories

A single generic bus error is not enough for a driver that will be debugged on real hardware. Address NACK, data NACK, timeout, arbitration loss, a stuck bus, a bad ID, invalid data, and not-ready status all point to different fixes.

That distinction matters during bring-up. Address NACK sends you toward power, reset, soldering, address pins, or the address format. Data NACK during a pointer write suggests an invalid register, the wrong transaction shape, or a busy device. Timeout and stuck-bus errors point more toward electrical problems, clock stretching support, a crashed target, or a controller that needs recovery.

A transport wrapper should translate vendor HAL errors into the driver's status vocabulary once. After that, the chip driver can make useful decisions without depending on one HAL's enum names.

The next snippet shows the transport error mapping layer. It converts board-driver results into the driver status enum once, so chip code does not depend on vendor-specific error names.

The driver does not need to expose every possible controller fault to the application, but it should preserve the failures that change the recovery decision. Retrying address NACK during EEPROM write polling can be correct. Retrying a stuck bus forever is usually not.

Share the I2C bus carefully

I2C is physically shared. The same two wires may connect the MCU, sensors, EEPROM, GPIO expanders, power monitors, level shifters, connectors, and test pads. That makes board-level assumptions part of the software problem, even when the chip driver is nicely written.

The driver should not choose pull-up resistor values, but it should not ignore the consequences either. Weak pull-ups, too much bus capacitance, a level shifter with poor edges, or a target that stretches SCL beyond the controller timeout can look like random software failures. The board transport owns the electrical setup, but the chip profile should record speed and timeout requirements so the bus manager can configure the controller correctly.

On RTOS systems, the bus also needs serialization. A repeated-start register read is one transaction, not a write call that can be interrupted by another driver's read call. The transport or bus manager should hold the bus lock across the combined operation.

The snippet below shows a locked shared-bus transfer. It holds the bus lock across the whole combined transaction and applies the active bus speed before the board driver starts the transfer.

The example assumes the board layer already knows the active speed for the selected device. Some projects set the speed once for the whole bus. That is fine when all devices and the board layout support it. If one device requires 100 kHz and another is happy at 400 kHz, record that explicitly instead of letting whichever driver initialized last decide the controller speed.

Use DMA when the transaction is large enough

Most I2C transactions are short. A register pointer plus two data bytes usually does not deserve DMA, because setup cost, buffer lifetime rules, interrupt handling, and cache maintenance can be larger than the transfer itself.

DMA begins to make sense for larger EEPROM transfers, display updates, camera control blocks, long sensor FIFO reads, or systems where the CPU has useful work to do while the bus is active. Even there, buffer ownership has to be explicit, because starting DMA from a stack buffer that goes out of scope is a classic failure that only appears under load.

Use a threshold and keep DMA policy in the transport or a clearly named async path. The chip driver can request a block read, but the board transport usually knows whether the controller, memory region, and RTOS integration make DMA worthwhile.

The small helper below shows one way to keep DMA policy out of chip logic. It uses a named threshold so small register reads stay simple while larger transfers can use a board-specific DMA path.

This threshold is not universal. Measure it on the target if performance matters. On a small MCU, DMA may save CPU time but add interrupt latency, cache rules, or memory placement requirements. On a larger RTOS system, the controller driver may already make that decision for you.

Keep asynchronous operation explicit

Hidden blocking looks harmless until the system grows. A helper named like a normal read can hide a conversion delay, EEPROM write-cycle polling, repeated retries, or a long bus timeout, and that cost lands in watchdog servicing, control-loop timing, UI responsiveness, and power management.

For slow sensors, high resolution ADCs, EEPROM writes, and periodic acquisition, an explicit state machine is often clearer than a blocking function with hidden delays. The state machine can start conversion, return to the scheduler, poll readiness later, and read the result when it is available.

There are three common ways to expose that work. None is automatically the best choice:

  • State machines fit cooperative firmware and simple RTOS tasks. They are easy to inspect, easy to unit test, and make timeouts explicit.
  • Callbacks fit controller drivers that already signal completion from an interrupt or DMA path. Keep callback work short and move decoding to task context when it can block or allocate.
  • RTOS queues or futures fit larger systems where one bus worker owns I2C transactions. They make bus ownership clear, but they add queue lifetime and cancellation rules.

For small bare-metal firmware, a state machine is often the easiest first async shape. For Zephyr, Linux, or a project with a shared bus worker, a queued request may match the system better. The important part is that the caller can see whether the operation may wait, retry, or complete later.

The state-machine snippet below shows how an asynchronous read advances through protocol phases. Each call does a small amount of work and returns the current result instead of hiding a long wait inside a blocking read.

The value of this pattern is not style. It makes watchdog behavior, scheduler latency, and acquisition timing visible during review. Blocking reads can still be a practical choice in a simple system, but their cost should be visible in the function name, documentation, or call site.

By the end of this second page, the driver can handle the runtime behavior of ordinary I2C peripherals: conversion timing, ACK polling, data decoding, block reads, shared buses, DMA thresholds, and asynchronous operation. Page 3 turns that into something you can bring up on hardware, test on a host, and maintain across board revisions.

Saeid Yazdani working at an electronics workbench
Saeid Yazdani

Embedded Systems Engineer with 15+ years of professional experience developing firmware, electronics, measurement systems, and hardware-software solutions. I have been programming for more than two decades and write about Embedded C/C++, STM32, AURIX, PCB design, debugging, and practical engineering lessons from real-world projects.

Articles: 38

Leave a Reply

Your email address will not be published. Required fields are marked *