Whenever I need speed — driving an SD card, a fast SPI flash chip, or a TFT display that needs to push a lot of pixel data quickly — SPI is what I reach for. It’s less economical on pins than I2C, but it more than makes up for that in raw throughput and simplicity of implementation. In this article I’ll go through how SPI actually works, how the four wires interact, how to configure it in code, and where it fits best in real embedded designs.
What Is SPI?
SPI (Serial Peripheral Interface) is a synchronous, full-duplex, master-slave serial communication protocol developed by Motorola in the 1980s. Like I2C, it uses a shared clock line generated by the master. Unlike I2C, SPI has no built-in addressing scheme — instead, each slave device gets its own dedicated Chip Select (CS) line, and only the slave whose CS line is active participates in the current transaction.
graph TD
MCU[Microcontroller - Master] -->|MOSI| S1[SPI Flash]
S1 -->|MISO| MCU
MCU -->|SCK| S1
MCU -->|CS1| S1
MCU -->|MOSI| S2[SD Card]
S2 -->|MISO| MCU
MCU -->|SCK| S2
MCU -->|CS2| S2
The Four Wires
- MOSI (Master Out, Slave In) — data sent from master to slave.
- MISO (Master In, Slave Out) — data sent from slave to master.
- SCK (Serial Clock) — clock signal generated by the master.
- CS/SS (Chip Select / Slave Select) — one line per slave, active-low, selecting which device is currently communicating.
Because MOSI and MISO are separate lines, SPI is inherently full-duplex — data can flow in both directions simultaneously on every clock pulse, unlike I2C’s half-duplex, shared SDA line.
How a Transaction Works
On every SCK pulse, one bit is shifted out on MOSI (from master to slave) and simultaneously one bit is shifted in on MISO (from slave to master). This happens through two shift registers — one in the master, one in the slave — effectively connected in a ring.
graph LR
subgraph Master
MSR[Shift Register]
end
subgraph Slave
SSR[Shift Register]
end
MSR -->|MOSI| SSR
SSR -->|MISO| MSR
Every SPI transfer, even a “read,” is technically a simultaneous send-and-receive: to read a byte from a slave, the master must clock out a byte (often a dummy 0x00 or 0xFF) while clocking in the response.
Clock Polarity and Phase (CPOL/CPHA)
SPI has four possible “modes,” defined by two settings:
- CPOL (Clock Polarity) — whether the clock idles high (1) or low (0) when no data is being transferred.
- CPHA (Clock Phase) — whether data is sampled on the first clock edge (0) or the second clock edge (1) after CS goes active.
| Mode | CPOL | CPHA | Description |
|---|---|---|---|
| 0 | 0 | 0 | Clock idle low, sample on rising edge |
| 1 | 0 | 1 | Clock idle low, sample on falling edge |
| 2 | 1 | 0 | Clock idle high, sample on falling edge |
| 3 | 1 | 1 | Clock idle high, sample on rising edge |
Getting the mode wrong is one of the most common SPI bugs — the bus will often appear to “almost work,” returning shifted or garbled data, because master and slave are sampling at different points in the clock cycle. Always check the target device’s datasheet for its required mode.
Timing Diagram: SPI Mode 0 Transfer
sequenceDiagram
participant CS
participant SCK
participant MOSI
participant MISO
CS->>CS: Goes LOW (select slave)
loop 8 clock cycles
SCK->>SCK: Rising edge - sample data
MOSI->>MOSI: Bit driven, valid before rising edge
MISO->>MISO: Bit driven, valid before rising edge
end
CS->>CS: Goes HIGH (deselect slave, end transfer)
Configuring SPI on an STM32 (HAL Example)
#include "stm32f4xx_hal.h"
SPI_HandleTypeDef hspi1;
void SPI1_Init(void) {
hspi1.Instance = SPI1;
hspi1.Init.Mode = SPI_MODE_MASTER;
hspi1.Init.Direction = SPI_DIRECTION_2LINES;
hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; // CPOL = 0
hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; // CPHA = 0 -> Mode 0
hspi1.Init.NSS = SPI_NSS_SOFT; // Software-controlled CS
hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8;
hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
HAL_SPI_Init(&hspi1);
}
uint8_t SPI1_Transfer(uint8_t data) {
uint8_t rx;
HAL_SPI_TransmitReceive(&hspi1, &data, &rx, 1, 100);
return rx;
}
void CS_Select(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); }
void CS_Deselect(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); }
Example: Reading a Register from an SPI Flash Chip
#define CMD_READ_STATUS 0x05
uint8_t flash_read_status(void) {
uint8_t status;
CS_Select();
SPI1_Transfer(CMD_READ_STATUS);
status = SPI1_Transfer(0xFF); // Dummy byte to clock in response
CS_Deselect();
return status;
}
void flash_read_data(uint32_t addr, uint8_t *buf, uint16_t len) {
CS_Select();
SPI1_Transfer(0x03); // READ command
SPI1_Transfer((addr >> 16) & 0xFF);
SPI1_Transfer((addr >> 8) & 0xFF);
SPI1_Transfer(addr & 0xFF);
for (uint16_t i = 0; i < len; i++) {
buf[i] = SPI1_Transfer(0xFF);
}
CS_Deselect();
}
Configuring SPI on Arduino
#include <SPI.h>
const int csPin = 10;
void setup() {
pinMode(csPin, OUTPUT);
digitalWrite(csPin, HIGH);
SPI.begin();
SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
}
uint8_t spiTransfer(uint8_t data) {
digitalWrite(csPin, LOW);
uint8_t response = SPI.transfer(data);
digitalWrite(csPin, HIGH);
return response;
}
Multiple Slaves on One SPI Bus
Since MOSI, MISO, and SCK are shared, adding more devices to an SPI bus only costs one extra GPIO pin per additional device (for its CS line) — the master simply asserts the correct CS line before initiating a transaction with that device, and deasserts all others.
graph TD
MCU[Microcontroller] -->|Shared MOSI/MISO/SCK| Bus((SPI Bus Lines))
Bus --- D1[Display - CS1]
Bus --- D2[SD Card - CS2]
Bus --- D3[Flash Memory - CS3]
MCU -->|CS1| D1
MCU -->|CS2| D2
MCU -->|CS3| D3
This pin cost is the main practical trade-off compared to I2C — with eight SPI slaves you’d need eight dedicated CS pins (plus the three shared lines), whereas eight I2C slaves still use only two shared pins total.
DMA-Based SPI for High Throughput
For applications like driving a TFT/LCD display with thousands of pixels per frame, byte-by-byte polling would be far too slow. Using DMA, the SPI peripheral can stream an entire framebuffer out to the display with minimal CPU involvement:
HAL_SPI_Transmit_DMA(&hspi1, framebuffer, sizeof(framebuffer));
The CPU is freed to do other work (updating the next frame, handling touch input, etc.) while the DMA controller handles the actual byte-by-byte transfer in the background, only interrupting once the whole buffer has gone out.
SPI vs I2C vs UART
| Feature | SPI | I2C | UART |
|---|---|---|---|
| Wires | 4+ (MOSI, MISO, SCK, CS per device) | 2 (SDA, SCL) | 2 (TX, RX) |
| Duplex | Full | Half | Full |
| Speed | Very high (tens of MHz) | Moderate (up to 3.4 MHz) | Moderate |
| Addressing | Via CS lines | Built-in device address | None |
| Complexity | Low-moderate | Moderate | Low |
Real-World Applications of SPI
- SD/microSD cards: SD cards support an SPI mode alongside their native protocol, making them accessible from almost any microcontroller with basic SPI support.
- TFT/OLED displays: ILI9341, ST7735, and similar display controllers use SPI for fast framebuffer updates.
- External Flash/EEPROM: W25Q series SPI flash chips are extremely common for storing firmware images, logs, or configuration data.
- Wireless modules: nRF24L01+ and similar RF transceivers use SPI for configuration and data transfer.
- Sensors requiring high data rates: Some IMUs and ADCs offer SPI interfaces specifically for applications needing faster sampling than I2C could support.
IoT/Practical Example: Data Logger with SD Card
In a field data logger I built around an STM32, sensor readings were buffered in RAM and periodically flushed to an SD card over SPI using FatFs (a common embedded FAT filesystem library) layered on top of the SPI driver:
FRESULT write_log_entry(float temp, float humidity) {
FIL file;
char line[64];
FRESULT res = f_open(&file, "log.csv", FA_OPEN_APPEND | FA_WRITE);
if (res != FR_OK) return res;
UINT bw;
int len = snprintf(line, sizeof(line), "%.2f,%.2f\n", temp, humidity);
f_write(&file, line, len, &bw);
f_close(&file);
return FR_OK;
}
FatFs issues its own sequence of SPI commands under the hood (via the low-level disk I/O layer you provide), which in turn call functions much like SPI1_Transfer() shown earlier.
Performance Considerations
SPI clock speeds are largely limited by the physical wiring (trace length, capacitance) and the target device’s maximum supported clock. Short, clean traces on a PCB can often run SPI at 20–50 MHz reliably; longer wires (breadboard jumper cables) usually need to be kept well under 1–4 MHz to avoid signal integrity issues, especially without proper termination.
Debugging SPI Issues
The recurring problems I run into, in order of frequency:
- Wrong SPI mode (CPOL/CPHA) — always the first thing I check when a device returns all zeros, all
0xFF, or consistently shifted/garbled data. - CS timing — some devices are picky about CS needing to stay low for the entire multi-byte transaction, not toggled between bytes.
- MISO/MOSI swapped — an easy wiring mistake, especially when breakout boards label pins inconsistently.
- Clock speed too high for the wiring — works fine on a short jumper, fails intermittently on a longer cable; lowering the SPI clock often fixes “flaky” behavior instantly.
A logic analyzer with SPI decoding will show you CS, SCK, MOSI, and MISO all aligned in time, which makes mode mismatches and framing issues very easy to spot.
Security Considerations
Like I2C and UART, SPI has no built-in encryption or authentication. On products where sensitive data (like firmware images or credentials) sits in external SPI flash, that flash chip can often be desoldered or probed directly to extract its contents unless it’s encrypted at rest or the flash is inside a secure enclosure. For high-security designs, look at options like SPI flash with built-in encryption or verified/secure boot chains that check firmware integrity before execution.
Frequently Asked Questions
Can SPI slaves talk to each other directly? No — SPI is strictly master-controlled. All communication is initiated by the master; slaves cannot communicate with each other without the master relaying data between them.
Why does SPI need dummy bytes for reads? Because SPI is full-duplex and clock-driven by the master, every clock pulse shifts a bit in both directions. To receive data from a slave, the master must still generate clock pulses, which it does by transmitting dummy bytes (commonly 0x00 or 0xFF), even though the actual content of those dummy bytes doesn’t matter.
How many devices can share one SPI bus? There’s no strict protocol limit, but practically it’s limited by how many CS/GPIO pins your microcontroller can spare, plus signal integrity concerns as more devices load down the shared MOSI/MISO/SCK lines.
Is SPI always faster than I2C? In virtually all common implementations, yes — SPI’s typical operating range (several MHz to tens of MHz) is significantly faster than I2C’s typical range (100kHz–3.4MHz), which is why SPI is preferred for displays, flash memory, and other high-throughput peripherals.
Summary
SPI is a fast, full-duplex, master-driven serial protocol built around a shared clock and per-device chip-select lines, making it the go-to choice whenever throughput matters more than pin count — displays, SD cards, external flash, and high-speed sensors are classic use cases. Getting SPI right comes down to matching clock mode (CPOL/CPHA), managing chip-select timing carefully, and, for high-throughput use cases, offloading transfers to DMA so the CPU isn’t stuck shifting bytes one at a time. Once those fundamentals click, adding a new SPI peripheral to a design is usually a quick, predictable process.
References and Further Reading
- Motorola/NXP SPI Block Guide (original specification reference) — nxp.com
- STMicroelectronics STM32 SPI Peripheral Reference Manual — st.com
- Arduino SPI Library Documentation — docs.arduino.cc/language-reference/en/functions/communication/spi
- Espressif ESP32 SPI Master Driver Documentation — docs.espressif.com
- Winbond W25Q Series SPI Flash Datasheets — winbond.com
- FatFs Generic FAT Filesystem Module Documentation — elm-chan.org/fsw/ff