Imagine connecting a temperature sensor, an OLED display, an accelerometer, and a barometric pressure sensor to your microcontroller — all using just two wires. That is the power of I2C.
I2C (Inter-Integrated Circuit, pronounced "eye-squared-see" or "eye-two-see") is one of the most widely used communication protocols in the maker world. Whether you are building a weather station with a BME280, displaying data on an SSD1306 OLED, or reading motion from an MPU6050, chances are you are already using I2C without fully understanding what happens on those two little wires.
This guide breaks down everything you need to know — from the electrical fundamentals to working code you can flash right now.
Why I2C Matters
Most microcontrollers have a limited number of GPIO pins. SPI requires 4 wires per device (plus an extra chip-select line for each additional device). UART is point-to-point and typically connects just two devices. I2C solves the pin-scarcity problem elegantly:
- Only 2 wires (SDA and SCL) for any number of devices
- Up to 127 devices on a single bus (with 7-bit addressing)
- Bidirectional — the master can both send and receive data
- Widely supported — nearly every sensor, display, and peripheral module you will find at Wavtron speaks I2C
If you are prototyping on a breadboard with an ESP32 or Arduino Uno, I2C lets you add sensors without running out of pins.
How I2C Works
The Two Wires
| Wire | Name | Function |
|---|---|---|
| SDA | Serial Data | Carries the actual data bits back and forth |
| SCL | Serial Clock | Clock signal generated by the master to synchronize communication |
Both lines are open-drain, meaning devices can only pull the line LOW. They cannot drive it HIGH. This is why pull-up resistors are essential (more on that shortly).
Master and Slave
I2C uses a master-slave architecture:
- The master (your Arduino or ESP32) initiates all communication, generates the clock signal, and controls the bus.
- The slave (sensor, display, or peripheral) responds only when addressed by the master.
A single bus can have multiple masters, but in most maker projects you will have one master and several slaves.
Communication Sequence
Every I2C transaction follows this pattern:
- Start condition — Master pulls SDA LOW while SCL is HIGH. This alerts all devices on the bus.
- Address frame — Master sends the 7-bit address of the target device, plus a read/write bit (0 = write, 1 = read).
- ACK/NACK — The addressed slave pulls SDA LOW to acknowledge (ACK). If no device responds, the line stays HIGH (NACK).
- Data frames — One or more bytes are transmitted. Each byte is followed by an ACK/NACK from the receiver.
- Stop condition — Master releases SDA while SCL is HIGH, signaling the end of the transaction.
This handshake mechanism means the master always knows whether a device is present and responsive.
I2C Addressing
Every I2C device has a 7-bit address hardcoded or partially configurable by the manufacturer. Some common addresses:
| Device | Typical Address | Notes |
|---|---|---|
| BME280 | 0x76 or 0x77 | Selectable via SDO pin |
| SSD1306 OLED | 0x3C or 0x3D | Selectable via resistor/jumper |
| MPU6050 | 0x68 or 0x69 | Selectable via AD0 pin |
| ADS1115 ADC | 0x48 to 0x4B | 4 addresses via ADDR pin |
| PCA9685 PWM | 0x40 to 0x7F | 6 address bits configurable |
With 7 bits, there are 128 possible addresses (0x00 to 0x7F). Addresses 0x00 to 0x07 and 0x78 to 0x7F are reserved, leaving 112 usable addresses.
When two devices share the same address, you have a conflict. We will cover solutions later in this guide.
Pull-Up Resistors: The Most Overlooked Detail
Since SDA and SCL are open-drain, they need pull-up resistors to return to a HIGH state when no device is pulling them LOW.
Typical value: 4.7k ohm
This works well for most setups at standard speed (100 kHz) with short wires (under 30 cm) and a few devices.
Choosing the Right Value
| Condition | Recommended Resistance |
|---|---|
| Short bus, few devices, 100 kHz | 4.7k ohm |
| Longer bus or more devices | 2.2k ohm |
| Fast mode (400 kHz) | 2.2k ohm to 3.3k ohm |
| Fast mode plus (1 MHz) | 1k ohm to 2.2k ohm |
Too high (10k ohm+): Slow rise times, unreliable communication at higher speeds. Too low (below 1k ohm): Excessive current draw, devices may not be able to pull the line LOW.
Many breakout boards (like the ones we sell at Wavtron) include pull-up resistors on-board. If you connect multiple breakout boards that each have their own pull-ups, the effective resistance drops (resistors in parallel). With 3 boards each having 10k ohm pull-ups, you get an effective 3.3k ohm — usually fine. But with 5+ boards, you may need to desolder some of the on-board resistors.
I2C on Arduino: The Wire Library
Arduino makes I2C straightforward with the built-in Wire library.
Key Functions
| Function | Purpose |
|---|---|
Wire.begin() |
Initialize as master |
Wire.begin(address) |
Initialize as slave at given address |
Wire.beginTransmission(addr) |
Start talking to a specific slave |
Wire.write(data) |
Queue data bytes to send |
Wire.endTransmission() |
Send queued data and release bus |
Wire.requestFrom(addr, qty) |
Request bytes from a slave |
Wire.read() |
Read one received byte |
Wire.available() |
Number of bytes available to read |
Basic Pattern: Writing to a Device
#include <Wire.h>
void setup() {
Wire.begin();
// Write 0x01 to register 0x2D on device at address 0x53
Wire.beginTransmission(0x53);
Wire.write(0x2D); // register address
Wire.write(0x01); // value to write
Wire.endTransmission();
}
Basic Pattern: Reading from a Device
// Read 2 bytes from register 0xF7 on device at address 0x76
Wire.beginTransmission(0x76);
Wire.write(0xF7); // register to read from
Wire.endTransmission(false); // repeated start (don't release bus)
Wire.requestFrom(0x76, 2); // request 2 bytes
if (Wire.available() >= 2) {
uint8_t msb = Wire.read();
uint8_t lsb = Wire.read();
uint16_t value = (msb << 8) | lsb;
}
Note the false parameter in endTransmission(false) — this sends a repeated start instead of a stop condition, keeping the bus locked between the write (setting the register pointer) and the read. Most I2C devices require this.
I2C on ESP32: Flexibility and Power
The ESP32 is more flexible than the Arduino Uno when it comes to I2C:
- Any GPIO can be used as SDA or SCL (not limited to fixed pins)
- Two independent I2C buses (I2C0 and I2C1)
- Configurable clock speed up to 1 MHz
Custom Pins
#include <Wire.h>
// Use GPIO 21 for SDA and GPIO 22 for SCL (ESP32 defaults)
Wire.begin(21, 22);
// Or use any other pins
Wire.begin(16, 17); // SDA on GPIO16, SCL on GPIO17
Using Two I2C Buses
This is extremely useful when you have address conflicts or want to isolate high-speed and low-speed devices:
#include <Wire.h>
// Bus 0: sensors
TwoWire I2C_Sensors = TwoWire(0);
// Bus 1: display
TwoWire I2C_Display = TwoWire(1);
void setup() {
I2C_Sensors.begin(21, 22); // SDA=21, SCL=22
I2C_Display.begin(16, 17); // SDA=16, SCL=17
// Now use I2C_Sensors and I2C_Display
// instead of Wire for each bus
I2C_Sensors.beginTransmission(0x76);
// ...
I2C_Display.beginTransmission(0x3C);
// ...
}
Practical Code: I2C Scanner
The first thing you should do when wiring up I2C devices is scan the bus to confirm they are detected. This sketch scans all 127 addresses and prints every device it finds:
#include <Wire.h>
void setup() {
Serial.begin(115200);
Wire.begin(); // Use Wire.begin(SDA, SCL) on ESP32
Serial.println("I2C Scanner - Scanning...\n");
int devicesFound = 0;
for (byte addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
byte error = Wire.endTransmission();
if (error == 0) {
Serial.print("Device found at address 0x");
if (addr < 16) Serial.print("0");
Serial.println(addr, HEX);
devicesFound++;
}
}
if (devicesFound == 0) {
Serial.println("No I2C devices found!");
Serial.println("Check wiring, pull-ups, and power.");
} else {
Serial.print("\nTotal devices found: ");
Serial.println(devicesFound);
}
}
void loop() {
// Nothing here - scan runs once
}
Expected output with a BME280, OLED, and MPU6050 connected:
I2C Scanner - Scanning...
Device found at address 0x3C
Device found at address 0x68
Device found at address 0x76
Total devices found: 3
If you see "No I2C devices found", check: (1) wiring, (2) pull-up resistors, (3) that the device is powered, (4) correct voltage levels (3.3V vs 5V).
Reading Temperature from BME280
The BME280 is a popular I2C sensor that measures temperature, humidity, and barometric pressure. Here is a practical example using the Adafruit BME280 library:
#include <Wire.h>
#include <Adafruit_BME280.h>
Adafruit_BME280 bme;
void setup() {
Serial.begin(115200);
Wire.begin(); // GPIO 21, 22 on ESP32
if (!bme.begin(0x76)) { // Try 0x77 if this fails
Serial.println("BME280 not found! Check wiring.");
while (1) delay(10);
}
Serial.println("BME280 connected successfully.\n");
}
void loop() {
float temperature = bme.readTemperature();
float humidity = bme.readHumidity();
float pressure = bme.readPressure() / 100.0F; // hPa
Serial.print("Temperature: ");
Serial.print(temperature);
Serial.println(" C");
Serial.print("Humidity: ");
Serial.print(humidity);
Serial.println(" %");
Serial.print("Pressure: ");
Serial.print(pressure);
Serial.println(" hPa");
Serial.println("---");
delay(2000);
}
Install the library: In Arduino IDE, go to Sketch > Include Library > Manage Libraries, then search for "Adafruit BME280" and install it along with "Adafruit Unified Sensor".
Driving an SSD1306 OLED Display
The 0.96-inch SSD1306 OLED is one of the most satisfying I2C peripherals to work with — instant visual feedback with minimal wiring:
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1 // No reset pin
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
void setup() {
Serial.begin(115200);
Wire.begin();
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("SSD1306 not found!");
while (1) delay(10);
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("Hello from Wavtron!");
display.setTextSize(2);
display.setCursor(0, 20);
display.println("I2C OLED");
display.display();
}
void loop() {
// Static display - nothing to update
}
Install: Search for "Adafruit SSD1306" and "Adafruit GFX Library" in the Library Manager.
Reading Accelerometer Data from MPU6050
The MPU6050 combines a 3-axis accelerometer and 3-axis gyroscope in a single I2C package. Here is how to read raw accelerometer values:
#include <Wire.h>
#define MPU6050_ADDR 0x68
int16_t accelX, accelY, accelZ;
int16_t gyroX, gyroY, gyroZ;
int16_t temperature;
void setup() {
Serial.begin(115200);
Wire.begin();
// Wake up the MPU6050 (it starts in sleep mode)
Wire.beginTransmission(MPU6050_ADDR);
Wire.write(0x6B); // PWR_MGMT_1 register
Wire.write(0x00); // Wake up
Wire.endTransmission(true);
Serial.println("MPU6050 initialized.\n");
}
void loop() {
// Read 14 bytes starting from register 0x3B
Wire.beginTransmission(MPU6050_ADDR);
Wire.write(0x3B); // Starting register
Wire.endTransmission(false); // Repeated start
Wire.requestFrom(MPU6050_ADDR, 14);
// Each axis value is 2 bytes (high byte first)
accelX = (Wire.read() << 8) | Wire.read();
accelY = (Wire.read() << 8) | Wire.read();
accelZ = (Wire.read() << 8) | Wire.read();
temperature = (Wire.read() << 8) | Wire.read();
gyroX = (Wire.read() << 8) | Wire.read();
gyroY = (Wire.read() << 8) | Wire.read();
gyroZ = (Wire.read() << 8) | Wire.read();
// Convert to g (default sensitivity: 16384 LSB/g)
float ax = accelX / 16384.0;
float ay = accelY / 16384.0;
float az = accelZ / 16384.0;
// Convert temperature
float tempC = (temperature / 340.0) + 36.53;
Serial.print("Accel (g): X=");
Serial.print(ax, 2);
Serial.print(" Y=");
Serial.print(ay, 2);
Serial.print(" Z=");
Serial.println(az, 2);
Serial.print("Temp: ");
Serial.print(tempC, 1);
Serial.println(" C\n");
delay(500);
}
This example reads the raw registers directly — no library needed. You can see exactly how I2C register reads work under the hood.
Connecting Multiple I2C Devices
One of the best things about I2C is daisy-chaining devices. Wire all SDA lines together and all SCL lines together:
ESP32/Arduino
|
+--- SDA ---+---+---+--- 4.7k ohm --- 3.3V
| | | |
+--- SCL ---+---+---+--- 4.7k ohm --- 3.3V
| | |
BME280 OLED MPU6050
Only one pair of pull-up resistors is needed for the entire bus (assuming breakout boards do not have their own — check the board schematics).
Here is a combined example reading the BME280 and displaying on the OLED:
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <Adafruit_SSD1306.h>
Adafruit_BME280 bme;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
void setup() {
Wire.begin();
bme.begin(0x76);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
}
void loop() {
float temp = bme.readTemperature();
float hum = bme.readHumidity();
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("Weather Station");
display.println("----------------");
display.setTextSize(2);
display.print(temp, 1);
display.println(" C");
display.setTextSize(1);
display.print("Humidity: ");
display.print(hum, 0);
display.println(" %");
display.display();
delay(2000);
}
Three different I2C addresses, one bus, zero pin waste.
Address Conflicts and the TCA9548A Multiplexer
What if you need two identical sensors — say two BME280 modules — and both are stuck at 0x76? Some modules let you change the address via a jumper or solder bridge, giving you 0x76 and 0x77. But that only gets you two.
For more, use the TCA9548A I2C multiplexer. It sits at address 0x70 (configurable up to 0x77) and provides 8 independent I2C channels. You select a channel by writing to the multiplexer, then communicate with the device on that channel normally.
#include <Wire.h>
#define TCA9548A_ADDR 0x70
void tcaSelect(uint8_t channel) {
if (channel > 7) return;
Wire.beginTransmission(TCA9548A_ADDR);
Wire.write(1 << channel);
Wire.endTransmission();
}
void setup() {
Serial.begin(115200);
Wire.begin();
// Scan each channel
for (uint8_t ch = 0; ch < 8; ch++) {
tcaSelect(ch);
Serial.print("Channel ");
Serial.print(ch);
Serial.print(": ");
for (byte addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
if (Wire.endTransmission() == 0) {
Serial.print("0x");
if (addr < 16) Serial.print("0");
Serial.print(addr, HEX);
Serial.print(" ");
}
}
Serial.println();
}
}
void loop() {}
With a TCA9548A, you can connect up to 8 devices that all share the same address. Connect BME280 #1 to channel 0, BME280 #2 to channel 1, and so on.
I2C Speed Modes
| Mode | Speed | Common Use |
|---|---|---|
| Standard Mode | 100 kHz | Default for most sensors, maximum compatibility |
| Fast Mode | 400 kHz | Displays, faster sensor polling |
| Fast Mode Plus | 1 MHz | High-throughput applications |
| High Speed | 3.4 MHz | Rarely used in maker projects |
To change speed on ESP32:
Wire.begin(21, 22);
Wire.setClock(400000); // 400 kHz Fast Mode
On Arduino Uno:
Wire.begin();
Wire.setClock(400000); // 400 kHz
Tip: Start at 100 kHz. Only increase speed if you need faster updates (like refreshing an OLED at high frame rates). Higher speeds require shorter wires and lower pull-up resistance.
Troubleshooting I2C
"No devices found" in scanner
- Check wiring — SDA to SDA, SCL to SCL. The most common mistake is swapping them.
- Check power — Is the device getting 3.3V or 5V as required?
- Pull-up resistors — If neither the board nor the module has pull-ups, add 4.7k ohm to SDA and SCL, pulled to VCC.
- Voltage mismatch — ESP32 is 3.3V. If the sensor is 5V-only, use a level shifter.
- Solder joints — On breakout boards, check for cold solder joints on the header pins.
SDA or SCL stuck LOW
This happens when a transaction is interrupted mid-byte (e.g., by a reset). The slave holds SDA LOW waiting for clock pulses that never come.
Fix: Toggle SCL manually to unstick the bus:
void recoverI2C(int sdaPin, int sclPin) {
pinMode(sdaPin, INPUT_PULLUP);
pinMode(sclPin, OUTPUT);
// Send 9 clock pulses to release any stuck slave
for (int i = 0; i < 9; i++) {
digitalWrite(sclPin, LOW);
delayMicroseconds(5);
digitalWrite(sclPin, HIGH);
delayMicroseconds(5);
}
// Re-initialize I2C
Wire.begin(sdaPin, sclPin);
}
Intermittent failures or garbage data
- Noise on long wires: Keep I2C wires under 30 cm for reliable operation. Use twisted pair if possible.
- Too many devices: Each device adds capacitance to the bus. The I2C spec allows a maximum bus capacitance of 400 pF.
- Missing repeated start: Make sure you use
Wire.endTransmission(false)beforeWire.requestFrom()when reading registers.
Wire Length Limitations
I2C was designed for communication between chips on the same PCB. On a breadboard with jumper wires, you can typically go up to 30-50 cm without problems.
For longer distances:
| Distance | Solution |
|---|---|
| Up to 1 m | Lower pull-up resistance (2.2k ohm), reduce speed to 100 kHz, use shielded cable |
| 1-5 m | Use an I2C bus extender IC (P82B715, PCA9600) |
| 5+ m | Convert to a long-distance protocol (RS-485, CAN bus) and bridge back to I2C at each end |
Practical tip: If you need to place a sensor more than a metre from your microcontroller, consider using a sensor module that speaks UART or has its own microcontroller that can relay data over serial/WiFi.
I2C vs SPI: When to Choose Which
| Feature | I2C | SPI |
|---|---|---|
| Wires needed | 2 (SDA, SCL) | 4+ (MOSI, MISO, SCK, CS per device) |
| Max speed | Typically 400 kHz-1 MHz | 10-80 MHz |
| Number of devices | Up to 127 (address-based) | Limited by CS pins |
| Complexity | Moderate (addressing, ACK) | Simple (shift registers) |
| Pin usage | Low (2 pins for all devices) | High (1 extra pin per device) |
| Best for | Sensors, config chips, low-speed peripherals | Displays, SD cards, high-speed data transfer |
Choose I2C when:
- You are connecting multiple low-speed sensors
- Pin count is limited
- You want simpler wiring on a breadboard
- Devices are close together (under 30 cm)
Choose SPI when:
- You need high data throughput (TFT displays, SD cards)
- You have pins to spare
- Latency matters (SPI has no addressing overhead)
- You are only connecting 1-2 devices
Many devices support both protocols. The BME280, for example, works over I2C or SPI. For a single sensor, I2C is easier. For a data-logging application reading the sensor at high speed, SPI might be the better choice.
Wrapping Up
I2C is the backbone of most maker sensor projects. With just two wires, you can build sophisticated multi-sensor systems — weather stations, motion trackers, environmental monitors, and more.
Here is a quick reference to keep nearby:
- SDA: Data line. SCL: Clock line.
- Pull-ups: 4.7k ohm to VCC. One pair per bus.
- Scanner first: Always run the I2C scanner sketch before debugging code.
- Repeated start: Use
Wire.endTransmission(false)before reading registers. - ESP32 advantage: Any GPIO works for I2C, and you get two independent buses.
- Address conflicts: Use the TCA9548A multiplexer.
- Keep wires short: Under 30 cm for best reliability.
All the sensors and modules mentioned in this guide — BME280, MPU6050, SSD1306 OLED, TCA9548A multiplexer, ESP32 DevKit, and Arduino boards — are available at wavtron.in. Pick up a few, wire them up on a breadboard, and start experimenting. The best way to learn I2C is to watch the scanner find your devices for the first time.



