STM32 ยท I2C ยท Register-Level ยท Error Handling ยท Embedded

STM32 I2C Master Mode:
Register-Level Configuration and Error Handling

2026-06-25 ยท Davide Carrese

The I2C bus is the most common peripheral interconnect in embedded systems โ€” temperature sensors, IMUs, EEPROMs, DACs, IO expanders, power management ICs โ€” but it is also the one that silently fails most often in production. A missing pull-up, a stuck SCL line, a NACK from a powered-down slave, and your entire sensor read returns garbage without a single HardFault.

This article covers STM32's I2C peripheral in master mode at register level, focusing on the modern I2C v2 core used on STM32G4, STM32H7, STM32U5, STM32L4+, and most post-2019 STM32 series. We will look at timing calculation, the transmit and receive sequence, and โ€” most importantly โ€” how to detect and recover from every error the bus can throw at you.

I2C v2 Register Overview

The I2C v2 peripheral replaces the legacy shared-flag approach with a single 32-bit status register (I2C_ISR) and a dedicated clear register (I2C_ICR). Writing a flag in ICR clears the corresponding bit in ISR, avoiding the read-modify-write hazards of the old v1 peripheral.

RegisterPurpose
I2C_CR1Control: enable, NACK/STOP generation, reset, DMA
I2C_CR2Transfer control: slave address, direction, NBYTES, START/STOP, autoend
I2C_TIMINGRSCL timing: prescaler, SCLDEL, SDADEL, SCLH, SCLL
I2C_ISRStatus flags: TXIS, RXNE, TC, NACKF, BUSY, ARLO, BERR, OVR, TIMEOUT
I2C_ICRClear flags: write 1 to the bit you want to clear
I2C_TXDRTransmit data (8-bit, 16-bit for 10-bit addr)
I2C_RXDRReceive data (8-bit)

The key mental model shift from the old I2C v1: you do not poll SB, ADDR, BTF. Instead, I2C_CR2 starts a transfer, and I2C_ISR tells you what just happened.

Timing Register Calculation

Unlike the old v1 peripheral where you configured I2C_CCR based on APB clock and I2C speed, the v2 core uses a purely digital timing register. The formula is:

t_I2CCLK = (prescaler + 1) ร— t_PCLK1

SCLL = t_SCL_low  / t_I2CCLK - 1
SCLH = t_SCL_high / t_I2CCLK - 1

SDADEL = t_SDA_delay / t_I2CCLK - 1
SCLDEL = t_SCL_delay / t_I2CCLK - 1

Where the delays are defined by the I2C specification. For 100 kHz (Standard mode): SCL low ≥ 4.7 μs, SCL high ≥ 4.0 μs, SDA delay ≥ 0.1 μs, SCL delay ≥ 0.3 μs. For 400 kHz (Fast mode): SCL low ≥ 1.3 μs, SCL high ≥ 0.6 μs, SDA delay ≥ 0.1 μs, SCL delay ≥ 0.3 μs. For 1 MHz (Fast Mode+): SCL low ≥ 0.5 μs, SCL high ≥ 0.26 μs.

Here is a practical calculation for an STM32G474 at 170 MHz PCLK1 (170 ns period) for 400 kHz Fast Mode:

PCLK1 = 170 MHz โ†’ t_PCLK1 = 5.88 ns

Choose prescaler = 0 โ†’ t_I2CCLK = 5.88 ns

SCLH = ceil(0.6 ยตs / 5.88 ns) - 1 = 101 โ†’ 0x65
SCLL = ceil(1.3 ยตs / 5.88 ns) - 1 = 220 โ†’ 0xDC

SDADEL = 100 ns / 5.88 ns - 1 โ†’ 16 โ†’ 0x10
SCLDEL = 300 ns / 5.88 ns - 1 โ†’ 50 โ†’ 0x32

I2C_TIMINGR = (0 << 28)    /* PRESC */
            | (0x32 << 16)  /* SCLDEL */
            | (0x10 << 12)  /* SDADEL */
            | (0xDC << 8)   /* SCLL */
            | (0x65 << 0);  /* SCLH */

STM32CubeMX generates I2C_TIMINGR values for you, and I recommend starting from those. But verifying by hand โ€” especially when you change PCLK1 frequency for power management โ€” prevents the most common source of "I2C stopped working" after clock reconfiguration.

Master Transmit Sequence

Writing one byte to a slave register address requires this sequence:

