What Is I2C (Inter-Integrated Circuit) in Embedded Systems

What is an I2C (Inter-Integrated Circuit) in embedded systems

I2C is probably the protocol I use most often after UART, mainly because so many sensors — accelerometers, temperature sensors, EEPROMs, real-time clocks, OLED displays — expose an I2C interface. What drew me to it initially was how few wires it needs even when you’re talking to a dozen different chips. In this article I’ll go through how I2C actually works electrically and at the protocol level, how to configure and use it in code, and where it tends to bite you in real projects.

What Is I2C?

I2C (Inter-Integrated Circuit, sometimes written I²C or pronounced “I-squared-C”) is a synchronous, multi-master, multi-slave serial communication protocol developed by Philips (now NXP) in the 1980s. Unlike UART, I2C uses a shared clock line, so both devices sample data based on that clock rather than a pre-agreed baud rate. Unlike SPI, I2C needs only two wires total, regardless of how many devices are on the bus.

graph TD
    MCU[Microcontroller - Master] ---|SDA| Bus((I2C Bus))
    MCU ---|SCL| Bus
    Bus --- S1[Temperature Sensor - 0x48]
    Bus --- S2[EEPROM - 0x50]
    Bus --- S3[RTC - 0x68]
    Bus --- S4[OLED Display - 0x3C]

The Two Wires

  • SDA (Serial Data) — carries the actual data, bidirectionally.
  • SCL (Serial Clock) — carries the clock signal, generated by the master, that all devices use to time their reads/writes of SDA.

Both lines are open-drain, meaning devices can only pull them low, never drive them high. Pull-up resistors (typically 2.2kΩ–10kΩ, depending on bus speed and capacitance) bring the lines back to a logic-high level when no device is pulling them low. This open-drain design is what allows multiple devices to share the same two wires without conflicts — if any device pulls the line low, it goes low, and only when everyone releases it does it return high.

Addressing

Every I2C peripheral (slave) on the bus has a 7-bit address (or, less commonly, a 10-bit address for extended addressing). The master initiates every transaction by sending the target device’s address, so multiple devices can share the exact same two wires without interfering with each other — only the addressed device responds.

Address byte: [A6 A5 A4 A3 A2 A1 A0 | R/W]

The 8th bit indicates whether the master wants to Read (1) or Write (0) from/to that address.

I2C Transaction Structure

sequenceDiagram
    participant M as Master
    participant S as Slave (0x48)
    M->>S: START condition
    M->>S: Address (0x48) + Write bit
    S-->>M: ACK
    M->>S: Register address (e.g. 0x00)
    S-->>M: ACK
    M->>S: REPEATED START
    M->>S: Address (0x48) + Read bit
    S-->>M: ACK
    S-->>M: Data byte
    M-->>S: ACK
    S-->>M: Data byte
    M-->>S: NACK (end of read)
    M->>S: STOP condition
  • START condition: SDA transitions from high to low while SCL is high — this signals the beginning of a transaction.
  • STOP condition: SDA transitions from low to high while SCL is high — this signals the end.
  • ACK/NACK: After every byte, the receiver pulls SDA low for one clock cycle to acknowledge receipt (ACK), or leaves it high to indicate it did not/could not receive the byte (NACK).
  • Repeated START: A very common pattern is writing a register address, then issuing another START (without a STOP in between) to switch to reading — this is exactly how you typically read sensor registers.

Timing Diagram: START, Address, ACK

sequenceDiagram
    participant SDA
    participant SCL
    Note over SDA,SCL: Idle - both high
    SDA->>SDA: Falls while SCL high (START)
    loop 8 bits (Address + R/W)
        SCL->>SCL: Clock pulse
        SDA->>SDA: Data bit valid while SCL high
    end
    SCL->>SCL: 9th clock pulse
    SDA->>SDA: Slave pulls low (ACK)

I2C Bus Speeds

ModeSpeed
Standard mode100 kHz
Fast mode400 kHz
Fast mode plus1 MHz
High-speed mode3.4 MHz

Most sensors and displays used in hobbyist and mid-range commercial embedded projects run at Standard (100kHz) or Fast (400kHz) mode.

Configuring I2C on an STM32 (HAL Example)

#include "stm32f4xx_hal.h"

I2C_HandleTypeDef hi2c1;

