I2C vs SPI vs UART: Choosing and Debugging Serial Protocols on Arduino and ESP32

I2C, SPI, or UART for your sensor? This guide compares the three serial protocols on speed, wiring, addressing, and distance limits, with wiring diagrams, an I2C scanner sketch, the register read/write pattern, SPI modes and speed tuning, UART cross-wiring rules, and a diagnosis table for the silent-bus failures every student meets.

Written by Projectech16 min readPublished
For B.E./B.Tech Electronics, E&TC and IoT final-year students wiring sensors, displays, and modules to Arduino and ESP32 Topics: I2C, SPI, UART, Arduino, ESP32, Sensors
Illustration of a microcontroller with three wire bundles: a shared I2C sensor bus, a fast SPI display link, and a UART serial connection.
Illustration generated for this guide.
In this guide

Connect a sensor to a microcontroller and you face the same three-letter choice every embedded student meets: I2C, SPI, or UART? They are the three serial protocols that carry nearly all sensor data in student projects — temperature, humidity, IMUs, displays, GPS modules, and SD cards all speak one of them. Picking wrong costs you days: an I2C sensor that never acknowledges, an SPI display wired to the wrong mode, a GPS module whose baud rate silently mismatches. This guide compares the three on speed, wiring, addressing, and distance, with wiring diagrams and working Arduino/ESP32 snippets for each — plus the diagnosis table for when the bus misbehaves.

If you have not chosen your sensors yet, the sensor-selection guide pairs with this one: pick the sensor for the job, then pick the protocol it speaks. And once the wiring leaves the breadboard, the soldering and prototyping guide covers making it permanent.

The short answer

Your situation Pick Why
Several slow sensors (temperature, humidity, pressure, RTC, EEPROM) on few pins I2C 2 wires total, each device has an address; easiest multi-device bus
One fast device (TFT display, SD card, high-rate IMU/ADC) SPI Fastest of the three, full-duplex; costs a chip-select pin per device
Talking to another intelligent device (GPS module, GSM module, a second microcontroller, USB-serial for debugging) UART Point-to-point, no clock line, no addressing — just agree on baud rate

The comparison table, quantified

Parameter I2C SPI UART
Wires 2 (SDA + SCL), shared by all devices 4 minimum (MOSI, MISO, SCK + one CS per device) 2 (TX + RX), crossed between the two devices
Topology Multi-controller/multi-target bus One controller, N targets (each needs CS) Point-to-point only
Addressing 7-bit address per device (up to 112 usable) Chip-select line per device (no addresses) None — whoever is wired, talks
Clock Shared clock from controller (100 kHz standard, 400 kHz fast, up to 3.4 MHz high-speed) Shared clock from controller (MHz to 10s of MHz) No shared clock — both sides use agreed baud rate
Duplex Half-duplex Full-duplex (send and receive simultaneously) Full-duplex
Typical practical speed 100–400 kbps 1–20 Mbps on ESP32/Arduino 9.6–115.2 kbps common; up to ~1 Mbps short-range
Distance Short: ~1 m reliably on a PCB/breadboard; degrades with bus capacitance Short: tens of cm at high speed; slower clocks reach further Moderate: metres at 9600 baud; RS-485 transceivers extend to 100s of metres
Pull-ups Required on SDA and SCL (4.7 kΩ typical) Not needed (push-pull drivers) Not needed
Voltage sensitivity 3.3 V vs 5 V mismatch needs level shifting or a 3.3 V-tolerant bus Same — level-shift MOSI/SCK/CS if the target is 3.3 V and controller is 5 V Same — never feed 5 V TX into a 3.3 V RX pin

The one-line version: I2C minimises pins, SPI maximises speed, UART minimises thinking.

I2C in depth: the two-wire sensor bus

I2C (Inter-Integrated Circuit) connects many devices to two shared lines: SDA (data) and SCL (clock). Every device has a 7-bit address; the controller addresses the device it wants, and everyone else ignores the traffic. A water-quality monitor with pH, TDS, and temperature probes plus an OLED display is the canonical I2C project: four devices, two pins.

Wiring

