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 3 of 3

Key patterns for Page 3

  • Test transaction shape without hardware, then use hardware captures to prove the electrical bus did the same thing.
  • Simulate NACKs, timeouts, stuck-bus recovery, endian mistakes, and invalid IDs before they happen on the bench.
  • Keep chip revision, power state, and calibration data visible in the driver object.
  • Let the board layer own pull-ups, level shifting, controller setup, and recovery sequences.

Test the codec without hardware

Host-side codec tests are valuable because they remove the board from the question. If the driver sends the wrong 7-bit address, builds the wrong register pointer, or accidentally uses separate write and read operations instead of a combined write-read, it will be wrong on the bench too. Catching that before probing a board saves time and keeps hardware debugging focused on hardware.

These tests also protect future edits. When someone adds a block read, changes a 16-bit register helper, ports the driver to a different controller API, or adds EEPROM page writes, the tests catch protocol drift before it becomes a measurement bug.

The following fake transport snippet shows what a useful host test can check. It verifies the address, pointer bytes, read length, and combined write-read behavior before returning fake data.

These tests do not prove the electrical interface. They prove that the driver asks for the I2C transactions it claims to ask for. That is still a large part of the bug surface, especially for register-pointer, endian, repeated-start, and error-handling mistakes.

A useful host test set should include more than the happy path. At minimum, test the driver with:

  • Every valid register pointer width and byte order used by the supported chips.
  • Boundary register values such as 0x00, the last valid register, and one invalid register.
  • Address NACK during probe, data NACK during a register write, timeout during readback, and a stuck-bus error.
  • A fake device ID for the wrong chip and for a known alternate revision.
  • Multi-byte payloads with swapped byte order, sign-extension edges, overrange flags, and stale-data flags.
  • EEPROM page writes that end exactly at a page boundary and writes that would cross one.

For codec-style helpers, fuzzing small register values and payload lengths is cheap. It will not prove the hardware, but it often finds off-by-one indexes, missing bounds checks, and accidental page wrap before the board is available.

I2C driver test and bring-up loop from datasheet profile to fake bus tests, board smoke test, logic capture, recovery checks, and regressions.
Bring-up is faster when byte-level tests, board checks, and logic-analyzer captures feed the same driver assumptions.

Bring up the hardware in layers

Layered bring-up keeps the number of unknowns small. If the first test is the full application, a missing pull-up, wrong address strap, unsupported clock stretching, bad level shifter, wrong register pointer, and decoder bug all collapse into the same symptom: bad data.

Start with the physical bus and one harmless transaction. I2C can fail quietly: SDA or SCL may be held low, pull-ups may be too weak for the chosen speed, a target may still be in reset, a level shifter may work at 100 kHz but fail at 400 kHz, or a connector may add enough capacitance that the waveform toggles while violating timing.

Lesson learned: The fastest I2C bring-up sessions usually look boring. Check that SDA and SCL idle high, read one known register, capture one combined transaction, compare it with the datasheet, then move to one measurement. Skipping straight to the acquisition task feels efficient until the first invalid sample has six possible causes.

Instead of starting with the complete application, start with the smallest observation that proves one layer at a time.

  1. Check power rails, reset pins, address pins, and I/O voltage domains.
  2. Confirm SDA and SCL idle high with the expected pull-ups installed.
  3. Verify the MCU pins are configured as open-drain or controller-managed I2C pins.
  4. Try one address probe or ID register read at a conservative bus speed.
  5. Capture SDA and SCL on a logic analyzer.
  6. Confirm START, address, ACK/NACK, repeated start, data bytes, final NACK, and STOP.
  7. Read one fixed register repeatedly before enabling complex modes.
  8. Read one measurement in a slow blocking path before adding interrupts, DMA, or scheduler integration.

This order may feel slow, but it is usually faster than debugging a full acquisition stack where the wrong address format, weak pull-ups, and wrong decoder all fail at once.

Make logic analyzer captures part of the review

A logic analyzer capture turns transaction assumptions into something reviewable. Code can say the driver uses a repeated start, but the capture shows whether the controller actually did it. Code can say the target NACKed the final byte, but the capture shows whether the controller acknowledged one byte too many before STOP.

