SPI — High-Speed Serial Bus

If UART is a casual phone conversation — both sides talking whenever they feel like it — then SPI is a military radio channel. One device is the master, it controls the clock, and everyone else listens. The result is a protocol that is fast, reliable, and beautifully deterministic.

How SPI Works

SPI stands for Serial Peripheral Interface. It uses four wires:

WireFull NameDirectionPurpose
SCKSerial ClockMaster -> SlaveClock signal generated by master
MOSIMaster Out, Slave InMaster -> SlaveData from master to slave
MISOMaster In, Slave OutSlave -> MasterData from slave to master
CSChip SelectMaster -> SlaveSelects which slave to talk to (active LOW)

The master generates the clock on SCK. On each clock pulse, one bit shifts out on MOSI (from master to slave) and simultaneously one bit shifts in on MISO (from slave to master). This is full duplex — data flows both directions at the same time.

  Master                Slave
  ------                -----
  SCK   ──────────────>  SCK
  MOSI  ──────────────>  MOSI (data in)
  MISO  <──────────────  MISO (data out)
  CS    ──────────────>  CS   (active LOW)

When CS is HIGH, the slave ignores everything on the bus. When the master pulls CS LOW, the slave wakes up and participates in the transfer. This is how you put multiple slaves on the same bus — they all share SCK, MOSI, and MISO, but each gets its own CS line.

  Master
  ------
  SCK   ──────┬──────── Slave A (SCK)
  MOSI  ──────┬──────── Slave A (MOSI)
  MISO  ──────┬──────── Slave A (MISO)
  CS_A  ──────────────── Slave A (CS)
              │
              ├──────── Slave B (SCK)
              ├──────── Slave B (MOSI)
              ├──────── Slave B (MISO)
  CS_B  ──────────────── Slave B (CS)

💡 Fun Fact: SPI has no formal specification document. Motorola (now NXP) invented it in the 1980s, but never published an official standard. Every vendor implements it slightly differently, which is why SPI datasheets require careful reading.

SPI Speed

SPI can run incredibly fast. While UART tops out around 1 Mbps in practice, SPI routinely runs at:

SpeedTypical Use Case
1 MHzConservative, works with anything
10 MHzMost sensors and peripherals
20-50 MHzFlash memory, fast displays
50+ MHzHigh-speed ADCs, FPGAs

The STM32H743 can push SPI up to 150 MHz on its SPI peripheral. In practice, you are usually limited by the slave device and your PCB trace quality, but even budget sensors happily run at 10 MHz.

SPI Modes

Here is where SPI gets a bit fiddly. The clock signal has two configurable properties:

  • CPOL (Clock Polarity): Is the clock idle LOW (0) or idle HIGH (1)?
  • CPHA (Clock Phase): Is data sampled on the first clock edge (0) or the second (1)?

This gives four combinations, called SPI modes:

ModeCPOLCPHAClock IdleData Sampled On
Mode 000LOWRising edge
Mode 101LOWFalling edge
Mode 210HIGHFalling edge
Mode 311HIGHRising edge

The vast majority of sensors and peripherals use Mode 0 or Mode 3. Mode 0 is the most common default. Always check the datasheet of the device you are talking to.

🧠 Think About It: Mode 0 and Mode 3 both sample data on the rising edge. The only difference is the idle state of the clock. Why might a device prefer one over the other? Think about what happens on the very first clock edge after CS goes low.

SPI in Embassy

Basic Setup

use embassy_stm32::spi::{Config, Spi};
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_stm32::time::Hertz;

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());

    let mut spi_config = Config::default();
    spi_config.frequency = Hertz(1_000_000); // 1 MHz to start

    let mut spi = Spi::new(
        p.SPI1,
        p.PA5,          // SCK
        p.PA7,          // MOSI
        p.PA6,          // MISO
        p.DMA2_CH3,     // TX DMA
        p.DMA2_CH2,     // RX DMA
        spi_config,
    );

    // CS is a regular GPIO — you control it manually
    let mut cs = Output::new(p.PA4, Level::High, Speed::VeryHigh);

    // Perform a transfer
    let tx_data = [0x01, 0x02, 0x03];
    let mut rx_data = [0u8; 3];

    cs.set_low();                             // Select slave
    spi.transfer(&mut rx_data, &tx_data).await.unwrap();
    cs.set_high();                            // Deselect slave

    defmt::info!("Received: {:?}", rx_data);
}

