The SPI (Serial Peripheral Interface) is a synchronous high-speed bus for communicating devices.
While I2C typically works at 100kHz or 400kHz, SPI can reach frequencies of several MHz (depending on the board, wiring, and peripheral). That’s why it’s the protocol we’ll almost obligatorily use for components that handle large volumes of data, such as:
- TFT/LCD Displays (drawing pixels requires millions of bits).
- SD Cards (file reading/writing).
- RFID Readers (like the RC522).
- Ethernet Chips (like the W5500).
The 4-wire architecture
Unlike I2C, which selects who to talk to using a numeric address, SPI is a more “physical” protocol. If you want to talk to a chip, you have to “tap it on the shoulder” by activating a specific wire.
This means we need more wires (usually 4):
- SCK (Serial Clock): The clock that sets the pace. Controlled by the Master.
- MOSI (Master Out Slave In): Data going from the microcontroller to the device.
- MISO (Master In Slave Out): Data coming into the microcontroller from the device.
- CS / SS (Chip Select / Slave Select): The “selector” wire.
Full Duplex A major advantage of SPI is that it is full duplex: it can send and receive data at the same time. You can imagine it as a two-way street, whereas in I2C devices take turns to transmit.
Signal Nomenclature
In modern devices, the names sometimes change for clarity (or to avoid master/slave terminology):
- MOSI SDO (Serial Data Out) on the Master / SDI on the Slave.
- MISO SDI (Serial Data In) on the Master / SDO on the Slave.
- CS CE (Chip Enable).
Hardware SPI and SoftSPI
Just like with I2C, in MicroPython we have two classes:
machine.SPI(Hardware): Uses the dedicated silicon block. This is the recommended option. It achieves blazing-fast speeds (40-80MHz) without burdening the CPU.machine.SoftSPI(Software): Simulation via bit-banging. Useful if you run out of specific pins, but much slower. On TFT displays, using SoftSPI will result in noticeably slow screen refresh.
Bus Initialization
Let’s configure a hardware SPI bus.
On ESP32
The ESP32 has two SPI buses free for the user: HSPI (ID 1) and VSPI (ID 2).
from machine import Pin, SPI
# Configuration using VSPI (ID 2)
# sck=18, mosi=23, miso=19 are the standard VSPI pins on the ESP32
spi = SPI(2, baudrate=10000000, polarity=0, phase=0, sck=Pin(18), mosi=Pin(23), miso=Pin(19))
# Note: The CS pin is managed manually as a regular Pin
cs = Pin(5, Pin.OUT)
cs.value(1) # Deactivated (Active low)
On Raspberry Pi Pico
The Pico has SPI0 and SPI1.
# Configuration using SPI0
# sck=Pin(18), mosi=Pin(19), miso=Pin(16)
spi = SPI(0, baudrate=10000000, polarity=0, phase=0, sck=Pin(18), mosi=Pin(19), miso=Pin(16))
cs = Pin(17, Pin.OUT)
cs.value(1)
SPI Modes: Polarity and Phase
This is where SPI gets a bit tricky. Not all chips listen the same way. There are 4 possible combinations for how the clock operates, defined by:
- Polarity (CPOL): Is the clock at 0 or 1 when idle?
- Phase (CPHA): Do we read data on the rising edge or the falling edge?
Most devices use Mode 0 (CPOL=0, CPHA=0), but if a device doesn’t respond, check its datasheet to see which mode it expects.
How to Send and Receive Data
In SPI, communication is an exchange. Imagine two gears turning: for every bit you push out (MOSI), a bit comes in (MISO). Always.
Even if you only want to send, the hardware will receive something (garbage or 0s). Even if you only want to receive, you have to send something (usually dummy bytes 0x00 or 0xFF) to “drive” the clock.
Chip Select (CS) Control
The SPI protocol almost always uses Active Low logic.
- Set CS to LOW (0V) The device wakes up and listens.
- Transmit data.
- Set CS to HIGH (3.3V) The device stops listening.
import time
# Data to send
message = b'Hello'
# 1. Select the slave
cs.value(0)
# 2. Write
spi.write(message)
# 3. Release the slave
cs.value(1)
Reading Data (Exchange)
If we want to read 5 bytes from a sensor:
# Buffer to store received data (filled with zeros)
receive_buffer = bytearray(5)
cs.value(0)
# Read 5 bytes.
# Internally, MicroPython is sending 5 bytes of 0x00 to generate the clock
spi.readinto(receive_buffer)
cs.value(1)
print("Received:", receive_buffer)
Complete Write and Read Transaction
Sometimes we need to send a command and read the response simultaneously.
command = b'\xAB' # Example command
response = bytearray(1)
cs.value(0)
# Writes the command AND saves whatever comes in to 'response' at the same time
spi.write_readinto(command, response)
cs.value(1)
print("Response to command:", response)
Practical SPI Loopback Example
Just like with UART, we can test SPI without sensors by connecting the MOSI wire directly to MISO.
from machine import Pin, SPI
import time
# Adjust the pins according to your board
spi = SPI(1, baudrate=1000000, polarity=0, phase=0, sck=Pin(10), mosi=Pin(11), miso=Pin(12))
# Test message
tx_data = b'MicroPython SPI'
rx_data = bytearray(len(tx_data))
print("Sending:", tx_data)
# Since MOSI and MISO are connected, what goes out comes back in
spi.write_readinto(tx_data, rx_data)
print("Received:", rx_data)
if tx_data == rx_data:
print("Loopback Test Successful!")
else:
print("Transmission failed.")
Which Implementation to Choose
- Use I2C if you have many slow sensors and want to save wires.
- Use SPI if you need to move data fast (Displays, SD cards) or if the device specifically requires it.
- Remember to always manually manage the CS pin before and after each transaction.
With UART, I2C, and SPI in your toolbox, no chip can resist you. Now, we can move on to connecting real and complex devices.