Read a Sensor over I2C

Goal: Read temperature from an I2C sensor (LM75 or similar) using bare-metal register-level I2C on STM32. Understand the I2C protocol at the wire level.

Prerequisites: UART SPI I2C, Memory-Mapped IO, GPIO and Digital IO, ADC and DAC


What We’re Building

Read temperature from an LM75/TMP102 sensor connected to PB8 (SCL) and PB9 (SDA) via I2C1. Print the result over UART.

STM32 (master) ──SCL──┬── LM75 (slave, addr 0x48)
                 ──SDA──┘   4.7kΩ pull-ups to 3.3V

Step 1: I2C Protocol Recap

I2C uses two open-drain lines with pull-up resistors:

Idle:   SDA = HIGH, SCL = HIGH

START:  SDA falls while SCL is HIGH
        ────╲___

Address byte: 7-bit address + R/W bit (0=write, 1=read)
Data bytes:   8 bits, MSB first
ACK:          Receiver pulls SDA LOW for one clock (NACK = HIGH)

STOP:   SDA rises while SCL is HIGH
        ___╱────

Full read transaction for LM75 temperature register:

START → [0x48<<1 | 0] → ACK → [0x00] → ACK → 
  RESTART → [0x48<<1 | 1] → ACK → [MSB] → ACK → [LSB] → NACK → STOP

Step 2: Configure I2C1

// Register definitions
#define RCC_AHB1ENR    (*(volatile uint32_t *)0x40023830)
#define RCC_APB1ENR    (*(volatile uint32_t *)0x40023840)
 
#define GPIOB_BASE     0x40020400
#define GPIOB_MODER    (*(volatile uint32_t *)(GPIOB_BASE + 0x00))
#define GPIOB_OTYPER   (*(volatile uint32_t *)(GPIOB_BASE + 0x04))
#define GPIOB_OSPEEDR  (*(volatile uint32_t *)(GPIOB_BASE + 0x08))
#define GPIOB_PUPDR    (*(volatile uint32_t *)(GPIOB_BASE + 0x0C))
#define GPIOB_AFRH     (*(volatile uint32_t *)(GPIOB_BASE + 0x24))
 
#define I2C1_BASE      0x40005400
#define I2C1_CR1       (*(volatile uint32_t *)(I2C1_BASE + 0x00))
#define I2C1_CR2       (*(volatile uint32_t *)(I2C1_BASE + 0x04))
#define I2C1_OAR1      (*(volatile uint32_t *)(I2C1_BASE + 0x08))
#define I2C1_DR        (*(volatile uint32_t *)(I2C1_BASE + 0x10))
#define I2C1_SR1       (*(volatile uint32_t *)(I2C1_BASE + 0x14))
#define I2C1_SR2       (*(volatile uint32_t *)(I2C1_BASE + 0x18))
#define I2C1_CCR       (*(volatile uint32_t *)(I2C1_BASE + 0x1C))
#define I2C1_TRISE     (*(volatile uint32_t *)(I2C1_BASE + 0x20))
 
void i2c_init(void) {
    // Enable clocks: GPIOB + I2C1
    RCC_AHB1ENR |= (1 << 1);    // GPIOBEN
    RCC_APB1ENR |= (1 << 21);   // I2C1EN
 
    // PB8 (SCL) and PB9 (SDA): AF4 (I2C1), open-drain, pull-up
    GPIOB_MODER  &= ~((3 << 16) | (3 << 18));
    GPIOB_MODER  |=  ((2 << 16) | (2 << 18));   // alternate function
    GPIOB_OTYPER |=  ((1 << 8) | (1 << 9));     // open-drain (required for I2C)
    GPIOB_OSPEEDR |= ((3 << 16) | (3 << 18));   // high speed
    GPIOB_PUPDR  &= ~((3 << 16) | (3 << 18));
    GPIOB_PUPDR  |=  ((1 << 16) | (1 << 18));   // pull-up
    GPIOB_AFRH   &= ~((0xF << 0) | (0xF << 4));
    GPIOB_AFRH   |=  ((4 << 0) | (4 << 4));     // AF4 = I2C1
 
    // Reset I2C1
    I2C1_CR1 |= (1 << 15);    // SWRST
    I2C1_CR1 &= ~(1 << 15);
 
    // Configure I2C: 100 kHz standard mode at 16 MHz APB1
    I2C1_CR2 = 16;                // APB1 clock = 16 MHz
    I2C1_CCR = 80;                // 16MHz / (2 * 100kHz) = 80
    I2C1_TRISE = 17;              // (16MHz / 1MHz) + 1 = 17
    I2C1_CR1 |= (1 << 0);        // PE: enable I2C
}

Why open-drain

I2C is a shared bus. Open-drain means any device can pull the line LOW, but no device drives it HIGH — the pull-up resistor does that. This prevents bus contention. See GPIO and Digital IO output modes.