void I2C_Master_Transmit(I2C_TypeDef *i2c, uint8_t dev_addr,
                         uint8_t *data, uint16_t len, uint32_t timeout_ms)
{
    uint32_t tickstart = HAL_GetTick();

    /* Wait for bus idle */
    while (i2c->ISR & I2C_ISR_BUSY) {
        if (HAL_GetTick() - tickstart > timeout_ms) { return; /* timeout */ }
    }

    /* Clear any stale flags */
    i2c->ICR |= I2C_ICR_NACKCF | I2C_ICR_ARLOCF | I2C_ICR_BERRCF
             | I2C_ICR_OVRCF | I2C_ICR_TIMOUTCF;

    /* Set slave address + direction (write = 0) + NBYTES + START */
    i2c->CR2 = (dev_addr << 1)                /* SADD[7:1] */
             | (len << 16)                     /* NBYTES */
             | I2C_CR2_START                    /* Generate START */
             | I2C_CR2_AUTOEND;                 /* Auto STOP after NBYTES */

    for (uint16_t i = 0; i < len; i++) {
        /* Wait for TXIS (transmit interrupt status) */
        while (!(i2c->ISR & I2C_ISR_TXIS)) {
            /* Check NACK */
            if (i2c->ISR & I2C_ISR_NACKF) {
                i2c->ICR = I2C_ICR_NACKCF;
                return; /* NACK: slave did not ACK address or data */
            }
            /* Check other errors */
            if (i2c->ISR & (I2C_ISR_ARLO | I2C_ISR_BERR | I2C_ISR_TIMEOUT)) {
                I2C_ClearError(i2c);
                return;
            }
            if (HAL_GetTick() - tickstart > timeout_ms) { return; }
        }
        i2c->TXDR = data[i];
    }

    /* Wait for transfer complete (TC or TC flag) */
    while (!(i2c->ISR & (I2C_ISR_TC | I2C_ISR_STOPF))) {
        if (HAL_GetTick() - tickstart > timeout_ms) { return; }
    }
    i2c->ICR = I2C_ICR_STOPCF; /* Clear STOP flag */
}

Key points:

Master Receive Sequence

Reading N bytes from a slave register is a combined transfer: first a write of the register address (with RESTART), then a read of the data.

void I2C_Master_Read_Register(I2C_TypeDef *i2c, uint8_t dev_addr,
                              uint8_t reg_addr, uint8_t *rx_buf, uint16_t len,
                              uint32_t timeout_ms)
{
    uint32_t tickstart = HAL_GetTick();

    while (i2c->ISR & I2C_ISR_BUSY) {
        if (HAL_GetTick() - tickstart > timeout_ms) return;
    }
    i2c->ICR |= I2C_ICR_NACKCF | I2C_ICR_ARLOCF | I2C_ICR_BERRCF
             | I2C_ICR_OVRCF | I2C_ICR_TIMOUTCF;

    /* Phase 1: write register address (1 byte) without STOP */
    i2c->CR2 = (dev_addr << 1) | (1 << 16) | I2C_CR2_START | I2C_CR2_NOSTRETCH;
    /* NOSTRETCH: don't stretch SCL after TC, allows quick RESTART */

    while (!(i2c->ISR & I2C_ISR_TXIS)) {
        if (i2c->ISR & I2C_ISR_NACKF) {
            i2c->ICR = I2C_ICR_NACKCF; return;
        }
        if (HAL_GetTick() - tickstart > timeout_ms) return;
    }
    i2c->TXDR = reg_addr;

    /* Wait for TC (transfer complete, no STOP generated) */
    while (!(i2c->ISR & I2C_ISR_TC)) {
        if (HAL_GetTick() - tickstart > timeout_ms) return;
    }

    /* Phase 2: read len bytes with RESTART + NACK on last byte */
    i2c->CR2 = (dev_addr << 1) | I2C_CR2_RD_WRN   /* Set read direction */
             | (len << 16) | I2C_CR2_START | I2C_CR2_AUTOEND;

    for (uint16_t i = 0; i < len; i++) {
        while (!(i2c->ISR & I2C_ISR_RXNE)) {
            if (i2c->ISR & I2C_ISR_NACKF) { i2c->ICR = I2C_ICR_NACKCF; return; }
            if (HAL_GetTick() - tickstart > timeout_ms) return;
        }
        rx_buf[i] = i2c->RXDR; /* Read clears RXNE automatically */
    }
}

Note on combined transfers: The first CR2 write uses I2C_CR2_NOSTRETCH (bit 17) to prevent clock stretching after TC, which would otherwise delay the RESTART. On some slaves this isn't needed โ€” omit it if the slave requires clock stretching between phases.

Error Handling: Every Failure Mode

I2C error handling is where production firmware separates from hobby code. Here is each error bit in I2C_ISR and its correct handling:

NACKF โ€” Not Acknowledge

Set when the slave does not ACK the address byte or a data byte. The peripheral automatically stops driving SCL/SDA and waits for software intervention. Clear with I2C_ICR_NACKCF, then you can either retry or reset the peripheral. Most common cause: device not powered, wrong address, or the slave is busy processing.

