Back to Technology

STM32 Part 7: I2C Protocol

March 31, 2026 Wasil Zafar 26 min read

From 7-bit addressing and clock stretching theory through DMA I2C sensor drivers to multi-master bus arbitration and hardware bus hang recovery — the complete STM32 I2C guide.

Table of Contents

  1. I2C Protocol Fundamentals
  2. HAL I2C Master API
  3. I2C Bus Scanner
  4. Interrupt & DMA Modes
  5. Multi-Device I2C Bus
  6. Error Handling & Recovery
  7. Sensor Driver: BMP280
  8. Exercises
  9. I2C Design Tool
  10. Conclusion & Next Steps
Series Overview: This is Part 7 of our 18-part STM32 Unleashed series. In this part we master STM32 I2C from address scanning and register reads through DMA sensor fusion to multi-device multiplexing and hard bus recovery.

STM32 Unleashed: HAL Driver Development

Your 18-step learning path • Currently on Step 7
1
Architecture & CubeMX Setup
STM32 family, clock tree, HAL vs LL, CubeMX workflow, first project
Completed
2
GPIO & Button Debounce
GPIO modes, pull-up/down, EXTI, software debounce, HAL_GPIO_ReadPin
Completed
3
UART Communication
Polling, interrupt, DMA modes, printf retargeting, ring buffers
Completed
4
Timers, PWM & Input Capture
TIM basics, PWM generation, input capture, encoder mode
Completed
5
ADC & DAC
Single/continuous conversion, DMA, injected channels, DAC waveforms
Completed
6
SPI Protocol
SPI master/slave, full-duplex, DMA transfers, sensor drivers
Completed
7
I2C Protocol
I2C master, 7/10-bit addressing, DMA, multi-master, error handling
You Are Here
8
DMA & Memory Efficiency
DMA streams, circular mode, memory-to-memory, zero-copy patterns
9
Interrupt Management & NVIC
Priority grouping, preemption, ISR design, HAL callbacks, latency
10
Low-Power Modes
Sleep, Stop, Standby modes, RTC wakeup, LP UART, power profiling
11
RTC & Calendar
RTC configuration, alarms, backup registers, calendar subseconds
12
CAN Bus
FDCAN/bxCAN, filters, message frames, error handling, automotive use
13
USB CDC Virtual COM Port
USB FS/HS, CDC class, virtual serial, control transfers, descriptors
14
FreeRTOS Integration
Tasks, queues, semaphores, mutexes, CMSIS-RTOS2 wrapper, stack sizing
15
Bootloader Development
Custom IAP bootloader, UART/USB DFU, flash programming, jump-to-app
16
External Storage: SD & QSPI Flash
FATFS on SD card, QSPI NOR flash, memory-mapped execution, wear levelling
17
Ethernet & TCP/IP Stack
LwIP integration, DHCP, TCP server, HTTP, MQTT, Ethernet DMA descriptors
18
Production Readiness
Watchdog, HardFault handler, flash option bytes, code signing, CI/CD

I2C Protocol Fundamentals

The Inter-Integrated Circuit (I2C) protocol, originally developed by Philips Semiconductors in 1982, is the dominant choice for connecting low-to-medium speed peripherals over short distances with minimal wiring. Unlike SPI's 4-wire (plus CS) topology, I2C uses only two bidirectional lines shared by every device on the bus. This makes I2C the natural choice for PCBs densely populated with sensors, EEPROMs, PMICs, real-time clocks, and display controllers.

The protocol is half-duplex — data travels in one direction at a time — but the overhead of the address/ACK mechanism is small compared to SPI for the sensor read rates typical in embedded systems (10 Hz to 1 kHz). At 400 kHz I2C, reading 6 bytes from an accelerometer takes approximately 175 µs. At 1 MHz, it drops to under 50 µs. These figures make I2C perfectly adequate for the vast majority of sensor fusion applications.

SDA, SCL and Pull-up Resistors

I2C uses two lines, both open-drain with pull-up resistors to the supply rail:

  • SDA (Serial Data) — carries address and data bytes. Both master and slave pull it low to transmit a '0'; the pull-up resistor returns it to '1' when no device is driving it low.
  • SCL (Serial Clock) — driven by the master. Slaves may hold SCL low to clock-stretch, pausing the master until they are ready to accept or supply data.

The pull-up resistor value is critical. Too high and the RC rise time violates the I2C specification (SDA/SCL must rise within t_r, typically 300 ns at 400 kHz). Too low and the bus current draw is excessive and devices cannot pull the line low. The commonly cited formula is:

