Serial Peripheral Interface (SPI) is the workhorse of high-speed, short-distance embedded communication. When you need to move bulk sensor data, drive a TFT display, or interface with high-resolution analog-to-digital converters (ADCs), I2C and UART often choke on the bandwidth requirements. SPI solves this with a synchronous, full-duplex, push-pull architecture. But unlike the forgiving nature of I2C, SPI demands strict attention to physical wiring, clock phases, and chip-select management. A single misrouted MISO line or an unmanaged boot-state glitch will silently corrupt your data stream.
This guide strips away the abstract theory and provides a concrete Arduino SPI example using the ubiquitous MCP3008 10-bit ADC. We will cover the physical layer mechanics, compare protocol trade-offs, provide a complete wiring and code implementation, and detail how to debug the bus when things go wrong.
The Physical Layer: SPI Bus Mechanics and Wiring
Before writing a single line of code, you must understand the physical constraints of the bus. SPI is not a standardized protocol in the same way I2C is; it is a de facto standard with hardware variations across manufacturers. The bus relies on four shared wires, plus individual chip-select lines for every peripheral.
| Feature | Specification / Theory | Real-World Arduino / ESP32 Limits |
|---|---|---|
| Wires | 4 shared (MOSI, MISO, SCK, CS) + 1 CS per device | Uno/Nano uses Pins 11-13 + ICSP header. ESP32 uses VSPI (18, 19, 23) or HSPI (12, 13, 14). |
| Speed (Baud) | Up to 50+ MHz on modern silicon | Arduino Uno (ATmega328P) maxes at 8 MHz (half of 16 MHz sys clock). ESP32 can hit 40-80 MHz. |
| Addressing | None. Hardware routing via Chip Select (CS/SS) | Requires one dedicated GPIO per peripheral. Pin count limits total device count. |
| Distance | Short-distance, point-to-point or daisy-chain | Reliable up to ~30 cm at high speeds. At 1 MHz, can stretch to 1 meter with proper cabling. |
| Duplex | Full-Duplex (Simultaneous TX/RX) | Shift registers exchange data concurrently on every clock edge. |
On AVR-based Arduinos (Uno, Nano, Mega), the SPI pins (MOSI, MISO, SCK) are duplicated on the 2x3 ICSP header. If you are building a custom shield or using an ESP8266/ESP32 alongside an AVR, route your SPI bus from the ICSP header. It guarantees pin compatibility across different Arduino form factors, whereas digital pins 11-13 map to entirely different internal ports on the Mega2560.
Protocol Selection: When to Choose SPI Over I2C or UART
Choosing the right protocol depends entirely on your constraints regarding distance, speed, and device count. Here is how SPI stacks up against the alternatives when designing a sensor node or control board.
| Criterion | SPI | I2C | UART |
|---|---|---|---|
| Best Fit | High-speed, short-distance, few devices (Displays, ADCs, Flash) | Low-speed, many devices on the same 2 wires (Sensors, EEPROMs) | Point-to-point telemetry, GPS modules, PC comms |
| Max Speed | 10 - 50+ MHz | 100 kHz (Std), 400 kHz (Fast), 3.4 MHz (High-Speed) | 115200 bps (typ), up to ~2 Mbps (hardware dependent) |
| Device Count | Low (Limited by available MCU GPIOs for CS lines) | High (Up to 127 with 7-bit addressing) | 1-to-1 (Requires multiplexers or RS-485 for multi-drop) |
| Wiring Complexity | High (4 shared + N chip selects) | Low (2 shared wires + pull-ups) | Low (TX, RX, GND) |
The Verdict: Choose SPI when you need raw throughput and are only connecting 1 to 4 peripherals. If you need to daisy-chain 15 temperature sensors across a 2-meter run, abandon SPI and use I2C or RS-485.
A Minimal Working Arduino SPI Example
For this implementation, we will interface an Arduino Uno with an MCP3008, a classic 10-bit, 8-channel SPI ADC. This is a perfect testbed because it requires precise timing and multi-byte shifting, exposing common SPI bugs.
Physical Wiring Map
Never wire SPI by guessing. Use this exact mapping. Note that the MCP3008 requires both analog and digital grounds tied together at the chip.
| MCP3008 Pin | Function | Arduino Uno Connection |
|---|---|---|
| 16 (VDD) | Digital Power | 5V |
| 15 (VREF) | Reference Voltage | 5V (or precise external reference) |
| 14 (AGND) | Analog Ground | GND |
| 9 (DGND) | Digital Ground | GND |
| 13 (CLK) | Serial Clock (SCK) | Pin 13 |
| 12 (DOUT) | Data Out (MISO) | Pin 12 |
| 11 (DIN) | Data In (MOSI) | Pin 11 |
| 10 (CS/SHDN) | Chip Select | Pin 10 |
The Code Implementation
Modern Arduino SPI code should avoid legacy functions like SPI.setClockDivider(), which break when multiple libraries try to configure the bus. Instead, use SPI.beginTransaction() with SPISettings to guarantee interrupt-safe, atomic bus access. For deeper reference on the SPI library API, consult the official Arduino SPI documentation.
#include <SPI.h>
// Define the Chip Select pin for the MCP3008
const int CS_PIN = 10;
void setup() {
Serial.begin(115200);
// Initialize the SPI bus
SPI.begin();
// Configure the CS pin as output and set HIGH (deselect)
pinMode(CS_PIN, OUTPUT);
digitalWrite(CS_PIN, HIGH);
Serial.println("MCP3008 SPI ADC Initialized.");
}
void loop() {
// Read from Channel 0 (Single-ended mode)
int adcValue = readMCP3008(0);
// Convert 10-bit ADC value to voltage (assuming 5V VREF)
float voltage = adcValue * (5.0 / 1023.0);
Serial.print("CH0 Raw: ");
Serial.print(adcValue);
Serial.print(" | Voltage: ");
Serial.println(voltage, 3);
delay(500);
}
// Function to handle the SPI transaction and byte shifting
int readMCP3008(byte channel) {
// MCP3008 max clock is ~3.6MHz at 5V. We set 1MHz for safety.
// SPI_MODE0: Clock Polarity 0 (Low idle), Clock Phase 0 (Sample on leading edge)
SPISettings settings(1000000, MSBFIRST, SPI_MODE0);
SPI.beginTransaction(settings);
digitalWrite(CS_PIN, LOW); // Select the chip
// Byte 1: Start bit (1), Single-ended (1), Channel select (3 bits), Don't care (3 bits)
byte command = B00000001 | ((channel & 0x07) << 4);
SPI.transfer(command);
// Byte 2: The ADC returns 2 null bits, then the 10-bit result across this and next byte
byte highByte = SPI.transfer(0x00);
byte lowByte = SPI.transfer(0x00);
digitalWrite(CS_PIN, HIGH); // Deselect the chip
SPI.endTransaction();
// Mask and combine the bytes to get the 10-bit integer
int result = ((highByte & 0x03) << 8) | lowByte;
return result;
}
Debugging the Bus: Sniffing and Classic Failure Modes
When your SPI bus returns garbage data (e.g., all 1023s or all 0s), do not rewrite your code blindly. Grab a logic analyzer. A $15 Saleae clone running PulseView or Sigrok will decode the MOSI/MISO hex streams and instantly reveal physical or timing faults. For a broader overview of SPI timing modes, SparkFun's SPI tutorial provides excellent oscilloscope captures.
Here are the three classic SPI failures and how to fix them:
1. The 'Address Clash' (CS Line Contention)
SPI does not use software addresses like I2C, so you will never see an 'address clash' in the traditional sense. The SPI equivalent is a Chip Select (CS) clash. This happens when two peripherals share the same CS GPIO, or when you forget to initialize unused CS pins as OUTPUT HIGH. If a CS pin floats during MCU boot, the peripheral will wake up, latch onto random noise on the MOSI line, and lock up the MISO bus. Fix: Ensure every CS pin is explicitly driven HIGH in setup() before calling SPI.begin(). Add 10kΩ pull-up resistors to the CS lines of sensitive peripherals to hold them dormant during MCU reset.
2. The Missing Pull-Up on MISO
A common misconception is that SPI requires pull-up resistors on data lines like I2C does. SPI drivers are push-pull, not open-drain. However, a missing pull-up becomes a critical failure on the MISO line in multi-peripheral setups. When a peripheral's CS is HIGH, its MISO pin must enter a high-impedance (tri-state) mode. Cheap or poorly designed clones sometimes fail to tri-state properly, dragging the MISO line low and causing bus contention. Fix: If you are multiplexing multiple SPI devices on one MISO line and getting corrupted reads, place a 10kΩ pull-up resistor on the MISO line near the master, or use a 74HC125 tri-state buffer to isolate rogue peripherals.
3. Baud Mismatch and Clock Phase (CPOL/CPHA)
A baud mismatch in SPI usually manifests as shifted bits or completely inverted data. This is rarely just about the clock speed being too fast (though running an 8 MHz clock into a 1 MHz-rated sensor will cause edge-timing failures). More often, it is a mismatch in SPI Modes (0, 1, 2, or 3). Mode 0 (CPOL=0, CPHA=0) is the most common, but many motor drivers and specific RF modules require Mode 1 or Mode 3. Fix: Check the peripheral's datasheet timing diagram. Look at the first clock edge. If data is sampled on the falling edge instead of the rising edge, change your code from SPI_MODE0 to SPI_MODE1 inside your SPISettings object.