ARLO โ€” Arbitration Lost

Set when another master on the multi-master bus starts driving the bus simultaneously and wins arbitration. The peripheral releases SDA and SCL. Clear with I2C_ICR_ARLOCF. In single-master designs (the vast majority of STM32 I2C scenarios), ARLO indicates a bus glitch โ€” treat it as a bus error and recover.

BERR โ€” Bus Error

Set when a START or STOP condition is detected at the wrong time in the frame. This happens when a noise spike or glitch on SDA/SCL is misinterpreted as a bus condition. Clear with I2C_ICR_BERRCF.

OVR โ€” Overrun/Underrun

Set when a new byte arrives in RXDR before the previous one was read (overrun), or when TXDR was empty when the bus needed the next byte (underrun). In master mode with proper NBYTES counting, OVR should never fire. If it does, your ISR service latency is too high โ€” increase the I2C priority or use DMA.

TIMEOUT โ€” Clock Stretch Timeout

The I2C v2 peripheral has a dedicated timeout counter (I2C_TIMEOUTR) that detects when SCL is held low by a slave for too long. This is a lifesaver: without it, a stuck slave can hang the bus indefinitely. Enable it once during init:

/* Enable timeout, TIMEOUTA = 0x0FFF bus cycles (~3 ms at 400 kHz) */
i2c->TIMEOUTR = (0x0FFF << 0)      /* TIMEOUTA[11:0] */
              | I2C_TIMEOUTR_TEXTEN; /* Enable timer A */

When the timer fires, I2C_ISR_TIMEOUT is set. Clear with I2C_ICR_TIMOUTCF. You must then perform bus recovery (see below) because the bus may be in an indeterminate state.

Bus Recovery Procedure

After any non-trivial error (ARLO, BERR, TIMEOUT), the I2C bus may be in a state where SDA is stuck low by a slave. The standard recovery is to toggle SCL 9 times while SDA floats high, then generate a STOP condition:

void I2C_Bus_Recovery(GPIO_TypeDef *scl_port, uint16_t scl_pin,
                       GPIO_TypeDef *sda_port, uint16_t sda_pin)
{
    /* Configure SCL and SDA as open-drain outputs, high */
    GPIO_InitTypeDef gpio = {
        .Pin = scl_pin | sda_pin,
        .Mode = GPIO_MODE_OUTPUT_OD,
        .Pull = GPIO_NOPULL,
        .Speed = GPIO_SPEED_FREQ_HIGH,
    };
    HAL_GPIO_Init(scl_port, &gpio);
    HAL_GPIO_Init(sda_port, &gpio);

    HAL_GPIO_WritePin(scl_port, scl_pin, GPIO_PIN_SET);
    HAL_GPIO_WritePin(sda_port, sda_pin, GPIO_PIN_SET);

    for (int i = 0; i < 9; i++) {
        HAL_GPIO_WritePin(scl_port, scl_pin, GPIO_PIN_RESET);
        delay_us(5);  /* > 4.7 ยตs low */
        HAL_GPIO_WritePin(scl_port, scl_pin, GPIO_PIN_SET);
        delay_us(5);  /* > 4.0 ยตs high */
    }

    /* Generate STOP: SDA low while SCL high, then SDA high */
    HAL_GPIO_WritePin(sda_port, sda_pin, GPIO_PIN_RESET);
    delay_us(5);
    HAL_GPIO_WritePin(scl_port, scl_pin, GPIO_PIN_SET);
    delay_us(5);
    HAL_GPIO_WritePin(sda_port, sda_pin, GPIO_PIN_SET);
    delay_us(5);

    /* Re-configure back to alternate function */
    /* ... restore your AF configuration ... */
}

After recovery, perform a software reset of the I2C peripheral by setting and clearing I2C_CR1_PE:

void I2C_Reset(I2C_TypeDef *i2c)
{
    i2c->CR1 &= ~I2C_CR1_PE;  /* Disable peripheral */
    __HAL_I2C_SOFT_RESET(i2c); /* Reset state machine */
    i2c->CR1 |= I2C_CR1_PE;   /* Re-enable */
}

Practical Example: Reading a Temperature Sensor

Here is a complete example reading the temperature register (0x00, 2 bytes) from an STTS751 sensor at address 0x4C on an STM32G474:

#define STTS751_ADDR  0x4C
#define STTS751_TEMP  0x00

int16_t STTS751_Read_Temp(void)
{
    uint8_t data[2];

    I2C_Master_Read_Register(I2C1, STTS751_ADDR, STTS751_TEMP, data, 2, 100);

    /* Data is big-endian: MSB first, LSB = 0.0625 ยฐC */
    int16_t raw = (data[0] << 8) | data[1];
    return (raw >> 4);  /* 12-bit signed, LSB = 0.0625 ยฐC */
}

