SPI (Serial Peripheral Interface) is a synchronous, full-duplex, 4-wire communication bus used to move data between a microcontroller (master) and peripheral devices (slaves) at speeds typically ranging from 1 MHz to 50 MHz. Unlike I2C, it uses separate physical lines for sending and receiving data simultaneously, but it requires a dedicated Chip Select (CS) wire for every single target device. If you are wiring an ESP32 to an SD card, a TFT display, or an SPI DAC, understanding the physical layer and clock timing is the difference between a working prototype and a bus that silently returns 0xFF.
The Physical Layer: Wires, Speeds, and Bus Limits
Before writing a single line of code, you must map the physical pins. SPI relies on four shared signals (SCK, MOSI, MISO) plus individual Chip Select (CS) lines. Because it is a push-pull architecture, the master drives SCK and MOSI, while the slave drives MISO.
| Feature | SPI | I2C | UART |
|---|---|---|---|
| Wires Required | 4 shared + 1 CS per device | 2 shared (SDA, SCL) | 2 (TX, RX) point-to-point |
| Max Speed (Typical) | 10 MHz - 50 MHz | 100 kHz - 3.4 MHz | 115.2 kbps - 1 Mbps |
| Addressing Method | Hardware CS pin per slave | 7-bit or 10-bit software address | None (point-to-point) |
| Max Distance | < 1 meter (highly capacitance-limited) | ~1 meter (at 100 kHz) | ~15 meters (RS-232), 1200m (RS-485) |
| Topology | Multi-slave (star/daisy-chain) | Multi-master / Multi-slave bus | Point-to-point only |
Choose SPI when you need high throughput (SD cards, SPI RAM, TFT screens) over short distances on a single PCB. Choose I2C when you have dozens of low-speed sensors (temperature, IMUs) and want to save GPIO pins. Choose UART/RS-485 for long-distance, point-to-point communication between separate enclosures.
Voltage and Level Shifting: SPI is not a 5V-tolerant standard on modern 3.3V microcontrollers. If you are connecting a 3.3V ESP32 to a 5V Arduino Uno or a 5V SPI shift register (like the 74HC595), you must use a logic level shifter. The SparkFun Logic Level Converter (based on the BSS138 MOSFET) or a CD4050 non-inverting buffer are the bench standards for this. Feeding 5V directly into an ESP32-WROOM-32 GPIO will permanently destroy the silicon.
Clock Modes, Pull-Up Myths, and Classic Failures
The most common reason an SPI bus fails to initialize is a mismatch in clock polarity and phase, collectively known as SPI Modes. The master dictates the clock, and the slave must be configured to sample data on the correct edge.
- CPOL (Clock Polarity): Is the SCK line idle LOW (0) or HIGH (1)?
- CPHA (Clock Phase): Is data sampled on the leading (first) edge or trailing (second) edge of the clock pulse?
According to Analog Devices' SPI interface guidelines, Mode 0 (CPOL=0, CPHA=0) and Mode 3 (CPOL=1, CPHA=1) account for roughly 90% of commercial SPI sensors. If your datasheet specifies Mode 3 and your microcontroller defaults to Mode 0, the slave will read garbage data.
The Classic SPI Failures
- Baud Mismatch & Capacitance: If you set your SPI clock to 20 MHz but use 30cm dupont wires, the parasitic capacitance will round off the square wave edges of the SCK signal. The slave will miss clock pulses. Fix: Drop the baud rate to 1 MHz or 4 MHz when using breadboards and long jumper wires.
- The Pull-Up Myth: Unlike I2C, SPI lines (SCK, MOSI, MISO) are push-pull and do not require pull-up resistors. However, CS lines do. If a CS line is left floating during microcontroller boot (before the GPIO is initialized as an OUTPUT HIGH), the slave device may wake up in an undefined state or clash with another device. Always place a 10kΩ pull-up resistor on every CS line to VCC.
- "Address Clash" (CS Exhaustion): SPI doesn't use software addresses, so you can't have an I2C-style address collision. The SPI equivalent is running out of GPIO pins for CS lines, or forgetting to drive CS HIGH on Device A before pulling CS LOW on Device B, causing both to drive the MISO line simultaneously and short-circuit their outputs.
Wiring a Minimal SPI Exchange (Hardware + Code)
Let's wire an ESP32 DevKit V1 to a generic SPI peripheral (like an MPU9250 IMU or an SPI FRAM chip) and perform a raw byte exchange. We will bypass high-level libraries to expose exactly how the protocol shifts bits.
| ESP32 DevKit V1 Pin | SPI Peripheral Pin | Function |
|---|---|---|
| GPIO 18 (SCK) | SCK / SCLK | Serial Clock (Master output) |
| GPIO 23 (MOSI) | MOSI / SDA / SDI | Master Out, Slave In |
| GPIO 19 (MISO) | MISO / SDO | Master In, Slave Out |
| GPIO 5 (CS) | CS / SS / NCS | Chip Select (Active LOW) |
| 3.3V | VCC / VDD | Power (Ensure 3.3V logic!) |
| GND | GND | Common Ground Reference |
Minimal Working Exchange (Arduino Framework)
This code demonstrates a raw SPI transaction. We pull CS LOW, send a command byte (e.g., 0x80 to read a register), clock in the response byte, and pull CS HIGH. We use SPISettings to explicitly define Mode 0 and a safe 1 MHz clock speed.
#include <SPI.h>
// Define the Chip Select pin for our target device
#define CS_PIN 5
void setup() {
Serial.begin(115200);
while(!Serial) { delay(10); }
// 1. Initialize CS pin as OUTPUT and set HIGH (deselect)
pinMode(CS_PIN, OUTPUT);
digitalWrite(CS_PIN, HIGH);
// 2. Initialize the ESP32 VSPI bus with default pins
SPI.begin();
Serial.println("SPI Bus Initialized.");
}
void loop() {
// 3. Configure bus parameters: 1MHz, MSB first, Mode 0
SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));
// 4. Assert Chip Select (Active LOW)
digitalWrite(CS_PIN, LOW);
// 5. Send Command Byte (e.g., 0x80 for Read Register 0x00)
SPI.transfer(0x80);
// 6. Clock in the response byte by sending a dummy byte (0x00)
uint8_t response = SPI.transfer(0x00);
// 7. De-assert Chip Select
digitalWrite(CS_PIN, HIGH);
// 8. Release the bus so other devices can use it
SPI.endTransaction();
Serial.printf("Register Value: 0x%02X\n", response);
delay(1000);
}
Sniffing the Bus: Debugging When MISO Goes Quiet
When your code compiles but your serial monitor prints 0xFF or 0x00 endlessly, the physical layer is failing. You cannot debug SPI with a standard multimeter; the toggling happens in microseconds. You need a logic analyzer.
A basic 8-channel 24MHz USB logic analyzer (often based on the Cypress CY7C68013A chip, available for ~$12) running PulseView / sigrok is mandatory for this. For professional bench work, the Saleae Logic Pro 8 remains the gold standard.
The SPI Debugging Decision Tree
- Is CS going LOW? Probe the CS line. If it stays HIGH, your microcontroller code is failing to assert the pin, or you have a wiring break. If it stays LOW permanently, you forgot the
digitalWrite(CS_PIN, HIGH)in your setup, and the slave is permanently selected, blocking other devices. - Is SCK toggling cleanly? Look at the clock trace. If the square wave looks like a shark fin (slow rise times), your baud rate is too high for the wire capacitance. Drop the speed in
SPISettings. - Is MOSI sending the right command? Decode the MOSI trace. If you are sending
0x80but the analyzer shows0x01, your bit-order is wrong. ChangeMSBFIRSTtoLSBFIRSTin your code. - Is MISO stuck at 0xFF? This is the classic "no slave connected" signature. The MISO line is floating high due to the master's internal pull-ups, or the slave is unpowered. Check your VCC and GND connections to the peripheral.
- Is MISO returning 0x00 or garbage? You likely have a CPOL/CPHA mismatch. The master is sampling the MISO line on the wrong edge of the SCK pulse. Switch from
SPI_MODE0toSPI_MODE3and re-test.
Mastering SPI requires treating it as a physical timing circuit, not just a software abstraction. By verifying your clock modes against the datasheet, managing your CS pull-ups, and validating the waveform with a logic analyzer, you will eliminate 99% of the communication errors that stall embedded projects.