The value is highest when the capture is taken early and kept with the bring-up notes. Months later, when a board spin changes pull-ups or moves the device behind a level shifter, that old capture gives the team a concrete reference for what good looked like.

For a driver that talks to a new I2C chip, the most useful review artifact is often a decoded capture. It answers questions code alone cannot answer:

  • Did SDA and SCL idle high before the transaction?
  • Did the controller send the expected 7-bit address?
  • Did the target ACK the address and register pointer?
  • Did the read use a repeated start instead of STOP followed by START?
  • Did the controller NACK the final read byte before STOP?
  • Did the target stretch SCL, and did the controller tolerate it?
  • Did another device or a stuck target hold the bus low?
  • Did the transaction length match the driver expectation?

The capture does not need to be pretty. It needs to show the transaction that the driver claims to perform.

Recover from a stuck bus deliberately

A stuck I2C bus is a board-level failure mode that software still has to handle. A target can hold SDA low after a reset during a transaction, the MCU I2C peripheral may time out and need reinitialization, or a sensor powered through leakage may keep one line clamped. If the driver only retries the same transfer, firmware can sit forever behind one blocked bus.

Bus recovery should live in the board transport or bus manager, not deep inside a sensor decoder. The recovery code may need to temporarily reconfigure SCL as GPIO, pulse SCL, generate a STOP condition, reset the controller peripheral, and then restore the I2C pins. That is hardware-specific behavior.

The bus-recovery snippet below shows why recovery belongs in the board layer. It temporarily owns the pins as GPIO, clocks SCL to release a stuck target, generates a STOP, and restores the controller.

The exact sequence depends on the controller, board, and targets. Recovery should also be serialized with the bus lock. In a multi-controller system, or on a bus that can be driven by another processor, recovery needs stronger coordination than a chip driver can provide.

Keep calibration and compensation visible

Calibration code is part of the measurement path, not polish added after the driver is done. If gain, offset, trim, or compensation data is hidden in application code, the driver can no longer explain what its output means, especially when two products share the same chip but use different references, sensor placements, or calibration flows.

Keeping calibration data near the driver also helps debugging. You can log raw counts, corrected values, and calibration constants in one place, then decide whether the fault is electrical, protocol-level, or mathematical.

Many I2C sensors and ADC front ends need calibration constants, trim values, gain correction, offset correction, or compensation formulas. Those rules should not be spread across application code. Read calibration data during initialization, store it in the device object, and apply it in the driver or a clearly named conversion layer.

The calibration snippet below shows how board trim can live next to the driver. It composes the reusable I2C chip object with ADC-specific calibration and uses wide math for ppm gain correction.

If the calibration formula comes from a datasheet, keep the variable names close enough that another engineer can compare code to the document without translating every symbol.

Avoid global scratch buffers

Global scratch buffers are easy to write during bring-up and hard to reason about once the driver is reused. They quietly assume one caller, one thread, no interrupt access, and no overlapping asynchronous transfer. Those assumptions are often true on the bench and false in the product.

The debugging consequence can be ugly. A measurement task corrupts a configuration write, or a background EEPROM write reuses a buffer while an async transfer is still in progress. The logic analyzer capture shows wrong bytes on the bus, but the bug happened before the controller ever started the transaction.

Keep ownership explicit. Small blocking helpers can use stack buffers, while larger transfers may need caller-provided scratch storage, a driver-owned buffer protected by a lock, or a transport-managed DMA buffer. The right choice depends on concurrency and transfer size, but the choice should be visible.

The scratch-buffer snippet below shows explicit ownership for a block read. The caller provides storage that remains valid for the whole transaction, and the helper owns the 16-bit EEPROM pointer bytes.

Passing scratch storage explicitly is not always necessary, but it makes ownership clear in drivers that may be called from more than one context. If a driver uses internal scratch storage, document whether the driver is single-threaded, lock-protected, or async-safe.

Decide where the driver ends

A good driver should make correct hardware operation easy and wrong operation obvious, without becoming the whole application. It should know how to talk to the chip, configure it, read useful data, apply chip-specific compensation, and report clear errors. It should not quietly own system policy unless that policy is truly part of the device.