Notice that CS is just a regular GPIO pin. Embassy does not manage it for you — you pull it LOW before a transfer and HIGH after. This gives you full control over multi-byte transactions where CS must stay low the entire time.

Write-Only and Read-Only

Sometimes you just want to send data (like to a display) or just read data:

#![allow(unused)]
fn main() {
// Write only — ignores incoming data
cs.set_low();
spi.write(&[0xAA, 0xBB, 0xCC]).await.unwrap();
cs.set_high();

// Read only — sends zeros while reading
let mut buf = [0u8; 4];
cs.set_low();
spi.read(&mut buf).await.unwrap();
cs.set_high();
}

Practical: Reading an IMU Over SPI

Most SPI sensors use a register-based protocol. To read a register, you send the register address with the read bit set (usually bit 7 = 1), then clock in the response.

Here is an example reading the WHO_AM_I register from an ICM-42688 IMU:

#![allow(unused)]
fn main() {
const WHO_AM_I_REG: u8 = 0x75;
const READ_FLAG: u8 = 0x80;         // Bit 7 set = read operation

async fn read_register(
    spi: &mut Spi<'_, embassy_stm32::mode::Async>,
    cs: &mut Output<'_>,
    reg: u8,
) -> u8 {
    let tx = [reg | READ_FLAG, 0x00]; // Send address, then dummy byte
    let mut rx = [0u8; 2];

    cs.set_low();
    spi.transfer(&mut rx, &tx).await.unwrap();
    cs.set_high();

    rx[1] // First byte is garbage (received while address was sent)
}

// Usage:
let who_am_i = read_register(&mut spi, &mut cs, WHO_AM_I_REG).await;
defmt::info!("WHO_AM_I = 0x{:02x}", who_am_i); // Should be 0x47
}

💡 Fun Fact: The WHO_AM_I register is a tradition in sensor design. It returns a fixed, known value so you can verify you are talking to the right chip. If you read it and get 0x00 or 0xFF, something is wrong with your wiring.

Debugging SPI

SPI bugs have distinctive signatures. Learn to recognize them:

SymptomLikely CauseFix
All zeros (0x00)CS stuck HIGH — slave is not selectedCheck CS pin, make sure it goes LOW
All ones (0xFF)Slave not powered, or MISO floatingCheck power supply, verify wiring
Garbage dataClock too fast for the slaveReduce SPI frequency
Correct first byte, then garbageCS bouncing between bytesKeep CS LOW during entire transaction
Data shifted by one bitWrong SPI mode (CPOL/CPHA)Check datasheet, try Mode 0 and Mode 3

🧠 Think About It: Why does a slave that is not selected return all zeros, while a slave that is not powered returns all ones? Think about what happens to the MISO line in each case. (Hint: active drive vs pull-up resistors.)

SPI vs UART

FeatureUARTSPI
Wires2 (TX, RX)4+ (SCK, MOSI, MISO, CS)
SpeedUp to ~1 MbpsUp to 150 MHz
DuplexFullFull
Multiple devicesNo (point to point)Yes (shared bus + individual CS)
ClockNo (async)Yes (master provides)
Best forDebug, GPS, BluetoothIMUs, flash, displays, fast sensors

Summary

SPI is the go-to protocol when you need speed and reliability. It trades simplicity (more wires) for performance (tens of MHz, full duplex, deterministic timing). You will use it for IMUs, barometers, flash memory, SD cards, and displays.

The key things to remember: the master controls the clock, CS is active LOW and you manage it yourself, and always check the datasheet for the correct SPI mode. Get those right, and SPI just works.

Next up: I2C — a two-wire bus that trades speed for simplicity and lets you connect dozens of sensors with just two wires.