STM32 basic I2C bare metal setup and transmit issue

Summary

The bare-metal I2C3 initialization and transmit sequence fails because the timing register is invalid (0x00000000), the slave address handling is incorrect for a 7-bit write operation, and peripheral clock configuration is incomplete. The HAL driver handles peripheral reset, clock setup, and timing calculation correctly, while the bare-metal code attempts to start transactions without a functional I2C configuration.

Root Cause

The failure stems from three critical errors in the bare-metal implementation:

  • Invalid I2C Timing: The I2C3->TIMINGR register is set to 0x00000000. This configuration corresponds to a maximum bus speed (effectively DC) and does not meet the standard-mode 100 kHz requirement. Consequently, the peripheral cannot generate valid clock signals or sample data correctly.
  • Incorrect Address and Direction: The CR2 register is configured with (0x23 << 1). While 0x23 is the device address, shifting it left by 1 bit implies a write to a 7-bit slave address. However, the standard CR2 expects the 7-bit address already shifted by one bit (or the 8-bit address for 10-bit mode). Furthermore, the RW bit (direction) is missing.
  • Missing Peripheral Reset: The bare-metal code enables the I2C3 clock (RCC->APB1ENR1) but does not perform the mandatory hardware reset (RCC->APB1RSTR1) and release sequence. Without a reset, residual configurations from the bootloader or previous state remain, potentially blocking the bus.

Why This Happens in Real Systems

I2C is a synchronous protocol with strict timing constraints. Unlike UART, which can often tolerate some timing mismatch, I2C relies on precise rise/fall times and setup/hold windows.

  • Timing Registers: STM32 hardware I2C requires a specific PRESCALER, SCLDEL, SDADEL, SCLH, and SCLL to generate the 100 kHz waveform. A value of 0 is physically invalid.
  • State Machine: The I2C peripheral is a state machine. If the CR1 or CR2 is not set to the required states (e.g., addressing mode, NBYTES, STOP/START generation), the peripheral will not initiate the START condition, or it will stall indefinitely waiting for events that never trigger.

Real-World Impact

  • Bus Hang: An invalid configuration can leave the SDA and SCL lines in a stuck state, requiring a hardware power cycle to recover.
  • Silent Failure: The MCU registers may appear to accept writes, but the external device (PCA9535) never receives the data, resulting in zero functionality without obvious error flags.
  • Latency Waste: Polling loops (e.g., while(!(I2C3->ISR & I2C_ISR_TXIS))) will spin forever, blocking the main execution thread.

Example or Code

Below is the corrected bare-metal initialization sequence. It includes the necessary peripheral reset, valid timing calculation for 100 kHz (assuming 16MHz APB1 clock), and correct address formatting.

#include "stm32g4xx.h"

// Function to calculate I2C timing for 100kHz with 16MHz PCLK
// Standard Mode: t_SCLL/t_SCLH = 250ns min, t_SYNC1/2 = 200ns min
// With PRESC=0, t_PCLK = 62.5ns. We need ~15.6 cycles per half-period.
void I2C3_Init_BareMetal(void) {
    // 1. Enable and Reset Peripheral
    RCC->APB1ENR1 |= RCC_APB1ENR1_I2C3EN;
    RCC->APB1RSTR1 |= RCC_APB1RSTR1_I2C3RST;
    RCC->APB1RSTR1 &= ~RCC_APB1RSTR1_I2C3RST;

    // 2. Configure GPIO (PC8/SCL, PC9/SDA)
    // Reset GPIOC
    RCC->AHB2ENR |= RCC_AHB2ENR_GPIOCEN;

    // Configure PC8 and PC9 as Alternate Function Open-Drain
    // Mode: 10 (Alternate Function)
    GPIOC->MODER &= ~((3U << 16) | (3U <MODER |= ((2U << 16) | (2U <OTYPER |= ((1U << 8) | (1U <AFR[1] &= ~((0xFU << 0) | (0xFU <AFR[1] |= ((8U << 0) | (8U <TIMINGR = (3 << 28) | (3 << 20) | (1 << 16) | (15 << 8) | (21 <CR1 |= I2C_CR1_PE;
}

void I2C3_WriteRegister(uint8_t slaveAddr7bit, uint8_t reg, uint8_t data) {
    // Wait for bus to be free
    while (I2C3->ISR & I2C_ISR_BUSY);

    // Configure CR2 for the transaction
    // 7-bit addressing: Address is left-shifted by 1 (bit 0 is R/W)
    // 0x23 <CR2 = (slaveAddr7bit << 1) | (2 <ISR & I2C_ISR_TXIS));
    I2C3->TXDR = reg;

    while (!(I2C3->ISR & I2C_ISR_TXIS));
    I2C3->TXDR = data;

    // Wait for STOP flag (AutoEnd generates it)
    while (!(I2C3->ISR & I2C_ISR_STOPF));
    I2C3->ICR |= I2C_ICR_STOPCF; // Clear flag
}

int main(void) {
    // Minimal System Init (Assuming default MSI/HSI clock 16MHz)
    // In production, ensure PLL is configured correctly.

    I2C3_Init_BareMetal();

    // PCA9535 Address: 0x23 (A2=0, A1=1, A0=1)
    // 7-bit address is 0x23. Left shift for write: 0x46
    // Defined as macro for clarity
    #define PCA_ADDR_WRITE (0x23 << 1)

    // Set all ports as OUTPUT
    I2C3_WriteRegister(PCA_ADDR_WRITE, 0x06, 0x00); // Config 0
    I2C3_WriteRegister(PCA_ADDR_WRITE, 0x07, 0x00); // Config 1

    // Enable output ports
    I2C3_WriteRegister(PCA_ADDR_WRITE, 0x02, 0xAA); // Output 0
    I2C3_WriteRegister(PCA_ADDR_WRITE, 0x03, 0x01); // Output 1

    while (1) {}
}

How Senior Engineers Fix It

Senior engineers approach bare-metal I2C by validating the physical layer before logical transmission:

  1. Reference Manual Calculation: They do not guess timing values. They calculate TIMINGR based on the MCU clock tree and I2C spec (tSU, tHD, tLOW, tHIGH). Using a logic analyzer to verify the SCL waveform is the definitive test.
  2. Peripheral Lifecycle Management: They ensure a full Reset -> Enable sequence. This clears any stuck bits in the peripheral from previous runs or bootloader states.
  3. Address Standardization: They explicitly handle the LSB of the address byte. For a 7-bit write, the address must be (SlaveAddress << 1). Using the raw address (without shift) usually results in a Read attempt or a NACK.
  4. State Machine Awareness: They understand that CR2 must be written before TXDR is filled and that START bit triggers the hardware state machine. They rely on AUTOEND to handle STOP generation cleanly.

Why Juniors Miss It

  • Copy-Paste Errors: Juniors often copy register addresses or values without understanding the underlying arithmetic. For example, copying 0x23 directly into CR2 without realizing the register expects a shifted value.
  • Overlooking Default States: Assuming that enabling a clock (APBxENR) initializes the peripheral. In reality, peripherals often start in undefined or “frozen” states requiring an explicit reset.
  • Lack of Timing Calculation: Trying to use “default” timing values found in online forums rather than calculating them for the specific clock source (e.g., HSI vs HSE).
  • Debugging Blindly: Checking register values in the debugger is helpful, but without a logic analyzer, it’s impossible to know if the SCL line is actually toggling or if the signal integrity is poor due to missing open-drain configuration.