I2C is a two-wire, multi-master, multi-slave serial protocol invented by Philips in 1982. “Inter-Integrated Circuit” — designed for chips communicating on the same PCB.
Used for: IMUs, temperature sensors, magnetometers, barometers, OLEDs, EEPROMs, real-time clocks — anything that doesn’t need high speed but benefits from simplicity (only 2 wires).
Master (STM32) Slaves
──────────────────────────────────────────────────────────
SDA ─────┬───────────────────────── SDA SDA SDA (data)
│ pull-up to VCC (4.7kΩ) [A] [B] [C]
SCL ─────┴───────────────────────── SCL SCL SCL (clock)
│ pull-up to VCC (4.7kΩ)
SDA: bidirectional data line — both master and slaves share this wire SCL: clock, driven by master
The pull-up resistors are essential. Both lines are open-drain — devices can only pull line LOW. The resistors pull the line HIGH when nobody is pulling it low. Without pull-ups, nothing works.
Typical pull-up: 4.7 kΩ at 100kHz/400kHz, 2.2 kΩ at 1MHz (Fast-mode+).
Unlike SPI (which uses a dedicated CS wire per slave), I2C identifies slaves by address.
Each slave has a 7-bit address (0x00-0x7F) — up to 127 slaves on one bus. Some devices use 10-bit addresses for up to 1023 slaves.
Common I2C addresses:
ICM-42688 IMU: 0x68 (AD0 low) or 0x69 (AD0 high)
MPU-6050 IMU: 0x68 or 0x69
BMP280 pressure: 0x76 or 0x77
ADS1115 ADC: 0x48, 0x49, 0x4A, or 0x4B
OLED SSD1306: 0x3C or 0x3D
AT24C EEPROM: 0x50-0x57
Many devices have 1-2 address pins (ADDRx) so you can configure the address and put 2-4 of the same chip on one bus.
Master pulls SDA LOW while SCL is HIGH (special "start" signal)
SCL: ─────┐ ...
SDA: ──┐ └─ ...
└── transition while SCL high = START
Master sends 7-bit address + R/W bit:
SCL: ─┐ ┌┐ ┌┐ ┌┐ ┌┐ ┌┐ ┌┐ ┌┐ ┌────
└─┘└─┘└─┘└─┘└─┘└─┘└─┘└─┘└
SDA: ─A6─A5─A4─A3─A2─A1─A0─R/W─ACK
↑ ↑
0=Write slave pulls SDA LOW
1=Read to acknowledge
If slave present and ready: pulls SDA LOW (ACK = 0)
If slave absent or busy: SDA stays HIGH (NACK = 1 → master gets error)
[START] [ADDR+W] [ACK] [REG] [ACK] [DATA0] [ACK] [DATA1] [ACK] [STOP]
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
master master slave master slave master slave ... slave master
[START] [ADDR+W] [ACK] [REG] [ACK] [RESTART] [ADDR+R] [ACK] [DATA0] [NACK] [STOP]
↑ ↑ ↑ ↑ ↑
master sets master slave slave master NACK
read direction drives SDA = "I'm done reading"
The repeated start (restart) is key — master switches from write (sending register address) to read (receiving data) without releasing the bus.
| Mode | Speed | Use |
|---|---|---|
| Standard | 100 kHz | Old sensors, EEPROMs |
| Fast | 400 kHz | Most modern sensors ← default for IMUs |
| Fast-mode+ | 1 MHz | High-speed sensors, displays |
| High-speed | 3.4 MHz | Rare, needs special hardware |
At 400kHz: 1 byte ≈ 25µs. Reading a 12-byte IMU frame = ~300µs. That’s 3% of our 10ms 100Hz budget — acceptable.
/* app.overlay */
&i2c1 {
status = "okay";
pinctrl-0 = <&i2c1_default>;
pinctrl-names = "default";
clock-frequency = <I2C_BITRATE_FAST>; /* 400kHz */
/* IMU at address 0x68 */
imu: icm42688@68 {
compatible = "invensense,icm42688p";
reg = <0x68>;
int-gpios = <&gpiob 3 GPIO_ACTIVE_HIGH>; /* data-ready interrupt */
};
};
#include <zephyr/drivers/i2c.h>
static const struct device *i2c = DEVICE_DT_GET(DT_NODELABEL(i2c1));
#define IMU_ADDR 0x68
/* Write a single register */
int imu_write_reg(uint8_t reg, uint8_t val)
{
uint8_t buf[2] = { reg, val };
return i2c_write(i2c, buf, 2, IMU_ADDR);
}
/* Read a single register */
int imu_read_reg(uint8_t reg, uint8_t *out)
{
return i2c_write_read(i2c, IMU_ADDR, ®, 1, out, 1);
/* i2c_write_read: writes reg addr then issues RESTART + read */
}
/* Read multiple consecutive registers (burst read — most efficient) */
int imu_burst_read(uint8_t start_reg, uint8_t *out, size_t len)
{
return i2c_write_read(i2c, IMU_ADDR, &start_reg, 1, out, len);
}
#define ACCEL_XOUT_H 0x1F /* ICM-42688 register map */
#define GYRO_XOUT_H 0x25
void imu_thread_fn(void *a, void *b, void *c)
{
const struct device *i2c = DEVICE_DT_GET(DT_NODELABEL(i2c1));
uint8_t raw[12];
struct imu_data msg;
/* One-time: wake IMU from sleep */
imu_write_reg(0x4E /* PWR_MGMT0 */, 0x0F); /* accel+gyro = low noise mode */
k_msleep(10); /* wait for startup */
while (1) {
/* Burst read: 6 bytes accel + 6 bytes gyro in one I2C transaction */
int rc = imu_burst_read(ACCEL_XOUT_H, raw, 12);
if (rc != 0) {
LOG_ERR("I2C read failed: %d", rc);
k_msleep(10);
continue;
}
/* Parse big-endian int16 → float */
msg.accel_x = (int16_t)((raw[0] << 8) | raw[1]) / 2048.0f;
msg.accel_y = (int16_t)((raw[2] << 8) | raw[3]) / 2048.0f;
msg.accel_z = (int16_t)((raw[4] << 8) | raw[5]) / 2048.0f;
msg.gyro_x = (int16_t)((raw[6] << 8) | raw[7]) / 16.4f;
msg.gyro_y = (int16_t)((raw[8] << 8) | raw[9]) / 16.4f;
msg.gyro_z = (int16_t)((raw[10]<< 8) | raw[11]) / 16.4f;
msg.timestamp_us = k_ticks_to_us_near64(k_uptime_ticks());
zbus_chan_pub(&imu_chan, &msg, K_NO_WAIT);
k_msleep(10); /* 100Hz */
}
}
Many sensors have a data-ready (DRDY) interrupt pin. Instead of polling every 10ms, the IMU tells you “new data ready”:
#include <zephyr/drivers/gpio.h>
static const struct gpio_dt_spec drdy_gpio =
GPIO_DT_SPEC_GET(DT_NODELABEL(imu), int_gpios);
static struct gpio_callback drdy_cb_data;
static K_SEM_DEFINE(imu_drdy_sem, 0, 1);
static void drdy_isr(const struct device *dev,
struct gpio_callback *cb, uint32_t pins)
{
k_sem_give(&imu_drdy_sem); /* wake up reader thread */
}
void imu_thread_fn(void *a, void *b, void *c)
{
/* Setup DRDY interrupt */
gpio_pin_configure_dt(&drdy_gpio, GPIO_INPUT);
gpio_init_callback(&drdy_cb_data, drdy_isr, BIT(drdy_gpio.pin));
gpio_add_callback(drdy_gpio.port, &drdy_cb_data);
gpio_pin_interrupt_configure_dt(&drdy_gpio, GPIO_INT_EDGE_RISING);
/* Configure IMU to assert INT1 when data ready */
imu_write_reg(0x14 /* INT_CONFIG */, 0x02); /* INT1 = data-ready */
while (1) {
/* Block until IMU says "new data" */
k_sem_take(&imu_drdy_sem, K_FOREVER);
/* Read immediately — data is guaranteed fresh */
imu_burst_read(ACCEL_XOUT_H, raw, 12);
/* ... parse and publish ... */
}
}
Why interrupt-driven is better: sleep exactly until data is ready → lower CPU load, more precise timing, no polling jitter.
I2C can get stuck if a transfer is interrupted (power glitch, software reset mid-transfer):
Symptom: slave is holding SDA LOW (it thinks a transfer is in progress)
master cannot send START condition
all subsequent reads return -EIO
Fix: send 9 clock pulses on SCL (enough to complete any stuck byte + ACK)
then send STOP condition
Zephyr handles this automatically with i2c_recover_bus():
int rc = i2c_write_read(i2c, IMU_ADDR, ®, 1, out, 1);
if (rc == -EIO) {
LOG_WRN("I2C stuck, recovering...");
i2c_recover_bus(i2c);
/* retry once */
rc = i2c_write_read(i2c, IMU_ADDR, ®, 1, out, 1);
}
| I2C | SPI | |
|---|---|---|
| Wires | 2 (shared, pulled high) | 4+ (one CS per slave) |
| Speed | Up to 3.4 MHz | Up to 50+ MHz |
| Slaves | Up to 127 (by address) | Unlimited (by CS lines) |
| Full duplex | No | Yes |
| Error detection | ACK/NACK | None (need CRC in application) |
| Cable length | <30cm | <100cm |
| Use for IMU | Yes (convenient) | Yes (faster, better for high-rate) |
For our 100Hz IMU: I2C works fine. If you need 1000Hz+, switch to SPI.
| Mistake | Symptom | Fix |
|---|---|---|
| Missing pull-up resistors | Nothing works, all reads fail | Add 4.7kΩ to VCC on SDA and SCL |
| Wrong I2C address | -ENXIO error |
Scan with i2c_scan or check AD0/AD1 pin |
| Too many slaves → pull-up too weak | Works at low speed, fails at fast | Use lower pull-up value (1kΩ), use buffer/mux |
| Polling too slowly | Miss first data, stale reads | Use DRDY interrupt |
| Not waiting for sensor startup | First read returns zeros | Add k_msleep(20) after power-on |