Controller (ESP32/Arduino)          I2C devices (sensor, display, RTC...)
                                                                         
  3V3 ──────┬──────────────────────── VCC of each device                  
            │                                                             
  GPIO21 ───┼──────────────────────── SDA of each device                  
            │    ┌─── 4.7kΩ pull-up ──┘                                   
  GPIO22 ───┼──────────────────────── SCL of each device                  
            │    ┌─── 4.7kΩ pull-up ──┘                                   
  GND ──────┴──────────────────────── GND of each device                  

(ESP32 default I2C pins: SDA = GPIO21, SCL = GPIO22. Arduino Uno: SDA = A4, SCL = A5. Almost any GPIO pair works on the ESP32 with Wire.begin(sda, scl).)

The pull-up resistors are not optional: I2C lines idle HIGH and devices pull them LOW to signal. Without pull-ups the lines float and nothing works — the most common "I2C sensor not found" cause on a breadboard. Most sensor breakout boards include pull-ups already; if yours does, do not add a second pair (parallel pull-ups make the total resistance too low — two 4.7 kΩ pairs in parallel = 2.35 kΩ, usually still fine, but three or four boards' worth can overload weak drivers).

The scanner sketch: your first diagnostic

Before writing any sensor code, run an I2C scan. It lists every address that acknowledges — if your sensor's address is not there, the problem is wiring/power/address, not your sensor library:

#include <Wire.h>

void setup() {
  Serial.begin(115200);
  Wire.begin();  // Wire.begin(21, 22) on ESP32 to choose pins
  Serial.println("Scanning I2C bus...");
  for (uint8_t addr = 1; addr < 127; addr++) {
    Wire.beginTransmission(addr);
    if (Wire.endTransmission() == 0) {
      Serial.printf("Device found at 0x%02X\n", addr);
    }
  }
  Serial.println("Scan done.");
}
void loop() {}

Compare the found address against the sensor datasheet. Many sensors have an ADDR/SDO pin that selects between two addresses (e.g. 0x76 vs 0x77 on Bosch pressure sensors) — if the scan shows the "other" address, your code's hardcoded address is simply wrong.

I2C traps

  • Address collisions: two devices with the same address cannot share the bus. Check before buying: two identical sensor breakouts (two BME280s, say) will clash unless one offers an address-select pin. The fix is an I2C multiplexer (TCA9548A) or using SPI for one of them.
  • Clock stretching: some slow devices hold SCL low to say "wait." The ESP32 Arduino Wire library handles this, but very long stretches can look like a hang — know it exists before blaming the code.
  • Bus capacitance: every device and every centimetre of wire adds capacitance; too much and the 400 kHz edges round off into garbage. Keep I2C runs short (under ~30–50 cm on breadboard jumpers is comfortable; metres need lower speed or an extender). If a bus works at 100 kHz but fails at 400 kHz, capacitance is your prime suspect.
  • 5 V vs 3.3 V: an Arduino Uno's I2C pins idle at 5 V (its pull-ups go to 5 V). Most modern sensors are 3.3 V and not 5 V-tolerant — 5 V on their SDA pin can damage them. Use a bidirectional level shifter (the common 4-channel MOSFET boards) or run the whole bus at 3.3 V if the controller allows it. The power and regulators guide covers the voltage-domain thinking.

Reading a sensor: the register pattern

Nearly all I2C sensors follow the same dance — write the register address you want, then read N bytes back:

#include <Wire.h>
#define SENSOR_ADDR 0x48

int16_t readRegister16(uint8_t reg) {
  Wire.beginTransmission(SENSOR_ADDR);
  Wire.write(reg);                 // "I want register <reg>"
  Wire.endTransmission(false);     // false = keep the bus (repeated start)
  Wire.requestFrom(SENSOR_ADDR, 2); // read 2 bytes
  int16_t val = Wire.read() << 8 | Wire.read();
  return val;
}

Learn this pattern once and every I2C datasheet becomes readable: find the register map, write register, read bytes, convert per the datasheet's formula. In practice you will use a sensor library that does this for you — but when the library misbehaves, this is the level you debug at.

SPI in depth: the fast four-wire bus

SPI (Serial Peripheral Interface) trades pins for speed: a shared MOSI (controller-out), MISO (controller-in), and SCK (clock), plus one chip-select (CS) line per device. The controller pulls a device's CS low to talk to it. An ultrasonic radar scanner driving a TFT display, or any project logging to an SD card, is SPI territory — displays and SD cards need the megabits I2C cannot give.

Wiring

Controller                SPI device 1 (display)      SPI device 2 (SD card)
                                                                            
 GPIO23 (MOSI) ──────────── MOSI                      MOSI                  
 GPIO19 (MISO) ──────────── MISO                      MISO                  
 GPIO18 (SCK)  ──────────── SCK                       SCK                   
 GPIO5  (CS)   ──────────── CS                                              
 GPIO15 (CS2)  ─────────────────────────────────────── CS                  
 GND           ──────────── GND                        GND                   
 3V3           ──────────── VCC                        VCC                   

MOSI/MISO/SCK are shared; each device gets its own CS on any free GPIO. (ESP32 default VSPI pins: MOSI = 23, MISO = 19, SCK = 18; CS is your choice. Arduino Uno: MOSI = 11, MISO = 12, SCK = 13, CS typically 10 for the SD library.)

SPI modes: the setting that breaks displays

SPI has four modes (0–3) defined by clock polarity (CPOL) and phase (CPHA) — essentially, does the device sample data on the rising or falling clock edge, and is the clock idle high or low? The controller and device must agree. A display initialised in the wrong mode shows garbage or nothing, with no error message — the most confusing SPI failure because "the wiring is right."

In practice: the device library sets the mode for you (SPI.beginTransaction(SPISettings(speed, MSBFIRST, SPI_MODE0))). You only touch modes when writing your own driver or when two SPI devices need different modes — then wrap each device's transfers in beginTransaction/endTransaction with its own settings, and the bus reconfigures per device. Forgetting endTransaction (or sharing the bus without transactions) is how one device's settings corrupt another's transfers.

Speed: how fast can you actually go?

The ESP32's SPI peripheral reaches 10s of MHz; an Arduino Uno tops out at 8 MHz (half its 16 MHz clock). But the device sets the limit — check its datasheet maximum, then stay under it:

  • SD cards: initialise at ~400 kHz, then run at up to 20+ MHz once initialised (the SD library handles the switch).
  • TFT displays (ILI9341/ST7789): typically 20–40 MHz for pixel data.
  • Long jumper wires: drop the speed. At 40 MHz, 20 cm of breadboard jumper is an antenna and a capacitor farm; if the display glitches, halve the clock before rewiring anything.

A practical rule: start at 1 MHz, verify the device works, then raise the speed until it breaks, then back off 2×. That last stable speed is your design value — write it in the report.

The MISO trap: 5 V controllers and 3.3 V devices

Like I2C, SPI has the voltage-domain problem, but it bites asymmetrically: the controller's MOSI/SCK/CS (outputs) must not exceed the device's VCC, and the device's MISO (its output) must be readable by the controller. A 5 V Arduino talking to a 3.3 V SD card needs level shifting on MOSI/SCK/CS; MISO from the card (3.3 V) usually reads fine on a 5 V Arduino input (3.3 V > the ~3 V logic-high threshold — usually, not guaranteed). "Usually" is doing heavy lifting there: for a graded project, level-shift properly instead of relying on thresholds. The ESP32 is 3.3 V throughout, so ESP32-to-3.3 V-device SPI needs no shifting at all — one more quiet reason students migrate.

UART in depth: the simplest serial there is

UART (Universal Asynchronous Receiver-Transmitter) is two wires — TX and RX, crossed: one side's TX goes to the other's RX. No shared clock (both sides agree on a baud rate in advance), no addresses, no chip selects. It is how GPS modules, GSM modules, and USB-serial debugging talk, and how two microcontrollers chat.

Wiring

Device A (ESP32)              Device B (GPS module)
                                                    
 GPIO17 (TX2) ─────────────────── RX                
 GPIO16 (RX2) ─────────────────── TX                
 GND         ─────────────────── GND                

Three rules, all commonly violated:

  1. Cross TX/RX. TX-to-TX is the wiring mistake behind half of all "GPS not working" forum posts. Say it aloud while wiring: "transmit goes to receive."
  2. Common ground. Without a shared GND reference, the signal voltages are meaningless and you get garbage. Every UART link needs GND — no exceptions.
  3. Agree on the baud rate. Both sides must use the same rate (9600 is the GPS/GSM classic; 115200 for ESP32 USB serial). A mismatch produces consistent-looking garbage — often mistaken for a broken module.

Hardware vs software serial

  • Hardware UART (the ESP32 has three: UART0 for USB-serial, UART1, UART2) is reliable at high baud rates and does not load the CPU. Always prefer it.
  • SoftwareSerial (bit-banging UART on any GPIO) works in a pinch but is timing-sensitive: it struggles above ~38400 baud, breaks under interrupts, and on the ESP32 it can conflict with WiFi timing. Use it only when all hardware UARTs are taken — and on the ESP32, with three UARTs available, that is rare.

On the ESP32, remap UART1/UART2 to convenient pins:

#include <HardwareSerial.h>
HardwareSerial gpsSerial(2);  // use UART2
void setup() {
  gpsSerial.begin(9600, SERIAL_8N1, 16, 17);  // baud, config, RX, TX pins
}

The GPS example: UART in a real project

A soil EC mapper with a GPS field dashboard reads NMEA sentences from a GPS module over UART. The pattern is universal for UART peripherals:

void loop() {
  while (gpsSerial.available()) {
    char c = gpsSerial.read();
    // feed c to a parser (e.g. TinyGPS++) one character at a time
    gps.encode(c);
  }
  if (gps.location.isUpdated()) {
    // use gps.location.lat(), gps.location.lng()
  }
}

Notes from the field: GPS modules take 30 s to several minutes for a cold-start fix (longer indoors — they effectively do not work indoors); the 1 Hz NMEA stream at 9600 baud is ~80 characters per sentence, well within UART's comfort zone; and the module's TX is 3.3 V, safe for the ESP32's RX directly.

Choosing between them: the decision procedure

  1. How many devices? More than 2–3 slow sensors → I2C (2 pins total beats SPI's per-device CS). One fast device → SPI.
  2. How fast? Sub-MHz sensor data → I2C is fine. Display/SD/high-rate sampling → SPI. Human-speed text streams (GPS, GSM, debug) → UART.
  3. Who is the peer? Another intelligent module (GPS, GSM, second MCU) → UART (it is what they speak). A "dumb" sensor/display/memory → I2C or SPI per speed.
  4. How far? On the PCB or a short breadboard → any. Metres between boards → UART at 9600 baud, or add RS-485 transceivers; I2C/SPI are not distance buses.
  5. How many pins can you spare? Pin-starved → I2C (2 pins for N devices). Pins to burn and need speed → SPI.

Diagnosis table: the bus is silent — now what?

Symptom Likely cause Check
I2C scanner finds nothing Missing pull-ups, no power to sensor, SDA/SCL swapped Verify 4.7 kΩ pull-ups to the right voltage; check sensor VCC with a multimeter; swap SDA/SCL and rescan
I2C scanner finds a device at an unexpected address ADDR pin strapped to the alternate address Read the datasheet's address table; change the address in code, not the wiring
I2C works with one device, fails adding a second Address collision, or bus capacitance from long wires Scan with each device alone; shorten wires; try 100 kHz
I2C worked yesterday, garbage today A second breakout's built-in pull-ups paralleled too low, or a loose jumper Count pull-up sets; reseat jumpers; measure SDA idle voltage (should be VCC)
SPI device totally dead CS wired to the wrong pin, or device not powered Scope/log: CS must go low during transfers; verify VCC at the device pins
SPI display shows garbage/static Wrong SPI mode, or clock too fast for the wiring Set the mode from the datasheet; halve the clock; shorten wires
Two SPI devices, one works at a time Missing beginTransaction/endTransaction — settings leak between devices Wrap every device's transfers in its own transaction with its own settings
UART prints garbage characters Baud mismatch, or TX-TX wiring, or missing common GND Match baud on both sides; cross TX/RX; add the GND wire
UART works at 9600, garbage at 115200 SoftwareSerial at its limit, or long wires Switch to hardware UART; shorten wires; drop back to 57600
GPS sends data but never gets a fix Indoors, or cold start still in progress, or antenna issue Test outdoors with clear sky view; wait up to 5 minutes; check antenna connection

The logic-analyser trick (₹500 that saves weeks)

A cheap USB logic analyser (24 MHz, 8-channel clones are a few hundred rupees) plus free PulseView software shows you exactly what the bus is doing: I2C start/stop/ACK bits, SPI clock vs data alignment, UART baud timing. When the scanner finds nothing and you have stared at the wiring for an hour, ten seconds of captured waveforms tells you whether the controller is even transmitting. If your lab has an oscilloscope, that works too — but the analyser decodes the protocols for you, which a scope does not. For a final-year project, a screenshot of a decoded I2C transaction in your report is strong evidence you debugged at the signal level.

Pre-wiring checklist

  • Protocol chosen by the decision procedure above — and the choice written down with reasons (your report will ask).
  • I2C: pull-ups present (exactly one effective pair), addresses checked against datasheets, no collisions, scanner run before sensor code.
  • SPI: CS pin per device verified, mode from the datasheet, speed started low and raised deliberately.
  • UART: TX/RX crossed, baud matched, common GND, hardware UART preferred over software.
  • Voltage domains checked: no 5 V signal into a 3.3 V-only pin without level shifting.
  • Wire lengths short for I2C/SPI; logic-analyser capture saved for the report if debugging was needed.

Putting it together

I2C for the sensor crowd, SPI for the speed demons, UART for talking to smart modules — and the diagnosis table above for the inevitable silent bus. The deeper lesson is that these protocols fail in characteristic ways (missing ACKs, mode mismatches, baud garbage), so matching the symptom to the table beats random rewiring every time.

Builds that exercise all three: a pH/TDS/temperature water-quality monitor (I2C sensors + UART debug), a GPS-tagged soil EC field mapper (UART GPS + I2C/SPI sensors), or an ultrasonic radar scanner (SPI display + sensor timing). Debug systematically with the test-and-debug guide when the bus stays silent.

A complete multi-protocol wiring map: one project, all three buses

Real projects mix protocols. Here is a realistic ESP32 sensor node using all three at once — the kind of diagram worth drawing before touching a jumper wire:

ESP32 DevKit
                                 I2C bus (GPIO21 SDA, GPIO22 SCL, 4.7k pull-ups to 3V3)
                                  ├── BME280 (0x76) — temperature/humidity/pressure
                                  ├── DS3231 (0x68) — real-time clock
                                  └── OLED 0.96" (0x3C) — status display

                                 SPI bus (VSPI: MOSI 23, MISO 19, SCK 18)
                                  ├── SD card module (CS = GPIO5) — data logging
                                  └── (CS = GPIO15 unused spare)

                                 UART2 (RX 16, TX 17, 9600 baud)
                                  └── NEO-6M GPS module — location + time

                                 GPIO (direct)
                                  ├── GPIO26 → relay module (pump/valve control)
                                  └── GPIO34 (input-only) → soil-moisture analog (ADC1)

Check this map against every rule in this guide: one I2C pull-up pair (the breakouts' extras removed or accounted for), no address collisions (0x76/0x68/0x3C are distinct), SPI CS pins verified free, UART crossed with common GND, analog on ADC1, relay on a normal output-capable GPIO. Drawing this map takes twenty minutes; debugging without it takes days. Put the final version in your report — it is the single most useful figure in a hardware project's documentation.

Protocol cheat sheet (tape this to the wall)

  • I2C: 2 wires, addresses, needs pull-ups, 100–400 kHz, short runs. Scanner first, sensor code second.
  • SPI: 4+ wires, chip-selects, set the mode, start slow. One transaction per device.
  • UART: TX→RX, common GND, matched baud, hardware UART. Garbage text = baud or wiring.
  • All three: check voltage domains before powering anything; short wires beat long wires; the logic analyser settles arguments.

More project guides

More in IoT & Embedded