Table of Contents
KEY TAKEAWAYS
- A production I2C driver uses the MCU’s hardware I2C peripheral (not bit-banging) for reliability and CPU efficiency.
- The driver architecture separates low-level register access (HAL) from device-specific logic (device drivers).
- Interrupt-driven I2C with state machines eliminates busy-waiting and lets the CPU do other work during transfers.
- DMA integration enables block transfers (EEPROM reads, display updates) without CPU intervention per byte.
- Robust error handling covers NACK, bus errors, arbitration loss, and timeout — with automatic recovery.
Why Write Your Own I2C Driver?
In the I2C protocol deep dive, we explored the protocol using bit-banged implementations. While bit-banging is great for learning, production firmware uses the MCU’s hardware I2C peripheral for several reasons: it handles timing automatically, supports clock stretching in hardware, frees the CPU during transfers, and is far more reliable at higher speeds.
In this article, we’ll build a complete I2C driver for an STM32F1 (ARM Cortex-M3) microcontroller. The same architectural patterns apply to any MCU — only the register addresses change. We’ll progress from polling mode through interrupt-driven and DMA-based implementations, then build a real sensor driver on top.
Driver Architecture
A well-structured I2C driver has three layers:
/*
* I2C Driver Architecture
*
* ┌─────────────────────────┐
* │ Application Layer │ bmp280_read_temperature()
* ├─────────────────────────┤
* │ Device Driver Layer │ bmp280_read_register()
* ├─────────────────────────┤
* │ I2C HAL Layer │ i2c_master_transmit(), i2c_master_receive()
* ├─────────────────────────┤
* │ Hardware (I2C1, I2C2) │ Registers: CR1, CR2, SR1, SR2, DR, CCR
* └─────────────────────────┘
*/I2C Configuration Structure
First, let’s define the data structures that configure and manage an I2C peripheral. Using structures and function pointers (rather than hardcoded register addresses) makes the driver reusable across I2C1 and I2C2.
/* i2c_driver.h — I2C Driver Interface */
#ifndef I2C_DRIVER_H
#define I2C_DRIVER_H
#include
#include
/* I2C peripheral register map (STM32F1) */
typedef struct {
volatile uint32_t CR1; /* Control register 1 (offset 0x00) */
volatile uint32_t CR2; /* Control register 2 (offset 0x04) */
volatile uint32_t OAR1; /* Own address register 1 (offset 0x08) */
volatile uint32_t OAR2; /* Own address register 2 (offset 0x0C) */
volatile uint32_t DR; /* Data register (offset 0x10) */
volatile uint32_t SR1; /* Status register 1 (offset 0x14) */
volatile uint32_t SR2; /* Status register 2 (offset 0x18) */
volatile uint32_t CCR; /* Clock control register (offset 0x1C) */
volatile uint32_t TRISE; /* Rise time register (offset 0x20) */
} I2C_TypeDef;
/* Base addresses */
#define I2C1 ((I2C_TypeDef *)0x40005400)
#define I2C2 ((I2C_TypeDef *)0x40005800)
/* CR1 bit definitions */
#define I2C_CR1_PE (1 << 0) /* Peripheral enable */
#define I2C_CR1_START (1 << 8) /* Generate START */
#define I2C_CR1_STOP (1 << 9) /* Generate STOP */
#define I2C_CR1_ACK (1 << 10) /* ACK enable */
#define I2C_CR1_SWRST (1 << 15) /* Software reset */
/* CR2 bit definitions */
#define I2C_CR2_ITERREN (1 << 8) /* Error interrupt enable */
#define I2C_CR2_ITEVTEN (1 << 9) /* Event interrupt enable */
#define I2C_CR2_ITBUFEN (1 << 10) /* Buffer interrupt enable */
/* SR1 bit definitions */
#define I2C_SR1_SB (1 << 0) /* START bit generated */
#define I2C_SR1_ADDR (1 << 1) /* Address sent/matched */
#define I2C_SR1_BTF (1 << 2) /* Byte transfer finished */
#define I2C_SR1_RXNE (1 << 6) /* Receive buffer not empty */
#define I2C_SR1_TXE (1 << 7) /* Transmit buffer empty */
#define I2C_SR1_BERR (1 << 8) /* Bus error */
#define I2C_SR1_ARLO (1 << 9) /* Arbitration lost */
#define I2C_SR1_AF (1 << 10) /* Acknowledge failure */
#define I2C_SR1_OVR (1 << 11) /* Overrun/underrun */
#define I2C_SR1_TIMEOUT (1 << 14) /* Timeout (SCL stuck low) */
/* SR2 bit definitions */
#define I2C_SR2_BUSY (1 << 1) /* Bus busy */
/* I2C speed modes */
typedef enum {
I2C_SPEED_STANDARD = 100000, /* 100 kHz */
I2C_SPEED_FAST = 400000 /* 400 kHz */
} i2c_speed_t;
/* Transfer status */
typedef enum {
I2C_OK = 0,
I2C_ERROR_NACK = -1,
I2C_ERROR_BUS = -2,
I2C_ERROR_ARB = -3,
I2C_ERROR_TIMEOUT = -4,
I2C_ERROR_DMA = -5
} i2c_status_t;
/* I2C handle (per-peripheral state) */
typedef struct {
I2C_TypeDef *periph; /* Pointer to I2C peripheral registers */
uint32_t clock_speed; /* APB1 clock in Hz */
i2c_speed_t bus_speed; /* Desired I2C bus speed */
/* For interrupt-driven transfers */
volatile uint8_t *buffer;
volatile uint16_t buf_len;
volatile uint16_t buf_idx;
volatile i2c_status_t status;
volatile uint8_t busy;
} i2c_handle_t;
/* API */
void i2c_init(i2c_handle_t *hi2c);
i2c_status_t i2c_master_transmit(i2c_handle_t *hi2c, uint8_t addr,
const uint8_t *data, uint16_t len,
uint32_t timeout_ms);
i2c_status_t i2c_master_receive(i2c_handle_t *hi2c, uint8_t addr,
uint8_t *data, uint16_t len,
uint32_t timeout_ms);
i2c_status_t i2c_mem_write(i2c_handle_t *hi2c, uint8_t addr,
uint8_t mem_addr, const uint8_t *data,
uint16_t len, uint32_t timeout_ms);
i2c_status_t i2c_mem_read(i2c_handle_t *hi2c, uint8_t addr,
uint8_t mem_addr, uint8_t *data,
uint16_t len, uint32_t timeout_ms);
#endif /* I2C_DRIVER_H */I2C Initialization
Initializing the I2C peripheral involves configuring the GPIO pins (open-drain, alternate function), setting the clock speed via the CCR (Clock Control Register), and configuring the maximum rise time.
/* i2c_driver.c — I2C Driver Implementation */
#include "i2c_driver.h"
/* RCC register for enabling I2C and GPIO clocks */
#define RCC_APB1ENR (*(volatile uint32_t *)0x4002101C)
#define RCC_APB2ENR (*(volatile uint32_t *)0x40021018)
#define RCC_APB1ENR_I2C1EN (1 << 21)
#define RCC_APB1ENR_I2C2EN (1 << 22)
#define RCC_APB2ENR_IOPBEN (1 <periph == I2C1) {
RCC_APB1ENR |= RCC_APB1ENR_I2C1EN;
/* Configure PB6 (SCL) and PB7 (SDA) as alternate function open-drain
* CNF = 11 (AF open-drain), MODE = 11 (50MHz output)
* CRL bits [27:24] = PB6, bits [31:28] = PB7 */
GPIOB_CRL &= ~(0xFFu << 24);
GPIOB_CRL |= (0xFFu <periph->CR1 |= I2C_CR1_SWRST;
hi2c->periph->CR1 &= ~I2C_CR1_SWRST;
/* Configure clock: CR2 FREQ field = APB1 clock in MHz */
uint32_t apb1_mhz = hi2c->clock_speed / 1000000;
hi2c->periph->CR2 = apb1_mhz; /* e.g., 36 for 36MHz APB1 */
/* Configure speed (CCR register) */
if (hi2c->bus_speed clock_speed / (2 * hi2c->bus_speed);
hi2c->periph->CCR = ccr_val;
/* TRISE = (max_rise_time_ns / (1/APB1_clock_ns)) + 1
* Standard mode max rise = 1000ns */
hi2c->periph->TRISE = apb1_mhz + 1;
} else {
/* Fast mode: CCR = APB1_clock / (3 * I2C_speed) for duty=0 */
uint32_t ccr_val = hi2c->clock_speed / (3 * hi2c->bus_speed);
if (ccr_val periph->CCR = ccr_val | (1 <periph->TRISE = (apb1_mhz * 300) / 1000 + 1;
}
/* Enable peripheral */
hi2c->periph->CR1 |= I2C_CR1_PE;
/* Enable ACK */
hi2c->periph->CR1 |= I2C_CR1_ACK;
hi2c->busy = 0;
hi2c->status = I2C_OK;
}Polling-Mode Transmit and Receive
The simplest I2C driver mode is polling, where the CPU waits for each step to complete. This is fine for initialization code and infrequent operations, but wastes CPU cycles during transfers.
/* Helper: wait for a flag with timeout */
static i2c_status_t wait_flag(volatile uint32_t *reg, uint32_t flag,
uint32_t expected, uint32_t timeout_ms) {
uint32_t start = get_tick();
while (((*reg) & flag) != expected) {
if ((get_tick() - start) > timeout_ms) {
return I2C_ERROR_TIMEOUT;
}
}
return I2C_OK;
}
/* Helper: wait for bus to be free */
static i2c_status_t wait_bus_free(i2c_handle_t *hi2c, uint32_t timeout_ms) {
return wait_flag(&hi2c->periph->SR2, I2C_SR2_BUSY, 0, timeout_ms);
}
/* Check for errors in SR1 */
static i2c_status_t check_errors(i2c_handle_t *hi2c) {
uint32_t sr1 = hi2c->periph->SR1;
if (sr1 & I2C_SR1_AF) {
hi2c->periph->SR1 &= ~I2C_SR1_AF; /* Clear flag */
hi2c->periph->CR1 |= I2C_CR1_STOP;
return I2C_ERROR_NACK;
}
if (sr1 & I2C_SR1_ARLO) {
hi2c->periph->SR1 &= ~I2C_SR1_ARLO;
return I2C_ERROR_ARB;
}
if (sr1 & I2C_SR1_BERR) {
hi2c->periph->SR1 &= ~I2C_SR1_BERR;
return I2C_ERROR_BUS;
}
return I2C_OK;
}
/* Polling-mode master transmit */
i2c_status_t i2c_master_transmit(i2c_handle_t *hi2c, uint8_t addr,
const uint8_t *data, uint16_t len,
uint32_t timeout_ms) {
i2c_status_t status;
/* Wait for bus free */
status = wait_bus_free(hi2c, timeout_ms);
if (status != I2C_OK) return status;
/* Generate START */
hi2c->periph->CR1 |= I2C_CR1_START;
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_SB, I2C_SR1_SB, timeout_ms);
if (status != I2C_OK) return status;
/* Send address with write bit */
hi2c->periph->DR = (addr <periph->SR1, I2C_SR1_ADDR, I2C_SR1_ADDR, timeout_ms);
if (status != I2C_OK) {
status = check_errors(hi2c);
return (status != I2C_OK) ? status : I2C_ERROR_NACK;
}
/* Clear ADDR flag by reading SR1 then SR2 */
(void)hi2c->periph->SR1;
(void)hi2c->periph->SR2;
/* Send data bytes */
for (uint16_t i = 0; i periph->SR1, I2C_SR1_TXE,
I2C_SR1_TXE, timeout_ms);
if (status != I2C_OK) return check_errors(hi2c);
hi2c->periph->DR = data[i];
}
/* Wait for last byte to finish */
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_BTF,
I2C_SR1_BTF, timeout_ms);
if (status != I2C_OK) return check_errors(hi2c);
/* Generate STOP */
hi2c->periph->CR1 |= I2C_CR1_STOP;
return I2C_OK;
}
/* Polling-mode master receive */
i2c_status_t i2c_master_receive(i2c_handle_t *hi2c, uint8_t addr,
uint8_t *data, uint16_t len,
uint32_t timeout_ms) {
i2c_status_t status;
status = wait_bus_free(hi2c, timeout_ms);
if (status != I2C_OK) return status;
/* Enable ACK for multi-byte reads */
if (len > 1) {
hi2c->periph->CR1 |= I2C_CR1_ACK;
}
/* Generate START */
hi2c->periph->CR1 |= I2C_CR1_START;
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_SB, I2C_SR1_SB, timeout_ms);
if (status != I2C_OK) return status;
/* Send address with read bit */
hi2c->periph->DR = (addr <periph->SR1, I2C_SR1_ADDR, I2C_SR1_ADDR, timeout_ms);
if (status != I2C_OK) {
status = check_errors(hi2c);
return (status != I2C_OK) ? status : I2C_ERROR_NACK;
}
if (len == 1) {
/* Single byte: disable ACK before clearing ADDR */
hi2c->periph->CR1 &= ~I2C_CR1_ACK;
(void)hi2c->periph->SR1;
(void)hi2c->periph->SR2; /* Clear ADDR */
hi2c->periph->CR1 |= I2C_CR1_STOP;
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_RXNE,
I2C_SR1_RXNE, timeout_ms);
if (status != I2C_OK) return status;
data[0] = (uint8_t)hi2c->periph->DR;
} else {
(void)hi2c->periph->SR1;
(void)hi2c->periph->SR2; /* Clear ADDR */
for (uint16_t i = 0; i periph->CR1 &= ~I2C_CR1_ACK;
hi2c->periph->CR1 |= I2C_CR1_STOP;
}
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_RXNE,
I2C_SR1_RXNE, timeout_ms);
if (status != I2C_OK) return status;
data[i] = (uint8_t)hi2c->periph->DR;
}
}
return I2C_OK;
}Memory Read/Write (Register Access)
Most I2C devices (sensors, EEPROMs) use a register-based access model: write a register address, then read or write data. This combines a write phase (register address) with a read phase (data) using a repeated START.
/* Write to a device register (or EEPROM address) */
i2c_status_t i2c_mem_write(i2c_handle_t *hi2c, uint8_t addr,
uint8_t mem_addr, const uint8_t *data,
uint16_t len, uint32_t timeout_ms) {
i2c_status_t status;
status = wait_bus_free(hi2c, timeout_ms);
if (status != I2C_OK) return status;
/* START + address (write) */
hi2c->periph->CR1 |= I2C_CR1_START;
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_SB, I2C_SR1_SB, timeout_ms);
if (status != I2C_OK) return status;
hi2c->periph->DR = (addr <periph->SR1, I2C_SR1_ADDR, I2C_SR1_ADDR, timeout_ms);
if (status != I2C_OK) return check_errors(hi2c);
(void)hi2c->periph->SR1;
(void)hi2c->periph->SR2;
/* Send register/memory address */
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_TXE, I2C_SR1_TXE, timeout_ms);
if (status != I2C_OK) return check_errors(hi2c);
hi2c->periph->DR = mem_addr;
/* Send data */
for (uint16_t i = 0; i periph->SR1, I2C_SR1_TXE,
I2C_SR1_TXE, timeout_ms);
if (status != I2C_OK) return check_errors(hi2c);
hi2c->periph->DR = data[i];
}
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_BTF,
I2C_SR1_BTF, timeout_ms);
if (status != I2C_OK) return check_errors(hi2c);
hi2c->periph->CR1 |= I2C_CR1_STOP;
return I2C_OK;
}
/* Read from a device register using Repeated START */
i2c_status_t i2c_mem_read(i2c_handle_t *hi2c, uint8_t addr,
uint8_t mem_addr, uint8_t *data,
uint16_t len, uint32_t timeout_ms) {
i2c_status_t status;
status = wait_bus_free(hi2c, timeout_ms);
if (status != I2C_OK) return status;
/* Phase 1: Write the register address */
hi2c->periph->CR1 |= I2C_CR1_START;
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_SB, I2C_SR1_SB, timeout_ms);
if (status != I2C_OK) return status;
hi2c->periph->DR = (addr <periph->SR1, I2C_SR1_ADDR, I2C_SR1_ADDR, timeout_ms);
if (status != I2C_OK) return check_errors(hi2c);
(void)hi2c->periph->SR1;
(void)hi2c->periph->SR2;
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_TXE, I2C_SR1_TXE, timeout_ms);
if (status != I2C_OK) return check_errors(hi2c);
hi2c->periph->DR = mem_addr;
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_BTF, I2C_SR1_BTF, timeout_ms);
if (status != I2C_OK) return check_errors(hi2c);
/* Phase 2: Repeated START, then read */
hi2c->periph->CR1 |= I2C_CR1_START;
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_SB, I2C_SR1_SB, timeout_ms);
if (status != I2C_OK) return status;
hi2c->periph->DR = (addr <periph->SR1, I2C_SR1_ADDR, I2C_SR1_ADDR, timeout_ms);
if (status != I2C_OK) return check_errors(hi2c);
if (len == 1) {
hi2c->periph->CR1 &= ~I2C_CR1_ACK;
(void)hi2c->periph->SR1;
(void)hi2c->periph->SR2;
hi2c->periph->CR1 |= I2C_CR1_STOP;
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_RXNE,
I2C_SR1_RXNE, timeout_ms);
if (status != I2C_OK) return status;
data[0] = (uint8_t)hi2c->periph->DR;
} else {
hi2c->periph->CR1 |= I2C_CR1_ACK;
(void)hi2c->periph->SR1;
(void)hi2c->periph->SR2;
for (uint16_t i = 0; i periph->CR1 &= ~I2C_CR1_ACK;
hi2c->periph->CR1 |= I2C_CR1_STOP;
}
status = wait_flag(&hi2c->periph->SR1, I2C_SR1_RXNE,
I2C_SR1_RXNE, timeout_ms);
if (status != I2C_OK) return status;
data[i] = (uint8_t)hi2c->periph->DR;
}
}
return I2C_OK;
}Interrupt-Driven I2C with State Machine
For real-time systems and RTOS-based applications, polling wastes valuable CPU time. An interrupt-driven approach lets the CPU do other work while the I2C peripheral handles each step autonomously, only interrupting when it needs the next byte or has completed.
/* Interrupt-driven I2C transmit */
typedef enum {
I2C_STATE_IDLE,
I2C_STATE_START_SENT,
I2C_STATE_ADDR_SENT,
I2C_STATE_TRANSMITTING,
I2C_STATE_RECEIVING,
I2C_STATE_COMPLETE,
I2C_STATE_ERROR
} i2c_state_t;
/* Extended handle for interrupt mode */
typedef struct {
i2c_handle_t base;
volatile i2c_state_t state;
uint8_t dev_addr;
uint8_t direction; /* 0 = write, 1 = read */
void (*callback)(i2c_status_t status); /* Completion callback */
} i2c_async_handle_t;
static i2c_async_handle_t i2c1_async;
/* Start an async transmit */
i2c_status_t i2c_master_transmit_it(i2c_async_handle_t *hi2c, uint8_t addr,
uint8_t *data, uint16_t len,
void (*cb)(i2c_status_t)) {
if (hi2c->state != I2C_STATE_IDLE) {
return I2C_ERROR_BUS; /* Already busy */
}
hi2c->base.buffer = data;
hi2c->base.buf_len = len;
hi2c->base.buf_idx = 0;
hi2c->dev_addr = addr;
hi2c->direction = 0;
hi2c->callback = cb;
hi2c->state = I2C_STATE_START_SENT;
/* Enable interrupts */
hi2c->base.periph->CR2 |= I2C_CR2_ITEVTEN | I2C_CR2_ITERREN | I2C_CR2_ITBUFEN;
/* Generate START */
hi2c->base.periph->CR1 |= I2C_CR1_START;
return I2C_OK;
}
/* I2C1 Event IRQ Handler */
void I2C1_EV_IRQHandler(void) {
i2c_async_handle_t *hi2c = &i2c1_async;
uint32_t sr1 = hi2c->base.periph->SR1;
/* START condition generated */
if (sr1 & I2C_SR1_SB) {
hi2c->base.periph->DR = (hi2c->dev_addr <direction;
hi2c->state = I2C_STATE_ADDR_SENT;
return;
}
/* Address sent and ACKed */
if (sr1 & I2C_SR1_ADDR) {
(void)hi2c->base.periph->SR1;
(void)hi2c->base.periph->SR2; /* Clear ADDR */
if (hi2c->direction == 0) {
/* Transmit: load first byte */
hi2c->state = I2C_STATE_TRANSMITTING;
if (hi2c->base.buf_len > 0) {
hi2c->base.periph->DR = hi2c->base.buffer[hi2c->base.buf_idx++];
}
} else {
/* Receive: prepare ACK/NACK */
hi2c->state = I2C_STATE_RECEIVING;
if (hi2c->base.buf_len == 1) {
hi2c->base.periph->CR1 &= ~I2C_CR1_ACK;
hi2c->base.periph->CR1 |= I2C_CR1_STOP;
} else {
hi2c->base.periph->CR1 |= I2C_CR1_ACK;
}
}
return;
}
/* Transmit buffer empty */
if ((sr1 & I2C_SR1_TXE) && hi2c->state == I2C_STATE_TRANSMITTING) {
if (hi2c->base.buf_idx base.buf_len) {
hi2c->base.periph->DR = hi2c->base.buffer[hi2c->base.buf_idx++];
} else if (sr1 & I2C_SR1_BTF) {
/* All bytes sent, generate STOP */
hi2c->base.periph->CR1 |= I2C_CR1_STOP;
hi2c->state = I2C_STATE_IDLE;
/* Disable interrupts */
hi2c->base.periph->CR2 &= ~(I2C_CR2_ITEVTEN | I2C_CR2_ITERREN
| I2C_CR2_ITBUFEN);
if (hi2c->callback) hi2c->callback(I2C_OK);
}
return;
}
/* Receive buffer not empty */
if ((sr1 & I2C_SR1_RXNE) && hi2c->state == I2C_STATE_RECEIVING) {
hi2c->base.buffer[hi2c->base.buf_idx++] = (uint8_t)hi2c->base.periph->DR;
if (hi2c->base.buf_idx == hi2c->base.buf_len - 1) {
/* Next byte is last: disable ACK, prepare STOP */
hi2c->base.periph->CR1 &= ~I2C_CR1_ACK;
hi2c->base.periph->CR1 |= I2C_CR1_STOP;
}
if (hi2c->base.buf_idx >= hi2c->base.buf_len) {
hi2c->state = I2C_STATE_IDLE;
hi2c->base.periph->CR2 &= ~(I2C_CR2_ITEVTEN | I2C_CR2_ITERREN
| I2C_CR2_ITBUFEN);
if (hi2c->callback) hi2c->callback(I2C_OK);
}
return;
}
}
/* I2C1 Error IRQ Handler */
void I2C1_ER_IRQHandler(void) {
i2c_async_handle_t *hi2c = &i2c1_async;
uint32_t sr1 = hi2c->base.periph->SR1;
i2c_status_t err = I2C_ERROR_BUS;
if (sr1 & I2C_SR1_AF) {
hi2c->base.periph->SR1 &= ~I2C_SR1_AF;
err = I2C_ERROR_NACK;
}
if (sr1 & I2C_SR1_ARLO) {
hi2c->base.periph->SR1 &= ~I2C_SR1_ARLO;
err = I2C_ERROR_ARB;
}
if (sr1 & I2C_SR1_BERR) {
hi2c->base.periph->SR1 &= ~I2C_SR1_BERR;
err = I2C_ERROR_BUS;
}
hi2c->base.periph->CR1 |= I2C_CR1_STOP;
hi2c->state = I2C_STATE_IDLE;
hi2c->base.periph->CR2 &= ~(I2C_CR2_ITEVTEN | I2C_CR2_ITERREN
| I2C_CR2_ITBUFEN);
if (hi2c->callback) hi2c->callback(err);
}Building a Device Driver: BMP280 Sensor
Now let’s build a real device driver on top of our I2C HAL. The BMP280 is a popular temperature and pressure sensor from Bosch — perfect for demonstrating how to layer device-specific logic on a generic I2C driver.
/* bmp280.h — BMP280 Sensor Driver */
#ifndef BMP280_H
#define BMP280_H
#include "i2c_driver.h"
/* BMP280 I2C addresses (depends on SDO pin) */
#define BMP280_ADDR_LOW 0x76 /* SDO = GND */
#define BMP280_ADDR_HIGH 0x77 /* SDO = VCC */
/* Register addresses */
#define BMP280_REG_CHIP_ID 0xD0
#define BMP280_REG_RESET 0xE0
#define BMP280_REG_STATUS 0xF3
#define BMP280_REG_CTRL_MEAS 0xF4
#define BMP280_REG_CONFIG 0xF5
#define BMP280_REG_PRESS_MSB 0xF7
#define BMP280_REG_TEMP_MSB 0xFA
#define BMP280_REG_CALIB_START 0x88
#define BMP280_CHIP_ID 0x58
#define BMP280_RESET_VALUE 0xB6
/* Calibration data from device */
typedef struct {
uint16_t dig_T1;
int16_t dig_T2;
int16_t dig_T3;
uint16_t dig_P1;
int16_t dig_P2;
int16_t dig_P3;
int16_t dig_P4;
int16_t dig_P5;
int16_t dig_P6;
int16_t dig_P7;
int16_t dig_P8;
int16_t dig_P9;
} bmp280_calib_t;
typedef struct {
i2c_handle_t *i2c;
uint8_t addr;
bmp280_calib_t calib;
int32_t t_fine; /* Used internally for compensation */
} bmp280_dev_t;
i2c_status_t bmp280_init_device(bmp280_dev_t *dev, i2c_handle_t *i2c, uint8_t addr);
i2c_status_t bmp280_read_temperature(bmp280_dev_t *dev, int32_t *temp_cdegc);
i2c_status_t bmp280_read_pressure(bmp280_dev_t *dev, uint32_t *press_pa);
#endif/* bmp280.c — BMP280 Driver Implementation */
#include "bmp280.h"
/* Read calibration data from the sensor's NVM */
static i2c_status_t bmp280_read_calibration(bmp280_dev_t *dev) {
uint8_t calib_data[24];
i2c_status_t status;
status = i2c_mem_read(dev->i2c, dev->addr, BMP280_REG_CALIB_START,
calib_data, 24, 100);
if (status != I2C_OK) return status;
/* Parse calibration values (little-endian in the sensor) */
dev->calib.dig_T1 = (uint16_t)(calib_data[1] <calib.dig_T2 = (int16_t)(calib_data[3] <calib.dig_T3 = (int16_t)(calib_data[5] <calib.dig_P1 = (uint16_t)(calib_data[7] <calib.dig_P2 = (int16_t)(calib_data[9] <calib.dig_P3 = (int16_t)(calib_data[11] <calib.dig_P4 = (int16_t)(calib_data[13] <calib.dig_P5 = (int16_t)(calib_data[15] <calib.dig_P6 = (int16_t)(calib_data[17] <calib.dig_P7 = (int16_t)(calib_data[19] <calib.dig_P8 = (int16_t)(calib_data[21] <calib.dig_P9 = (int16_t)(calib_data[23] <i2c = i2c;
dev->addr = addr;
/* Verify chip ID */
status = i2c_mem_read(i2c, addr, BMP280_REG_CHIP_ID, &chip_id, 1, 100);
if (status != I2C_OK) return status;
if (chip_id != BMP280_CHIP_ID) {
return I2C_ERROR_NACK; /* Wrong device */
}
/* Soft reset */
uint8_t reset_cmd = BMP280_RESET_VALUE;
status = i2c_mem_write(i2c, addr, BMP280_REG_RESET, &reset_cmd, 1, 100);
if (status != I2C_OK) return status;
/* Wait for reset to complete (startup time ~2ms) */
for (volatile int i = 0; i < 100000; i++);
/* Read calibration data */
status = bmp280_read_calibration(dev);
if (status != I2C_OK) return status;
/* Configure: normal mode, temperature oversampling x1, pressure x4 */
uint8_t ctrl = (0x01 << 5) | /* osrs_t = x1 */
(0x03 <> 3) - ((int32_t)dev->calib.dig_T1 <calib.dig_T2)) >> 11;
var2 = (((((adc_T >> 4) - ((int32_t)dev->calib.dig_T1))
* ((adc_T >> 4) - ((int32_t)dev->calib.dig_T1))) >> 12)
* ((int32_t)dev->calib.dig_T3)) >> 14;
dev->t_fine = var1 + var2;
T = (dev->t_fine * 5 + 128) >> 8;
return T; /* Temperature in 0.01°C (e.g., 2345 = 23.45°C) */
}
i2c_status_t bmp280_read_temperature(bmp280_dev_t *dev, int32_t *temp_cdegc) {
uint8_t raw[3];
i2c_status_t status;
status = i2c_mem_read(dev->i2c, dev->addr, BMP280_REG_TEMP_MSB, raw, 3, 100);
if (status != I2C_OK) return status;
/* 20-bit raw value, MSB first */
int32_t adc_T = ((int32_t)raw[0] << 12) |
((int32_t)raw[1] <> 4);
*temp_cdegc = bmp280_compensate_temp(dev, adc_T);
return I2C_OK;
}
/* Compensate raw pressure */
static uint32_t bmp280_compensate_press(bmp280_dev_t *dev, int32_t adc_P) {
int64_t var1, var2, p;
var1 = ((int64_t)dev->t_fine) - 128000;
var2 = var1 * var1 * (int64_t)dev->calib.dig_P6;
var2 = var2 + ((var1 * (int64_t)dev->calib.dig_P5) <calib.dig_P4) <calib.dig_P3) >> 8)
+ ((var1 * (int64_t)dev->calib.dig_P2) << 12);
var1 = (((((int64_t)1) <calib.dig_P1) >> 33;
if (var1 == 0) return 0;
p = 1048576 - adc_P;
p = (((p <calib.dig_P9) * (p >> 13) * (p >> 13)) >> 25;
var2 = (((int64_t)dev->calib.dig_P8) * p) >> 19;
p = ((p + var1 + var2) >> 8) + (((int64_t)dev->calib.dig_P7) <> 8); /* Pressure in Pa */
}
i2c_status_t bmp280_read_pressure(bmp280_dev_t *dev, uint32_t *press_pa) {
uint8_t raw[3];
i2c_status_t status;
int32_t temp;
/* Must read temperature first to update t_fine */
status = bmp280_read_temperature(dev, &temp);
if (status != I2C_OK) return status;
status = i2c_mem_read(dev->i2c, dev->addr, BMP280_REG_PRESS_MSB, raw, 3, 100);
if (status != I2C_OK) return status;
int32_t adc_P = ((int32_t)raw[0] << 12) |
((int32_t)raw[1] <> 4);
*press_pa = bmp280_compensate_press(dev, adc_P);
return I2C_OK;
}
/* ─── Usage Example ─── */
/*
int main(void) {
i2c_handle_t i2c1 = {
.periph = I2C1,
.clock_speed = 36000000,
.bus_speed = I2C_SPEED_STANDARD
};
i2c_init(&i2c1);
bmp280_dev_t bmp;
if (bmp280_init_device(&bmp, &i2c1, BMP280_ADDR_LOW) == I2C_OK) {
int32_t temperature;
uint32_t pressure;
while (1) {
if (bmp280_read_temperature(&bmp, &temperature) == I2C_OK) {
// temperature is in 0.01°C
// e.g., 2534 = 25.34°C
}
if (bmp280_read_pressure(&bmp, &pressure) == I2C_OK) {
// pressure is in Pa
// e.g., 101325 Pa = 1013.25 hPa = 1 atm
}
delay_ms(1000);
}
}
return 0;
}
*/EEPROM Driver: 24C256 Example
EEPROMs are another common I2C device. The 24C256 is a 32KB EEPROM with 16-bit internal addressing. Unlike sensors that have fixed register maps, EEPROMs use the address bytes as a memory pointer. This example shows a complete page-write implementation with the critical write-cycle wait.
/* 24C256 I2C EEPROM Driver
*
* Key differences from sensor access:
* - 16-bit internal address (2 address bytes)
* - Page write limited to 64 bytes per write cycle
* - 5ms write cycle time after each write operation
* - Can't cross page boundaries in a single write
*/
#define EEPROM_ADDR 0x50 /* A0=A1=A2=GND */
#define EEPROM_PAGE_SIZE 64
#define EEPROM_WRITE_TIME 5 /* ms */
/* Write up to one page to EEPROM */
i2c_status_t eeprom_write_page(i2c_handle_t *i2c, uint16_t mem_addr,
const uint8_t *data, uint8_t len) {
i2c_status_t status;
uint8_t buf[66]; /* 2 address bytes + up to 64 data bytes */
if (len > EEPROM_PAGE_SIZE) len = EEPROM_PAGE_SIZE;
/* Check page boundary */
uint8_t page_remaining = EEPROM_PAGE_SIZE - (mem_addr % EEPROM_PAGE_SIZE);
if (len > page_remaining) len = page_remaining;
/* Prepare buffer: address (MSB first) + data */
buf[0] = (uint8_t)(mem_addr >> 8);
buf[1] = (uint8_t)(mem_addr & 0xFF);
for (uint8_t i = 0; i < len; i++) {
buf[2 + i] = data[i];
}
status = i2c_master_transmit(i2c, EEPROM_ADDR, buf, len + 2, 100);
if (status != I2C_OK) return status;
/* Wait for write cycle to complete (ACK polling) */
uint32_t start = get_tick();
while ((get_tick() - start) periph->CR1 |= I2C_CR1_START;
if (wait_flag(&i2c->periph->SR1, I2C_SR1_SB, I2C_SR1_SB, 5) != I2C_OK)
continue;
i2c->periph->DR = (EEPROM_ADDR << 1) | 0;
/* Brief wait for ADDR or AF */
for (volatile int d = 0; d periph->SR1 & I2C_SR1_ADDR) {
(void)i2c->periph->SR1;
(void)i2c->periph->SR2;
i2c->periph->CR1 |= I2C_CR1_STOP;
return I2C_OK; /* EEPROM is ready */
}
i2c->periph->SR1 &= ~I2C_SR1_AF;
i2c->periph->CR1 |= I2C_CR1_STOP;
}
return I2C_ERROR_TIMEOUT;
}
/* Write arbitrary length data across page boundaries */
i2c_status_t eeprom_write(i2c_handle_t *i2c, uint16_t addr,
const uint8_t *data, uint16_t len) {
while (len > 0) {
uint8_t page_remaining = EEPROM_PAGE_SIZE - (addr % EEPROM_PAGE_SIZE);
uint8_t chunk = (len > 8),
(uint8_t)(addr & 0xFF)
};
/* Write the 16-bit address */
i2c_status_t status = i2c_master_transmit(i2c, EEPROM_ADDR, addr_buf, 2, 100);
if (status != I2C_OK) return status;
/* Read data (sequential read wraps around at 32K boundary) */
return i2c_master_receive(i2c, EEPROM_ADDR, data, len, 100);
}
/* Example: Store and retrieve configuration */
/*
typedef struct {
uint32_t magic;
uint16_t sensor_interval_ms;
uint8_t uart_baud_index;
uint8_t checksum;
} config_t;
void save_config(i2c_handle_t *i2c, config_t *cfg) {
cfg->magic = 0xDEADBEEF;
uint8_t sum = 0;
uint8_t *p = (uint8_t *)cfg;
for (size_t i = 0; i checksum = ~sum + 1;
eeprom_write(i2c, 0x0000, (uint8_t *)cfg, sizeof(*cfg));
}
int load_config(i2c_handle_t *i2c, config_t *cfg) {
eeprom_read(i2c, 0x0000, (uint8_t *)cfg, sizeof(*cfg));
if (cfg->magic != 0xDEADBEEF) return -1;
uint8_t sum = 0;
uint8_t *p = (uint8_t *)cfg;
for (size_t i = 0; i < sizeof(*cfg); i++) sum += p[i];
return (sum == 0) ? 0 : -1;
}
*/I2C Debugging Tips
When your I2C driver isn’t working, follow this systematic debugging approach:
- Check the hardware first: Verify pull-up resistors are present and correctly valued. Use a multimeter to check that SDA and SCL are at VCC when idle.
- Run a bus scan: Implement the bus scanner from the I2C protocol article to verify your device is responding at the expected address.
- Use a logic analyzer: Capture the actual SDA/SCL signals. Look for proper START/STOP conditions, correct address bytes, and ACK/NACK responses. See our debugging communication protocols guide for detailed procedures.
- Add error counters: Track NACK, bus error, arbitration loss, and timeout counts to identify intermittent issues.
- Check clock speed: Verify the actual SCL frequency with an oscilloscope. Misconfigured clock dividers are a common source of failures.
/* I2C Debug Statistics */
typedef struct {
uint32_t tx_bytes;
uint32_t rx_bytes;
uint32_t nack_count;
uint32_t bus_errors;
uint32_t arb_lost;
uint32_t timeouts;
uint32_t successful_transfers;
uint32_t failed_transfers;
} i2c_stats_t;
static i2c_stats_t i2c1_stats = {0};
void i2c_print_stats(i2c_stats_t *stats) {
printf("I2C Statistics:\n");
printf(" TX bytes: %lu\n", stats->tx_bytes);
printf(" RX bytes: %lu\n", stats->rx_bytes);
printf(" Success: %lu\n", stats->successful_transfers);
printf(" Failed: %lu\n", stats->failed_transfers);
printf(" NACKs: %lu\n", stats->nack_count);
printf(" Bus Err: %lu\n", stats->bus_errors);
printf(" Arb Lost: %lu\n", stats->arb_lost);
printf(" Timeouts: %lu\n", stats->timeouts);
}
/* Wrap transmit with statistics tracking */
i2c_status_t i2c_transmit_tracked(i2c_handle_t *hi2c, uint8_t addr,
const uint8_t *data, uint16_t len,
uint32_t timeout_ms) {
i2c_status_t status = i2c_master_transmit(hi2c, addr, data, len, timeout_ms);
if (status == I2C_OK) {
i2c1_stats.tx_bytes += len;
i2c1_stats.successful_transfers++;
} else {
i2c1_stats.failed_transfers++;
switch (status) {
case I2C_ERROR_NACK: i2c1_stats.nack_count++; break;
case I2C_ERROR_BUS: i2c1_stats.bus_errors++; break;
case I2C_ERROR_ARB: i2c1_stats.arb_lost++; break;
case I2C_ERROR_TIMEOUT: i2c1_stats.timeouts++; break;
default: break;
}
}
return status;
}Related Articles
- I2C Protocol Deep Dive for Embedded Engineers
- SPI Protocol Deep Dive for Embedded Engineers
- Writing an SPI Driver in Embedded C
- UART Protocol Deep Dive for Embedded Engineers
- Writing a UART Driver in Embedded C
- Debugging Communication Protocols with Logic Analyzers
- How to Read a Microcontroller Datasheet
- Communication Interfaces: UART, SPI, and I2C
- Hardware Abstraction Layer (HAL) Design in C
- Building a Driver Interface in C
- Polling vs Interrupts in Embedded Systems
📖 Related: State Machine Pattern in C — With Practical Examples • Error Handling Patterns in C — With Practical Examples
Related on this site
- For the wire-level protocol behind this driver — start condition, address frame, ACK/NACK, arbitration — see I²C protocol deep dive.
- When the driver doesn’t talk to a slave, debugging communication protocols walks through systematic UART/SPI/I²C troubleshooting with a logic analyzer.
- For the broader pattern of how an embedded driver layer is structured — registers up to API — see device drivers development introduction.

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.




