Table of Contents
Embedded Basics for Autonomous Car - This article is part of a series.
Part 4: This Article

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 = 0xB2

Each frame consists of:

  1. Start bit (always LOW) — signals the beginning
  2. Data bits (5-9, usually 8) — LSB first
  3. Parity bit (optional) — error detection
  4. 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 edge

Rule 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  Slave1

Daisy 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
#

ModeSpeedTypical Use
Standard100 kHzSimple sensors
Fast400 kHzIMU, magnetometer
Fast Plus1 MHzDisplays
High Speed3.4 MHzCamera 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 0

Why 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 use

USB 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-10

6. Protocol Comparison
#

FeatureUARTSPII2CCANUSB
Wires2 (TX/RX)4+ (SCLK/MOSI/MISO/CS)2 (SDA/SCL)2 (CANH/CANL)4 (D+/D-/VCC/GND)
ClockNone (async)Master providesMaster providesNone (async)Embedded in data
SpeedUp to ~1 MbpsUp to 100+ MHz100 kHz - 3.4 MHzUp to 1 Mbps (5 Mbps FD)1.5 - 480 Mbps (USB 2.0)
TopologyPoint-to-pointStar (1 master, N slaves)Bus (multi-master)Bus (multi-master)Tree (1 host, 127 devices)
Distance~15m~1m (PCB level)~1mUp to 1 km~5m
Devices21 master + N slavesUp to 128Up to 110+Up to 127
DuplexFullFullHalfHalfHalf/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 0x68

Lab 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.org

Experiment: 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 wrong

What 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
#

  1. UART: Simple, 2 wires, async — good for debug and GPS, limited speed
  2. SPI: Fast, synchronous, full-duplex — check CPOL/CPHA mode carefully
  3. I2C: 2 wires, addressable, multi-device — the go-to for sensors
  4. CAN: Differential, robust, arbitrated — built for noisy automotive environments
  5. 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? → UART

Looking 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.

Embedded Basics for Autonomous Car - This article is part of a series.
Part 4: This Article