Good boundaries:

  • The ADC driver returns samples, overrange flags, freshness, and status.
  • The sensor driver returns compensated measurements and data-ready state.
  • The EEPROM driver owns page boundaries and write-cycle polling.
  • The GPIO expander driver owns register layout and interrupt flag handling.
  • The board layer owns pins, pull-ups, voltage domains, controller setup, and bus recovery.
  • The application owns sampling rate policy, filtering policy, alarms, storage, and user-visible behavior.

This boundary matters when the product changes. If the application decides to sample at a different rate, you should not have to rewrite register-pointer encoding. If the board moves the chip to another I2C controller, you should not have to rewrite compensation math. If a blocking transfer needs to become asynchronous, you should not have to change every caller that only wanted a temperature.

Account for variants, power, and host environment

Chip revisions are not rare. A newer revision may keep the same I2C address while changing reset values, adding status bits, altering conversion timing, or fixing a behavior that older firmware accidentally depended on. If the chip exposes an ID or revision register, read it once during initialization and select a profile or feature flags from that result. If it does not, keep the board-level part number and expected behavior in the profile.

Low-power firmware adds another boundary. If the MCU disables the I2C peripheral, changes pin states, or powers down a sensor rail, the driver should know whether cached configuration is still valid after wake. A practical pattern is to separate attach, init, suspend, and resume paths: attach binds the transport, init proves the device and writes configuration, suspend leaves the chip or bus safe, and resume restores only what may have been lost.

The same driver shape appears in larger environments, but the boundary moves. In Linux kernel drivers, the kernel I2C core owns adapters and clients. In Linux userspace, i2c-dev can be useful for tools and bring-up, but production device behavior often belongs in a kernel driver or a supervised service. In Zephyr, the i2c_dt_spec pattern moves address and bus selection into device tree. In Rust, embedded-hal encourages drivers that depend on traits instead of one MCU HAL. The design goal is the same: keep chip behavior separate from the transport that moves bytes.

One final edge case is trust. I2C is usually an internal board bus, not a secure communication channel. If a connector, replaceable module, or external cable exposes the bus, a device can spoof an address, hold lines low, or return plausible but false data. Most embedded products do not need cryptography on I2C, but safety or security-sensitive systems should not treat an ACK and a matching ID byte as proof of trust.

Common I2C driver mistakes

These mistakes share one theme: the code worked once with one setup, but the assumptions were never written down. The first board, first bus speed, first target address, and first caller then become invisible requirements.

The fix is not to make the driver complicated. The fix is to put each assumption in the right place: address representation in the profile, register-pointer layout in the codec, bus behavior in the transport, measurement meaning in the decoder, and system policy outside the driver.

These are the mistakes that show up repeatedly during bring-up:

  • Passing an 8-bit address byte to a HAL that expects a 7-bit address.
  • Splitting a required repeated-start register read into separate write and read transactions.
  • Treating address NACK during EEPROM write cycle as a fatal device failure.
  • Hiding clock-stretching timeouts behind a generic bus error.
  • Assuming all multi-byte registers are big endian.
  • Reading high and low measurement bytes in separate transactions when the datasheet requires a coherent block read.
  • Forgetting that address pins change the low bits of the target address.
  • Running the whole bus at 400 kHz when one target or board segment only works reliably at 100 kHz.
  • Ignoring pull-up strength, bus capacitance, level shifters, or powered-off targets during software debugging.
  • Retrying forever when SDA is physically stuck low.
  • Using one global scratch buffer across task, interrupt, and async paths.
  • Returning one generic error for address NACK, data NACK, timeout, bad ID, and invalid data.

Most of these are easy to avoid once the driver makes addressing, transaction shape, timing, decoding, and error categories explicit.

Practical reference checklist

