Hardware, IoT & Embedded · Protocols

UART vs I2C vs SPI: Which Protocol to Use and Why

A practical guide to choosing the right bus.

Reading time: ~8–12 min
Level: All levels
Updated:

Picking between UART, I2C, and SPI isn’t about “which is best” — it’s about what problem you’re solving: point-to-point logging, a handful of sensors on two wires, or fast streaming to a display. This guide gives you a decision process you can reuse, plus wiring rules, common failure modes, and copy/paste snippets to debug quickly.


Quickstart

Use this as a fast chooser when you’re staring at a dev board and a pile of sensors. If you do nothing else, decide your top constraint (wires, speed, distance, number of devices) and pick the protocol that matches it.

Pick UART when…

Simple, reliable point-to-point serial. Great for debug consoles, modules, and “just send bytes”.

  • You’re connecting two devices (MCU ↔ module, MCU ↔ USB adapter)
  • You want the lowest complexity and easiest debugging
  • Latency is fine, throughput is “good enough”
  • You want human-friendly logs (terminal)

Pick I2C when…

A shared, two-wire bus for multiple low/medium-speed peripherals: sensors, RTCs, GPIO expanders.

  • You need multiple devices on two wires (SDA/SCL)
  • Speed is moderate and wiring is short/on-board
  • You’re okay with a master-driven bus
  • Peripherals have addresses (and you can avoid conflicts)

Pick SPI when…

Fast, synchronous transfers. Ideal for displays, ADCs/DACs, flash, radios, and “streaming bytes fast”.

  • You need high throughput and predictable timing
  • You can spare extra pins for chip select lines
  • You’re wiring on a PCB (or short, tidy wiring)
  • You want full-duplex transfers and low protocol overhead

Two sanity checks (do these first)

Most “protocol issues” are actually wiring, voltage, or configuration mismatches.

  • Confirm a shared ground and correct logic level (3.3V vs 5V)
  • Confirm pin mapping and polarity (TX/RX swapped, CS active-low)
  • Lower speed until it works, then step up
  • Use a logic analyzer if you’re stuck for >15 minutes
A simple rule that works surprisingly well

If it’s one device and you want “easy”, start with UART. If it’s many small peripherals, start with I2C. If it’s fast data (display, memory, high-rate ADC), start with SPI.

Overview

UART, I2C, and SPI are the “workhorse” serial protocols you’ll see across microcontrollers, sensor boards, radios, and SBCs. They overlap in what they can do, but they differ in wiring, bus topology, timing, and failure modes. Choosing well makes your design cheaper, more reliable, and easier to debug.

What this post covers

  • A mental model of each protocol (how data moves, who controls timing)
  • A practical decision matrix (wires, device count, speed, distance)
  • Implementation steps: wiring, configuration, and validation
  • The mistakes that waste the most time (and how to fix them)
Don’t optimize for bandwidth first

Most embedded links are limited by integration friction (pin count, wiring, address conflicts, debug time), not by raw Mbps. Optimize for what will break in your environment.

Core concepts

Think of these protocols as answers to two questions: Who decides when bits move? and How do devices share the wires? Once you internalize that, “which one should I use?” gets much easier.

UART vs I2C vs SPI at a glance

Protocol Topology Wires (typical) Timing Best for Typical pain point
UART Point-to-point (sometimes multi-drop with extra hardware) TX, RX, GND (optional RTS/CTS) Asynchronous (no clock line) Debug console, modules (GPS, BT), simple byte streams Baud/parity mismatch, level standards (TTL vs RS-232/RS-485)
I2C Shared bus (multi-drop) SDA, SCL, GND (+ pull-ups) Synchronous (clocked) Sensors, RTC, PMIC, expanders, many devices on few pins Pull-ups, bus capacitance, address conflicts, stuck bus
SPI Bus with per-device chip select SCLK, MOSI, MISO, CS (+ GND) Synchronous (clocked) Displays, flash, fast ADC/DAC, radios, streaming data Mode mismatch (CPOL/CPHA), signal integrity at high speed, CS handling