R_pull-up = (V_CC - V_OL_max) / I_max ≈ (3.3 - 0.4) / 3 mA ≈ 970 Ω minimum

For the maximum value, the constraint is the rise time: R_max = t_r / (0.8473 × C_bus) where C_bus is the total capacitance on the line (PCB trace + device input pins). For a typical 4-device board at 400 kHz on a 100 pF bus, R_max ≈ 3.5 kΩ. A 2.2 kΩ pull-up is a safe default for most 3.3V, Fast-mode designs.

I2C Speed Mode Max Bit Rate Max Rise Time (t_r) Typical Pull-up (3.3V, 1 device) Typical Pull-up (3.3V, 4 devices)
Standard Mode (Sm) 100 kbit/s 1000 ns 4.7 kΩ 4.7 kΩ
Fast Mode (Fm) 400 kbit/s 300 ns 2.2 kΩ 1.5 kΩ
Fast-Mode Plus (Fm+) 1 Mbit/s 120 ns 1.0 kΩ 560 Ω
High-Speed Mode (Hs) 3.4 Mbit/s 40 ns Active pull-up required Active pull-up required

7-bit vs 10-bit Addressing

The original I2C specification defined 7-bit addressing, giving 128 addresses (0x00–0x7F). Addresses 0x00–0x07 and 0x78–0x7F are reserved, leaving 112 usable addresses. This is sufficient for most single-board designs. The 10-bit addressing mode (introduced in Fast-Mode) extends the address space to 1024 devices using a two-byte header sequence, but is rarely needed and has limited HAL support — most STM32 HAL functions accept the address as a uint16_t, so you simply provide the full 10-bit address and set the address mode parameter accordingly.

STM32 I2C Peripheral and Timing Register

On STM32F1/F2/F3 the I2C peripheral uses a CR2/CCR/TRISE register set where you specify a divider ratio. On STM32F4, F7, H7, G4, L4 and later families, the I2C peripheral was redesigned and uses a single TIMINGR register encoding pre-scaler, setup/hold time, and high/low period directly. CubeMX calculates the TIMINGR value from the source clock and target speed automatically — however, understanding which input clock (PCLK1 or an independent I2C clock mux) feeds the peripheral is important to avoid misconfiguration after clock changes.

/* ---------------------------------------------------------------
 * I2C1 Init — 400 kHz Fast Mode, STM32F4 (PCLK1 = 42 MHz)
 * TIMINGR computed by CubeMX for 400 kHz from 42 MHz PCLK1
 * --------------------------------------------------------------- */
I2C_HandleTypeDef hi2c1;

static void MX_I2C1_Init(void)
{
    hi2c1.Instance              = I2C1;
    hi2c1.Init.ClockSpeed       = 400000;             /* 400 kHz          */
    hi2c1.Init.DutyCycle        = I2C_DUTYCYCLE_2;   /* Fm: t_low/t_high = 2 */
    hi2c1.Init.OwnAddress1      = 0;
    hi2c1.Init.AddressingMode   = I2C_ADDRESSINGMODE_7BIT;
    hi2c1.Init.DualAddressMode  = I2C_DUALADDRESS_DISABLE;
    hi2c1.Init.OwnAddress2      = 0;
    hi2c1.Init.GeneralCallMode  = I2C_GENERALCALL_DISABLE;
    hi2c1.Init.NoStretchMode    = I2C_NOSTRETCH_DISABLE; /* allow clock stretch */

    if (HAL_I2C_Init(&hi2c1) != HAL_OK) {
        Error_Handler();
    }
    /* Enable Fast-Mode Plus drive capability on I2C1 SCL/SDA pins */
    HAL_I2CEx_ConfigAnalogFilter(&hi2c1, I2C_ANALOGFILTER_ENABLE);
    HAL_I2CEx_ConfigDigitalFilter(&hi2c1, 0);
}

HAL I2C Master API

STM32 HAL provides a rich set of I2C master functions. The most important design decision is whether to use the raw transmit/receive API or the memory API. The memory API is almost always the right choice for sensor drivers.

Transmit and Receive

  • HAL_I2C_Master_Transmit(&hi2c1, DevAddr, pData, Size, Timeout) — generates START, sends 8-bit address with R/W=0, sends Size bytes, generates STOP.
  • HAL_I2C_Master_Receive(&hi2c1, DevAddr, pData, Size, Timeout) — generates START, sends 8-bit address with R/W=1, clocks in Size bytes, generates STOP.

