Every microcontroller project eventually needs to talk to something else — a sensor, a display, a wireless module, or another microcontroller. The three protocols you will encounter over and over again are UART, I2C, and SPI. Picking the wrong one can mean sluggish data rates, wiring headaches, or hours of debugging silence on a bus that should be chattering away.
This guide breaks down all three protocols with real hardware examples, working code, a side-by-side comparison table, and a decision matrix so you can choose confidently every time.
Why Communication Protocols Matter for Every Maker
When you buy a sensor breakout or a wireless module, the datasheet will tell you it speaks one (or sometimes two) of these protocols. Understanding the trade-offs lets you:
- Plan your wiring before you solder anything.
- Estimate data throughput so your project does not bottleneck on communication.
- Debug faster when things go wrong — and they will.
- Design scalable systems that can grow from a single sensor to a dozen without rewiring.
Let us start with the simplest of the three.
UART: The Simple Serial Link
UART (Universal Asynchronous Receiver/Transmitter) is the oldest and most straightforward protocol of the three. It connects exactly two devices using two data lines:
- TX (Transmit) on device A connects to RX (Receive) on device B.
- RX on device A connects to TX on device B.
There is no clock line. Both devices must agree on a baud rate (bits per second) ahead of time — typically 9600 or 115200. Because there is no shared clock, UART is asynchronous. Each byte is framed with a start bit, 8 data bits, and a stop bit so the receiver can synchronise.
Key Characteristics
- Point-to-point only — one transmitter, one receiver per bus.
- Full-duplex — both sides can send and receive simultaneously.
- No addressing — there is no concept of device addresses since only two devices share the link.
- Distance — works reliably up to a few metres at standard logic levels (3.3 V / 5 V). With RS-485 level shifting, UART can reach over 1 km.
Common UART Devices
| Module | Typical Baud Rate | Use Case |
|---|---|---|
| NEO-6M GPS | 9600 | Location data (NMEA sentences) |
| HC-05 Bluetooth | 9600 / 38400 | Wireless serial bridge |
| SIM800L GSM | 9600 / 115200 | SMS and cellular data |
| Fingerprint sensor (R307) | 57600 | Biometric authentication |
Code Example: Reading GPS Data via UART
This example reads NMEA sentences from a NEO-6M GPS module connected to the ESP32's second hardware UART (GPIO 16 = RX, GPIO 17 = TX).
#include <HardwareSerial.h>
// Use UART2 on ESP32
HardwareSerial gpsSerial(2);
void setup() {
Serial.begin(115200); // USB serial for debug output
gpsSerial.begin(9600, SERIAL_8N1, 16, 17); // GPS default baud
Serial.println("GPS UART initialized");
}
void loop() {
while (gpsSerial.available()) {
String line = gpsSerial.readStringUntil('\n');
if (line.startsWith("$GPGGA")) {
Serial.println(line); // Print fix data sentence
}
}
}
Watch out: If you see garbage characters, the baud rate on one side does not match the other. This is the single most common UART mistake.
I2C: The Multi-Device Bus
I2C (Inter-Integrated Circuit, pronounced "I-squared-C") uses just two wires to connect many devices on the same bus:
- SDA (Serial Data) — bidirectional data line.
- SCL (Serial Clock) — clock driven by the master.
One device acts as the master (usually your microcontroller) and all others are slaves. Each slave has a unique 7-bit address (for example, the BME280 sensor defaults to 0x76 or 0x77). The master calls out an address, and only the matching slave responds.
Key Characteristics
- Multi-device — up to 127 devices on a single pair of wires (in practice, 10-20 before capacitance becomes a problem).
- Half-duplex — data travels in one direction at a time on the single SDA line.
- Clock speeds — Standard mode runs at 100 kHz, Fast mode at 400 kHz, and Fast mode Plus at 1 MHz.
- Pull-up resistors required — both SDA and SCL need pull-ups to VCC (typically 4.7 k ohm for 3.3 V systems). Most breakout boards include these on-board, but stacking too many boards with pull-ups can cause problems.
Common I2C Devices
| Module | Default Address | Use Case |
|---|---|---|
| BME280 | 0x76 / 0x77 | Temperature, humidity, pressure |
| SSD1306 OLED (128x64) | 0x3C | Small display |
| MPU6050 | 0x68 | Accelerometer + gyroscope |
| ADS1115 | 0x48 | 16-bit ADC |
| DS3231 RTC | 0x68 | Real-time clock |
Code Example: Reading Temperature from BME280
#include <Wire.h>
#include <Adafruit_BME280.h>
Adafruit_BME280 bme; // Uses I2C by default
void setup() {
Serial.begin(115200);
Wire.begin(21, 22); // SDA = GPIO 21, SCL = GPIO 22 on ESP32
if (!bme.begin(0x76)) {
Serial.println("BME280 not found! Check wiring and address.");
while (1) delay(10);
}
Serial.println("BME280 connected via I2C");
}
void loop() {
float temperature = bme.readTemperature();
float humidity = bme.readHumidity();
float pressure = bme.readPressure() / 100.0F; // hPa
Serial.printf("Temp: %.1f C Humidity: %.1f %% Pressure: %.1f hPa\n",
temperature, humidity, pressure);
delay(2000);
}
Watch out: If begin() returns false, run an I2C scanner sketch first. Address conflicts (two devices sharing 0x68, for example the MPU6050 and DS3231) are a common headache. Some modules expose an address select pin — use it.
SPI: The High-Speed Workhorse
SPI (Serial Peripheral Interface) trades simplicity for raw speed. It uses four lines:
- MOSI (Master Out, Slave In) — data from master to slave.
- MISO (Master In, Slave Out) — data from slave to master.
- SCK (Serial Clock) — clock driven by master.
- CS / SS (Chip Select) — one per slave device, active low. The master pulls CS low to select which slave it is talking to.
Because transmit and receive happen on separate wires, SPI is full-duplex — the master can send and receive simultaneously in the same clock cycle.
Key Characteristics
- Very fast — typically 1-80 MHz depending on the device. In practice, most modules work well at 1-10 MHz.
- Full-duplex — simultaneous bidirectional data transfer.
- No addressing — device selection is done via individual CS lines, which means each additional slave costs one more GPIO pin.
- Four SPI modes — defined by clock polarity (CPOL) and clock phase (CPHA). Mode 0 (CPOL=0, CPHA=0) is the most common, but you must check the datasheet.
Common SPI Devices
| Module | Typical Clock | Use Case |
|---|---|---|
| Ra-01H (SX1276 LoRa) | 1-10 MHz | Long-range wireless |
| SD card module | Up to 25 MHz | Data logging |
| ILI9341 TFT display | 40-80 MHz | Colour LCD |
| W5500 Ethernet | Up to 33 MHz | Wired networking |
| MAX31855 | 5 MHz | Thermocouple reader |
Code Example: Sending Data via LoRa (Ra-01H) over SPI
#include <SPI.h>
#include <LoRa.h>
// SPI pins for ESP32 default VSPI bus
#define LORA_SCK 18
#define LORA_MISO 19
#define LORA_MOSI 23
#define LORA_CS 5
#define LORA_RST 14
#define LORA_IRQ 26
void setup() {
Serial.begin(115200);
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS);
LoRa.setPins(LORA_CS, LORA_RST, LORA_IRQ);
if (!LoRa.begin(433E6)) { // 433 MHz for Ra-01H
Serial.println("LoRa init failed!");
while (1);
}
LoRa.setSpreadingFactor(7);
LoRa.setSignalBandwidth(125E3);
Serial.println("LoRa transmitter ready (SPI)");
}
void loop() {
LoRa.beginPacket();
LoRa.print("Hello from Wavtron!");
LoRa.endPacket();
Serial.println("Packet sent");
delay(5000);
}
Watch out: SPI mode mismatches produce garbled data or complete silence. Always check the datasheet for CPOL and CPHA. Also, never leave a CS pin floating — unused slave CS lines should be pulled HIGH.
Side-by-Side Comparison
| Feature | UART | I2C | SPI |
|---|---|---|---|
| Wires (minimum) | 2 (TX, RX) | 2 (SDA, SCL) | 4 + 1 CS per slave |
| Clock | None (asynchronous) | Master-driven | Master-driven |
| Duplex | Full | Half | Full |
| Typical Speed | 9600 - 921600 bps | 100 kHz - 1 MHz | 1 - 80 MHz |
| Max Devices | 2 (point-to-point) | 127 (address-based) | Limited by CS pins |
| Addressing | None | 7-bit address | CS pin per device |
| Distance | Up to ~15 m (logic level) | ~1 m (capacitance limited) | ~0.1 - 0.3 m |
| Pull-ups Needed | No | Yes (SDA + SCL) | No |
| Power Consumption | Low | Low-medium | Medium (higher clock) |
| Complexity | Very low | Medium | Medium-high |
| Error Detection | Parity bit (optional) | ACK/NACK | None built-in |
| Best For | Debug, GPS, Bluetooth | Sensors, displays, RTCs | High-speed transfers |
Performance Benchmarks
To give you real numbers, here is what you can expect when transferring 1 KB of data on an ESP32:
| Protocol | Configuration | Transfer Time (1 KB) | Effective Throughput |
|---|---|---|---|
| UART | 9600 baud | ~1066 ms | ~0.94 KB/s |
| UART | 115200 baud | ~89 ms | ~11.25 KB/s |
| I2C | 100 kHz (Standard) | ~82 ms | ~12.2 KB/s |
| I2C | 400 kHz (Fast) | ~21 ms | ~48.8 KB/s |
| SPI | 1 MHz | ~8.2 ms | ~122 KB/s |
| SPI | 10 MHz | ~0.82 ms | ~1220 KB/s |
| SPI | 40 MHz | ~0.21 ms | ~4880 KB/s |
The difference is dramatic. If you are streaming pixel data to a TFT display or writing large files to an SD card, SPI at high clock rates is the only viable option. For reading a temperature value every two seconds, I2C at 100 kHz is more than enough.
Decision Matrix: Which Protocol Should You Use?
| Your Situation | Recommended Protocol | Why |
|---|---|---|
| Connecting a GPS or Bluetooth module | UART | These modules speak serial natively. No choice needed. |
| Reading one or two slow sensors | I2C | Minimal wiring, easy addressing. |
| Connecting 5-10 sensors on one bus | I2C | Two wires, many devices. Just verify unique addresses. |
| Driving a colour TFT display | SPI | Needs high bandwidth for pixel data. |
| Logging data to SD card | SPI | SD cards use SPI mode by default. |
| Long-range wireless with LoRa | SPI | LoRa modules (SX1276, SX1262) are SPI devices. |
| Debugging / serial monitor output | UART | USB-serial is UART under the hood. |
| Connecting two microcontrollers | UART or SPI | UART for simplicity. SPI if you need speed. |
| Minimal wiring, low pin count board | I2C | Only two GPIOs needed for many devices. |
| Maximum data transfer speed | SPI | Nothing else comes close at board level. |
Can You Mix Protocols? Absolutely.
The ESP32 is particularly generous with its peripheral options:
- 3 UART buses (UART0 is typically used for USB serial)
- 2 I2C buses (configurable on almost any GPIO pair)
- 2 SPI buses (HSPI and VSPI, plus an internal flash SPI)
This means you can — and often will — use all three protocols simultaneously in a single project. There is no conflict as long as you do not assign the same GPIO to two different peripherals.
Real-World Project: Weather Station with LoRa Telemetry
Here is a practical example that uses all three protocols on a single ESP32:
- UART: NEO-6M GPS module for location stamping (GPIO 16 RX, GPIO 17 TX)
- I2C: BME280 for temperature, humidity, and pressure (GPIO 21 SDA, GPIO 22 SCL)
- SPI: Ra-01H LoRa module to transmit readings to a remote base station (GPIO 18/19/23/5)
#include <Wire.h>
#include <SPI.h>
#include <HardwareSerial.h>
#include <Adafruit_BME280.h>
#include <LoRa.h>
#include <TinyGPSPlus.h>
// UART - GPS
HardwareSerial gpsSerial(2);
TinyGPSPlus gps;
// I2C - BME280
Adafruit_BME280 bme;
// SPI - LoRa
#define LORA_CS 5
#define LORA_RST 14
#define LORA_IRQ 26
void setup() {
Serial.begin(115200);
// Initialize UART for GPS
gpsSerial.begin(9600, SERIAL_8N1, 16, 17);
Serial.println("[UART] GPS initialized");
// Initialize I2C for BME280
Wire.begin(21, 22);
if (!bme.begin(0x76)) {
Serial.println("[I2C] BME280 not found!");
while (1);
}
Serial.println("[I2C] BME280 connected");
// Initialize SPI for LoRa
SPI.begin(18, 19, 23, LORA_CS);
LoRa.setPins(LORA_CS, LORA_RST, LORA_IRQ);
if (!LoRa.begin(433E6)) {
Serial.println("[SPI] LoRa init failed!");
while (1);
}
Serial.println("[SPI] LoRa transmitter ready");
}
void loop() {
// Read GPS via UART
while (gpsSerial.available()) {
gps.encode(gpsSerial.read());
}
// Read environment via I2C
float temp = bme.readTemperature();
float humidity = bme.readHumidity();
float pressure = bme.readPressure() / 100.0F;
// Build telemetry payload
String payload = String(temp, 1) + "," +
String(humidity, 1) + "," +
String(pressure, 1);
if (gps.location.isValid()) {
payload += "," + String(gps.location.lat(), 6) +
"," + String(gps.location.lng(), 6);
}
// Transmit via SPI (LoRa)
LoRa.beginPacket();
LoRa.print(payload);
LoRa.endPacket();
Serial.println("Sent: " + payload);
delay(10000); // Send every 10 seconds
}
This is a genuine, deployable project pattern. A remote weather station in a field reads its environment over I2C, gets its location over UART, and beams the data kilometres away over SPI-connected LoRa. Three protocols, one ESP32 board, working in harmony.
Common Mistakes and How to Avoid Them
UART Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Mismatched baud rate | Garbage characters | Verify both sides use the same baud rate |
| TX connected to TX | Complete silence | Cross the lines: TX to RX, RX to TX |
| Voltage mismatch (5 V to 3.3 V) | Intermittent data or damaged pin | Use a logic level converter |
| Using UART0 for a peripheral | Conflicts with USB serial monitor | Use UART1 or UART2 instead |
I2C Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Missing pull-up resistors | Bus hangs, no ACK | Add 4.7 k ohm pull-ups to SDA and SCL |
| Too many pull-ups (stacked boards) | Signal distortion at high speed | Remove duplicate pull-ups, keep one pair |
| Address conflict | One device works, the other does not | Change address via hardware pin or use an I2C multiplexer (TCA9548A) |
| Long wires (over 50 cm) | Intermittent reads, CRC errors | Shorten wires or reduce clock speed |
SPI Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Wrong SPI mode (CPOL/CPHA) | Garbled data or no response | Check datasheet, set SPI.beginTransaction(SPISettings(freq, MSBFIRST, SPI_MODE0)) |
| CS pin left floating | Slave responds erratically | Explicitly set CS HIGH for all inactive devices |
| Shared MISO without tri-state | Bus contention, corrupted data | Verify slaves release MISO when CS is HIGH |
| Clock too fast for wire length | Bit errors at high speed | Reduce SPI clock or shorten jumper wires |
Quick Reference Cheat Sheet
When you are staring at a new module and wondering which protocol to set up, run through this checklist:
-
Does the module datasheet say "serial" or "UART/TTL"? Use UART. Connect TX/RX, match baud rate, done.
-
Does it list an I2C address (like 0x76)? Use I2C. Connect SDA/SCL, add pull-ups if your board does not have them, scan for the address.
-
Does it mention MOSI/MISO/SCK or SPI? Use SPI. Connect all four lines plus a CS pin for each device, check the SPI mode.
-
Does the module support both I2C and SPI? Many sensors (BME280, MPU6050, ADXL345) do. Choose I2C if you want fewer wires and have room on the bus. Choose SPI if you need maximum read speed or your I2C bus is already crowded.
Wrapping Up
There is no single "best" protocol. Each one exists because it solves a specific set of constraints:
- UART is the easiest to set up and perfect for modules that stream data at moderate rates.
- I2C is the most wire-efficient and ideal for connecting many low-speed sensors on a shared bus.
- SPI is the fastest and necessary for high-bandwidth peripherals like displays, SD cards, and radio modules.
The real power comes from combining all three, which the ESP32 handles effortlessly. A single board can read GPS over UART, poll a dozen I2C sensors, and blast data through a LoRa radio over SPI — all running concurrently.
Understanding these three protocols is not optional knowledge for embedded development. It is foundational. Once you have built a project or two using each one, choosing the right protocol becomes instinct.
Pick up an ESP32, wire up a few modules, and start experimenting. The best way to learn a protocol is to debug one that is not working yet.