void I2C1_Init(void) {
    hi2c1.Instance = I2C1;
    hi2c1.Init.ClockSpeed = 400000;              // 400 kHz Fast mode
    hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
    hi2c1.Init.OwnAddress1 = 0;
    hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
    hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
    hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
    hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
    HAL_I2C_Init(&hi2c1);
}

uint8_t I2C_ReadRegister(uint8_t dev_addr, uint8_t reg_addr) {
    uint8_t data;
    HAL_I2C_Master_Transmit(&hi2c1, dev_addr << 1, &reg_addr, 1, 100);
    HAL_I2C_Master_Receive(&hi2c1, (dev_addr << 1) | 0x01, &data, 1, 100);
    return data;
}

void I2C_WriteRegister(uint8_t dev_addr, uint8_t reg_addr, uint8_t value) {
    uint8_t buf[2] = { reg_addr, value };
    HAL_I2C_Master_Transmit(&hi2c1, dev_addr << 1, buf, 2, 100);
}

Note the dev_addr << 1: HAL expects the 7-bit address pre-shifted into the upper 7 bits of the byte, leaving bit 0 free for the R/W bit that the HAL library manages internally.

Example: Reading Temperature from an LM75-Style Sensor

#define LM75_ADDR   0x48
#define TEMP_REG    0x00

float read_temperature(void) {
    uint8_t raw[2];
    uint8_t reg = TEMP_REG;
    HAL_I2C_Master_Transmit(&hi2c1, LM75_ADDR << 1, &reg, 1, 100);
    HAL_I2C_Master_Receive(&hi2c1, (LM75_ADDR << 1) | 1, raw, 2, 100);

    int16_t temp_raw = (raw[0] << 8) | raw[1];
    temp_raw >>= 5;                 // LM75 uses 11-bit resolution
    return temp_raw * 0.125f;       // 0.125 °C per LSB
}

Configuring I2C on Arduino (Wire Library)

#include <Wire.h>

void setup() {
    Wire.begin();          // Join I2C bus as master
    Serial.begin(9600);
}

void loop() {
    Wire.beginTransmission(0x48);
    Wire.write(0x00);              // Point to temperature register
    Wire.endTransmission(false);   // Repeated START, no STOP
    Wire.requestFrom(0x48, 2);     // Read 2 bytes

    if (Wire.available() == 2) {
        int16_t raw = (Wire.read() << 8) | Wire.read();
        raw >>= 5;
        float tempC = raw * 0.125;
        Serial.println(tempC);
    }
    delay(1000);
}

Clock Stretching

Some slower slave devices need extra time to process a request before responding. I2C supports clock stretching, where the slave holds SCL low even after the master releases it, effectively pausing the clock until the slave is ready. The master must monitor SCL and wait for it to actually go high before proceeding — most hardware I2C peripherals handle this automatically, but it’s worth knowing about when debugging unexplained slowdowns on the bus.

Multi-Master Arbitration

I2C technically supports multiple masters on the same bus. If two masters start a transaction at the same time, they monitor the bus while transmitting; the moment one master’s transmitted bit doesn’t match what’s actually on the bus (because another master pulled it low), that master immediately backs off and waits — this is called arbitration. In practice, multi-master I2C setups are uncommon in typical embedded projects, but the protocol was designed with this in mind from the start.

Common I2C Devices in Embedded Projects

Device TypeExample Chips
Temperature sensorsLM75, TMP102, MCP9808
Accelerometers/IMUsMPU6050, ADXL345, BMI160
Real-time clocksDS3231, PCF8523
EEPROMsAT24C256, 24LC512
OLED/LCD displaysSSD1306, SH1106
Port expandersPCF8574, MCP23017
ADCs/DACsADS1115, MCP4725

I2C vs SPI vs UART

FeatureI2CSPIUART
Wires2 (SDA, SCL)4 (MOSI, MISO, SCK, CS)2 (TX, RX)
AddressingBuilt-in (7/10-bit)Via separate CS linesNone (point-to-point)
Max devicesMany (address-limited)Limited by CS pins2
SpeedModerate (up to 3.4 MHz)High (tens of MHz)Moderate
ComplexityModerateModerateLow

Real-World and IoT Applications

In a home automation sensor node I worked on, a single I2C bus carried a BME280 (temperature/humidity/pressure), an MPU6050 (motion detection for tamper alerts), and a small SSD1306 OLED for local status display — all on the same two wires, differentiated purely by their I2C addresses. This is exactly the kind of design where I2C shines: lots of low-speed peripherals, minimal pin usage, simple wiring.