HAL_I2C_Mem_Write and HAL_I2C_Mem_Read

The memory functions encapsulate the most common I2C sensor access pattern: write a register address byte, then read back the register value(s) — all in a single call that generates a repeated START between write and read phases. This is cleaner and less error-prone than manually chaining Master_Transmit and Master_Receive.

  • HAL_I2C_Mem_Write(&hi2c1, DevAddr, MemAddr, MemAddrSize, pData, Size, Timeout)
  • HAL_I2C_Mem_Read(&hi2c1, DevAddr, MemAddr, MemAddrSize, pData, Size, Timeout)

7-bit Address Shift — The Common Gotcha

The STM32 HAL I2C functions expect the 8-bit (left-shifted) form of the 7-bit address. The MPU-6050 has a 7-bit I2C address of 0x68 (AD0 pin low). In HAL calls you must pass 0x68 << 1 = 0xD0. Forgetting this shift is the single most frequent I2C bring-up mistake — it results in a NACK on every transaction.

/* ---------------------------------------------------------------
 * MPU-6050 IMU — read WHO_AM_I register and 14-byte accel/gyro burst
 * I2C address 0x68 (AD0=0) → HAL 8-bit address 0xD0
 * Registers:
 *   0x75 WHO_AM_I (should read 0x68)
 *   0x3B ACCEL_XOUT_H through 0x48 GYRO_ZOUT_L (14 bytes)
 * --------------------------------------------------------------- */
#define MPU6050_I2C_ADDR   (0x68 << 1)  /* 0xD0 — 8-bit left-shifted form */
#define MPU6050_REG_WHOAMI  0x75
#define MPU6050_REG_ACCEL   0x3B

typedef struct {
    int16_t ax, ay, az;   /* raw accel (full-scale ±2g @ 16384 LSB/g) */
    int16_t temp;         /* raw temp (°C = raw/340 + 36.53)           */
    int16_t gx, gy, gz;   /* raw gyro  (full-scale ±250°/s @ 131 LSB/°/s) */
} MPU6050_Raw_t;

/* Verify device is present and returns expected WHO_AM_I */
HAL_StatusTypeDef mpu6050_check_id(I2C_HandleTypeDef *hi2c)
{
    uint8_t who_am_i = 0;
    HAL_StatusTypeDef ret =
        HAL_I2C_Mem_Read(hi2c, MPU6050_I2C_ADDR,
                         MPU6050_REG_WHOAMI, I2C_MEMADD_SIZE_8BIT,
                         &who_am_i, 1, HAL_MAX_DELAY);
    if (ret != HAL_OK) return ret;
    return (who_am_i == 0x68) ? HAL_OK : HAL_ERROR;
}

/* Read 14 bytes of accelerometer, temperature, and gyroscope data */
HAL_StatusTypeDef mpu6050_read_all(I2C_HandleTypeDef *hi2c,
                                    MPU6050_Raw_t *out)
{
    uint8_t buf[14];
    HAL_StatusTypeDef ret =
        HAL_I2C_Mem_Read(hi2c, MPU6050_I2C_ADDR,
                         MPU6050_REG_ACCEL, I2C_MEMADD_SIZE_8BIT,
                         buf, 14, HAL_MAX_DELAY);
    if (ret != HAL_OK) return ret;

    /* Combine high/low bytes — data is big-endian */
    out->ax   = (int16_t)((buf[0]  << 8) | buf[1]);
    out->ay   = (int16_t)((buf[2]  << 8) | buf[3]);
    out->az   = (int16_t)((buf[4]  << 8) | buf[5]);
    out->temp = (int16_t)((buf[6]  << 8) | buf[7]);
    out->gx   = (int16_t)((buf[8]  << 8) | buf[9]);
    out->gy   = (int16_t)((buf[10] << 8) | buf[11]);
    out->gz   = (int16_t)((buf[12] << 8) | buf[13]);
    return HAL_OK;
}

I2C Bus Scanner

Before writing a single line of sensor code, always run an I2C bus scan. It confirms that devices are properly powered, pull-up resistors are installed, address pins are configured correctly, and the I2C peripheral is initialised. An I2C scanner probes each of the 128 possible addresses by attempting to generate a START condition followed by the address byte — if a device ACKs, it is present at that address.

The HAL function HAL_I2C_IsDeviceReady() does exactly this: it sends the address and returns HAL_OK if an ACK is received or HAL_ERROR/HAL_TIMEOUT if no device responds. The third parameter is the number of trials (retries) before giving up.

