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.

The first I2C driver for a new chip often starts with one register read. Send the device address, send the register address, read a byte, and the bench suddenly looks friendly. Then the same code fails after reset, works only at 100 kHz, reads the wrong register after a mode change, hangs when a sensor stretches the clock, or splits a register read into two transactions because the HAL function looked convenient. That is where an I2C driver stops being a wrapper around bus calls and starts becoming firmware that owns a datasheet contract.

This guide is about ordinary I2C chips: temperature sensors, pressure sensors, accelerometers, ADCs, DACs, EEPROMs, GPIO expanders, power monitors, front-end devices, and small measurement chips. The examples stay with normal embedded peripherals where the hard work is address handling, register-pointer behavior, repeated-start transactions, timing, ACK/NACK handling, data decoding, recovery, and hardware bring-up.

Guide to this reference

This guide is split into three pages so it can be used as a reference instead of one long scroll.

  1. Page 1, datasheet to driver shape. Start here for I2C address rules, register-pointer behavior, public API design, transport boundaries, combined transactions, and identity checks.
  2. Page 2, sensors, EEPROMs, and runtime behavior. Use this page for conversion timing, NACK polling, raw data decoding, block reads, error categories, shared bus rules, DMA, and asynchronous flow.
  3. Page 3, testing, bring-up, and long-term maintenance. Finish here for fake transports, logic-analyzer checks, stuck-bus recovery, calibration, scratch-buffer ownership, and the final checklist.

Page 1 builds the driver skeleton first. The later pages assume you have separated the application API from the I2C transaction details, because that separation makes every later decision easier to test and easier to review.

Key patterns for Page 1

  • Keep one address representation at the driver boundary, normally the 7-bit I2C target address.
  • Treat register-pointer writes and repeated-start reads as one transaction, not two casual bus calls.
  • Put chip-specific register, command, timing, and identity rules inside the driver.
  • Keep the board transport replaceable so host tests can check transaction shape before hardware exists.

What this guide covers

The thread through the whole article is ownership. A useful I2C driver owns the details that the rest of the firmware should not have to remember. It knows whether the chip uses a 7-bit address, whether a register read requires a repeated start, whether a write cycle temporarily NACKs the address, whether multi-byte reads auto-increment, and whether the last read byte must be followed by NACK before STOP.

In practice, the first version of an I2C driver is usually written under bring-up pressure. That is exactly when it is easiest to accept a shortcut that works for one board, one bus speed, and one register read. The sections below are meant to keep those shortcuts from becoming the permanent interface.

Most I2C driver mistakes are not caused by misunderstanding that I2C has SDA and SCL. They happen because the driver does not capture the exact contract of the chip. A temperature sensor might require a command write, a conversion delay, and then a read. An EEPROM might NACK while it is internally writing a page. A GPIO expander might use address pins that change the low bits of the 7-bit address. An ADC might return status flags in the same bytes as conversion data.

This guide focuses on the parts that make those details manageable:

  • Reading the datasheet as a driver contract.
  • Choosing a public API that does not leak register bytes into application code.
  • Keeping the I2C transport replaceable for host tests and board ports.
  • Building combined write-read transactions in one place.
  • Handling repeated starts, ACK/NACK behavior, and register pointer state.
  • Modeling conversion timing, EEPROM write cycles, and stale data.
  • Decoding raw measurements without burying assumptions in callers.
  • Sharing the bus across chips with different speeds and timing needs.
  • Debugging with logic-analyzer captures and fake transports.
  • Keeping the driver useful across board revisions and chip variants.

The examples are written in C because C still fits many small MCU projects, but the structure maps cleanly to C++, Rust, Zephyr, ESP-IDF, Linux userspace tools, or a project-specific hardware abstraction layer.

Common chip shapes and gotchas

Different I2C chips fail in different ways. A driver pattern that works for a small register-map sensor may be the wrong shape for an EEPROM or a command-driven ADC. Before writing the public API, decide which family the chip belongs to and which datasheet rule is most likely to be forgotten.

Chip style Practical driver gotcha
TMP117-style temperature sensors Temperature data is easy to read, but configuration, conversion mode, data-ready behavior, and device ID checks still belong in the driver.
BME280-style environmental sensors The register reads are not the hard part. Calibration registers, compensation formulas, oversampling, and mode transitions are the long-term maintenance risk.
24AA256-style EEPROMs Page boundaries and write-cycle ACK polling matter more than the simple byte read path. A driver that ignores page limits can wrap writes inside the EEPROM page.
ADS1115-style ADCs Channel, gain, data rate, and conversion mode are configuration state. A single-shot read should not assume the conversion register already contains a fresh result.
GPIO expanders Direction, pull-up, polarity, output latch, interrupt flag, and interrupt capture registers often need a clear ownership model or the application will fight the driver.

The names above are examples of common shapes, not a claim that every device in a family behaves the same way. The point is to classify the hard part early. That keeps the driver from becoming a generic I2C wrapper with chip knowledge scattered through application code.