1) Asynchronous vs synchronous

UART is asynchronous: both sides must agree on the “beat” (baud rate) ahead of time, then decode bits by timing. I2C and SPI are synchronous: the clock line defines when bits are sampled, which tends to be more robust at speed — but adds wiring and timing rules.

2) Push-pull vs open-drain (why I2C needs pull-ups)

I2C lines are typically open-drain/open-collector: devices can pull the line low, but they don’t drive it high. A resistor (pull-up) brings the line back high. This enables safe multi-device sharing on the same wire, but it also means the rise time depends on resistance and bus capacitance — a common source of flaky I2C.

I2C “works on the bench” but fails in the enclosure

Long wires, high capacitance, and weak pull-ups can round off edges so much that devices misread bits. If your I2C becomes unreliable, the first fixes are usually: shorter wires, stronger pull-ups, or lower clock speed.

3) Addressing vs chip-select

I2C uses addresses on the bus. You can connect many devices as long as their addresses don’t collide and the bus stays electrically healthy. SPI usually uses a dedicated chip select (CS) per slave device: it scales well electrically at high speed, but costs GPIO pins.

4) Full-duplex vs half-duplex

UART is effectively full-duplex with separate TX/RX lines. SPI is naturally full-duplex (MOSI and MISO) during a transaction. I2C is typically half-duplex on the shared SDA line (one direction at a time), which is fine for sensors and control.

A useful mental model

  • UART: “Two people agreed on a speaking tempo, then talk over two one-way lanes.”
  • I2C: “One conductor leads an orchestra; everyone shares the same sheet music lines.”
  • SPI: “One host talks to one selected device at a time, very fast, with a shared clock.”

Step-by-step

Here’s a practical workflow that avoids the most common traps. The goal is not “pick a protocol” — it’s to pick one that works reliably in your environment and stays easy to debug months later.

Step 1 — Write your constraints (30 seconds)

Hardware constraints

  • Pin budget (how many GPIOs can you spend?)
  • Distance (same PCB, short wires, or cable?)
  • Voltage levels (3.3V/5V) and need for level shifting
  • EMI/noise environment (motors, long cables)

Product constraints

  • Throughput (occasional reads vs streaming)
  • Latency and timing sensitivity
  • How many devices share the link?
  • How painful is failure? (debug logs vs safety-critical)

Step 2 — Choose using the decision matrix

Decision matrix (practical)

Situation Best default Why Fallback if it fails
Debug console, logs, CLI, simple module UART Minimal wiring + easy tools UART + RTS/CTS, or move to RS-485 for long/noisy runs
Multiple sensors on the same board I2C Two wires for many devices Lower clock, stronger pull-ups, or split buses
Display, external flash, fast ADC/DAC SPI High throughput, low overhead Lower SCLK, add series resistors, improve routing
Long cable in noisy environment Not raw UART/I2C/SPI Signal integrity becomes the real problem Use transceivers (RS-485), CAN, or differential links

Step 3 — Wire it like you want it to work

UART wiring checklist

  • Cross TX/RX (TX → RX, RX → TX)
  • Share GND (always)
  • Confirm logic levels (3.3V vs 5V)
  • If you see garbled text: check baud/parity/stop bits

I2C wiring checklist

  • One pair of pull-ups on SDA/SCL (often 2.2k–10k depending on bus)
  • Keep wires short; route SDA/SCL together
  • Confirm 7-bit address (don’t accidentally shift it)
  • If the bus locks: implement “bus recovery” (toggle SCL)

SPI wiring checklist

  • SCLK, MOSI, MISO + one CS per device
  • Keep SCLK short and clean; ground reference matters
  • Confirm SPI mode (CPOL/CPHA) from the datasheet
  • Deassert CS between commands (many devices require it)

Board-level “speed fixes”