/* ---------------------------------------------------------------
 * I2C Bus Scanner — probes all 128 7-bit addresses
 * Prints found devices over UART and returns count.
 * found_addrs[] will contain the 7-bit addresses (not shifted).
 * --------------------------------------------------------------- */
#include <stdio.h>   /* printf to UART via retargeted _write */

uint8_t i2c_bus_scan(I2C_HandleTypeDef *hi2c,
                     uint8_t *found_addrs,
                     uint8_t  max_found)
{
    uint8_t count = 0;
    printf("I2C Bus Scan (400 kHz):\r\n");
    printf("    0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F\r\n");

    for (uint8_t addr = 0; addr < 128; addr++) {
        if (addr % 16 == 0) {
            printf("%02X: ", addr);
        }

        /* HAL expects 8-bit address: shift left by 1 */
        HAL_StatusTypeDef ret =
            HAL_I2C_IsDeviceReady(hi2c,
                                  (uint16_t)(addr << 1),
                                  2,       /* 2 trials */
                                  10);     /* 10 ms timeout */

        if (ret == HAL_OK) {
            printf("%02X ", addr);
            if (count < max_found) {
                found_addrs[count++] = addr;
            }
        } else {
            printf("-- ");
        }

        if ((addr + 1) % 16 == 0) {
            printf("\r\n");
        }
    }
    printf("Found %u device(s).\r\n", count);
    return count;
}

Run this during system initialisation and log the results. If an expected device is missing, check power, pull-ups, address pin solder joints, and whether the I2C peripheral clock was enabled in RCC. A scan that returns no devices almost always indicates missing pull-up resistors or a floating SDA/SCL.

Interrupt & DMA Modes

Polling I2C is fine for infrequent register reads during initialisation. But reading a 6-axis IMU at 1 kHz is 1000 × ~175 µs = 17.5% of CPU time at 400 kHz I2C — purely wasted in a blocking HAL call. Switching to DMA I2C reduces this to near zero CPU load: the DMA engine orchestrates the entire I2C transaction on behalf of the CPU, firing a callback interrupt only when the final byte lands in your buffer.

The non-blocking variants follow the same naming convention as SPI:

  • HAL_I2C_Mem_Read_IT() — interrupt-driven, uses I2C IRQ, suitable for short bursts.
  • HAL_I2C_Mem_Read_DMA() — DMA-driven, uses DMA channel, best for high-rate or large bursts.
  • HAL_I2C_MemRxCpltCallback() — override this weak function to process data on completion.
DMA I2C and Cache: On Cortex-M7 (STM32H7, F7) with D-cache enabled, DMA destination buffers must be 32-byte aligned and cache-invalidated after DMA completion using SCB_InvalidateDCache_by_Addr(). On Cortex-M4 (F4, G4, L4) D-cache is not present — this restriction does not apply.
/* ---------------------------------------------------------------
 * DMA I2C — MPU-6050 14-byte burst at 1 kHz via TIM6 trigger
 * TIM6 runs at 1 kHz, its update interrupt triggers a new DMA read
 * MemRxCpltCallback processes the raw data
 * --------------------------------------------------------------- */
static MPU6050_Raw_t  imu_raw;
static uint8_t        imu_dma_buf[14];
static volatile uint8_t imu_data_ready = 0;

/* Call from TIM6_IRQHandler or a 1 kHz task to kick off next read */
void mpu6050_start_dma_read(I2C_HandleTypeDef *hi2c)
{
    /* Non-blocking: returns immediately, DMA runs in background */
    HAL_I2C_Mem_Read_DMA(hi2c,
                         MPU6050_I2C_ADDR,
                         MPU6050_REG_ACCEL,
                         I2C_MEMADD_SIZE_8BIT,
                         imu_dma_buf,
                         14);
}

/* I2C DMA completion callback — runs from DMA ISR */
void HAL_I2C_MemRxCpltCallback(I2C_HandleTypeDef *hi2c)
{
    if (hi2c->Instance == I2C1) {
        /* Parse raw big-endian sensor data */
        imu_raw.ax   = (int16_t)((imu_dma_buf[0]  << 8) | imu_dma_buf[1]);
        imu_raw.ay   = (int16_t)((imu_dma_buf[2]  << 8) | imu_dma_buf[3]);
        imu_raw.az   = (int16_t)((imu_dma_buf[4]  << 8) | imu_dma_buf[5]);
        imu_raw.temp = (int16_t)((imu_dma_buf[6]  << 8) | imu_dma_buf[7]);
        imu_raw.gx   = (int16_t)((imu_dma_buf[8]  << 8) | imu_dma_buf[9]);
        imu_raw.gy   = (int16_t)((imu_dma_buf[10] << 8) | imu_dma_buf[11]);
        imu_raw.gz   = (int16_t)((imu_dma_buf[12] << 8) | imu_dma_buf[13]);
        imu_data_ready = 1;
        /* In FreeRTOS: xSemaphoreGiveFromISR(imu_sem, &xHigherPriorityTaskWoken) */
    }
}