Before considering a normal I2C chip driver ready, check the following:

  • The 7-bit address and address-pin assumptions are recorded in driver or board configuration.
  • The code does not confuse 7-bit target addresses with 8-bit address-plus-R/W bytes.
  • The maximum bus speed, timeout, and clock-stretching expectations are recorded.
  • The public API talks in device concepts, not raw I2C buffers.
  • Register pointer construction is centralized.
  • Required repeated-start transactions use one combined write-read transport call.
  • Command-style devices are not forced into a fake register model.
  • Multi-byte fields have one decoder with explicit byte order and sign handling.
  • Conversion timing uses ready flags, ACK polling, or named datasheet delays.
  • Address NACK, data NACK, timeout, bus stuck, bad ID, invalid sample, and not-ready status are distinguishable where recovery differs.
  • Shared bus access is serialized across complete transactions.
  • Board-level pull-ups, capacitance, level shifting, and voltage domains have been checked during bring-up.
  • DMA or async use has clear buffer ownership and a size threshold or system reason.
  • Host tests cover address use, register-pointer bytes, combined transactions, and response decoding.
  • Hardware bring-up includes logic-analyzer confirmation of START, address, ACK/NACK, repeated start, data, final NACK, and STOP.
  • Calibration and compensation are kept close to the driver or clearly named conversion layer.
  • Unit tests cover NACKs, timeouts, stuck-bus recovery, invalid IDs, byte-order edges, and page-boundary limits.
  • Power-management paths say whether configuration is retained, restored, or revalidated after wake.
  • Chip variants and revisions are handled through IDs, profiles, or board-specific configuration instead of scattered conditionals.

Appendix: two compact mini-driver shapes

These are intentionally small examples, not complete production drivers. They show how the earlier patterns become ordinary code once the address, register pointer, timing, and data conversion rules have a home.

TMP117 temperature sensor mini example

This is a concrete TMP117 example, but still intentionally small. It assumes the board layer has already attached the I2C transport and timing to dev->chip, and it reuses the repeated-start register helper from Part 1. The TMP117-specific code owns the register names, ID check, configuration word, calibration offset, and temperature scaling.

The TMP117 mini-driver below ties the article patterns together for one concrete chip. It keeps TMP117 register names, ID masking, configuration generation, 16-bit helpers, and milli-Celsius scaling inside the driver.

The profile keeps board-level choices out of application code: address straps, system calibration offset, and whether this board wants heavier averaging. For one-shot mode, add a separate start-and-poll path that sets MOD[1:0] to one-shot and watches the Data_Ready flag in bit 13 of the configuration register with a timeout. Do not hide that wait inside a function that looks like a simple cached temperature read.

24AA256-style EEPROM

This EEPROM-style example shows a different shape. The hard parts are not temperature scaling or data-ready flags. They are 16-bit memory offsets, page boundaries, bounded writes, and ACK polling after the internal write cycle starts.

The final snippet shows the EEPROM shape for comparison. It names the 24AA256 geometry, protects page boundaries, builds offset-plus-data frames, and uses ACK polling after a page write.

The public EEPROM API should usually split larger writes into page writes above this helper. Keeping the page rule here prevents a higher layer from accidentally creating a write that wraps inside one EEPROM page.

Useful primary references

These are good examples of the kinds of documents that force different I2C driver shapes:

Final takeaways

Writing an I2C driver is not only about making a target ACK. The useful work is turning a chip datasheet into a small interface that the rest of the firmware can trust, review, test, and reuse.

The first successful I2C transaction is only the start. It proves that the pins, address, and controller setup are close enough to get a response. It does not prove that repeated-start behavior is correct, that the register pointer is coherent, that conversion timing is fresh, that ACK polling is handled, or that the driver will survive the next board revision.

For simple chips, a good driver may only need a few helpers and a clean decoder. For ADCs, sensors, EEPROMs, and front-end devices, it usually needs explicit timing, status handling, scaling, calibration, error categories, and recovery behavior. Those details are not ceremony; they are the difference between a driver you can debug in an afternoon and one that keeps producing unexplained field data.

Hardware abstraction matters because products change. The I2C controller may move, the board support package may change, the same chip may appear with different address pins, or a blocking transfer may need to become asynchronous later. If the driver already separates transport, register or command encoding, decoding, and application policy, those changes stay local. If it does not, every change becomes a search through unrelated application code.

The driver is done when the application can ask for the thing it actually needs, a voltage, a temperature, an acceleration vector, EEPROM bytes, GPIO state, or a data-ready flag, and the I2C details stay where they belong. That is what makes the driver maintainable after the first ACK on the bench.

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 *