ES: ESP32 I2C

I2C (Inter-Integrated Circuit) enables the ESP32 to communicate with multiple sensors, EEPROMs, displays, and other peripherals using just two wires. This guide covers I2C fundamentals, the Wire library for Arduino, master and slave operation, and practical examples for reading and writing device registers.

I2C is the protocol of choice when you need to connect multiple devices to a single bus with minimal wiring. A temperature sensor, an accelerometer, an OLED display, and an RTC can all share the same two-wire I2C bus with each device identified by a unique 7-bit address.


What is I2C?

I2C (Inter-Integrated Circuit), pronounced "I-squared-C", is a synchronous, half-duplex serial communication protocol that uses just two signals:

SignalDescription
SDASerial Data — bidirectional data line
SCLSerial Clock — clock generated by the master

Key characteristics:

FeatureI2C
Wires2 (SDA and SCL)
TopologyMulti-master, multi-slave bus
Addressing7-bit (up to 127 devices) or 10-bit addresses
SpeedStandard (100 kHz), Fast (400 kHz), Fast-Plus (1 MHz)
DirectionHalf-duplex

Every device on the I2C bus has a unique address. The master selects which device to communicate with by addressing it at the start of each transaction.

md
I2C Bus Topology
-----------------
ESP32 (Master)
  SDA ----+--------+--------+--------+
  SCL ----+--------+--------+--------+
          |        |        |        |
       Sensor    OLED    EEPROM    RTC
       0x48     0x3C    0x50      0x68


ESP32 I2C Default Pins

SignalDefault GPIO
SDAGPIO21
SCLGPIO22

Both signals require pull-up resistors to 3.3V (typically 4.7 kΩ). Many breakout boards include these resistors. Without pull-ups, the I2C bus will not function.


I2C with the Arduino Framework (Wire Library)

The Arduino Wire.h library provides I2C support on the ESP32.

Initialize I2C

cpp
#include <Wire.h>

#define I2C_SCL 22   // SCL pin (default)
#define I2C_SDA 21   // SDA pin (default)
#define DEVICE_ADDRESS 0x3C  // I2C device address (example: SSD1306 OLED)

void setup() {
    Wire.begin(I2C_SDA, I2C_SCL);  // Initialize I2C as master with specified pins
    Serial.begin(115200);
    Serial.println("I2C initialized");
}

Write Data to an I2C Device Register

cpp
void i2c_write_data(uint8_t reg_addr, uint8_t data) {
    Wire.beginTransmission(DEVICE_ADDRESS);  // Start transmission to device
    Wire.write(reg_addr);                    // Send register address
    Wire.write(data);                        // Send data byte
    Wire.endTransmission();                  // Stop transmission
}

Read Data from an I2C Device Register

cpp
void i2c_read_data(uint8_t reg_addr, uint8_t *data, size_t len) {
    // Tell device which register to read
    Wire.beginTransmission(DEVICE_ADDRESS);
    Wire.write(reg_addr);
    Wire.endTransmission(false);  // false = send repeated start (keep bus active)

    // Request bytes from device
    Wire.requestFrom(DEVICE_ADDRESS, len);

    // Read received bytes
    for (size_t i = 0; i < len; i++) {
        if (Wire.available()) {
            data[i] = Wire.read();
        }
    }
}

The Wire.endTransmission(false) is important — passing false sends a repeated start rather than a stop condition, which tells the device that the following read request is part of the same transaction. Many sensors require this behavior.


I2C Master: Reading from a Sensor

This example scans for a device at address 8 and reads one byte:

cpp
#include <Wire.h>

void setup() {
    Wire.begin();   // Initialize as I2C master (uses default SDA/SCL pins)
    Serial.begin(9600);
    Serial.println("I2C Master initialized");
}

void loop() {
    Wire.requestFrom(8, 1);  // Request 1 byte from device at address 8

    if (Wire.available()) {
        int receivedData = Wire.read();  // Read the byte
        Serial.print("Received from Slave: ");
        Serial.println(receivedData);
    }

    delay(1000);
}


