If you need multi-master communication on just two wires, use I2C. If you need raw speed for high-bandwidth peripherals on short PCB traces, use SPI. If you need point-to-point asynchronous data over distances greater than a few centimeters, use UART. Choosing between these serial bus communication protocols comes down to physical layer constraints: wire count, bus capacitance, and clock synchronization. This guide skips the abstract history and goes straight to the bench-level wiring, failure modes, and decision frameworks you need to get your microcontroller talking to peripherals.

The Physical Layer: Wiring, Speed, and Distance

Every protocol fails first at the physical layer. Before writing a single line of firmware, you must account for bus capacitance, pull-up requirements, and signal integrity. Here is the hard data for the big three protocols.

ProtocolWires RequiredTypical Max SpeedAddressingPractical Max Distance
I2C2 (SDA, SCL) + GND100 kHz (Std) / 400 kHz (Fast)7-bit or 10-bit hardware~30 cm (highly capacitance-dependent)
SPI4 (MOSI, MISO, SCK, CS) + GND10 MHz to 80+ MHzHardware CS lines (no software address)~10 cm (without differential drivers)
UART2 (TX, RX) + GND115,200 baud (typical) / 1 MbpsNone (point-to-point)~1.5 m (TTL) / 1200 m (RS-485)

Physical Wiring and Pull-Up Requirements

I2C is an open-drain bus. The microcontroller pulls the line low, but relies on external resistors to pull it high. If you omit pull-ups, the SDA and SCL lines will float, and your logic analyzer will show slow, jagged RC rise times instead of crisp square waves. For a standard 100 kHz bus on a 3.3V system with low capacitance (<200pF), use 4.7 kΩ pull-ups to VCC. For 400 kHz Fast Mode, drop to 2.2 kΩ to overcome bus capacitance and meet the 300ns rise-time spec outlined in the NXP I2C-bus specification (UM10204).

SPI is a push-pull bus. It does not require pull-ups on MOSI, MISO, or SCK. However, the Chip Select (CS) line must be explicitly driven high and low by the master. If you leave a CS line floating during microcontroller boot, the peripheral may interpret noise as a select signal and drive MISO, colliding with other devices. Always enable internal microcontroller pull-ups on CS pins in your setup code, or use external 10 kΩ pull-ups.

UART requires a common ground. A frequent bench mistake is connecting TX and RX between two boards powered by separate supplies without tying their GND pins together. Without a shared reference, the voltage differential drifts, causing framing errors and garbage characters.

Minimal Working Exchange: How the Bits Actually Move

Let us look at a concrete I2C exchange. We will wire an ESP32-WROOM-32 to a Bosch BME280 environmental sensor. The BME280 defaults to I2C address 0x76 (if the SDO pin is tied to GND) or 0x77 (if SDO is tied to VCC).

Wiring Map (ESP32 to BME280):
• GPIO 21 → SDA (with 4.7kΩ pull-up to 3.3V)
• GPIO 22 → SCL (with 4.7kΩ pull-up to 3.3V)
• 3.3V → VCC
• GND → GND & SDO

Here is the minimal Arduino-framework code to initialize the bus and read the sensor ID register (0xD0), which should return 0x60 for a genuine BME280.

#include <Wire.h>

#define I2C_SDA 21
#define I2C_SCL 22
#define BME_ADDR 0x76
#define REG_CHIP_ID 0xD0

void setup() {
  Serial.begin(115200);
  // Explicitly define pins and set clock to 400kHz
  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(400000);
  
  Wire.beginTransmission(BME_ADDR);
  Wire.write(REG_CHIP_ID);
  Wire.endTransmission(false); // Repeated start condition
  
  Wire.requestFrom(BME_ADDR, 1);
  if (Wire.available()) {
    uint8_t chipID = Wire.read();
    Serial.printf('BME280 Chip ID: 0x%02X\n', chipID);
  } else {
    Serial.println('I2C NACK received. Check wiring and pull-ups.');
  }
}