Multi-Device I2C Bus

One of I2C's greatest strengths is that all devices share two wires — no CS pin proliferation. However, this creates a challenge: many popular sensor ICs have a fixed I2C address determined only by one or two address pins. For example, the BMP280 has only two possible addresses (0x76 and 0x77). If you need four BMP280 sensors on one MCU, you cannot put all four on the same I2C bus without an address conflict.

The solution is an I2C multiplexer (MUX). The TCA9548A (and its pin-compatible sibling PCA9548A) is an 8-channel I2C bus switch. It has its own I2C address (0x70–0x77 depending on A0/A1/A2 pins) and a 1-byte control register that selects which of its 8 downstream buses is connected to the upstream master. Each downstream bus is electrically isolated from the others, so identical-address devices on different channels cannot interfere.

Device 7-bit Address Address Config Pins Max on one bus Notes
BMP280 0x76, 0x77 SDO (1 pin) 2 Use TCA9548A for >2
MPU-6050 0x68, 0x69 AD0 (1 pin) 2 Use TCA9548A for >2
SSD1306 OLED 0x3C, 0x3D SA0 (1 pin) 2 Often hard-wired on module
PCF8574 0x20–0x27 A0, A1, A2 (3 pins) 8 Full address range
TCA9548A 0x70–0x77 A0, A1, A2 (3 pins) 8 (cascadable) Each with 8 downstream buses
/* ---------------------------------------------------------------
 * TCA9548A I2C multiplexer — channel select + sensor read
 * MUX I2C address: 0x70 (A2=A1=A0=0)
 * Control register: bit N = enable channel N
 * --------------------------------------------------------------- */
#define TCA9548A_ADDR    (0x70 << 1)   /* 8-bit form: 0xE0 */

/* Select a single MUX channel (0–7). Pass 0xFF to disable all. */
HAL_StatusTypeDef tca9548a_select_channel(I2C_HandleTypeDef *hi2c,
                                           uint8_t channel)
{
    uint8_t ctrl = (channel < 8) ? (1U << channel) : 0x00;
    return HAL_I2C_Master_Transmit(hi2c, TCA9548A_ADDR,
                                   &ctrl, 1, 10);
}

/* Read BMP280 chip ID from MUX channel N
 * BMP280 chip ID register 0xD0 should return 0x58 (or 0x60 for BME280) */
uint8_t read_bmp280_id_on_channel(I2C_HandleTypeDef *hi2c, uint8_t ch)
{
    uint8_t chip_id = 0;
    if (tca9548a_select_channel(hi2c, ch) != HAL_OK) return 0;

    HAL_I2C_Mem_Read(hi2c,
                     (0x76 << 1),   /* BMP280 address on this channel */
                     0xD0,           /* chip_id register */
                     I2C_MEMADD_SIZE_8BIT,
                     &chip_id, 1, 10);

    tca9548a_select_channel(hi2c, 0xFF); /* disable all channels after read */
    return chip_id;
}

I2C Error Handling & Recovery

I2C bus errors are more common than SPI errors because the shared open-drain topology is susceptible to several failure modes. Understanding each is essential for production-quality firmware:

  • ARLO (Arbitration Lost) — another master on the bus started a transaction simultaneously. The peripheral detects this by monitoring SDA while driving it — if SDA reads high when you drove it low, you lost arbitration. The HAL error code is HAL_I2C_ERROR_ARLO.
  • BERR (Bus Error) — a misplaced START or STOP condition was detected. Usually indicates electrical noise or a glitch on SDA/SCL. Code: HAL_I2C_ERROR_BERR.
  • AF (Acknowledge Failure) — the slave did not ACK the address or a data byte. Most common cause: wrong address, device not powered, or device in a locked state. Code: HAL_I2C_ERROR_AF.
  • TIMEOUT — the transfer did not complete within the specified timeout. Often caused by a slave clock-stretching indefinitely (bus hang). Code: HAL_I2C_ERROR_TIMEOUT.