Read the datasheet as a driver contract

I2C gives you a bus protocol, not a device protocol. The bus defines START, STOP, addressing, ACK/NACK, arbitration, and the timing relationship between SDA and SCL. The chip datasheet decides what an address means, whether a register pointer exists, how many bytes are in that pointer, whether the pointer auto-increments, whether a repeated start is required, and how long a conversion or internal write cycle can take.

Field note: A common bring-up failure is a driver that works with a quick register read but fails once the acquisition task starts. The root cause is often a hidden transaction assumption. The code writes a register pointer, sends STOP, then performs a separate read. The chip expected a repeated start, so the register pointer changed, expired, or was interpreted differently. The bus looks active, the address ACKs, and yet the data is wrong.

A good I2C driver starts with a small checklist, not with code. Before writing the first function, pull out the details that affect every transaction. For a normal peripheral chip, that usually means:

  • 7-bit target address, including any address pins or board straps.
  • Whether the datasheet shows addresses as 7-bit values or 8-bit address-plus-R/W bytes.
  • Maximum bus speed, and whether the chip supports Standard-mode, Fast-mode, Fast-mode Plus, or High-speed mode.
  • Whether the target may stretch SCL and whether the controller driver supports that.
  • Register pointer width, often 0, 1, or 2 bytes.
  • Register pointer byte order for 16-bit register maps.
  • Whether register reads require a repeated start.
  • Whether multi-byte reads auto-increment the register pointer.
  • Reset delay, first valid measurement timing, and conversion delay.
  • Busy, data-ready, overflow, or invalid-data flags.
  • EEPROM or flash-style write cycle behavior, including ACK polling.
  • Whether the bus can be shared safely with other chips at the chosen speed and pull-up values.

This is where many drivers start drifting. The code says "read register", but the chip actually says "write register pointer, repeated start, read two bytes, NACK the final byte, STOP". The code says "delay 10 ms", but the datasheet says the conversion time depends on oversampling. The code says address 0x90, but that is the 8-bit write address printed in one table, not the 7-bit address the MCU HAL expects.

The practical way to avoid this is to create a driver profile that names the assumptions. It does not need to expose every datasheet line, but it should hold the parameters that can change between chips, boards, or operating modes.

The snippet below shows a compact profile shape for an I2C chip. Notice how the bus timing, 7-bit address, register-pointer format, repeated-start rule, reset delay, and conversion timeout are all named before any transfer code uses them.

The exact values above are example values, not a replacement for a datasheet. The point is the shape. If the driver has a profile, you can review what the chip expects before you read the bus code.

Datasheets do not always present I2C addresses the same way. Some show a 7-bit target address such as 0x48. Others show the 8-bit write and read bytes, such as 0x90 and 0x91. Many MCU HALs expect the 7-bit address, while some older APIs expect the shifted address byte. Pick one representation at the driver boundary, name it clearly, and convert only at the board transport boundary if needed.

Layered I2C chip driver architecture showing application API, chip driver, transport, codec, timing, board layer, and shared SDA/SCL bus.
The application should not own register pointer rules, repeated-start behavior, or bus recovery. Keep those details inside the driver and transport boundary.

Choose the public API before the bus code

The API is where the driver commits to what kind of device it is. If the API exposes register addresses and byte buffers, every caller has to learn part of the datasheet. If it exposes measurements, channels, ranges, modes, flags, and status, the protocol stays replaceable.

This matters most after the first board revision. The second temperature sensor may move the data-ready bit. The replacement ADC may use a command sequence instead of a register pointer. The same EEPROM may be wired with different address pins. A device-oriented API lets those changes stay inside the driver instead of spreading through acquisition, logging, filtering, calibration, and UI code.

For an ADC, application code usually wants samples, channels, ranges, and validity flags. For a temperature sensor, it wants degrees, raw readings, or a refresh result. For an EEPROM, it wants bounded reads and writes with clear write-cycle behavior. The I2C details are implementation details.

The next snippet shows the application-facing side of that boundary. It defines a shared status vocabulary, sample structs with named units, and function prototypes that talk about device operations instead of I2C bytes.

The caller should not have to remember that register 0x00 is a device ID, that a two-byte register pointer is big endian, or that the final byte of an I2C read should be NACKed. Those rules belong inside the driver and transport.

Keep the I2C transport replaceable

This separation prevents two different problems. The first is testability: you can check address use, register-pointer encoding, repeated-start choices, and decode logic without waiting for hardware. The second is portability: the device driver can move from one board support package to another without dragging a vendor HAL through every function.

Lesson learned: Drivers that call the MCU HAL directly from every helper often look faster to write, but they become expensive when the board changes. A move from blocking transfers to an RTOS bus driver, a controller that expects shifted addresses, or a target that needs bus recovery can force edits across the whole driver. A small transport wrapper keeps that churn in one place.

