06 · I2C Peripherals¶
Not flashed to hardware
Reasoned through against the Arduino core's documented Wire
library API (Wire.begin(), Wire.beginTransmission(),
Wire.requestFrom()) shared by ESP8266 and ESP32, and Adafruit's
documented Adafruit_BME280/Adafruit_SSD1306 library APIs as
representative I2C peripheral drivers. Not compiled or flashed to
physical hardware in this environment.
What I2C is and why it's everywhere¶
I2C ("Inter-Integrated Circuit") is a two-wire bus — SDA (data) and
SCL (clock) — that lets a microcontroller talk to many peripheral chips
using only those two shared wires, with each chip distinguished by a
7-bit address. It's the standard bus for small sensors, OLED
displays, RTCs (real-time clocks), and port expanders because wiring
stays simple even with several devices on the same bus.
On NodeMCU-style ESP8266 boards, the default I2C pins are whatever you
pass to Wire.begin(sda, scl) — commonly D2 (GPIO4, SDA) and D1
(GPIO5, SCL). ESP32 boards default to GPIO21 (SDA) / GPIO22 (SCL) but
Wire.begin() accepts explicit pins there too.
Scanning the bus¶
Before wiring blind, an I2C scanner sketch (a well-known community
pattern built entirely from the documented Wire API) finds what
addresses are actually present:
// i2c-scanner.ino
#include <Wire.h>
#if defined(ESP8266)
const int SDA_PIN = D2;
const int SCL_PIN = D1;
#elif defined(ESP32)
const int SDA_PIN = 21;
const int SCL_PIN = 22;
#endif
void setup() {
Serial.begin(115200);
Wire.begin(SDA_PIN, SCL_PIN);
Serial.println("I2C scanner starting...");
int found = 0;
for (byte address = 1; address < 127; address++) {
Wire.beginTransmission(address);
// endTransmission() returns 0 on ACK (device present), documented
// by the Wire library as the standard presence-detection method.
byte error = Wire.endTransmission();
if (error == 0) {
Serial.printf("Device found at 0x%02X\n", address);
found++;
}
}
Serial.printf("Scan complete, %d device(s) found\n", found);
}
void loop() {}
Reading a BME280 (temperature/humidity/pressure) over I2C¶
The BME280 is a common combined environmental sensor, typically at I2C
address 0x76 or 0x77. Adafruit's Adafruit_BME280 library documents
a begin(address) call and simple accessor methods:
// i2c-bme280-read.ino
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
Adafruit_BME280 bme;
void setup() {
Serial.begin(115200);
Wire.begin(); // default pins for the board
// begin() returns false if it can't find/verify the chip ID over I2C --
// documented failure mode for a wrong address or bad wiring.
if (!bme.begin(0x76)) {
Serial.println("Could not find BME280 sensor, check wiring/address!");
while (true) delay(1000);
}
}
void loop() {
Serial.printf("Temp: %.2fC Humidity: %.1f%% Pressure: %.1fhPa\n",
bme.readTemperature(),
bme.readHumidity(),
bme.readPressure() / 100.0F); // library returns Pa; hPa = Pa/100
delay(2000);
}
Driving an SSD1306 OLED display over I2C¶
Small 128x64 OLED displays (SSD1306 driver) are another common I2C
peripheral, driven by Adafruit's Adafruit_SSD1306 + Adafruit_GFX
libraries:
// i2c-oled-display.ino
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
void setup() {
Serial.begin(115200);
Wire.begin();
// 0x3C is the documented default I2C address for most common
// SSD1306 128x64 breakout modules.
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("SSD1306 not found");
while (true) delay(1000);
}
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("Hello IoT");
display.display(); // buffered -- nothing shows until display() is called
}
void loop() {}
Combining two I2C devices on one bus¶
Since the bus is shared, both the BME280 (0x76) and SSD1306 (0x3C)
can coexist on the same SDA/SCL wires as long as their addresses
don't collide — the scanner sketch above is exactly how you'd confirm
that before writing combined code.
How It Actually Works¶
I2C is a two-wire, open-drain bus: both SDA and SCL are pulled to a HIGH idle state by external (or, weakly, internal) pull-up resistors, and every device on the bus — including the ESP master — only ever actively pulls a line LOW, never drives it HIGH, which is what lets multiple devices share the same two wires without contention (two devices pulling low simultaneously is fine; the danger case, two devices both trying to drive high vs low, simply can't happen by design). A transaction starts with a START condition — SDA transitioning HIGH-to-LOW while SCL stays HIGH, a sequence that's illegal during normal data transfer (where SDA only changes while SCL is LOW) and is exactly why it unambiguously signals "begin" to every listening device. Each subsequent byte is clocked out bit-by-bit on SCL's rising edge, MSB first, followed by a 9th clock pulse during which the receiving device pulls SDA low to ACK (or leaves it high to NAK) — Wire.endTransmission() returning nonzero specifically means that ACK bit never came back.
The 7-bit address in the first byte after START is why I2C devices can collide: two sensors hardwired to the same address (a common issue with cheap breakout boards sharing a fixed default) will both try to ACK the same address byte, and the bus has no way to tell them apart — this is the actual reason many I2C sensor breakouts expose an address-select pin (usually tied through a resistor to GND/VCC to hardcode one bit of the 7-bit address), letting you put two of the same sensor on one bus.
(These examples were written and reasoned through at the register/protocol level but were not flashed to a physical board for this pass — verify timing-sensitive details against your exact chip datasheet before relying on them in production.)
Exercise¶
- Write the scanner sketch and reason through what addresses you'd
expect to see for a BME280 + SSD1306 combo (
0x76and0x3C). - Write the BME280 read sketch and explain what a
begin()failure (returningfalse) would mean in practice. - Write the OLED sketch and explain why
display.display()is a separate call from drawing/printing commands. - Combine both into one sketch that shows the BME280's live readings on the OLED screen, refreshed every 2 seconds.