The most dangerous scenario is a bus hang: a slave was in the middle of a data byte when the master reset (power cycle, watchdog, debugger halt). The slave's shift register still holds a partially transmitted byte and is driving SDA low, waiting for more SCL pulses. The master's I2C peripheral will see SDA stuck low and refuse to generate a START condition — the bus is dead until a hardware reset of the slave. The recovery procedure is to bit-bang exactly 9 SCL pulses on the clock line while monitoring SDA — this clocks out the slave's remaining byte, prompting it to NAK and release SDA. Then generate a STOP condition and re-initialise the I2C peripheral.

/* ---------------------------------------------------------------
 * I2C Bus Hang Recovery — bit-bang 9 SCL clocks then STOP
 * Call this when HAL_I2C_Mem_Read returns HAL_TIMEOUT or BERR
 * Assumes: I2C1 SCL = PB6, SDA = PB7 (typical STM32F4 mapping)
 * After calling this, re-call HAL_I2C_Init() to restore peripheral
 * --------------------------------------------------------------- */
#define I2C_RECOV_SCL_PORT  GPIOB
#define I2C_RECOV_SCL_PIN   GPIO_PIN_6
#define I2C_RECOV_SDA_PORT  GPIOB
#define I2C_RECOV_SDA_PIN   GPIO_PIN_7

void i2c_recover_bus(I2C_HandleTypeDef *hi2c)
{
    GPIO_InitTypeDef gpio = {0};

    /* De-initialise I2C peripheral to release pins */
    HAL_I2C_DeInit(hi2c);

    /* Reconfigure SCL and SDA as open-drain GPIO outputs */
    __HAL_RCC_GPIOB_CLK_ENABLE();
    gpio.Mode  = GPIO_MODE_OUTPUT_OD;
    gpio.Pull  = GPIO_NOPULL;
    gpio.Speed = GPIO_SPEED_FREQ_LOW;

    gpio.Pin = I2C_RECOV_SCL_PIN;
    HAL_GPIO_Init(I2C_RECOV_SCL_PORT, &gpio);
    gpio.Pin = I2C_RECOV_SDA_PIN;
    HAL_GPIO_Init(I2C_RECOV_SDA_PORT, &gpio);

    /* Drive SDA high so slave can release it after clocking */
    HAL_GPIO_WritePin(I2C_RECOV_SDA_PORT, I2C_RECOV_SDA_PIN, GPIO_PIN_SET);

    /* Generate 9 SCL pulses — one full byte + ACK bit */
    for (int i = 0; i < 9; i++) {
        HAL_GPIO_WritePin(I2C_RECOV_SCL_PORT, I2C_RECOV_SCL_PIN, GPIO_PIN_SET);
        HAL_Delay(1);
        HAL_GPIO_WritePin(I2C_RECOV_SCL_PORT, I2C_RECOV_SCL_PIN, GPIO_PIN_RESET);
        HAL_Delay(1);
        /* Check if slave released SDA (recovery successful mid-sequence) */
        if (HAL_GPIO_ReadPin(I2C_RECOV_SDA_PORT, I2C_RECOV_SDA_PIN)) break;
    }

    /* Generate STOP condition: SDA low → SCL high → SDA high */
    HAL_GPIO_WritePin(I2C_RECOV_SDA_PORT, I2C_RECOV_SDA_PIN, GPIO_PIN_RESET);
    HAL_Delay(1);
    HAL_GPIO_WritePin(I2C_RECOV_SCL_PORT, I2C_RECOV_SCL_PIN, GPIO_PIN_SET);
    HAL_Delay(1);
    HAL_GPIO_WritePin(I2C_RECOV_SDA_PORT, I2C_RECOV_SDA_PIN, GPIO_PIN_SET);
    HAL_Delay(1);

    /* Restore pins to AF mode and re-initialise the peripheral */
    gpio.Mode      = GPIO_MODE_AF_OD;
    gpio.Alternate = GPIO_AF4_I2C1;
    gpio.Pin = I2C_RECOV_SCL_PIN;
    HAL_GPIO_Init(I2C_RECOV_SCL_PORT, &gpio);
    gpio.Pin = I2C_RECOV_SDA_PIN;
    HAL_GPIO_Init(I2C_RECOV_SDA_PORT, &gpio);

    HAL_I2C_Init(hi2c);
}

Override HAL_I2C_ErrorCallback() to call i2c_recover_bus() when HAL_I2C_GetError() returns HAL_I2C_ERROR_TIMEOUT or HAL_I2C_ERROR_BERR. Log the recovery event to UART or a ring buffer for post-mortem analysis.

Sensor Driver Example — BMP280 Pressure & Temperature