I2C Slave: Responding to a Master

The ESP32 can also operate as an I2C slave — useful when another microcontroller is the bus master.

cpp
#include <Wire.h>

int counter = 0;

// Callback: called when master requests data from this slave
void sendDataToMaster() {
    Wire.write(counter);   // Send current counter value
    counter++;
    Serial.print("Sent to Master: ");
    Serial.println(counter);
}

void setup() {
    Wire.begin(8);                      // Initialize as I2C slave with address 8
    Wire.onRequest(sendDataToMaster);   // Register the request callback
    Serial.begin(9600);
    Serial.println("I2C Slave initialized");
}

void loop() {
    delay(1000);  // Idle — requests handled by callback
}

The Wire.onRequest() callback is triggered automatically each time the master sends a data request to this slave's address.


I2C Device Scanner

Before writing device-specific code, scan the bus to verify which devices are connected and confirm their addresses:

cpp
#include <Wire.h>

void setup() {
    Wire.begin();
    Serial.begin(115200);
    Serial.println("I2C Device Scanner");
    Serial.println("Scanning...");

    int devices_found = 0;

    for (uint8_t addr = 1; addr < 127; addr++) {
        Wire.beginTransmission(addr);
        uint8_t error = Wire.endTransmission();

        if (error == 0) {
            Serial.print("Device found at address 0x");
            if (addr < 16) Serial.print("0");
            Serial.println(addr, HEX);
            devices_found++;
        }
    }

    if (devices_found == 0) {
        Serial.println("No I2C devices found");
    } else {
        Serial.print("Found ");
        Serial.print(devices_found);
        Serial.println(" device(s)");
    }
}

void loop() {}


Common I2C Device Addresses

DeviceAddressUse
SSD1306 OLED0x3C or 0x3DDisplay
BME2800x76 or 0x77Temperature, humidity, pressure
MPU-60500x68 or 0x69Accelerometer, gyroscope
DS1307 RTC0x68Real-time clock
AT24C32 EEPROM0x50–0x57Non-volatile storage
BMP1800x77Barometric pressure
ADS1115 ADC0x48–0x4B16-bit ADC

Wire API Summary

MethodDescription
Wire.begin()Initialize as master with default pins
Wire.begin(sda, scl)Initialize as master with custom pins
Wire.begin(address)Initialize as slave with given address
Wire.beginTransmission(addr)Start a write transaction to addr
Wire.write(data)Add byte to transmit buffer
Wire.endTransmission()Send buffer and stop
Wire.endTransmission(false)Send buffer, hold bus (repeated start)
Wire.requestFrom(addr, len)Request len bytes from addr
Wire.available()Number of bytes available to read
Wire.read()Read one byte from receive buffer
Wire.onRequest(callback)Register slave request handler
Wire.onReceive(callback)Register slave receive handler

I2C vs SPI vs UART

FeatureI2CSPIUART
Wires24+2
SpeedUp to 1 MHzUp to 80 MHzUp to 5 Mbaud
Multi-deviceUp to 127 (addressing)One CS per devicePoint-to-point
Pull-ups neededYesNoNo
Best forSensors, displays, EEPROMDisplays, flash, high-speed ADCPC comms, GPS, GSM

Final Thoughts

I2C is the ideal protocol for connecting multiple slow-to-medium-speed peripherals with minimal wiring. The ESP32's Wire library makes it easy to communicate with virtually any I2C device — from simple sensors to complex display controllers.

Key practices for reliable I2C:

  • Always use pull-up resistors on SDA and SCL
  • Use the I2C scanner to verify device addresses before writing device code
  • Use endTransmission(false) for read-after-write register access patterns
  • Check Wire.available() before reading to avoid reading stale or invalid data

I2C turns two wires into a bus that can connect an entire ecosystem of sensors and peripherals.