void main(void)
{
    HAL_Init();
    SystemClock_Config(); /* HSE โ†’ PLL โ†’ 170 MHz */

    /* I2C1: PB6=SCL, PB7=SDA, AF4 on G4 */
    __HAL_RCC_GPIOB_CLK_ENABLE();
    __HAL_RCC_I2C1_CLK_ENABLE();

    GPIO_InitTypeDef gpio = {0};
    gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7;
    gpio.Mode = GPIO_MODE_AF_OD;
    gpio.Pull = GPIO_PULLUP;
    gpio.Speed = GPIO_SPEED_FREQ_HIGH;
    gpio.Alternate = GPIO_AF4_I2C1;
    HAL_GPIO_Init(GPIOB, &gpio);

    /* I2C1 init at register level */
    I2C1->CR1 = 0;
    I2C1->TIMINGR = (0 << 28) | (0x32 << 16) | (0x10 << 12)
                  | (0xDC << 8) | (0x65 << 0);

    /* Enable clock stretch timeout */
    I2C1->TIMEOUTR = (0x0FFF << 0) | I2C_TIMEOUTR_TEXTEN;

    I2C1->CR1 |= I2C_CR1_PE; /* Enable peripheral */

    int16_t temp = STTS751_Read_Temp();
    float temp_c = temp * 0.0625f;

    /* temp_c now holds the die temperature in Celsius */
    while (1) {
        HAL_Delay(1000);
        temp = STTS751_Read_Temp();
        temp_c = temp * 0.0625f;
        /* Log or display */
    }
}

For production, add error classification: if NACK occurs three times in a row, report "missing device" instead of retrying forever. If TIMEOUT occurs, run bus recovery and reset the peripheral before retrying.

Error classification strategy

ErrorLikely causeAction
NACKDevice missing, wrong address, powered downRetry 2ร—; if persistent, report "device offline"
ARLONoise glitch / multi-master collisionClear flag, retry immediately; if repeated, run bus recovery
BERRNoise glitch on SDA/SCLClear flag, retry; if persistent, run bus recovery
TIMEOUTSlave stuck holding SCL lowMust run bus recovery + peripheral reset
OVRISR latency too high for I2C speedIncrease I2C IRQ priority or switch to DMA

Practical Checklist

  1. Always verify I2C_TIMINGR after changing PCLK1 โ€” the most common "I2C broke after clock change" root cause is a stale timing register.
  2. Enable TIMEOUTR for production builds โ€” without it, a stuck slave hangs the bus forever. This is non-negotiable for industrial firmware.
  3. Check NACKF in every TXIS/RXNE wait loop โ€” otherwise a missing device hangs the task permanently. Pair with a timeout counter.
  4. Use AUTOEND for simple transfers and manual STOP + TC for combined transfers (write register address, then read).
  5. Reset the peripheral after bus recovery โ€” clearing flags is not enough if the state machine is in an inconsistent state.
  6. Verify with a scope first โ€” connect an oscilloscope to SDA/SCL and confirm the START, address, ACK/NACK, data, and STOP timing before trusting the firmware.
  7. Pull-ups matter: 4.7 kฮฉ for 100 kHz, 2.2 kฮฉ for 400 kHz, 1 kฮฉ for 1 MHz. Use the I2C TIMINGR to compensate for rise time, not to fix missing pull-ups.

How I Would Approach This on a Client Project

On a recent STM32G474-based battery management system, I had twelve I2C temperature sensors, a fuel-gauge IC, and an EEPROM on three separate I2C buses. Each bus ran at 400 kHz with fully register-level drivers โ€” no HAL_I2C in sight, because the HAL's timeout model in polling mode is synchronous per transfer and blocks the control loop.

I structured each I2C transaction as a finite state machine with three outcomes: success, NACK-retryable, and fatal (needs bus recovery). A supervisor task ran bus recovery every 10 seconds on any bus that had accumulated more than 3 errors per minute, logging the event to the EEPROM. Over six months of field data, bus errors occurred roughly once per 100,000 transfers โ€” always from connector micro-disconnections on the external sensor harness. Without the TIMEOUT detection and automatic recovery, each such glitch would have required a power cycle.

The single biggest lesson: never assume I2C is reliable. Build error detection and recovery into the driver from day one, not as an afterthought when the field reports start coming in.

Sources

๐Ÿ“ฌ Comments / discussion

Prefer email: comments@carrese.eu โ€” include the article URL so I can follow up. For corrections or deeper questions, I typically reply within 48 hours.