KevsRobots Learning Platform
63% Percent Complete
By Kevin McAleer, 9 Minutes
SPI (Serial Peripheral Interface) is another communication protocol for connecting microcontrollers to sensors, displays, and other devices. While I2C uses two wires and addresses, SPI uses more wires but is significantly faster. Let’s explore how it works and when to choose it over I2C.
Think of SPI like a teacher calling on students. I2C is like shouting a student’s name across the classroom (addressing), while SPI is like tapping them directly on the shoulder (chip select). More physical contact, but instant and unambiguous.
SPI uses four main signals:
Note: Modern terminology is replacing “Master/Slave” with “Controller/Peripheral”, so you might see MOSI/MISO called COPI (Controller Out, Peripheral In) and CIPO (Controller In, Peripheral Out).
You’ll encounter various names for the same signals:
| Function | Common Names |
|---|---|
| Clock | SCLK, SCK, CLK |
| Data Out | MOSI, SDO, DO, COPI |
| Data In | MISO, SDI, DI, CIPO |
| Chip Select | CS, SS, CE, nCS |
They all refer to the same four signals - just different naming conventions from different manufacturers.
Here’s how SPI communication works:
# Conceptual flow (MicroPython handles this automatically)
cs.value(0) # Select device
spi.write(b'data') # Send data
response = spi.read() # Receive data
cs.value(1) # Deselect device
The Pico has two SPI buses (SPI0 and SPI1), each with flexible pin assignments:
Typical SPI0 setup:
from machine import Pin, SPI
spi = SPI(0,
baudrate=1000000, # 1 MHz
polarity=0,
phase=0,
sck=Pin(2),
mosi=Pin(3),
miso=Pin(4))
cs = Pin(5, Pin.OUT)
cs.value(1) # Start deselected (HIGH)
Let’s initialize SPI and communicate with a simple device:
from machine import Pin, SPI
import time
# Initialize SPI
spi = SPI(0,
baudrate=1000000, # 1 MHz clock speed
polarity=0, # Clock idle state LOW
phase=0, # Sample on first edge
sck=Pin(2), # Clock on GP2
mosi=Pin(3), # Data out on GP3
miso=Pin(4)) # Data in on GP4
# Chip select on GP5
cs = Pin(5, Pin.OUT)
cs.value(1) # Deselected (HIGH is inactive)
print("SPI initialized")
print(f"Configuration: {spi}")
# Example: Send command to device
def spi_write_read(data):
"""Write data and read response"""
cs.value(0) # Select device
time.sleep_us(1) # Small delay for device setup
result = spi.write_readinto(data, data) # Send and receive
time.sleep_us(1) # Small delay before deselect
cs.value(1) # Deselect device
return result
# Send a command byte
command = bytearray([0x00])
spi_write_read(command)
print("Command sent via SPI")
SPI has four modes defined by two parameters:
| Mode | CPOL | CPHA | Description |
|---|---|---|---|
| 0 | 0 | 0 | Clock LOW when idle, sample on rising edge |
| 1 | 0 | 1 | Clock LOW when idle, sample on falling edge |
| 2 | 1 | 0 | Clock HIGH when idle, sample on falling edge |
| 3 | 1 | 1 | Clock HIGH when idle, sample on rising edge |
Most devices use Mode 0 or Mode 3. Check the datasheet!
# Mode 0 (most common)
spi = SPI(0, baudrate=1000000, polarity=0, phase=0, sck=Pin(2), mosi=Pin(3), miso=Pin(4))
# Mode 3 (also common)
spi = SPI(0, baudrate=1000000, polarity=1, phase=1, sck=Pin(2), mosi=Pin(3), miso=Pin(4))
SPI can run much faster than I2C:
| Protocol | Typical Speed | Maximum Speed |
|---|---|---|
| I2C | 100-400 kHz | 3.4 MHz |
| SPI | 1-10 MHz | 60+ MHz |
# Slow - for testing and compatibility
spi = SPI(0, baudrate=100000, ...) # 100 kHz
# Medium - good for sensors
spi = SPI(0, baudrate=1000000, ...) # 1 MHz
# Fast - for displays and memory
spi = SPI(0, baudrate=10000000, ...) # 10 MHz
# Very fast - Pico can handle up to 62.5 MHz
spi = SPI(0, baudrate=62500000, ...) # 62.5 MHz
Tip: Start with 1 MHz and increase speed once everything works. Some sensors have maximum speed limits.
Unlike I2C’s addressing system, SPI uses physical chip select lines:
from machine import Pin, SPI
# One SPI bus
spi = SPI(0, baudrate=1000000, sck=Pin(2), mosi=Pin(3), miso=Pin(4))
# Separate CS for each device
cs_sensor = Pin(5, Pin.OUT)
cs_display = Pin(6, Pin.OUT)
cs_memory = Pin(7, Pin.OUT)
# Start with all deselected
cs_sensor.value(1)
cs_display.value(1)
cs_memory.value(1)
# Talk to sensor
cs_sensor.value(0)
spi.write(b'\x00') # Send command
cs_sensor.value(1)
# Talk to display
cs_display.value(0)
spi.write(b'\xFF') # Send command
cs_display.value(1)
# Talk to memory
cs_memory.value(0)
spi.write(b'\x20') # Send command
cs_memory.value(1)
Key point: All devices share SCLK, MOSI, and MISO. Only CS is unique per device.
| Feature | I2C | SPI |
|---|---|---|
| Wires | 2 (SDA, SCL) | 4+ (SCLK, MOSI, MISO, CS per device) |
| Speed | 100-400 kHz (typically) | 1-60 MHz |
| Device selection | Address (0x76, etc.) | Chip select pin |
| Max devices | 127 (theoretical) | Limited by available CS pins |
| Full duplex | No | Yes (send and receive simultaneously) |
| Complexity | Simpler wiring | More wires |
| Power | Lower | Higher (faster clocking) |
| Distance | ~1 meter | ~10 cm (very short) |
Choose SPI when you need:
Speed
Full-duplex communication
Simplicity in addressing
Choose I2C when you need:
Many devices
Longer distances
Lower power
Simplicity in wiring
Here’s a typical pattern for reading from an SPI sensor:
from machine import Pin, SPI
import time
# Setup
spi = SPI(0, baudrate=1000000, polarity=0, phase=0,
sck=Pin(2), mosi=Pin(3), miso=Pin(4))
cs = Pin(5, Pin.OUT)
cs.value(1)
def read_register(address):
"""Read a single register from SPI device"""
# Most SPI devices use bit 7 to indicate read (1) vs write (0)
read_cmd = address | 0x80 # Set bit 7 for read
# Prepare buffers
tx_buf = bytearray([read_cmd, 0x00]) # Command + dummy byte
rx_buf = bytearray(2) # Receive buffer
# Transfer
cs.value(0)
spi.write_readinto(tx_buf, rx_buf)
cs.value(1)
# Return data byte (second byte received)
return rx_buf[1]
# Example: Read WHO_AM_I register at 0x0F
device_id = read_register(0x0F)
print(f"Device ID: 0x{device_id:02X}")
Experiment with SPI initialization:
from machine import Pin, SPI
speeds = [100000, 500000, 1000000, 5000000, 10000000]
for speed in speeds:
spi = SPI(0, baudrate=speed, sck=Pin(2), mosi=Pin(3), miso=Pin(4))
print(f"SPI at {speed/1000000:.1f} MHz: {spi}")
for mode in range(4):
polarity = mode >> 1 # Bit 1 of mode
phase = mode & 1 # Bit 0 of mode
spi = SPI(0, baudrate=1000000, polarity=polarity, phase=phase,
sck=Pin(2), mosi=Pin(3), miso=Pin(4))
print(f"Mode {mode} (CPOL={polarity}, CPHA={phase}): {spi}")
class SPIDevice:
"""Helper class for SPI device with chip select"""
def __init__(self, spi, cs_pin):
self.spi = spi
self.cs = Pin(cs_pin, Pin.OUT)
self.cs.value(1) # Deselected
def write(self, data):
"""Write data to device"""
self.cs.value(0)
self.spi.write(data)
self.cs.value(1)
def read(self, num_bytes):
"""Read data from device"""
buf = bytearray(num_bytes)
self.cs.value(0)
self.spi.readinto(buf)
self.cs.value(1)
return buf
# Use it
spi = SPI(0, baudrate=1000000, sck=Pin(2), mosi=Pin(3), miso=Pin(4))
device = SPIDevice(spi, cs_pin=5)
device.write(b'\x00') # Automatically handles CS
Problem: Device doesn’t respond to commands
Possible causes:
Solution:
# Try Mode 0 first
spi = SPI(0, baudrate=1000000, polarity=0, phase=0, ...)
# If that doesn't work, try Mode 3
spi = SPI(0, baudrate=1000000, polarity=1, phase=1, ...)
# Try slower speed
spi = SPI(0, baudrate=100000, ...)
Problem: Data received doesn’t match expected values
Causes:
Solution:
# Check wire connections - MOSI should go to MOSI (or SDI)
# Some devices need dummy bytes for reading
tx_buf = bytearray([read_cmd, 0x00, 0x00]) # Extra dummy bytes
rx_buf = bytearray(3)
spi.write_readinto(tx_buf, rx_buf)
data = rx_buf[1:] # Skip first byte (command echo)
In this lesson, you mastered:
You now understand the fundamental differences between I2C and SPI. SPI trades simplicity for speed - more wires, but much faster communication.
Theory is great, but let’s wire up a real SPI device! In the next lesson, we’ll connect an SPI sensor or display to your Pico, covering proper wiring, timing, and troubleshooting.
You can use the arrows ← → on your keyboard to navigate between lessons.
Comments