graph LR
    ESP32[ESP32 - Master] ---|SDA/SCL| BME[BME280 - 0x76]
    ESP32 ---|SDA/SCL| MPU[MPU6050 - 0x68]
    ESP32 ---|SDA/SCL| OLED[SSD1306 OLED - 0x3C]

Debugging I2C Issues

The most common problems I’ve run into, roughly in order of frequency:

  1. Missing or wrong-value pull-up resistors — many dev boards include them, but breadboard setups with bare sensor modules often don’t, leading to unreliable or completely dead communication.
  2. Wrong 7-bit address, or confusing the 8-bit written address with the 7-bit address — datasheets sometimes list the 8-bit form (already including the R/W bit), which trips people up constantly.
  3. Bus lockups — if a slave gets stuck holding SDA low mid-transaction (e.g., after a reset during a transfer), the bus can hang. A common fix is toggling SCL manually a few times on startup to force the stuck device to release SDA.
  4. Address conflicts — two devices on the bus sharing the same address (common with multiple identical breakout boards) will corrupt communication; many sensor chips provide address-select pins for exactly this reason.

A logic analyzer with I2C decoding is, in my experience, the single most useful debugging tool here — you can see the address byte, ACK/NACK bits, and data bytes laid out clearly, which usually makes the root cause obvious within seconds.

Reliability and Power Considerations

I2C’s shared bus and moderate speed make it well-suited to sensor networks where update rates don’t need to be extremely fast. For lower-power designs, keep in mind that pull-up resistors constantly draw a small current whenever a line is held low; very low-power designs sometimes use higher-value pull-ups or only enable the I2C peripheral’s power domain when actively communicating.

Security Considerations

I2C has no built-in authentication or encryption, and because most I2C buses in a product are only accessible with physical access to internal test points, this is rarely a primary attack surface — but on devices where I2C peripherals are exposed on an external connector (like a debug or expansion header), an attacker could potentially spoof sensor readings or extract data from EEPROMs by tapping the bus.

Frequently Asked Questions

Can I connect I2C devices that use different voltage levels (3.3V vs 5V)? Not directly without a level shifter — since I2C lines are open-drain, mixing voltage domains without a bidirectional level-shifting circuit can damage a lower-voltage device or cause unreliable communication.

What happens if two I2C devices have the same address? Communication will be corrupted or unreliable, since both devices will respond to the same address. Many sensors provide address pins (like ADDR0/ADDR1) to let you choose between two or more possible addresses to avoid conflicts.

Do I always need pull-up resistors? Yes, unless your microcontroller’s internal pull-ups are strong enough for your specific bus length/speed/capacitance (they usually aren’t for anything beyond very short, low-speed buses) — external resistors in the 2.2kΩ–10k�. range are standard practice.

Is I2C faster than SPI? No, generally SPI is significantly faster than I2C, since I2C’s maximum standard speeds top out around 3.4 MHz (High-speed mode) versus SPI, which routinely runs at tens of MHz.

Summary

I2C is a two-wire, clocked, address-based serial protocol that lets a microcontroller talk to many peripherals — sensors, displays, EEPROMs, RTCs — over a shared bus with minimal pin usage. Its open-drain, pull-up-resistor design allows multiple devices to coexist safely on the same lines, addressed individually by their unique 7-bit addresses, with START/STOP conditions and ACK/NACK bits framing every transaction. Once you understand the address-then-register-then-data pattern most I2C devices follow, working with new sensors becomes largely a matter of reading the right datasheet register map rather than learning a new protocol each time.

References and Further Reading

  • NXP I2C-Bus Specification and User Manual (UM10204) — nxp.com
  • STMicroelectronics STM32 I2C Peripheral Reference Manual — st.com
  • Arduino Wire Library Documentation — docs.arduino.cc/language-reference/en/functions/communication/wire
  • Espressif ESP32 I2C Driver Documentation — docs.espressif.com
  • Microchip AT24C Series EEPROM Datasheets — microchip.com
  • Bosch Sensortec BME280/BMI160 Datasheets — bosch-sensortec.com
Total
0
Shares

Leave a Reply

Previous Post
What is an SPI (Serial Peripheral Interface) in embedded systems?

What Is SPI (Serial Peripheral Interface) in Embedded Systems?

Next Post
Explain the concept of UART (Universal Asynchronous Receiver/Transmitter) in embedded systems

Explaining the Concept of UART (Universal Asynchronous Receiver/Transmitter) in Embedded Systems

Related Posts