The BMP280 is a Bosch digital pressure and temperature sensor with an I2C interface, widely used in weather stations, altitude measurement, and drone flight controllers. It stores a unique set of trimming parameters in factory-programmed registers (0x88–0x9F) that must be read once at startup and used in the temperature/pressure compensation formulas. The raw ADC values from the sensor are meaningless without these calibration coefficients.

The compensation formula for temperature uses 32-bit signed integer arithmetic (avoiding floating point). The intermediate value t_fine produced during temperature compensation is reused in the pressure compensation formula — they must be computed in order.

/* ---------------------------------------------------------------
 * BMP280 Driver — init, raw read, and integer compensation
 * Register map (partial):
 *   0xD0 = chip_id (0x58 BMP280, 0x60 BME280)
 *   0xE0 = reset (write 0xB6)
 *   0x88–0x9D = trimming parameters (12 × uint16)
 *   0xF3 = status, 0xF4 = ctrl_meas, 0xF5 = config
 *   0xF7–0xFC = press_msb/lsb/xlsb, temp_msb/lsb/xlsb
 * --------------------------------------------------------------- */
#define BMP280_I2C_ADDR   (0x76 << 1)  /* SDO=GND → 0xEC */
#define BMP280_REG_ID      0xD0
#define BMP280_REG_RESET   0xE0
#define BMP280_REG_CALIB   0x88
#define BMP280_REG_CTRL    0xF4
#define BMP280_REG_DATA    0xF7
#define BMP280_CHIP_ID     0x58

typedef struct {
    uint16_t dig_T1;
    int16_t  dig_T2, dig_T3;
    uint16_t dig_P1;
    int16_t  dig_P2, dig_P3, dig_P4, dig_P5,
             dig_P6, dig_P7, dig_P8, dig_P9;
    int32_t  t_fine;   /* shared between T and P compensation */
} BMP280_Calib_t;

static BMP280_Calib_t bmp280_calib;

HAL_StatusTypeDef bmp280_init(I2C_HandleTypeDef *hi2c)
{
    uint8_t id = 0, calib_raw[24];

    /* Verify chip ID */
    HAL_I2C_Mem_Read(hi2c, BMP280_I2C_ADDR,
                     BMP280_REG_ID, I2C_MEMADD_SIZE_8BIT, &id, 1, 10);
    if (id != BMP280_CHIP_ID) return HAL_ERROR;

    /* Read 24 calibration bytes starting at 0x88 */
    HAL_I2C_Mem_Read(hi2c, BMP280_I2C_ADDR,
                     BMP280_REG_CALIB, I2C_MEMADD_SIZE_8BIT,
                     calib_raw, 24, 10);

    /* Unpack little-endian calibration coefficients */
    bmp280_calib.dig_T1 = (calib_raw[1]  << 8) | calib_raw[0];
    bmp280_calib.dig_T2 = (calib_raw[3]  << 8) | calib_raw[2];
    bmp280_calib.dig_T3 = (calib_raw[5]  << 8) | calib_raw[4];
    bmp280_calib.dig_P1 = (calib_raw[7]  << 8) | calib_raw[6];
    bmp280_calib.dig_P2 = (calib_raw[9]  << 8) | calib_raw[8];
    /* ... remaining P coefficients follow the same pattern */

    /* Set oversampling: temp ×2, pressure ×4, normal mode */
    uint8_t ctrl = 0x93; /* osrs_t=010 osrs_p=100 mode=11 */
    return HAL_I2C_Mem_Write(hi2c, BMP280_I2C_ADDR,
                              BMP280_REG_CTRL, I2C_MEMADD_SIZE_8BIT,
                              &ctrl, 1, 10);
}

/* Returns temperature in units of 0.01 °C (e.g., 2534 = 25.34 °C) */
int32_t bmp280_compensate_temperature(int32_t adc_T)
{
    int32_t var1, var2, T;
    var1 = ((((adc_T >> 3) - ((int32_t)bmp280_calib.dig_T1 << 1))) *
            ((int32_t)bmp280_calib.dig_T2)) >> 11;
    var2 = (((((adc_T >> 4) - ((int32_t)bmp280_calib.dig_T1)) *
              ((adc_T >> 4) - ((int32_t)bmp280_calib.dig_T1))) >> 12) *
            ((int32_t)bmp280_calib.dig_T3)) >> 14;
    bmp280_calib.t_fine = var1 + var2;
    T = (bmp280_calib.t_fine * 5 + 128) >> 8;
    return T;
}

