For 90% of embedded sensor and peripheral projects under 1 meter, I2C is the default serial bus protocol. Use SPI for high-throughput devices (TFT displays, SD cards) and UART for point-to-point ASCII streams (GPS modules, PC debugging). This guide cuts through the abstract theory and gives you the exact wiring, pull-up values, and debugging steps to get your bus running on the bench.
The Physical Layer: Wiring, Pull-Ups, and Bus Mechanics
Before writing a single line of code, you must understand the electrical physics of the bus. A protocol is not just a software handshake; it is a physical circuit with specific capacitance, resistance, and voltage thresholds.
| Protocol | Wires | Max Speed (Typical) | Addressing | Max Distance | Electrical Topology |
|---|---|---|---|---|---|
| I2C | 2 (SDA, SCL) | 100kHz / 400kHz / 1MHz | 7-bit or 10-bit | ~1 meter | Open-drain, requires pull-ups |
| SPI | 4 (MOSI, MISO, SCK, CS) | 10MHz - 50MHz+ | Hardware CS lines | ~0.5 meter | Push-pull, no pull-ups needed |
| UART | 2 (TX, RX) | 9600 - 115200 baud | None (Point-to-Point) | ~15 meters (at 9600) | Push-pull, idle high |
Physical Wiring and Pull-Up Requirements
I2C (Inter-Integrated Circuit): Because I2C uses an open-drain topology, the bus lines (SDA and SCL) can only be pulled low by devices; they cannot be driven high. You must provide external pull-up resistors to VCC. For standard 100kHz operation, use 4.7kΩ resistors. For 400kHz Fast Mode, drop to 2.2kΩ to overcome bus capacitance and ensure sharp rise times. If you are mixing 3.3V and 5V devices, do not just rely on internal microcontroller pull-ups; use a dedicated level-shifter like the BSS138 MOSFET circuit.
SPI (Serial Peripheral Interface): SPI uses a push-pull topology. The master drives SCK and MOSI high and low actively, and the slave drives MISO. No pull-up resistors are required on the data or clock lines. However, the Chip Select (CS) line is typically active-low and should be pulled high via a 10kΩ resistor if the master pin floats during boot.
UART (Universal Asynchronous Receiver-Transmitter): UART is strictly point-to-point. Connect the TX pin of Device A to the RX pin of Device B, and vice versa. The lines idle high. If you are connecting a 3.3V ESP32 to a 5V Arduino, use a simple voltage divider (e.g., 1kΩ and 2kΩ) on the 5V TX line going into the 3.3V RX pin to prevent silicon damage.
The Decision Matrix: Matching Protocol to Distance, Speed, and Device Count
Choosing a serial bus protocol comes down to three constraints: how far the data must travel, how fast it must arrive, and how many devices share the wires. Use this decision path to lock in your architecture.
| Condition / Constraint | Recommended Protocol | Concrete Part / Implementation |
|---|---|---|
| Distance > 10 meters (industrial/long runs) | RS-485 (Differential UART) | MAX485 transceiver module |
| Throughput > 10 Mbps (TFT screens, SD cards, audio) | SPI | ILI9341 Display or MicroSD breakout |
| Simple ASCII stream, GPS, or PC debug console | UART | NEO-6M GPS module or USB-to-Serial |
| Multiple low-speed sensors, tight pin constraints | I2C | Bosch BME280 (Env Sensor) |
The Default Pick: For standard environmental, motion, or proximity sensor nodes on an ESP32 or Arduino, I2C is the definitive choice. It requires only two GPIO pins regardless of whether you attach one sensor or ten, and the 400kHz speed is more than adequate for polling temperature or IMU data. Our reference part for this guide is the Bosch BME280 (I2C addresses 0x76 or 0x77).
Minimal Working Exchange: I2C Wiring and Code
Below is a complete, verified setup for reading a BME280 sensor over I2C using an ESP32 DevKit v1. This includes the physical pin mapping and the exact pull-up requirements.
Wiring Table
| ESP32 DevKit v1 Pin | BME280 Breakout Pin | Notes |
|---|---|---|
| 3V3 | VCC / VIN | Do not use 5V on a 3.3V sensor |
| GND | GND | Common ground is mandatory |
| GPIO 21 (Default SDA) | SDA | Add 4.7kΩ pull-up to 3V3 |
| GPIO 22 (Default SCL) | SCL | Add 4.7kΩ pull-up to 3V3 |
Arduino/ESP32 Code
This code uses the standard Wire library and the Adafruit BME280 library. It includes explicit error handling for bus initialization failures, which is critical for catching physical wiring faults at runtime.
#include <Wire.h>
#include <Adafruit_BME280.h>
// Explicitly define pins for clarity, though ESP32 defaults to 21/22
#define I2C_SDA 21
#define I2C_SCL 22
Adafruit_BME280 bme;
void setup() {
Serial.begin(115200);
delay(100); // Allow serial monitor to connect
// Initialize I2C bus with explicit pins and 400kHz Fast Mode
Wire.begin(I2C_SDA, I2C_SCL, 400000);
Serial.println("Initializing BME280 over I2C...");
// 0x76 is default for Adafruit/SparkFun, 0x77 for some generic clones
if (!bme.begin(0x76, &Wire)) {
Serial.println("FATAL: Could not find BME280. Check wiring, pull-ups, and I2C address.");
while (1) { delay(10); } // Halt execution to prevent bus spam
}
Serial.println("BME280 found. Bus operational.");
}
void loop() {
Serial.print("Temp: "); Serial.print(bme.readTemperature()); Serial.print(" *C | ");
Serial.print("Hum: "); Serial.print(bme.readHumidity()); Serial.print(" % | ");
Serial.print("Pres: "); Serial.print(bme.readPressure() / 100.0F); Serial.println(" hPa");
delay(2000); // BME280 needs time between samples for stable readings
}
Classic Failures: Sniffing, Debugging, and Fixing the Bus
When the bus fails, it is almost always a physical layer issue masquerading as a software bug. Here is how to diagnose the three most common serial bus protocol failures, backed by exact measurement thresholds.
1. The Missing Pull-Up (I2C)
Symptom: The bme.begin() function hangs indefinitely or returns false. The serial monitor prints nothing.
The Physics: Without pull-ups, the open-drain lines float. When a device pulls SDA low, it never returns high, causing the microcontroller's I2C state machine to lock up waiting for a clock edge.
The Fix: Connect an oscilloscope to SCL. Trigger on the falling edge. If the signal looks like a slow, sloping ramp instead of a sharp square wave, your bus capacitance is too high or pull-ups are missing. If the rise time (30% to 70% of VCC) exceeds 300ns, add 4.7kΩ pull-up resistors to VCC. For heavily loaded buses (>3 devices), drop to 2.2kΩ.
2. Address Clash (I2C)
Symptom: One sensor reads perfectly, but a second identical sensor returns garbage data or fails to initialize.
The Physics: I2C relies on unique 7-bit addresses. If you buy two identical OLED displays or two BME280 modules, they often default to the exact same address (e.g., 0x3C or 0x76). The master sends data, both devices ACK simultaneously, and the bus collides.
The Fix: Check the datasheet for an address-select jumper or pad. On the BME280, tying the SDO pin to GND sets the address to 0x76; tying it to VCC sets it to 0x77. If the module lacks this option, insert a TCA9548A I2C Multiplexer between the master and the sensors to isolate the buses.
3. Baud Mismatch (UART)
Symptom: The serial monitor outputs gibberish, wingdings, or reversed question marks instead of readable text.
The Physics: UART has no shared clock line. The receiver samples the RX line based on its internal baud rate generator. If the transmitter sends at 115200 baud and the receiver listens at 9600 baud, the sampling windows misalign completely, resulting in framing errors.
The Fix: Verify the peripheral's default baud rate in its datasheet. Many GPS modules default to 9600, while ESP32 boot logs output at 115200. If you cannot change the software baud rate, use a logic analyzer to measure the actual bit width. A 9600 baud bit is 104.16 µs wide; a 115200 baud bit is 8.68 µs wide.
How to Sniff and Debug the Bus
Do not guess; decode the packets. For under $15, you can buy a FX2LA-based 8-channel logic analyzer (often sold as a generic 'Saleae clone').
- Connect the logic analyzer ground to your circuit ground.
- Connect Channel 0 to SDA and Channel 1 to SCL.
- Open PulseView (the open-source sigrok GUI).
- Set the sample rate to at least 4x your bus speed (e.g., 2 MHz for a 400kHz I2C bus).
- Add the 'I2C' protocol decoder, map the SDA/SCL pins, and hit 'Run'.
PulseView will render the raw hex payloads directly over the waveform. If you see a NACK (Not Acknowledged) bit on the 9th clock cycle, the master is talking to an address that does not exist on the physical bus. If the waveform is completely flat at 3.3V, your microcontroller hasn't initialized the peripheral clock in software. For deeper electrical specifications, always refer to the official NXP I2C-bus specification (UM10204).