If you’re pushing SPI fast (or your wiring is messy), these fixes often help.

  • Lower clock first (prove correctness, then optimize)
  • Add small series resistors on SCLK/MOSI near the master
  • Avoid long stubs and flying wires
  • Use a logic analyzer to verify edges and modes

Step 4 — Validate with a 2-minute smoke test

Before writing “real” firmware, do a quick loopback or discovery test. It catches 80% of integration issues early. The snippet below focuses on common Linux/SBC workflows (USB-UART dongles and quick CLI checks).

# UART smoke test (Linux):
# 1) Identify the device (examples: /dev/ttyUSB0, /dev/ttyACM0, /dev/serial0)
ls -l /dev/ttyUSB* /dev/ttyACM* 2>/dev/null

# 2) Configure and listen (115200 8N1, raw, no echo)
stty -F /dev/ttyUSB0 115200 cs8 -cstopb -parenb -ixon -ixoff raw -echo
cat /dev/ttyUSB0

# 3) In another terminal, send a line
echo "ping" > /dev/ttyUSB0

# I2C presence check (Linux):
# - Requires i2c-tools. If you see addresses in the grid, your wiring + pull-ups likely work.
sudo i2cdetect -y 1
Fastest debugging upgrade

A basic logic analyzer pays for itself the first time you chase a “software bug” that’s actually a clock polarity mismatch. For UART, even a cheap USB serial adapter plus a terminal is a superpower.

Step 5 — Implement the first “known good” transaction

Your first goal is a minimal transaction you can reproduce. For I2C, that might be “scan the bus then read one register.” For SPI, it might be “read device ID.” Once that works, build upward.

I2C example: scan + read a register (Arduino-style)

This snippet helps you confirm addresses and perform a simple register read. Replace the address/register with your sensor’s values. If reads fail intermittently, slow down I2C and revisit pull-ups.

  • Address conflicts are common — scan first.
  • Use 7-bit addresses (many datasheets show an 8-bit “write/read” form).
  • Read the datasheet on repeated-start requirements.
#include <Wire.h>

static bool i2cReadReg(uint8_t addr7, uint8_t reg, uint8_t *out, size_t n) {
  Wire.beginTransmission(addr7);
  Wire.write(reg);
  if (Wire.endTransmission(false) != 0) {  // false = repeated start
    return false;
  }
  size_t got = Wire.requestFrom((int)addr7, (int)n, (int)true);
  for (size_t i = 0; i < got; i++) out[i] = (uint8_t)Wire.read();
  return got == n;
}

void setup() {
  Serial.begin(115200);
  Wire.begin();          // SDA/SCL pins are board-specific
  Wire.setClock(100000); // start slow; increase after it's stable

  // Quick I2C scan (prints discovered 7-bit addresses)
  Serial.println("I2C scan:");
  for (uint8_t a = 1; a < 127; a++) {
    Wire.beginTransmission(a);
    if (Wire.endTransmission() == 0) {
      Serial.print(" - found 0x");
      if (a < 16) Serial.print('0');
      Serial.println(a, HEX);
    }
  }

  // Example register read
  const uint8_t dev = 0x76; // change to your device address
  uint8_t whoami = 0x00;    // change to your register (e.g., WHO_AM_I)
  uint8_t val = 0;
  if (i2cReadReg(dev, whoami, &val, 1)) {
    Serial.print("Reg 0x00 = 0x");
    if (val < 16) Serial.print('0');
    Serial.println(val, HEX);
  } else {
    Serial.println("I2C read failed (check address, pull-ups, wiring, clock).");
  }
}

void loop() {}

Step 6 — SPI example: read a device ID (transaction discipline)

SPI isn’t hard — it’s strict. Most issues come from CS timing, wrong SPI mode, or clocking faster than your wiring can handle. The snippet below shows a simple JEDEC ID read (common for SPI flash). Even if you’re not using flash, the structure is reusable: assert CS → transfer → deassert CS.

#include <SPI.h>

static const int PIN_CS = 10; // change for your board