Exercises

Exercise 1 Beginner

I2C Bus Scanner and Device Identification

Run the i2c_bus_scan() function from Section 3 on your development board with at least two I2C devices connected (e.g. BMP280 at 0x76 and MPU-6050 at 0x68). Verify both addresses appear in the scan output over UART at 115200 baud. Read the WHO_AM_I or chip_id register from each device to confirm correct communication at both 100 kHz (standard mode) and 400 kHz (fast mode). Measure the UART output to confirm the correct addresses and register values. If a device does not appear, systematically check pull-up resistor installation, address pin configuration, and supply voltage.

I2C Scanner Device ID Pull-up Resistors
Exercise 2 Intermediate

Polling to DMA Upgrade — CPU Load Measurement

Start with a polling I2C driver reading an MPU-6050 at 100 Hz using HAL_I2C_Mem_Read(). Measure the CPU load by toggling a spare GPIO high at the start of each read and low at completion — observe the duty cycle on an oscilloscope or logic analyser to calculate the percentage of CPU time consumed. Then upgrade to DMA I2C at 1 kHz using HAL_I2C_Mem_Read_DMA() and HAL_I2C_MemRxCpltCallback(). Repeat the GPIO measurement. Compare the CPU utilisation figures before and after the upgrade. Document the achievable read rate, CPU usage, and response latency for both approaches.

DMA I2C CPU Load MPU-6050 Performance
Exercise 3 Advanced

TCA9548A Round-Robin Scheduler with Bus Recovery

Connect a TCA9548A I2C multiplexer with four identical BMP280 sensors on channels 0–3 (all at I2C address 0x76 on their respective downstream buses). Build a FreeRTOS round-robin DMA scheduler that reads all four sensors in sequence at 500 Hz each (2 kHz total throughput). Implement the HAL_I2C_ErrorCallback() to detect bus hangs and invoke i2c_recover_bus() automatically. Test recovery by physically unplugging and reinserting a sensor module while the firmware is running — verify that the firmware detects the error, recovers the bus, re-scans, and resumes normal operation within 100 ms. Log all recovery events with timestamps over UART.

TCA9548A FreeRTOS Bus Recovery DMA I2C

I2C Bus Design Tool

Use this tool to document your STM32 I2C configuration — peripheral instance, speed mode, transfer method, pull-up values, connected devices, and error handling strategy. Download as Word, Excel, PDF, or PPTX for project documentation or design review.

STM32 I2C Bus Design Generator

Document your I2C peripheral configuration, sensor addresses, pull-up values, and error handling approach. Download as Word, Excel, PDF, or PPTX.

Draft auto-saved

All data stays in your browser. Nothing is sent to or stored on any server.

Conclusion & Next Steps

In this part we have built a complete I2C skill set from protocol fundamentals through production-quality error handling:

  • The 2-wire I2C bus (SDA + SCL, open-drain with pull-ups) is the dominant sensor interface protocol. Pull-up resistor selection — balancing minimum drive strength against maximum rise-time constraint — directly affects bus reliability at Fast Mode and above.
  • 7-bit address left-shifting (e.g. 0x68 → 0xD0 in HAL calls) is the single most common bring-up mistake. Always double-check this when HAL returns HAL_ERROR on the first transaction.
  • HAL_I2C_Mem_Read / Mem_Write is the canonical pattern for sensor register access — it handles the write-then-read repeated-START sequence automatically. Use it for 99% of sensor drivers.
  • The I2C bus scanner should be the first code you run on any new hardware. It immediately surfaces wiring, pull-up, and address configuration issues before any application code runs.
  • DMA I2C eliminates CPU blocking on high-rate sensor reads. At 1 kHz with a 14-byte IMU burst, DMA reduces CPU involvement from ~17% to near-zero, freeing that time for signal processing.
  • The TCA9548A multiplexer is the standard solution for placing more than two identical-address devices on a single MCU. Understanding its channel-select mechanism is essential for multi-sensor designs.
  • Bus hang recovery via 9-clock bit-banging is the professional production pattern. Implement it in every I2C project — the alternative (a system reset when one sensor hangs) is unacceptable in deployed hardware.

Next in the Series

In Part 8: DMA & Memory Efficiency, we dive deep into the STM32 DMA controller — streams vs channels, circular mode for continuous ADC/UART reception, memory-to-memory transfers, zero-copy ring buffer patterns, and cache coherency pitfalls on Cortex-M7 devices with D-cache. We will profile real CPU load improvements and build a zero-copy audio pipeline as a working example.

Technology