If you are building a sensor node or wiring up a microcontroller project, the direct answer for protocol selection is this: use I2C for multiple low-speed sensors on the same PCB, use SPI for high-bandwidth local peripherals like displays and SD cards, and use UART for off-board communication, GPS modules, or PC debugging. Stop guessing based on library availability and start choosing based on physical layer constraints.
Choosing the right embedded communication protocols comes down to three hard constraints: distance, bandwidth, and wire count. Below is the exact decision framework, physical wiring requirements, and debugging playbook you need to get your bus talking on the first try.
The Quick Decision Path: Which Protocol to Pick
Do not default to I2C just because it only uses two wires. Use this decision tree to terminate your protocol selection with a concrete hardware pick.
| If your project requires... | Then pick this protocol | Concrete Hardware / Part Pick |
|---|---|---|
| Raw speed (>10 MHz) or SD card / TFT display interfacing | SPI | 74HC595 shift registers, ILI9341 displays, or direct MCU SPI pins. |
| Multiple sensors (>2) on a single board, low pin count | I2C | BME280, MPU6050. If you have >16 devices or address clashes, add a TCA9548A I2C multiplexer. |
| Talking to a PC, GPS module, or distances > 1 meter | UART | CP2102 USB-to-TTL for PC. For distances >10m, use a MAX485 RS-485 transceiver. |
| Daisy-chaining hundreds of LEDs | 1-Wire / Custom | WS2812B (NeoPixel) using a single GPIO with strict timing. |
Bus Mechanics and Physical Layer Requirements
Abstract protocol theory will not save you when your signal integrity falls apart. You must understand the physical layer. Here is how the big three compare at the copper level.
| Feature | I2C (Inter-Integrated Circuit) | SPI (Serial Peripheral Interface) | UART (Universal Asynchronous Receiver-Transmitter) |
|---|---|---|---|
| Wires | 2 (SDA, SCL) + GND | 4 (MOSI, MISO, SCK, CS) + GND | 2 (TX, RX) + GND |
| Topology | Multi-master, multi-slave bus | Single master, multi-slave (requires separate CS per slave) | Point-to-point only |
| Speed | 100 kHz, 400 kHz, 1 MHz, 3.4 MHz | 10 MHz to 50+ MHz (limited by trace length) | 9600 to 115200 baud (up to 1 Mbps locally) |
| Addressing | 7-bit or 10-bit hardware address | Hardware Chip Select (CS) lines | None (point-to-point) |
| Max Distance | < 1 meter (limited by 400pF bus capacitance) | < 0.5 meters (signal integrity degrades fast) | < 15 meters (at 9600 baud); >1km with RS-485 |
| Drive Type | Open-drain (requires pull-ups) | Push-pull | Push-pull |
Physical Wiring and Pull-Up Rules
I2C Pull-ups are Mandatory: Because I2C uses open-drain outputs, the lines default to floating. You must pull SDA and SCL high to VCC. The NXP I2C-bus specification (UM10204) dictates that bus capacitance cannot exceed 400pF. For standard 100 kHz operation, use 4.7kΩ resistors. If you are pushing 400 kHz (Fast Mode), drop to 2.2kΩ to ensure the RC rise time is fast enough to meet the 300ns maximum rise time spec.
SPI Chip Select (CS) Management: SPI data lines (MOSI, MISO, SCK) are push-pull and do not need pull-ups. However, the CS line must be managed carefully. Always add a 10kΩ pull-up resistor on the CS line to VCC. During microcontroller boot, GPIO pins often float before the firmware initializes the SPI peripheral. Without a pull-up, a floating CS pin can cause the slave device to drive MISO, colliding with other devices on the bus and potentially damaging the output drivers.
UART Crossovers: UART is push-pull. The golden rule is TX to RX, and RX to TX. Never connect TX to TX. If you are connecting a 3.3V MCU to a 5V peripheral, you must use a logic level shifter (like the BSS138 bidirectional shifter) on the RX line of the 3.3V device to prevent 5V backfeeding into a 3.3V-tolerant GPIO.
Minimal Working Exchange: Wiring and Code
Let us look at a minimal working exchange using I2C, as it is the most common point of failure for hobbyists. We will wire an ESP32 to a BME280 environmental sensor and read its hardcoded WHO_AM_I register (0xD0) to verify the bus is physically sound before loading heavy libraries.
Wiring Table
| ESP32 DevKit v1 Pin | BME280 Breakout Pin | Notes |
|---|---|---|
| 3V3 | VIN / VCC | Do not use 5V on a 3.3V sensor breakout. |
| GND | GND | Ensure common ground. |
| GPIO 21 (SDA) | SDA | Add 4.7kΩ pull-up to 3V3. |
| GPIO 22 (SCL) | SCL | Add 4.7kΩ pull-up to 3V3. |
Arduino / ESP32 Core Code
This code bypasses bloated sensor libraries to perform a raw I2C register read. If this returns 0x60 (the BME280 chip ID), your physical layer is perfect.
#include <Wire.h>
#define I2C_SDA 21
#define I2C_SCL 22
#define BME280_ADDR 0x76 // Check your breakout; some are 0x77
#define REG_WHO_AM_I 0xD0
void setup() {
Serial.begin(115200);
delay(1000); // Allow serial monitor to connect
// Initialize I2C with explicit pins and 400kHz clock
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(400000);
Serial.println("Scanning I2C bus for BME280...");
// 1. Start transmission to the sensor address
Wire.beginTransmission(BME280_ADDR);
// 2. Write the register address we want to read
Wire.write(REG_WHO_AM_I);
// 3. End transmission, but check for ACK
uint8_t error = Wire.endTransmission(false);
if (error != 0) {
Serial.print("I2C Bus Error Code: ");
Serial.println(error);
// Error 2 = NACK on address (wrong address or missing pull-ups)
// Error 4 = Unknown error (usually bus lockup)
while(1) { delay(10); } // Halt execution
}
// 4. Request 1 byte from the sensor
Wire.requestFrom(BME280_ADDR, 1);
if (Wire.available()) {
uint8_t chipID = Wire.read();
Serial.print("BME280 WHO_AM_I Register: 0x");
Serial.println(chipID, HEX);
if (chipID == 0x60) {
Serial.println("Success! Physical layer verified.");
}
}
}
void loop() {
// Main sensor reading logic goes here
}
Classic Failures: Sniffing and Debugging the Bus
When the bus refuses to talk, do not rewrite your code. The physical layer is almost always at fault. Here is how to diagnose the classic failures using a multimeter, logic analyzer, or oscilloscope.
1. The Missing Pull-Up (I2C)
Symptom: Wire.endTransmission() returns error 2 or 4. The bus hangs indefinitely.
The Physics: Without pull-up resistors, the open-drain MOSFETs pull the line low, but nothing pulls it back high. The line floats, and the MCU reads random noise as clock pulses, causing a bus lockup.
The Fix: Measure SDA and SCL with a multimeter relative to GND. If they read 0.0V or float randomly, you are missing pull-ups. Solder 4.7kΩ resistors from SDA/SCL to VCC. If you are using a cheap clone sensor board, check the back of the PCB; many omit the onboard pull-ups to save $0.02 in manufacturing.
2. The Address Clash (I2C)
Symptom: You have two identical sensors (e.g., two BME280s), but you can only read one.
The Physics: I2C devices have hardcoded addresses. The BME280 can only be 0x76 or 0x77, selected by pulling the SDO pin high or low. If you need three of them, you have run out of addresses.
The Fix: Do not try to bit-bang a software I2C bus to get around this. Buy a TCA9548A I2C Multiplexer (Adafruit product 2717). It sits on the main bus and gives you 8 downstream I2C channels, allowing you to use the exact same address on every channel.
3. Clock Polarity and Baud Mismatch (SPI & UART)
Symptom (SPI): Data reads as all 0xFF or all 0x00, or bytes are shifted by one bit.
The Fix: SPI defines four modes based on Clock Polarity (CPOL) and Clock Phase (CPHA). Mode 0 (CPOL=0, CPHA=0) and Mode 3 (CPOL=1, CPHA=1) cover 95% of devices. Check the sensor datasheet timing diagram. If the clock idles low, use Mode 0. If it idles high, use Mode 3.
Symptom (UART): Serial monitor prints garbage characters like ÿÿÿ or ???.
The Fix: This is a baud rate mismatch or parity error. Verify both sides are set to exactly 115200 (or your chosen rate). Ensure both sides are using 8N1 (8 data bits, No parity, 1 stop bit). If one side is using hardware flow control (RTS/CTS) and the other is not, the transmitter will halt.
How to Sniff the Bus
When a multimeter is not enough, you need to see the digital edges.
- For I2C and SPI: Use a logic analyzer. A $15 clone Saleae Logic 8-channel analyzer running the open-source PulseView / sigrok software will decode I2C and SPI packets natively. Look for jagged rise times on I2C (indicating too much capacitance) or missing CS toggles on SPI.
- For UART: You do not need an oscilloscope. Plug a $10 CP2102 USB-to-TTL adapter into your PC, connect its RX to the target's TX, and open PuTTY. If you see the data perfectly on your PC but your MCU cannot read it, the issue is in your MCU's UART interrupt handler or baud clock divisor, not the physical wire.
Default to I2C for local environmental sensors, SPI for high-bandwidth displays, and UART for off-board telemetry. Verify your pull-ups, check your clock modes, and sniff the bus with sigrok when things go quiet.