void setup() {
  Serial.begin(115200);
  pinMode(PIN_CS, OUTPUT);
  digitalWrite(PIN_CS, HIGH);

  SPI.begin();

  // These settings MUST match the device datasheet:
  // - clock speed: start low (e.g., 1 MHz), increase later
  // - data mode: SPI_MODE0/1/2/3 (CPOL/CPHA)
  SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));

  // Read JEDEC ID (0x9F) - typical for SPI flash
  digitalWrite(PIN_CS, LOW);
  SPI.transfer(0x9F);
  uint8_t manufacturer = SPI.transfer(0x00);
  uint8_t memoryType   = SPI.transfer(0x00);
  uint8_t capacity     = SPI.transfer(0x00);
  digitalWrite(PIN_CS, HIGH);

  SPI.endTransaction();

  Serial.print("JEDEC ID: ");
  Serial.print(manufacturer, HEX); Serial.print(" ");
  Serial.print(memoryType, HEX);   Serial.print(" ");
  Serial.println(capacity, HEX);

  Serial.println("If this looks wrong: check SPI mode, CS pin, wiring, and lower SCLK.");
}

void loop() {}
The “slow first” principle

For I2C and SPI, start at conservative clock rates until everything is correct and stable. Once you’ve confirmed correct transactions, speed becomes an optimization — not a guess.

Common mistakes

If you’ve ever said “the datasheet says it should work,” this section is for you. These are the repeat offenders that cause most UART/I2C/SPI headaches in real builds.

Mistake 1 — Mixing up electrical standards

UART the protocol is not the same as RS-232 the voltage standard. Many “UART issues” are actually level issues.

  • Symptom: garbage data or no data
  • Fix: confirm logic levels (TTL/CMOS 3.3V/5V) vs RS-232 levels; use the right transceiver
  • Fix: always share ground (or use differential + transceiver for long runs)

Mistake 2 — UART config mismatch (8N1 isn’t guaranteed)

Baud rate, parity, and stop bits must match exactly. Even “close” is often unreadable.

  • Symptom: readable sometimes, then nonsense
  • Fix: set and document baud/parity/stop bits on both ends
  • Fix: if data bursts are dropped, enable flow control (RTS/CTS) where supported

Mistake 3 — No (or wrong) I2C pull-ups

Without proper pull-ups, SDA/SCL can float, edges can be too slow, and the bus becomes flaky.

  • Symptom: scan finds nothing, or devices “appear/disappear”
  • Fix: add/adjust pull-ups; don’t stack too many modules with their own pull-ups
  • Fix: reduce bus speed and shorten wiring

Mistake 4 — I2C address confusion (7-bit vs 8-bit)

Some datasheets show an 8-bit value that includes the R/W bit. Most libraries expect the 7-bit address.

  • Symptom: writes “succeed” but reads fail (or nothing responds)
  • Fix: use the scanned address from your tooling/libraries
  • Fix: check for address pin jumpers and alternate addresses

Mistake 5 — SPI mode mismatch (CPOL/CPHA)

SPI has four modes. If the mode is wrong, you’ll read “almost plausible” garbage.

  • Symptom: bytes look random but stable
  • Fix: confirm SPI mode from the datasheet and set it explicitly
  • Fix: check bit order (MSB/LSB) and word size

Mistake 6 — Bad CS discipline (especially with multiple slaves)

Some devices require CS to go high between commands. Others don’t. Assumptions here cause very weird failures.

  • Symptom: first command works, subsequent commands fail
  • Fix: deassert CS between frames unless datasheet says otherwise
  • Fix: don’t share CS; one CS per device
The “three levers” debugging order

When a bus fails, don’t change ten things. Change in this order: (1) wiring/ground/levels(2) mode/address(3) speed. Most problems disappear by step 2.

FAQ

Is UART the same thing as RS-232?

