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:
| Signal | Description |
|---|---|
| SDA | Serial Data — bidirectional data line |
| SCL | Serial Clock — clock generated by the master |
Key characteristics:
| Feature | I2C |
|---|---|
| Wires | 2 (SDA and SCL) |
| Topology | Multi-master, multi-slave bus |
| Addressing | 7-bit (up to 127 devices) or 10-bit addresses |
| Speed | Standard (100 kHz), Fast (400 kHz), Fast-Plus (1 MHz) |
| Direction | Half-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.
I2C Bus Topology
-----------------
ESP32 (Master)
SDA ----+--------+--------+--------+
SCL ----+--------+--------+--------+
| | | |
Sensor OLED EEPROM RTC
0x48 0x3C 0x50 0x68
ESP32 I2C Default Pins
| Signal | Default GPIO |
|---|---|
| SDA | GPIO21 |
| SCL | GPIO22 |
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
#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
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
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:
#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.
#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:
#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
| Device | Address | Use |
|---|---|---|
| SSD1306 OLED | 0x3C or 0x3D | Display |
| BME280 | 0x76 or 0x77 | Temperature, humidity, pressure |
| MPU-6050 | 0x68 or 0x69 | Accelerometer, gyroscope |
| DS1307 RTC | 0x68 | Real-time clock |
| AT24C32 EEPROM | 0x50–0x57 | Non-volatile storage |
| BMP180 | 0x77 | Barometric pressure |
| ADS1115 ADC | 0x48–0x4B | 16-bit ADC |
Wire API Summary
| Method | Description |
|---|---|
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
| Feature | I2C | SPI | UART |
|---|---|---|---|
| Wires | 2 | 4+ | 2 |
| Speed | Up to 1 MHz | Up to 80 MHz | Up to 5 Mbaud |
| Multi-device | Up to 127 (addressing) | One CS per device | Point-to-point |
| Pull-ups needed | Yes | No | No |
| Best for | Sensors, displays, EEPROM | Displays, flash, high-speed ADC | PC 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.