Step 3: I2C Low-Level Operations

static void i2c_wait_flag(volatile uint32_t *reg, uint32_t flag) {
    while (!(*reg & flag)) {}
}
 
void i2c_start(void) {
    I2C1_CR1 |= (1 << 8);                 // generate START
    i2c_wait_flag(&I2C1_SR1, (1 << 0));   // wait for SB (start bit)
}
 
void i2c_stop(void) {
    I2C1_CR1 |= (1 << 9);                 // generate STOP
}
 
void i2c_send_addr(uint8_t addr, uint8_t rw) {
    I2C1_DR = (addr << 1) | rw;
    i2c_wait_flag(&I2C1_SR1, (1 << 1));   // wait for ADDR
    (void)I2C1_SR2;                        // clear ADDR by reading SR2
}
 
void i2c_write_byte(uint8_t data) {
    I2C1_DR = data;
    i2c_wait_flag(&I2C1_SR1, (1 << 7));   // wait for TXE
}
 
uint8_t i2c_read_byte_ack(void) {
    I2C1_CR1 |= (1 << 10);                // ACK = 1
    i2c_wait_flag(&I2C1_SR1, (1 << 6));   // wait for RXNE
    return I2C1_DR;
}
 
uint8_t i2c_read_byte_nack(void) {
    I2C1_CR1 &= ~(1 << 10);               // ACK = 0 (NACK)
    i2c_stop();                             // generate STOP before reading last byte
    i2c_wait_flag(&I2C1_SR1, (1 << 6));   // wait for RXNE
    return I2C1_DR;
}

Why NACK on the last byte

The I2C spec requires the master to NACK the last byte it wants to read. This tells the slave to stop driving the bus and let the master generate STOP.


Step 4: Read Temperature from LM75

int16_t read_temperature(uint8_t sensor_addr) {
    // Write: select temperature register (0x00)
    i2c_start();
    i2c_send_addr(sensor_addr, 0);   // write mode
    i2c_write_byte(0x00);            // temperature register pointer
 
    // Read: 2 bytes (MSB, LSB)
    i2c_start();                      // repeated start
    i2c_send_addr(sensor_addr, 1);   // read mode
    uint8_t msb = i2c_read_byte_ack();
    uint8_t lsb = i2c_read_byte_nack();
 
    // LM75: temperature = (MSB:LSB) >> 5, in 0.125°C steps
    int16_t raw = (msb << 8) | lsb;
    return raw >> 5;   // 11-bit signed value, each bit = 0.125°C
}

Step 5: Main Loop

// Assumes uart_init(), uart_puts(), uart_print_int() from Tutorial 08
 
void main(void) {
    uart_init();
    i2c_init();
 
    uart_puts("=== I2C Temperature Reader ===\r\n");
 
    while (1) {
        int16_t raw = read_temperature(0x48);
 
        // Convert: raw value × 0.125°C
        int whole = raw / 8;
        int frac = (raw % 8) * 125;   // millidegrees
        if (frac < 0) frac = -frac;
 
        uart_puts("Temp: ");
        uart_print_int(whole);
        uart_putc('.');
        uart_print_int(frac);
        uart_puts(" C\r\n");
 
        delay(1000000);   // ~1 second
    }
}

Test

make flash
screen /dev/ttyACM0 115200
 
# Output:
# === I2C Temperature Reader ===
# Temp: 23.375 C
# Temp: 23.500 C
# ...

If you don’t have a physical sensor, you can test the I2C code with a logic analyzer or by connecting SDA to ground briefly — the bus should detect the condition and the code should hang at the flag wait (add timeouts in exercises).


Debugging I2C

ProblemLikely Cause
Hangs at ADDR flagWrong slave address, missing pull-ups, SDA/SCL swapped
Hangs at SB flagI2C not enabled, clock not configured
Reads 0xFFSlave not responding (NACK), bus stuck
Reads wrong dataWrong register pointer, byte order, or data format
# Logic analyzer is the best I2C debug tool
# Or use sigrok/PulseView with a $10 USB logic analyzer

Exercises

  1. Timeout protection: Add a timeout to each flag wait. If the bus is stuck, reset I2C and retry. Never hang forever in production firmware.

  2. I2C scanner: Write a function that tries all 127 addresses and prints which ones ACK. This is the embedded equivalent of i2cdetect -y 1.

  3. Multiple sensors: Read from two I2C sensors (e.g., LM75 at 0x48 and 0x49) and print both temperatures. I2C addressing allows multiple devices on the same bus.

  4. SPI comparison: Wire up an SPI temperature sensor (e.g., MAX31855). Compare the code complexity, speed, and wire count with I2C.


Next: 10 - PID Controller Simulation — close the control loop in software.