No. UART is a digital serial framing method (start/stop bits, baud rate). RS-232 is a physical/electrical standard with different voltage levels and signaling conventions. If you connect TTL UART directly to RS-232, it often won’t work (and can be unsafe).

How many devices can I put on an I2C bus?

Practically: as many as you can support without address conflicts and without breaking the bus electrically. The limiting factors are usually pull-ups, bus capacitance (wiring length + device inputs), and whether devices behave well with clock stretching and arbitration. If the bus gets flaky, split it (multiple I2C peripherals) or slow it down.

Why does SPI need more wires than I2C?

SPI trades pins for speed and simplicity of timing. I2C shares two lines among many devices using addressing, while SPI typically needs a chip select per device so the master can talk to one slave at a time with minimal overhead.

What’s the biggest “gotcha” for each protocol?

UART: configuration mismatch and level standards. I2C: pull-ups and address confusion. SPI: mode/CS timing and signal integrity at high clocks. If you remember those three, you’ll avoid most pain.

Can I run I2C over a long cable?

You can, but it’s often a trap. I2C was designed for short, on-board connections. Long cables add capacitance and noise. If you must, lower clock speed, use proper cabling and grounding, and consider bus extenders or switching to a differential protocol for reliability.

What if I need both speed and multiple devices?

A common pattern is: I2C for slow control/sensors and SPI for high-bandwidth devices (display, flash, high-rate ADC). Split responsibilities rather than forcing one protocol to do everything.

Cheatsheet

A scan-fast summary you can keep open while wiring and configuring.

Choose the protocol in 15 seconds

If you care most about… Start with… Remember…
Debugability + simplicity UART Match baud/parity/stop; confirm logic levels; TX/RX cross
Few wires + many peripherals I2C Pull-ups matter; keep wires short; avoid address conflicts
Speed + predictable timing SPI SPI mode + CS discipline; start slow; watch SCLK integrity

Bring-up checklist (do in order)

  • Shared ground + correct voltage levels (use level shifting if needed)
  • Confirm pin mapping (TX/RX, SDA/SCL, MOSI/MISO/SCLK/CS)
  • Set conservative speeds first
  • Validate with a minimal known-good transaction (scan, read ID, echo)
  • Only then increase speed and add more devices/features

If it fails, try these fixes

  • UART: re-check baud/parity/stop; swap TX/RX; confirm TTL vs RS-232
  • I2C: lower clock; check pull-ups; run a scan; handle stuck bus recovery
  • SPI: verify mode; slow SCLK; ensure CS toggles correctly; shorten wiring
  • All: test with fewer devices and shorter wires to isolate the issue
A handy pin-count reminder

UART is ~3 wires per link (TX/RX/GND). I2C is ~3 wires total (SDA/SCL/GND) for many devices. SPI is ~4 wires plus one CS per device. That’s usually the deciding factor on small MCUs.

Wrap-up

UART, I2C, and SPI all move bytes — but they shine in different scenarios: UART for simple point-to-point and debugging, I2C for many low/medium-speed peripherals on two wires, and SPI for fast, clocked transfers when you can afford chip-select pins.

The fastest way to get unstuck is to treat bus bring-up as a short, repeatable process: confirm electrical basics, pick conservative settings, validate one transaction, then scale. If you save one thing from this post, save the Cheatsheet.

Next actions

  • Pick your protocol using the Quickstart cards.
  • Run a smoke test (UART terminal, I2C scan, SPI ID read).
  • Write down your “known good” settings (baud, address, SPI mode) in your repo README.
  • If you’re shipping hardware: test on worst-case wiring and power conditions, not just the bench.

Quiz

Quick self-check (demo). This quiz is auto-generated for hardware / iot / embedded.

1) You need to connect five sensors on the same two data wires (plus power/ground). Which protocol is the best default?
2) Which wiring requirement is most specific to I2C and commonly causes flaky communication?
3) A device returns consistent but incorrect-looking bytes over SPI. What is the most likely first thing to verify?
4) Which statement best describes UART compared to I2C and SPI?