Table of Contents
Embedded Basics for Autonomous Car - This article is part of a series.
What You’ll Learn#
- Five communication protocols that connect every sensor in our autonomous car
- UART framing and baud rate tolerance
- SPI four clock modes (CPOL/CPHA) and daisy chaining
- I2C addressing, ACK/NACK, and multi-master arbitration
- CAN bus differential signaling for automotive systems
- How to capture and decode signals with a logic analyzer
1. UART — Universal Asynchronous Receiver/Transmitter#
We used UART yesterday for the debug console. Now let’s understand it deeply.
Framing#
UART has no clock wire — both sides must agree on timing before communication starts.
Idle Start D0 D1 D2 D3 D4 D5 D6 D7 Stop Idle
(HIGH) │ │
──────────┐│┌────┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌───┐┌────┘──────────
└┘│ 1 ││ 0 ││ 1 ││ 1 ││ 0 ││ 0 ││ 1 ││ 0 │
└────┘└───┘└───┘└───┘└───┘└───┘└───┘└───┘
LSB first ──────────────────────── MSB
Data byte: 01001101 (reversed) = 0b10110010 = 0xB2Each frame consists of:
- Start bit (always LOW) — signals the beginning
- Data bits (5-9, usually 8) — LSB first
- Parity bit (optional) — error detection
- Stop bit(s) (1 or 2, always HIGH) — signals the end
Baud Rate and Tolerance#
Both sides must use the same baud rate. But how much mismatch is tolerable?
The receiver samples each bit at the center of the bit period. Over a 10-bit frame, cumulative timing error must be less than half a bit period:
$$\text{Max error per frame} < \frac{0.5 \text{ bit}}{10 \text{ bits}} = 5\%$$In practice, the tolerance is about ±3% to account for noise and sampling jitter.
Common baud rates: 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600
Why not faster? UART has no clock recovery mechanism. At very high baud rates, cable capacitance and noise cause bit errors. For speeds above ~1 Mbps, you need clocked protocols (SPI) or differential signaling (CAN, USB).
Flow Control#
What if the sender transmits faster than the receiver can process?
Hardware flow control (RTS/CTS):
Sender Receiver
┌──────┐ ┌──────┐
│ TX ├──────────────────┤ RX │
│ RX ├──────────────────┤ TX │
│ RTS ├──────────────────┤ CTS │ "Ready To Send" / "Clear To Send"
│ CTS ├──────────────────┤ RTS │
│ GND ├──────────────────┤ GND │
└──────┘ └──────┘When the receiver’s buffer is almost full, it de-asserts CTS, telling the sender to pause.
2. SPI — Serial Peripheral Interface#
SPI is a synchronous protocol — it has a clock wire, so no baud rate agreement is needed. It’s fast (up to 100+ MHz) but uses more wires.
Signal Lines#
Master (RPi 5) Slave (Sensor)
┌──────────┐ ┌──────────┐
│ SCLK ├────────────────┤ SCLK │ Clock
│ MOSI ├────────────────┤ MOSI │ Master Out, Slave In
│ MISO ├────────────────┤ MISO │ Master In, Slave Out
│ CS0 ├────────────────┤ CS/SS │ Chip Select (active LOW)
│ GND ├────────────────┤ GND │
└──────────┘ └──────────┘- SCLK: Clock generated by the master. Data is valid on clock edges.
- MOSI: Data from master to slave
- MISO: Data from slave to master
- CS/SS: Chip Select — pulled LOW to activate a specific slave
The Four Clock Modes (CPOL/CPHA)#
This is where most SPI bugs come from. The clock has two configurable parameters:
- CPOL (Clock Polarity): Is the clock idle-HIGH (1) or idle-LOW (0)?
- CPHA (Clock Phase): Is data sampled on the first edge (0) or second edge (1)?
Mode 0 (CPOL=0, CPHA=0) — Most common
SCLK: ___╱‾╲___╱‾╲___╱‾╲___╱‾╲___
MOSI: ═══X═══╤═══X═══╤═══X═══╤═══
Sample on RISING edge, data changes on FALLING edge
Mode 1 (CPOL=0, CPHA=1)
SCLK: ___╱‾╲___╱‾╲___╱‾╲___╱‾╲___
MOSI: ══════X═══╤═══X═══╤═══X════
Sample on FALLING edge, data changes on RISING edge
Mode 2 (CPOL=1, CPHA=0)
SCLK: ‾‾‾╲_╱‾‾‾╲_╱‾‾‾╲_╱‾‾‾╲_╱‾‾‾
MOSI: ═══X═══╤═══X═══╤═══X═══╤═══
Sample on FALLING edge, data changes on RISING edge
Mode 3 (CPOL=1, CPHA=1)
SCLK: ‾‾‾╲_╱‾‾‾╲_╱‾‾‾╲_╱‾‾‾╲_╱‾‾‾
MOSI: ══════X═══╤═══X═══╤═══X════
Sample on RISING edge, data changes on FALLING edgeRule of thumb: Check the sensor datasheet for which mode it expects. Most sensors use Mode 0.
Multiple Slaves#
Dedicated CS lines (standard):
Master
┌──────────┐
│ SCLK ───┼──────┬──────┐
│ MOSI ───┼──────┼──────┤
│ MISO ───┼──────┼──────┤
│ CS0 ───┼──────┤ │
│ CS1 ───┼──────┼──────┤
└──────────┘ Slave0 Slave1Daisy chain (saves CS pins):
Master Slave 0 Slave 1
MOSI ────────► DIN DOUT──► DIN DOUT──► (nowhere)
MISO ◄──────────────────────────────────── DOUT
CS ────────────────────────────────────── CS (shared)Data shifts through: master sends 16 bits, first 8 go to Slave 1, last 8 stay in Slave 0.
3. I2C — Inter-Integrated Circuit#
I2C uses only two wires for multiple devices — perfect for connecting many slow sensors.
Signal Lines#
3.3V
│ │
┌┴┐ ┌┴┐
Rp │ │ Rp │ │ Pull-up resistors (4.7kΩ typical)
└┬┘ └┬┘
│ │
────────┴──────────┴──────────── SDA (Serial Data)
────────┬──────────┬──────────── SCL (Serial Clock)
│ │
┌──┴──┐ ┌──┴──┐
│Master│ │Slave│
│(RPi) │ │(IMU)│
└──────┘ └─────┘Both SDA and SCL are open-drain — devices can only pull the line LOW. The pull-up resistors bring the line back to HIGH when released. This allows multiple devices to share the same wires.
I2C Address Frame#
Every I2C device has a unique 7-bit address (0x00-0x7F, 128 possible addresses):
Start A6 A5 A4 A3 A2 A1 A0 R/W ACK
│ │
▼ ▼
SDA: ┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┐
└─┤ 1 ├─┤ 1 ├─┤ 0 ├─┤ 1 ├─┤ 0 ├─┤ 0 ├─┤ 0 ├─┤0│
└───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └─┘
Address: 0b1101000 = 0x68 Write ACK
(MPU6050 IMU default) (0) (slave
pulls LOW)- Start condition: SDA goes LOW while SCL is HIGH
- Address (7 bits): Which device to talk to
- R/W bit: 0 = Write (master → slave), 1 = Read (slave → master)
- ACK/NACK: Receiver pulls SDA LOW = ACK (understood), leaves HIGH = NACK (error)
Clock Stretching#
If a slave needs more time to prepare data, it can hold SCL LOW — the master must wait:
Normal: SCL ──╱‾╲──╱‾╲──╱‾╲──
Stretching: SCL ──╱‾╲──╱‾‾‾‾‾╲──╱‾╲──
↑
Slave holds clock LOW
(master waits)This is why I2C bus speed isn’t guaranteed. A slow slave can throttle the entire bus.
I2C Speed Modes#
| Mode | Speed | Typical Use |
|---|---|---|
| Standard | 100 kHz | Simple sensors |
| Fast | 400 kHz | IMU, magnetometer |
| Fast Plus | 1 MHz | Displays |
| High Speed | 3.4 MHz | Camera config registers |
4. CAN — Controller Area Network#
CAN is the backbone of automotive communication. Every modern car has 1-5 CAN buses connecting ECUs. Understanding CAN gives you context for autonomous vehicle architectures.
Differential Signaling#
Unlike UART/SPI/I2C (single-ended signals referenced to GND), CAN uses differential signaling:
CAN_H ──────────────────────────
╱╲ ╱╲
Dominant: ──────╱──╲──────╱──╲──── (Logic 0)
╱ ╲ ╱ ╲
CAN_L ──╱──────╲──╱──────╲────
Recessive: Both lines at ~2.5V (Logic 1)
Dominant: CAN_H ≈ 3.5V, CAN_L ≈ 1.5V (Logic 0)
Receiver reads: V_diff = CAN_H - CAN_L
Recessive: V_diff ≈ 0V → Logic 1
Dominant: V_diff ≈ 2V → Logic 0Why differential? Noise affects both wires equally (common-mode noise). The receiver subtracts them, canceling the noise. This allows CAN to work reliably over long distances (up to 1 km at 125 kbps) in electrically noisy environments like cars.
Arbitration — Who Gets to Talk?#
CAN has no master. Any node can transmit at any time. What if two nodes start simultaneously?
Bitwise arbitration: Each message starts with an ID field. During transmission, each node monitors the bus:
Node A sends ID: 0x100 = 0001 0000 0000
Node B sends ID: 0x200 = 0010 0000 0000
Bit position: 11 10 9 8 7 6 5 4 3 2 1
Node A sends: 0 0 0 1 0 0 0 0 0 0 0
Node B sends: 0 0 1 0 ...
Bus (wired-AND): 0 0 0 ← Dominant wins!
At bit 9: Node A sends 0 (dominant), Node B sends 1 (recessive)
Bus shows 0 → Node B sees its bit was overridden → backs off
Node A wins! (lower ID = higher priority)This is non-destructive arbitration — the winning message isn’t corrupted. The loser automatically retries.
CAN Frame Format#
┌─────┬─────────┬───┬──────┬───────┬─────┬─────┬─────┬─────┐
│ SOF │ ID │RTR│ DLC │ Data │ CRC │ ACK │ EOF │ IFS │
│ 1b │ 11b │1b │ 4b │ 0-64b │ 15b │ 2b │ 7b │ 3b │
└─────┴─────────┴───┴──────┴───────┴─────┴─────┴─────┴─────┘- SOF: Start of Frame (1 dominant bit)
- ID: Message identifier (11-bit standard, 29-bit extended)
- RTR: Remote Transmission Request
- DLC: Data Length Code (0-8 bytes, or 0-64 for CAN FD)
- Data: Actual payload
- CRC: 15-bit CRC for error detection
- ACK: All receivers acknowledge by pulling dominant
CAN in Autonomous Cars#
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Engine │ │ Braking │ │ Steering │
│ ECU │ │ ECU │ │ ECU │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
═════╪════════════════╪════════════════╪═══ CAN Bus (Powertrain)
500 kbps
═════╪════════════════╪════════════════╪═══ CAN Bus (Body)
│ │ │ 125 kbps
┌────┴─────┐ ┌──────┴─────┐ ┌──────┴──────┐
│ Lights │ │ Windows │ │ Locks │
│ ECU │ │ ECU │ │ ECU │
└──────────┘ └────────────┘ └─────────────┘5. USB — Universal Serial Bus#
USB is relevant because our Hailo-10 NPU connects via PCIe (similar enumeration concepts), and many sensors use USB interfaces.
USB Enumeration Process#
When you plug in a USB device, the host goes through a discovery sequence:
1. Device connected → pulls D+ or D- HIGH (speed detection)
- Low Speed (1.5 Mbps): D- pulled HIGH
- Full Speed (12 Mbps): D+ pulled HIGH
- High Speed (480 Mbps): negotiated after reset
2. Host resets device (drives both lines LOW for 10ms)
3. Host assigns address (SET_ADDRESS)
- Device starts at address 0
- Host assigns unique address (1-127)
4. Host reads descriptors:
GET_DESCRIPTOR → Device Descriptor
→ Configuration Descriptor
→ Interface Descriptor
→ Endpoint Descriptor
5. Host loads appropriate driver
6. Device is ready to useUSB Descriptor Hierarchy#
Device Descriptor (1 per device)
├── Vendor ID, Product ID
├── Device Class
└── Configuration Descriptor (1 or more)
└── Interface Descriptor (1 or more)
├── Interface Class (HID, CDC, UVC, etc.)
└── Endpoint Descriptor (1 or more)
├── Direction (IN/OUT)
├── Transfer type (Control/Bulk/Interrupt/Isochronous)
└── Max packet size# View USB descriptors on Linux
lsusb -v
# Compact view
lsusb
# Bus 001 Device 003: ID 10c4:ea60 Silicon Labs CP210x UART Bridge
# Bus 001 Device 004: ID 2dcf:6002 Hailo Technologies Ltd. Hailo-106. Protocol Comparison#
| Feature | UART | SPI | I2C | CAN | USB |
|---|---|---|---|---|---|
| Wires | 2 (TX/RX) | 4+ (SCLK/MOSI/MISO/CS) | 2 (SDA/SCL) | 2 (CANH/CANL) | 4 (D+/D-/VCC/GND) |
| Clock | None (async) | Master provides | Master provides | None (async) | Embedded in data |
| Speed | Up to ~1 Mbps | Up to 100+ MHz | 100 kHz - 3.4 MHz | Up to 1 Mbps (5 Mbps FD) | 1.5 - 480 Mbps (USB 2.0) |
| Topology | Point-to-point | Star (1 master, N slaves) | Bus (multi-master) | Bus (multi-master) | Tree (1 host, 127 devices) |
| Distance | ~15m | ~1m (PCB level) | ~1m | Up to 1 km | ~5m |
| Devices | 2 | 1 master + N slaves | Up to 128 | Up to 110+ | Up to 127 |
| Duplex | Full | Full | Half | Half | Half/Full |
When to Use What#
- UART: Debug console, GPS module, simple sensor (few wires, easy setup)
- SPI: High-speed sensors (ADC, display, SD card) — fast but uses many pins
- I2C: Multiple slow sensors on one bus (IMU, temperature, pressure) — only 2 wires
- CAN: Automotive, long-distance, noisy environments — robust but complex
- USB: Cameras, LiDAR, complex peripherals — high bandwidth, plug-and-play
For our autonomous car:
- I2C: IMU (MPU6050/BNO055) → Day 7
- UART/USB: 1D LiDAR → Day 10
- USB: Depth Camera, RGB Camera → Day 10-11
- PCIe (like USB on steroids): Hailo-10 NPU → Day 20
7. Hands-On Lab#
Lab 1: I2C Sensor Communication#
#!/usr/bin/env python3
"""I2C communication with an IMU sensor (MPU6050)."""
import smbus2
import time
# MPU6050 registers
MPU6050_ADDR = 0x68
PWR_MGMT_1 = 0x6B
ACCEL_XOUT_H = 0x3B
TEMP_OUT_H = 0x41
WHO_AM_I = 0x75
# Open I2C bus 1
bus = smbus2.SMBus(1)
# Check device identity
who = bus.read_byte_data(MPU6050_ADDR, WHO_AM_I)
print(f"WHO_AM_I register: 0x{who:02X} (expected: 0x68)")
# Wake up the MPU6050 (it starts in sleep mode)
bus.write_byte_data(MPU6050_ADDR, PWR_MGMT_1, 0x00)
time.sleep(0.1)
def read_word_2c(addr, reg):
"""Read a signed 16-bit value from two consecutive registers."""
high = bus.read_byte_data(addr, reg)
low = bus.read_byte_data(addr, reg + 1)
val = (high << 8) + low
if val >= 0x8000:
val = val - 0x10000 # Two's complement
return val
# Read accelerometer and temperature
try:
while True:
ax = read_word_2c(MPU6050_ADDR, ACCEL_XOUT_H) / 16384.0 # ±2g range
ay = read_word_2c(MPU6050_ADDR, ACCEL_XOUT_H + 2) / 16384.0
az = read_word_2c(MPU6050_ADDR, ACCEL_XOUT_H + 4) / 16384.0
temp_raw = read_word_2c(MPU6050_ADDR, TEMP_OUT_H)
temp_c = temp_raw / 340.0 + 36.53
print(f"Accel: X={ax:+.3f}g Y={ay:+.3f}g Z={az:+.3f}g "
f"Temp: {temp_c:.1f}°C")
time.sleep(0.5)
except KeyboardInterrupt:
bus.close()
print("Done.")# Scan for I2C devices on bus 1
i2cdetect -y 1
# 0 1 2 3 4 5 6 7 8 9 a b c d e f
# 60: -- -- -- -- -- -- -- -- 68 -- -- -- -- -- -- --
# ↑ MPU6050 at 0x68
# Dump all registers of device 0x68
i2cdump -y 1 0x68Lab 2: SPI Communication#
#!/usr/bin/env python3
"""SPI communication example."""
import spidev
import time
# Open SPI bus 0, chip select 0
spi = spidev.SpiDev()
spi.open(0, 0)
# Configure
spi.max_speed_hz = 1000000 # 1 MHz
spi.mode = 0b00 # Mode 0 (CPOL=0, CPHA=0)
spi.bits_per_word = 8
# SPI is full-duplex: you send and receive simultaneously
# To read a register, you typically send the register address
# and read the response on the next byte
# Example: Read register 0x0F (WHO_AM_I) of an SPI sensor
tx_data = [0x8F, 0x00] # 0x80 | 0x0F = read bit | register
# ↑ MSB=1 means "read" for many SPI sensors
rx_data = spi.xfer2(tx_data)
print(f"Sent: {[hex(b) for b in tx_data]}")
print(f"Received: {[hex(b) for b in rx_data]}")
# rx_data[0] is garbage (received while sending address)
# rx_data[1] is the actual register value
spi.close()Lab 3: UART Communication with pyserial#
#!/usr/bin/env python3
"""UART communication with pyserial."""
import serial
import time
# Open serial port
ser = serial.Serial(
port='/dev/ttyUSB0', # or /dev/ttyAMA0 for GPIO UART
baudrate=115200,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1 # Read timeout in seconds
)
print(f"Port: {ser.name}, Baudrate: {ser.baudrate}")
# Send data
message = "Hello from RPi 5!\n"
ser.write(message.encode('utf-8'))
print(f"Sent: {message.strip()}")
# Receive data
try:
while True:
if ser.in_waiting > 0:
data = ser.readline().decode('utf-8').strip()
print(f"Received: {data}")
time.sleep(0.01)
except KeyboardInterrupt:
ser.close()
print("Port closed.")Lab 4: Logic Analyzer Waveform Capture#
Using PulseView (open-source logic analyzer software):
# Install PulseView (on your laptop, not RPi)
# For Linux:
sudo apt install pulseview
# For Windows: Download from sigrok.orgExperiment: Intentional baud rate mismatch
#!/usr/bin/env python3
"""Demonstrate baud rate mismatch effect."""
import serial
import time
# Sender at 115200
sender = serial.Serial('/dev/ttyAMA0', 115200)
# Receiver at 9600 (WRONG!)
# This simulates what happens on the logic analyzer
# when you configure the wrong baud rate
sender.write(b"HELLO")
time.sleep(0.1)
sender.close()
# On the logic analyzer:
# - Capture the TX pin at 10 MHz sampling rate
# - Decode as UART at 115200 → clean "HELLO"
# - Decode as UART at 9600 → garbage characters
# - Decode as UART at 57600 → some bits correct, some wrongWhat to observe on the logic analyzer:
- Correct baud rate: Clean character decode
- 2× baud rate: Each bit read as two bits → garbled
- Half baud rate: Two bits read as one → different garbled pattern
- The start bit detection fails entirely with large mismatches
8. Review#
Key Takeaways#
- UART: Simple, 2 wires, async — good for debug and GPS, limited speed
- SPI: Fast, synchronous, full-duplex — check CPOL/CPHA mode carefully
- I2C: 2 wires, addressable, multi-device — the go-to for sensors
- CAN: Differential, robust, arbitrated — built for noisy automotive environments
- USB: Complex but versatile — cameras and high-bandwidth devices
Protocol Selection Decision Tree#
Need speed > 1 Mbps?
├── Yes → USB or SPI
│ ├── Plug-and-play? → USB
│ └── PCB-level? → SPI
└── No
├── Multiple devices on 2 wires? → I2C
├── Long distance / noisy? → CAN
└── Simple point-to-point? → UARTLooking Ahead#
Tomorrow (Day 5), we shift from hardware communication to software concurrency: threads, processes, and the critical question — “What happens when your camera callback blocks your motor control loop?” This directly connects to ROS2 Executors on Day 14.