Bus Protocols: UART, I2C and SPI
How chips on one board talk to each other: serial UART, the two-wire I2C bus with addresses, the fast SPI bus, when to use which, and how to debug them with a logic analyzer.
Why chips need buses
A microcontroller rarely works alone. On the same board, or on a few wires away, sit a temperature sensor, a display, flash memory, a GPS module or a radio chip. They need a way to exchange bytes. Running eight parallel data lines to every chip would use too many pins, so embedded systems use serial buses: bits go one after another over a few wires.
Three serial buses cover most of what you meet in IoT devices. UART connects two devices and needs no clock line. I2C connects many slow chips with only two wires. SPI is the fast option for chips that move a lot of data. All three work at the voltage of the chips themselves (usually 3.3 V or 5 V, see GPIO, PWM and Interrupts), over centimeters to at most a few meters. For longer cables in buildings and factories, industrial links such as RS-485 take over.
The examples on this page follow Lotte Kortenhoeve, a Kestrel Energy customer in Utrecht who builds her own reader for her smart meter: an ESP32 development board on the meter's P1 port, with a temperature sensor in the cabinet of her home battery. Both use a bus from this page.
UART: two wires, no clock
A UART (universal asynchronous receiver-transmitter) is the hardware block that turns bytes into a serial bit stream on one wire (TX, transmit) and back into bytes from another wire (RX, receive). "Asynchronous" means there is no shared clock line: the receiver knows when to read each bit only because both sides agreed on the speed in advance.
That speed is the baud rate: the number of signal changes (symbols) per second. On a UART each symbol is one bit, so 115200 baud is 115,200 bits per second on the wire. Common values are 9600 and 115200. Both sides must use the same baud rate and the same frame format; there is no automatic negotiation.
The UART frame
When nothing is sent, the line rests at the high level (idle). Each byte then travels as one frame:
- a start bit (low), which tells the receiver "a byte begins now" and lets it time the rest;
- usually eight data bits, least significant bit first;
- an optional parity bit for simple error detection (see parity bits);
- one or two stop bits (high), which return the line to idle.
The shorthand 8N1 means 8 data bits, no parity, 1 stop bit: 10 bits on the wire for every byte. "7E1" (7 data bits, even parity, 1 stop bit) is also 10 bits; older Dutch smart meters used it at 9600 baud.
The example builds the same frame in Python and works out how long a frame takes. The telegram is the 631-byte message that a Dutch smart meter sends on its P1 port (see Smart Meters and the P1 Port); it starts with "/".
def uart_frame(byte):
"""Line levels for one byte in 8N1: start bit 0, eight data bits LSB first, stop bit 1."""
data_bits = [(byte >> i) & 1 for i in range(8)]
return [0] + data_bits + [1]
slash = ord("/")
frame = uart_frame(slash)
print(f"'/' = 0x{slash:02X} = {slash:08b} (most significant bit first)")
print("data bits as sent, LSB first:", "".join(map(str, frame[1:9])))
print("whole frame on the wire: ", "".join(map(str, frame)))
print()
BITS_PER_BYTE = 10 # 8N1: 1 start + 8 data + 1 stop bit
TELEGRAM_BYTES = 631
for baud in (9600, 115200):
bytes_per_second = baud / BITS_PER_BYTE
ms = TELEGRAM_BYTES * BITS_PER_BYTE / baud * 1000
print(f"{baud:>6} baud: {bytes_per_second:>6,.0f} bytes/s, "
f"{TELEGRAM_BYTES}-byte telegram in {ms:5.1f} ms")
'/' = 0x2F = 00101111 (most significant bit first) data bits as sent, LSB first: 11110100 whole frame on the wire: 0111101001 9600 baud: 960 bytes/s, 631-byte telegram in 657.3 ms 115200 baud: 11,520 bytes/s, 631-byte telegram in 54.8 ms
At 115200 baud the whole telegram takes about 55 ms, comfortably inside the one second between telegrams on a DSMR 5 meter. The same arithmetic tells you whether a UART link can carry what you plan to send: bytes per second is the baud rate divided by 10 for 8N1. See bit rates and transfer times for the general method.
Wiring a UART
Three wires connect two UART devices: TX of one side to RX of the other, RX to TX, and a common ground (GND). Without the shared ground the receiver has no reference for "high" and "low" and reads noise.
The levels on a microcontroller UART are "TTL-level" logic: 0 V for low and 3.3 V or 5 V for high, depending on the chip. The old PC serial port (RS-232) uses different and much larger voltages, roughly ±3 to ±25 V and typically around ±13 V, with the meaning inverted. Never connect an RS-232 port directly to a microcontroller pin; use a level converter. A USB-to-UART adapter for 3.3 V logic is the usual way to talk to a board from a laptop, and many ESP32 development boards have one built in behind their USB port.
The P1 port is a UART with a twist
The P1 port of a Dutch smart meter is a one-way UART at 115200 baud, 8N1 (DSMR 4 and 5). Two details make it different from a plain UART:
- The data output is open collector: the meter can only pull the line low; something on the reader's side must pull it up (see open-drain outputs below). Pull it up to the 3.3 V of the ESP32, not to the meter's 5 V: with a 5 V pull-up the high pulses exceed 4 V, too much for a 3.3 V input.
-
The signal is logically inverted: the idle line
is low and the start bit is high, the opposite of the frame above.
The reader must invert it, in hardware with a transistor or in
software. On an ESP32 the UART can invert its RX input (in
MicroPython
invert=UART.INV_RX).
Pin-out, the Data Request line, power and the telegram format are on Smart Meters and the P1 Port.
I2C: many chips on two wires
I2C (Inter-Integrated Circuit, also written I²C) is a bus on which one controller and many target chips share two wires: SDA (serial data) and SCL (serial clock). The controller, usually the microcontroller, drives the clock and starts every transfer; targets such as sensors, small displays and memory chips only answer when they are addressed. The current specification (NXP UM10204, Rev. 7.0 of 2021) uses the words controller and target instead of the older master and slave.
Speed classes in the specification:
| Mode | Bit rate | Notes |
|---|---|---|
| Standard-mode | 100 kbit/s | supported by practically every I2C chip |
| Fast-mode | 400 kbit/s | the common choice for sensors |
| Fast-mode Plus | 1 Mbit/s | less common |
| High-speed mode | 3.4 Mbit/s | rare in IoT devices |
| Ultra Fast-mode | 5 Mbit/s | one direction only, for chips that only receive |
Open-drain outputs and pull-up resistors
Every chip on an I2C bus drives SDA and SCL with an open-drain output (open collector on bipolar transistors): an output that can only pull the line down to ground or let go of it. It never drives the line high. A pull-up resistor to the supply voltage makes the line high whenever nobody pulls it down.
This "wired-AND" design is why many chips can share one wire safely: if two chips disagree, the one pulling low wins, and nothing is short-circuited. It also means that without pull-ups the bus does not work at all. Many breakout boards include pull-up resistors; with several such boards on one bus, the resistors end up in parallel, which can make the pull-up too strong. The total capacitance of wires and chips (at most 400 pF in Standard- and Fast-mode, 550 pF in Fast-mode Plus) limits how long the bus can be; I2C is meant for one board or short cables.
Addresses
Each target on the bus has an I2C address: a 7-bit number, usually written in hexadecimal, that the controller sends to say which chip it wants to talk to. The address is set by the chip's manufacturer, often with one or two pins on the chip that select between a few alternatives, so that two identical sensors can share a bus. Two chips with the same address on one bus cannot both work.
On the wire, the address travels in the first byte after the start condition: the seven address bits shifted one place to the left (see bit shifts), followed by one R/W bit: 0 to write, 1 to read. That is why a data sheet may give the same sensor as "0x48" (the 7-bit address) or as "0x90/0x91" (the write and read bytes). Mixing up the two is a classic reason for "no device found". Of the 128 possible 7-bit addresses, two groups of eight (0000xxx and 1111xxx) are reserved for special purposes, which leaves 112 for targets.
def address_byte(address, read):
"""The first byte after START: 7-bit address shifted left, R/W bit last (1 = read)."""
return (address << 1) | (1 if read else 0)
for name, address in (("temperature sensor", 0x48), ("pressure sensor", 0x76)):
write, read = address_byte(address, False), address_byte(address, True)
print(f"{name:<18} 0x{address:02X}: write byte 0x{write:02X}, read byte 0x{read:02X}")
# Addresses 0000xxx and 1111xxx are reserved by the I2C specification.
reserved = [a for a in range(128) if a >> 3 in (0b0000, 0b1111)]
print(f"reserved: {len(reserved)}, usable 7-bit addresses: {128 - len(reserved)}")
temperature sensor 0x48: write byte 0x90, read byte 0x91 pressure sensor 0x76: write byte 0xEC, read byte 0xED reserved: 16, usable 7-bit addresses: 112
The first test on any new I2C setup is a bus scan: the controller tries every address and lists the ones that answer. In MicroPython on Lotte's ESP32 that is two lines (pin numbers are examples; they depend on the board):
from machine import I2C, Pin
i2c = I2C(1, scl=Pin(5), sda=Pin(4), freq=400000)
print(i2c.scan())
[72, 118]
The scan prints decimal numbers: 72 is 0x48 and 118 is 0x76, the two sensors. An empty list means no chip answered: check the pull-ups, the power and ground of the sensor, and whether SDA and SCL are swapped.
One read, step by step
Reading a register from a sensor takes two steps in one transaction. The controller first writes the register number it wants, then, with a repeated start instead of a stop, reads the bytes. After every byte the receiving side answers with an acknowledgment bit (ACK); the controller ends the read with a "not acknowledged" (NACK) to say it needs no more bytes, and then a STOP.
The example decodes such a transaction as a logic analyzer would list it and turns the two data bytes into a temperature. The sensor is a generic 12-bit model of a common kind: the reading is a signed two's complement number in the top 12 bits of two bytes, with 0.0625 °C per step. Your own sensor's data sheet gives the exact format.
# What a logic analyzer's I2C decoder reported for one read (values in hex).
capture = [
("START", None), ("ADDRESS", 0x90), ("ACK", None), # write to 0x48 ...
("DATA", 0x00), ("ACK", None), # ... register 0x00
("START", None), ("ADDRESS", 0x91), ("ACK", None), # repeated START, read
("DATA", 0x19), ("ACK", None),
("DATA", 0x40), ("NACK", None), # controller: "enough"
("STOP", None),
]
def temperature(msb, lsb):
"""Generic 12-bit sensor: two's complement in the top 12 bits, 0.0625 °C per step."""
raw = ((msb << 8) | lsb) >> 4
if raw & 0x800: # sign bit of a 12-bit number set: negative
raw -= 1 << 12
return raw, raw * 0.0625
reading = False
data = []
for event, value in capture:
if event == "ADDRESS":
reading = bool(value & 1)
print(f"address 0x{value >> 1:02X}, {'read' if reading else 'write'}")
elif event == "DATA" and reading:
data.append(value)
elif event == "DATA":
print(f"register 0x{value:02X} selected")
raw, celsius = temperature(*data)
print("bytes read:", " ".join(f"{b:02X}" for b in data))
print(f"raw {raw} -> {celsius} °C")
raw, celsius = temperature(0xFF, 0x00)
print(f"FF 00: raw {raw} -> {celsius} °C")
address 0x48, write register 0x00 selected address 0x48, read bytes read: 19 40 raw 404 -> 25.25 °C FF 00: raw -16 -> -1.0 °C
Firmware often avoids floating-point numbers and works in integers, such as thousandths of a degree. The same conversion in C, the usual language of firmware (see Firmware and Embedded Programming):
#include <stdint.h>
#include <stdio.h>
/* Convert two bytes from a generic 12-bit temperature sensor to
thousandths of a degree, with integers only (no floating point). */
static int32_t millidegrees(uint8_t msb, uint8_t lsb)
{
int32_t raw = (int32_t)((((uint16_t)msb << 8) | lsb) >> 4); /* top 12 bits */
if (raw & 0x800) { /* sign bit of a 12-bit two's complement number */
raw -= 0x1000;
}
return raw * 625 / 10; /* 0.0625 degrees = 62.5 millidegrees per step */
}
int main(void)
{
const uint8_t samples[][2] = {{0x19, 0x40}, {0x00, 0x10}, {0xFF, 0x00}, {0xE7, 0x00}};
for (unsigned i = 0; i < sizeof samples / sizeof samples[0]; i++) {
printf("%02X %02X -> %ld millidegrees\n",
samples[i][0], samples[i][1],
(long)millidegrees(samples[i][0], samples[i][1]));
}
return 0;
}
19 40 -> 25250 millidegrees 00 10 -> 62 millidegrees FF 00 -> -1000 millidegrees E7 00 -> -25000 millidegrees
Note the second line: one step is 62.5 millidegrees, and integer division drops the half. Choose the unit so that the rounding does not matter for the job; for a battery cabinet, tenths of a degree are plenty.
SPI: the fast bus
SPI (Serial Peripheral Interface) is a synchronous bus in which the controller drives a clock line and data moves in both directions at the same time (full duplex), one bit per clock tick in each direction. It has no addresses and no acknowledgments, and its lines are driven actively high and low instead of through pull-ups. That makes it simpler and much faster than I2C, which is why flash memory, SD cards, displays and radio chips use it. There is no single formal SPI standard; each chip's data sheet describes what it expects.
The signals have new names. The Open Source Hardware Association's resolution replaced the old master/slave names, and many newer data sheets follow it:
| Signal | Meaning | Older name |
|---|---|---|
SCK |
serial clock, driven by the controller | SCK, SCLK |
SDO |
serial data out (from the point of view of the chip it is printed on) | MOSI on a controller, MISO on a peripheral |
SDI |
serial data in | MISO on a controller, MOSI on a peripheral |
CS |
chip select, one per peripheral | SS (slave select) |
For pins that can work in both roles, the resolution uses PICO (peripheral in, controller out) and POCI (peripheral out, controller in). The wiring rule is the same as for a UART: the controller's SDO goes to the peripheral's SDI and the other way round.
Chip select instead of addresses
Because SPI has no addresses, the controller picks a peripheral with a separate wire: the chip select (CS) line. Each peripheral has its own CS line, normally active low: the controller pulls the line of one chip low, clocks the bytes, and lets the line go high again. All other chips keep their data outputs released and ignore the clock. The price is one extra pin per peripheral, which is why boards with many SPI chips run out of pins faster than with I2C.
SPI modes
Chips differ in two clock details: the level of the clock when idle (CPOL, clock polarity) and on which clock edge the data is read (CPHA, clock phase). The four combinations are numbered mode 0 to mode 3. Controller and peripheral must use the same mode, or every byte arrives shifted by one bit.
# SPI mode = (CPOL, CPHA). CPOL: clock level when idle. CPHA: sample on the first
# (leading) or second (trailing) clock edge of each bit.
for mode in range(4):
cpol, cpha = mode >> 1, mode & 1
idle = "high" if cpol else "low"
leading = "falling" if cpol else "rising"
trailing = "rising" if cpol else "falling"
sample = trailing if cpha else leading
print(f"mode {mode}: CPOL={cpol} CPHA={cpha} clock idles {idle:<4} sample on {sample} edge")
mode 0: CPOL=0 CPHA=0 clock idles low sample on rising edge mode 1: CPOL=0 CPHA=1 clock idles low sample on falling edge mode 2: CPOL=1 CPHA=0 clock idles high sample on falling edge mode 3: CPOL=1 CPHA=1 clock idles high sample on rising edge
Mode 0 is the most common. The data sheet of the peripheral tells you which mode it needs; the controller adapts.
Choosing a bus
Usually the peripheral chooses for you: a sensor is sold with an I2C interface, a display with SPI, a GPS module with a UART. When a chip offers more than one, this comparison helps:
| UART | I2C | SPI | |
|---|---|---|---|
| Signal wires | TX, RX (+ GND) | SDA, SCL (+ GND) | SCK, SDO, SDI, one CS per chip (+ GND) |
| Clock | none; agreed baud rate | from the controller | from the controller |
| Devices | two (point to point) | many, by 7-bit address | many, by chip select line |
| Direction | both at once | one at a time | both at once |
| Speed | 9600 to 115200 baud typical | 100 kbit/s to 1 Mbit/s typical | usually far faster than I2C |
| Typical use | GPS and modem modules, debug console, the P1 port | sensors, small displays, small memory chips | flash memory, SD cards, displays, radio chips |
The protocols on top differ too. A UART only delivers bytes; how a message starts and ends is up to the application (the P1 telegram starts with "/" and ends with "!" and a checksum). I2C and SPI define the transfer, and the chip's data sheet defines the registers. When the distance grows beyond a board or a short cable, the same ideas reappear on sturdier physical layers: a UART over RS-485 carries Modbus RTU in industry (see Modbus RTU and TCP).
Debugging with a logic analyzer
A multimeter shows an average voltage, which tells you a line is alive but not what it says. A logic analyzer records digital lines many times faster than the bus runs and shows them as high and low levels over time; its software decodes the levels into the bytes, addresses, ACKs and frames of UART, I2C or SPI. Small USB logic analyzers cost little and work with open-source software, which makes them one of the most useful tools for anyone who builds or supports devices.
Lotte connected one to the P1 data line (after her pull-up resistor) on Saturday 12 September 2026, because her reader received nothing useful. With the decoder set to 115200 baud, 8N1, it showed only errors:
time (µs) decoded
0.0 0x00 framing error
86.8 0x00 framing error
173.6 0x00 framing error
260.4 0x00 framing error
A stream of framing errors means the receiver does not find a stop bit where it expects one: wrong baud rate, or a signal that is upside down. Because the P1 signal is inverted, the decoder saw the idle line as one long run of start bits. With the decoder's "invert" option switched on, the telegram appeared:
time (µs) decoded
0.0 0x2F '/'
86.8 0x58 'X'
173.6 0x59 'Y'
260.4 0x5A 'Z'
347.2 0x35 '5'
434.0 0x5C '\'
That told her exactly what to change in her firmware: invert the RX input of the UART. Ten bits per byte at 115200 baud gives the 86.8 µs between bytes; if the decoded bytes look right at a different baud rate, the baud rate was the problem instead.
Troubleshooting bus problems
Most problems are found by checking, in this order:
- Power and ground. Is the peripheral powered at the right voltage, and do both sides share ground?
- Voltage levels. A 5 V module on a 3.3 V microcontroller (ESP32, Raspberry Pi, Pico) can damage the inputs. Use a level shifter or a module made for 3.3 V.
- Wiring. UART: TX to RX. I2C: SDA to SDA, SCL to SCL, pull-ups present. SPI: SDO to SDI, the right CS pin.
- Settings. UART baud rate and frame format; the I2C address (7-bit, not the shifted byte); the SPI mode and clock speed.
- Look at the signal. A logic analyzer shows which of the above is wrong in minutes.