Learning Goals 5 min
Yesterday was theory. Today you actually plug an I²C device in, scan the bus to find it, and read its address back. Wire is the Arduino library that hides every detail of START / ACK / STOP — you write 4-line transactions and the library handles the rest. By the end of this lesson you will:
- Wire one or more I²C breakout boards to the UNO's A4/A5 pins with shared GND.
- Run the canonical "I²C scanner" sketch and read off the address of every connected device.
- Use
Wire.beginTransmission,Wire.write,Wire.endTransmission,Wire.requestFrom, andWire.readto manually write one register and read one register from a device.
Warm-Up 10 min
Find any I²C breakout from your kit. Good candidates:
- SSD1306 OLED (we'll display on it tomorrow — for today, just scan).
- I²C LCD backpack (from L02-27 / L02-28).
- BMP280, BME280, MPU-6050, DS3231 if you have them.
- An I²C breakout you bought and haven't used yet — perfect, today is "just check that the wiring works".
Wire it to the UNO
| Breakout pin | UNO pin |
|---|---|
| VCC (often labelled +, 3V3, or VCC) | 5V (or 3V3 if the breakout requires 3.3 V — check first) |
| GND | GND |
| SDA | A4 |
| SCL | A5 |
Optional pull-ups
Most breakouts have built-in pull-ups. If yours doesn't (rare), add 4.7 kΩ from SDA→5V and SCL→5V on the breadboard.
New Concept · The Wire API 25 min
Step 1 · Include and begin
#include <Wire.h>
void setup() {
Wire.begin(); // join the bus as the controller
Serial.begin(9600);
}Wire.begin() with no arguments makes the UNO the I²C controller — driving the clock, initiating all transactions. With an argument, it becomes a peripheral with that address (very rarely needed for Arduino).
The five API methods you actually use
| Method | What it does |
|---|---|
Wire.beginTransmission(addr) | Queue up a write to addr. Doesn't actually send anything yet. |
Wire.write(byte) | Append one byte to the queue. |
Wire.endTransmission() | Send everything queued; returns a status code (0 = success, > 0 = error). |
Wire.requestFrom(addr, n) | Ask addr for n bytes. Bytes arrive into an internal buffer. |
Wire.read() | Read one byte from the buffer. |
A complete "set register, then read register" transaction
To read the temperature from a BMP280 (just an example):
const uint8_t BMP280_ADDR = 0x76;
const uint8_t REG_TEMP_MSB = 0xFA;
// Step 1: tell the device which register to read
Wire.beginTransmission(BMP280_ADDR);
Wire.write(REG_TEMP_MSB);
Wire.endTransmission();
// Step 2: ask for 3 bytes (temperature is 20 bits packed into 3 bytes)
Wire.requestFrom(BMP280_ADDR, (uint8_t)3);
uint32_t raw = 0;
raw |= ((uint32_t)Wire.read()) << 12;
raw |= ((uint32_t)Wire.read()) << 4;
raw |= ((uint32_t)Wire.read()) >> 4;You wouldn't normally write this — the Adafruit_BMP280 library does it for you. But knowing the pattern lets you read any I²C chip's datasheet and write minimal code without needing a library.
Error codes from endTransmission()
| Code | Meaning |
|---|---|
| 0 | Success — peripheral ACKed everything. |
| 1 | Data too long for the Wire library's 32-byte buffer. |
| 2 | NACK on the address byte. Device not present at that address. |
| 3 | NACK on a data byte. Device didn't want any more data. |
| 4 | Other error (bus error, timeout, etc.). |
The bus scanner uses code 0 vs 2 to distinguish "device exists" vs "nothing at this address".
The canonical I²C scanner
#include <Wire.h>
void setup() {
Serial.begin(9600);
Wire.begin();
delay(2000);
Serial.println("# I2C scanner");
}
void loop() {
byte found = 0;
for (byte addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
byte err = Wire.endTransmission();
if (err == 0) {
Serial.print("Found device at 0x");
if (addr < 16) Serial.print('0');
Serial.println(addr, HEX);
found++;
}
}
if (!found) Serial.println("No devices found.");
Serial.println();
delay(5000);
}This loops every 5 seconds. Plug in a device → it shows up next scan. Unplug → it disappears. The fastest debugging tool in I²C land.
Worked Example · Scan, identify, manually poke 25 min
Step 1 — wire your device(s) to the UNO
One or more I²C breakouts: VCC, GND, SDA → A4, SCL → A5. Multiple devices share the same pins.
Step 2 — upload the scanner from §3 and check the output
Open Serial Monitor at 9600 baud. After a few seconds you should see something like:
# I2C scanner Found device at 0x3C Found device at 0x68
Match the addresses against your device table from L03-17. 0x3C = SSD1306 OLED; 0x68 = MPU-6050 or DS3231. If your scan shows a device whose address you don't recognise, that's either a chip you forgot about or an address change you set.
Step 3 — write a single byte to a real device
Let's send the "display off" command to an SSD1306 OLED. Even without an OLED library, this should make the display go dark (if it was on).
// L03-18 · Manual OLED command using Wire
#include <Wire.h>
const uint8_t OLED_ADDR = 0x3C;
const uint8_t CTRL_COMMAND = 0x00; // next byte is a command, not data
const uint8_t CMD_DISPLAY_OFF = 0xAE;
const uint8_t CMD_DISPLAY_ON = 0xAF;
void sendCommand(uint8_t cmd) {
Wire.beginTransmission(OLED_ADDR);
Wire.write(CTRL_COMMAND);
Wire.write(cmd);
byte err = Wire.endTransmission();
Serial.print("# sent cmd 0x"); Serial.print(cmd, HEX);
Serial.print(" err="); Serial.println(err);
}
void setup() {
Serial.begin(9600);
Wire.begin();
delay(2000);
Serial.println("# OLED off in 2s, on in 4s, off in 6s...");
}
void loop() {
delay(2000); sendCommand(CMD_DISPLAY_OFF);
delay(2000); sendCommand(CMD_DISPLAY_ON);
}If your OLED is fresh-out-of-box, "display off" may have no visible effect (the OLED hasn't been initialised, so it's blank anyway). The point of this exercise is to confirm the WRITE works — check that err prints as 0. err = 2 means "address NACK" → no device responded.
Step 4 — read a byte back
Many I²C chips have a "chip ID" register at a fixed address. Reading it back is a self-test. For an MPU-6050, the WHO_AM_I register is 0x75 and should contain 0x68:
#include <Wire.h>
const uint8_t MPU_ADDR = 0x68;
const uint8_t WHO_AM_I = 0x75;
void setup() {
Serial.begin(9600);
Wire.begin();
delay(1000);
Wire.beginTransmission(MPU_ADDR);
Wire.write(WHO_AM_I);
Wire.endTransmission();
Wire.requestFrom(MPU_ADDR, (uint8_t)1);
if (Wire.available()) {
uint8_t id = Wire.read();
Serial.print("WHO_AM_I = 0x");
Serial.println(id, HEX);
} else {
Serial.println("# no reply");
}
}
void loop() { }If you have an MPU-6050, you should see WHO_AM_I = 0x68. That's your "hello" from the chip — and you got it without any motion library, just Wire.
Step 5 — break the wiring on purpose
Pull the SDA wire (A4) while the scanner is running. The scanner output stops showing devices — wires not connected, no ACK from anyone. Plug it back in → devices reappear. This is the "eyes on the bus" debugging that the scanner gives you. Save the scanner sketch with the rest of your reusable tools.
Try It Yourself 15 min
Goal: Modify the scanner to print the total count of devices found at the end of each scan: "3 devices found".
Hint
Serial.print(found); Serial.println(" devices found.");Add this just before the closing delay(5000). The found counter is already in the scanner sketch.
Goal: Write a helper i2cAlive(addr) that returns true if a device at addr responds, false if not. Call it once per second on a known address; if the result changes (e.g. the device disappears), print a warning.
Hint
bool i2cAlive(uint8_t addr) {
Wire.beginTransmission(addr);
return Wire.endTransmission() == 0;
}
bool wasAlive = true;
void loop() {
bool nowAlive = i2cAlive(0x3C);
if (nowAlive != wasAlive) {
Serial.print("# OLED at 0x3C: ");
Serial.println(nowAlive ? "appeared" : "disappeared");
wasAlive = nowAlive;
}
delay(1000);
}Production sketches use this pattern as a health check: detect when a sensor or display has been unplugged and react gracefully instead of just silently failing.
Goal: Build a tiny "register dump" tool. Type an address in hex into the Serial Monitor (e.g. 0x68), then a register (e.g. 0x75), and the sketch reads back one byte. Useful for poking unknown chips when their library hasn't been written yet.
Hint
Use Serial.parseInt(HEX) to read hex values from the monitor. Combine the two parseInts with the Wire read pattern from §4 Step 4. A few details to handle: detect "0x" prefix vs no prefix, time out if the user is slow, refuse addresses out of 1..127.
if (Serial.available() >= 8) {
uint8_t addr = (uint8_t)Serial.parseInt();
uint8_t reg = (uint8_t)Serial.parseInt();
if (addr == 0 || addr > 127) { Serial.println("# bad addr"); return; }
Wire.beginTransmission(addr); Wire.write(reg); Wire.endTransmission();
Wire.requestFrom(addr, (uint8_t)1);
if (Wire.available()) {
Serial.print("0x"); Serial.print(addr, HEX);
Serial.print(" reg 0x"); Serial.print(reg, HEX);
Serial.print(" = 0x"); Serial.println(Wire.read(), HEX);
} else {
Serial.println("# no reply");
}
}Mini-Challenge · Document your bus 10 min
Save the scanner output for your specific wiring as a permanent reference in your notebook.
- Run the scanner with everything plugged in.
- Write down, in a table: device name, I²C address, VCC (5 V or 3.3 V?), any address-jumper notes ("tied AD0 to VCC for 0x69").
- Tape this table to your engineering notebook's I²C section. Anytime you add a device, update the table.
- Identify any potential clashes before they bite you — DS3231 + MPU-6050 (both 0x68) is the classic.
For the L04 weather station and IoT room monitor, you'll be looking at this table a lot. Take 10 minutes to build it well now.
Recap 5 min
The Wire library is the I²C equivalent of Servo and Stepper — five method calls do all the wire-level work. Wire.begin(), then a transaction pattern: beginTransmission + write + endTransmission for sending; requestFrom + read for receiving. The address-scanner sketch is the most useful debugging tool in this cluster — keep it in your toolbox. Tomorrow we put a real graphical display on the bus: the SSD1306 OLED, 128×64 monochrome, no extra wires beyond the four you used today.
Wirelibrary- Arduino's built-in I²C library. Handles START, ACK, STOP and clock generation for you.
Wire.begin()- Join the I²C bus as the controller. With an argument, joins as a peripheral at the given address (rarely used in Arduino projects).
Wire.beginTransmission(addr)- Queue up a write to address
addr. No bytes are sent untilendTransmission(). Wire.write(byte)- Append one byte to the transmission queue. Can be called multiple times to queue up several bytes.
Wire.endTransmission()- Send the queued bytes. Returns a status code (0 = success, 2 = NACK on address, etc.). Always check the return value when debugging.
Wire.requestFrom(addr, n)- Ask address
addrto sendnbytes. Bytes arrive into the Wire library's internal buffer; read them out withWire.read(). Wire.read()- Pop one byte from the receive buffer.
- I²C scanner
- A diagnostic sketch that tries to ACK every address 1..126 and prints which ones respond. Standard first step for any I²C wiring problem.
- WHO_AM_I register
- A read-only register found on many sensors that returns a fixed identifier. Reading it back is a self-test that confirms both the wiring and the chip are alive.
Homework 5 min
- Save the scanner sketch as
i2c-scanner.ino. It'll be your first stop for every I²C bug for the rest of L3 and L4. - Update your I²C bus table from §6 with anything new.
- Bring an SSD1306 OLED tomorrow if you have one — we'll draw "Hello, world!" on it. If you don't have one, an I²C-backpack 16×2 LCD works too — the libraries differ but the bus is the same.
- Optional: install the Adafruit_SSD1306 and Adafruit_GFX libraries via the Library Manager so we're ready to roll.
Bring back next class:
- Your scanner sketch.
- Your bus table.
- An I²C display (OLED or LCD).