Serial communication protocols move data one bit at a time over a shared physical medium. While microcontrollers support dozens of proprietary and standard buses, 95% of bench and jobsite projects rely on the big three: I2C, SPI, and UART. Understanding these protocols requires looking past the software abstraction and examining the physical layer—voltage levels, bus capacitance, pull-up requirements, and termination.
The Physical Layer: Bus Mechanics and Wiring Requirements
Software libraries hide the electrical reality of serial buses, but a protocol is only as robust as its physical wiring. I2C is an open-drain bus that relies entirely on external pull-up resistors to pull the signal high. SPI is a push-pull bus driven directly by the microcontroller's GPIO pins, making it fast but susceptible to crosstalk and signal reflection on long traces. UART is an asynchronous point-to-point link that requires a shared ground reference and precise timing.
The table below defines the hard electrical and mechanical limits for standard implementations. Note that distances assume standard 24 AWG copper wire and standard CMOS logic levels (3.3V or 5V) without specialized transceivers.
| Protocol | Wires Required | Max Speed (Typical) | Addressing / Topology | Max Distance | Physical Layer Details |
|---|---|---|---|---|---|
| I2C (Standard) | 2 (SDA, SCL) + GND | 100 kbps | 7-bit / 10-bit address | ~1 meter | Open-drain; requires 4.7 kΩ pull-ups to VCC; 400 pF bus capacitance limit. |
| I2C (Fast) | 2 (SDA, SCL) + GND | 400 kbps | 7-bit / 10-bit address | ~0.5 meter | Open-drain; requires 2.2 kΩ pull-ups to overcome RC rise-time limits. |
| SPI (Mode 0/3) | 4 (MOSI, MISO, SCK, CS) + GND | 10 - 50 Mbps | Hardware Chip Select (CS) | ~0.3 meter | Push-pull; no pull-ups needed; requires separate CS line for every target device. |
| UART (TTL) | 2 (TX, RX) + GND | 115.2 kbps - 1 Mbps | None (Point-to-Point) | ~1.5 meters | Push-pull; TX connects to RX; requires common ground; highly sensitive to baud drift. |
| RS-485 (via UART) | 2 (A, B) + GND | 10 Mbps (short) / 100 kbps (long) | Software/Protocol dependent | ~1200 meters | Differential signaling; requires 120 Ω termination resistors at both ends of the bus. |
Do not blindly use 4.7 kΩ resistors for all I2C designs. The NXP I2C Specification (UM10204) dictates that the pull-up resistor must be low enough to pull the bus high within the specified rise time (usually 300 ns for 400 kHz Fast Mode), but high enough to keep the sink current under 3 mA. If you have long wires or multiple sensors adding parasitic capacitance, drop to 2.2 kΩ or even 1 kΩ for 3.3V systems.
Choosing the Right Protocol: Speed, Distance, and Device Count
Selecting a protocol is an exercise in managing trade-offs between pin count, throughput, and physical distance. Here is the decision framework for common project scenarios.
When to use I2C
Choose I2C when you need to connect multiple low-speed sensors (temperature, humidity, IMUs) on the same PCB or a short breadboard run, and you want to minimize microcontroller pin usage.
The Limit: I2C fails when bus capacitance exceeds 400 pF. If you are wiring sensors across a 3D printer frame, the wire capacitance will corrupt the signal. Furthermore, address clashes are common; if you need three BME280 sensors but they all share the 0x76/0x77 address, you must insert a TCA9548A I2C multiplexer to route the bus.
When to use SPI
Choose SPI for high-throughput peripherals: TFT displays, SD card modules, external flash memory (like the W25Q128), and high-sample-rate ADCs.
The Limit: Pin routing. SPI requires four shared wires plus one dedicated Chip Select (CS) wire per device. Connecting five SPI devices requires 9 GPIO pins. Keep traces short and parallel to avoid crosstalk between the SCK and MOSI lines.
When to use UART and RS-485
Choose raw TTL UART for point-to-point connections between two microcontrollers, or for talking to a PC via a USB-to-Serial adapter (like the FT232RL or CH340).
The Limit: UART cannot drive a multi-drop bus natively. If you need to wire sensors across a building, a greenhouse, or an industrial panel, you must convert the UART TTL signals to RS-485 using a differential transceiver like the Texas Instruments MAX485 or SN65HVD72. RS-485 rejects common-mode noise and easily spans 1,200 meters at 100 kbps.
Debugging the Bus: Sniffing, Classic Failures, and Fixes
When a serial bus fails, the microcontroller usually just hangs or returns a timeout error. You cannot debug physical layer faults with Serial.print(). You need a logic analyzer. A genuine Saleae Logic Pro 16 is the professional standard, but a $15 24MHz 8-channel Cypress FX2 clone running Sigrok/PulseView is perfectly adequate for I2C and UART debugging.
The Classic Failures and How to Fix Them
- Missing Pull-Ups (I2C): The bus floats. The logic analyzer shows slow, exponential rise times instead of sharp square waves, and the microcontroller throws a timeout. Fix: Add 4.7 kΩ resistors from SDA and SCL to VCC.
- Baud Rate Mismatch (UART): The receiver prints garbage characters (e.g.,
ÿor??). Fix: Verify both devices are set to the exact same baud rate. Note that a 115200 baud rate on a cheap ceramic resonator Arduino clone might actually be 113000 due to clock drift. Stick to 9600 for long-distance or unreliable clocks. - Address Clash (I2C): The bus ACKs the first device but NACKs the second. Fix: Run an I2C scanner sketch to map the bus. If addresses overlap, use hardware address pins (if available on the breakout) or an I2C multiplexer.
- CPOL/CPHA Mismatch (SPI): The display shows corrupted pixels or the SD card fails to initialize. Fix: Check the peripheral datasheet for the required SPI Mode (0, 1, 2, or 3). Mode 0 (CPOL=0, CPHA=0) and Mode 3 (CPOL=1, CPHA=1) are the most common. Configure your microcontroller's SPI library to match.
A Minimal Working Exchange: UART Wiring and Code
Below is a complete, minimal UART exchange between an ESP32 (Sender) and an Arduino Uno (Receiver). This demonstrates the mandatory TX-to-RX crossover and common ground.
| ESP32 DevKit v1 Pin | Wire Color | Arduino Uno Pin | Function |
|---|---|---|---|
| GPIO 17 (TX2) | Green | D10 (Software RX) | Data transmit to receive |
| GPIO 16 (RX2) | Yellow | D11 (Software TX) | Data receive from transmit |
| GND | Black | GND | Common voltage reference |
Sender Code (ESP32 - Hardware UART2):
// ESP32 Sender: Transmits a counter every 500ms
#define TX_PIN 17
#define RX_PIN 16
void setup() {
// Initialize Hardware Serial2 at 9600 baud
Serial2.begin(9600, SERIAL_8N1, RX_PIN, TX_PIN);
}
void loop() {
static int counter = 0;
Serial2.printf("FLUX_DATA:%d\n", counter++);
delay(500);
}
Receiver Code (Arduino Uno - SoftwareSerial):
// Arduino Uno Receiver: Reads UART data via SoftwareSerial
#include <SoftwareSerial.h>
// Note: RX is D10, TX is D11 (crossed from ESP32)
SoftwareSerial mySerial(10, 11);
void setup() {
Serial.begin(115200); // Debug output to PC
mySerial.begin(9600); // Match ESP32 baud rate
}
void loop() {
if (mySerial.available()) {
String incoming = mySerial.readStringUntil('\n');
Serial.print("Received: ");
Serial.println(incoming);
}
}
Sniffing the I2C Bus: Reading the Logic Trace
When you hook a logic analyzer to an I2C bus and trigger a read from a BME280 sensor (address 0x76), the Saleae I2C analyzer will decode the following sequence. Understanding this sequence is how you prove the hardware is actually working:
- Start Condition (S): SDA goes LOW while SCL is HIGH.
- Address + R/W Bit:
0x76shifted left (0xEC) plus the Read bit (1) =0xED. - ACK: The BME280 pulls SDA LOW on the 9th clock pulse to acknowledge.
- Data Bytes: The sensor clocks out the pressure/temperature registers, MSB first.
- NACK: The master (ESP32) pulls SDA HIGH on the final byte's 9th pulse to signal it is done reading.
- Stop Condition (P): SDA goes HIGH while SCL is HIGH, releasing the bus.
If your logic trace shows the Start condition and the Address byte, but the 9th clock pulse shows SDA staying HIGH (a NACK instead of an ACK), your wiring is correct, but the sensor is either unpowered, held in reset, or responding to a different address. This physical-layer visibility is what separates guessing from engineering.






