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:
| Wire | Full Name | Direction | Purpose |
|---|---|---|---|
| SCK | Serial Clock | Master -> Slave | Clock signal generated by master |
| MOSI | Master Out, Slave In | Master -> Slave | Data from master to slave |
| MISO | Master In, Slave Out | Slave -> Master | Data from slave to master |
| CS | Chip Select | Master -> Slave | Selects 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:
| Speed | Typical Use Case |
|---|---|
| 1 MHz | Conservative, works with anything |
| 10 MHz | Most sensors and peripherals |
| 20-50 MHz | Flash memory, fast displays |
| 50+ MHz | High-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:
| Mode | CPOL | CPHA | Clock Idle | Data Sampled On |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | LOW | Rising edge |
| Mode 1 | 0 | 1 | LOW | Falling edge |
| Mode 2 | 1 | 0 | HIGH | Falling edge |
| Mode 3 | 1 | 1 | HIGH | Rising 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:
| Symptom | Likely Cause | Fix |
|---|---|---|
All zeros (0x00) | CS stuck HIGH — slave is not selected | Check CS pin, make sure it goes LOW |
All ones (0xFF) | Slave not powered, or MISO floating | Check power supply, verify wiring |
| Garbage data | Clock too fast for the slave | Reduce SPI frequency |
| Correct first byte, then garbage | CS bouncing between bytes | Keep CS LOW during entire transaction |
| Data shifted by one bit | Wrong 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
| Feature | UART | SPI |
|---|---|---|
| Wires | 2 (TX, RX) | 4+ (SCK, MOSI, MISO, CS) |
| Speed | Up to ~1 Mbps | Up to 150 MHz |
| Duplex | Full | Full |
| Multiple devices | No (point to point) | Yes (shared bus + individual CS) |
| Clock | No (async) | Yes (master provides) |
| Best for | Debug, GPS, Bluetooth | IMUs, 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.