void loop() {}

Notice the Wire.endTransmission(false) parameter. This sends a repeated START condition instead of a STOP, keeping the bus locked so no other master can interrupt between writing the register address and reading the data.

Classic Bus Failures and How to Sniff Them Out

When the bus fails, the microcontroller usually just hangs or returns zeros. Here is how to diagnose the three most common physical and logical faults.

1. The Missing Pull-Up (I2C)

Symptom: Wire.endTransmission() returns 2 (NACK on address) or the code hangs indefinitely.

The Fix: Hook up an oscilloscope to the SDA line. Trigger on the falling edge. If the signal drops to 0V sharply but takes several microseconds to ramp back up to 3.3V in a curved slope, your pull-up resistor is missing or too large. The I2C spec requires a maximum rise time of 1000ns for standard mode. Solder a 2.2 kΩ resistor between SDA and VCC.

2. Address Clash and CPOL/CPHA Mismatch (SPI)

Symptom: SPI reads return 0xFF or garbage data.

The Fix: SPI has no addressing, so clashes happen when two masters drive CS low simultaneously, or when a peripheral's CS is left floating. For garbage data, check the clock polarity and phase (CPOL/CPHA). The Arduino SPI library defaults to Mode 0 (CPOL=0, CPHA=0). If your peripheral datasheet specifies Mode 3, you must explicitly set SPI.setDataMode(SPI_MODE3) or use the modern SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE3)).

3. Baud Mismatch and Ground Loops (UART)

Symptom: Serial monitor shows Wingdings-style garbage characters.

The Fix: A 1% baud rate mismatch is usually tolerated, but if your transmitter is clocked at 115,200 baud and your receiver is actually running at 112,500 due to a cheap ceramic resonator, the framing will drift by the 8th bit. Verify baud rates with a logic analyzer. If the garbage characters appear only when a motor turns on, you have a ground loop injecting noise into the UART RX line; isolate the grounds or switch to an RS-485 differential transceiver like the MAX485.

Sniffing the Bus

Do not debug blind. Use a logic analyzer. The Saleae Logic Pro 8 is the industry standard, but for hobbyists, a $15 Cypress FX2-based clone running the open-source Sigrok / PulseView software is entirely sufficient. Set your sample rate to at least 10x the bus clock (e.g., 4 MS/s for a 400 kHz I2C bus) and use the built-in protocol decoders to view the hex payloads directly on the timeline.

The Decision Tree: Which Protocol to Pick

Stop guessing. Use this decision matrix to select the right protocol for your specific hardware constraints.

If your project requires...Then choose...Required Hardware / Part Number
Multiple sensors on the same 2 wires, low speed (<400kHz)I2C4.7kΩ pull-ups, PCA9548A (if you need to multiplex clashing addresses)
High bandwidth (TFT displays, SD cards, external Flash)SPIDedicated CS lines per device, 74HC595 (if you run out of GPIO for CS)
Point-to-point debug console or GPS moduleUART (TTL)Direct TX/RX cross-connection, CP2102 (for USB-to-UART bridging)
Communication over cables longer than 1 meterUART (RS-485)MAX485 or SN75176 transceivers, twisted pair cable, 120Ω termination resistor
Daisy-chaining addressable LEDs or shift registersSPI (1-wire variant)WS2812B (uses custom timing, not true SPI) or 74HC595 via standard SPI MOSI
The Default Bench Pick: If you are designing a custom PCB and are unsure which bus to route to a breakout header, route I2C. It requires the fewest traces (2 signal + 2 power), supports multi-drop without complex CS routing, and 90% of modern environmental, IMU, and IO-expander peripherals support it natively. Reserve SPI strictly for high-throughput components like SPI Flash (e.g., W25Q128) or LCD controllers where I2C bandwidth will bottleneck your frame rate.