I2C drivers become easier to test when the transport exposes the operations the device protocol actually needs. For register devices, that usually includes a combined write-read operation so the driver can request a repeated start as one logical transaction.

In the snippet below, the transport table is the small adapter between portable chip code and the board-specific I2C implementation. The important operation is write_read, because that is how the driver asks for a repeated-start transaction as one unit.

This is not an abstraction for its own sake. It pays for itself when a host test can prove that a register read used a combined transaction, and when a second board uses the same chip through a different controller driver.

A minimal driver object is enough for the first examples. In a real reusable driver, the object usually needs a little more state. Keep this state about the chip and its communication contract. Do not add sampling policy, UI state, alarm policy, or product behavior here.

The short snippet below expands the chip object with runtime state. It shows which details belong inside the driver object and which policy decisions should stay outside it.

This is also a good place to store discovered chip revision or feature bits. If a later silicon revision changes a status mask, conversion delay, or calibration layout, the driver can select the right profile after reading the ID register instead of pushing revision checks into callers.

The recover_bus callback is optional in the structure above because some systems cannot safely recover the bus from a chip driver. On a simple single-controller board, bus recovery may live in the board transport. On a shared RTOS system, recovery may need coordination at a higher level so one driver does not toggle SCL while another transaction is in progress.

Understand the actual I2C transaction

This is the section that saves people from broken register reads. I2C devices often use a register pointer. The controller writes the register address first, then reads data from that pointer. Many devices expect the pointer write and the read phase to be connected by a repeated start, not separated by STOP.

When this is not explicit, engineers often fix the symptom in the decoder by changing indexes, adding delays, or rereading until the value looks plausible. That creates fragile code. The next register, burst read, or device variant may need a different transaction shape, and the hidden assumption breaks again.

I2C register read sequence showing start, address write, register pointer, repeated start, address read, data bytes, NACK, and stop.
Many I2C register reads are one logical transaction: write the register pointer, issue a repeated start, then read the response bytes.

A driver helper should own the register-pointer encoding and the transaction shape. It should also keep the 7-bit address separate from the R/W bit. The board transport can shift or adapt that address if a vendor HAL needs a different representation.

The following code snippet shows the register-pointer helper and the public register-read wrapper. It centralizes one-byte versus two-byte pointer encoding, byte order, argument checks, and the combined write-read transaction.

A small chip-level helper can also centralize validation, timeout use, and optional bus recovery. Keep it conservative. Retrying every NACK can hide real protocol mistakes, while one controlled recovery attempt after a stuck bus can be useful on simple boards.

The next helper shows the lower-level combined transaction wrapper. It validates buffers, uses the profile timeout, and performs exactly one controlled recovery retry when the board layer reports a stuck bus.

The helper is not a replacement for a bus manager. If the bus is shared across tasks, drivers, or processors, the transport still needs to serialize complete transactions and decide where recovery is allowed.

The helper above looks ordinary, which is the point. The important choice is that the register pointer and combined write-read behavior live in one place. A caller should not build pointer bytes or decide whether a repeated start is needed.

Do not hide device identity checks

A device ID or reset-value check is not a luxury. It catches wrong addresses, soldering mistakes, board strap mistakes, swapped parts, and accidental bus conflicts early. The check does not need to run before every transaction, but initialization should prove that the expected chip responded before the driver starts returning measurements.

Not every I2C chip has a useful identity register. Some simple EEPROMs and GPIO expanders do not. In that case, use a reset-value register, a harmless configuration readback, or a small smoke test that proves the expected behavior without changing outputs unexpectedly.

The initialization snippet below shows a simple identity check. It waits for reset timing, reads the ID register through the shared helper, and fails early if the expected device is not present.

The ID value above is an example value. In real code, use the value and register name from the datasheet. If the chip has multiple silicon revisions, record which IDs are accepted and why.

Keep register style separate from command style

Not every I2C chip is a simple register map. Some sensors use command words. Some ADCs use command writes followed by a conversion wait and a read. EEPROMs look like register devices at first, but page writes and write-cycle polling give them a different shape. Forcing all of those into one generic read_register helper usually leaks odd flags and special cases into callers.

Use a register helper for register-map chips. Use a command helper for command devices. Use an EEPROM helper for memory-like devices with page boundaries and write-cycle polling. They can share the same transport, status type, and timeout policy without pretending that every chip has the same protocol.

The following snippet shows a command-style chip path. Instead of inventing a fake register address, the driver sends the command bytes directly through the same transport vocabulary.

The driver boundary should reflect the chip. A clean command helper is easier to review than a register helper with magic register values that are not really registers.

By the end of this first page, the driver has the important shape: a device-oriented API, an explicit chip profile, a replaceable transport, a central register or command codec, and a clear initialization path. Page 2 uses that shape to handle the runtime behavior that usually causes I2C bugs on real boards.

Avatar photo
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: 36

Leave a Reply

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