Raspberry Pi Zero SPI Loopback Tester for C.
We go over a basic SPI diagnostic C program using the Raspberry Pi Zero!
Here is some nice boilerplate code for testing your SPI Bus. This code was human tested (suggested by Grok 4) and its results are outlined at the end of the article. It is a great way for learning SPI!
- LLM's make mistakes, you always need to actually verify. We found out right away that the pinouts it suggested did not really match, and checking showed it used the pinout for the Raspberry Pi 3, either way you need to always manually verify its code! LLM's typically at the time of this writing will get 95% of it right but we still as humans need to review and check the code! You will read the amended code, not the error code put out by Grok 4.
Here's a simple, self-contained C program to test SPI on a Raspberry Pi Zero (or any Raspberry Pi model) using the standard Linux spidev interface. It performs a basic loopback test by sending data and verifying it comes back on the same bus.
Prerequisites
Enable SPI:
- Run
sudo raspi-config→ Interface Options → SPI → Enable. - Or add
dtparam=spi=onto/boot/config.txtand reboot. - After reboot, you should see devices like
/dev/spidev0.0and/dev/spidev0.1.
We were able to test and find two spi devices (0.0 / 0.1) by simply:
c@c:~ $ ls -la /dev/ | grep spi
crw-rw---- 1 root spi 153, 0 Mar 24 05:17 spidev0.0
crw-rw---- 1 root spi 153, 1 Mar 24 05:17 spidev0.1To see the wiring all you need to do is simply
gpio readall

For the loopback test (recommended first step):
- Connect MOSI (GPIO 10, pin 19) directly to MISO (GPIO 9, pin 21) with a jumper wire.
- This lets the Pi talk to itself — sent bytes should be received unchanged.
Compile with:
gcc -o spi_test spi_test.c -Wall -O2
Run with root privileges (or add your user to the spi group):
sudo ./spi_test
spi_test.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/spi/spidev.h>
#include <string.h>
int main(void)
{
int fd;
const char *device = "/dev/spidev0.0"; // Change to spidev0.1 if using CE1
uint8_t mode = 0; // SPI mode 0 (most common)
uint8_t bits = 8; // 8 bits per word
uint32_t speed = 500000; // 500 kHz (safe starting speed)
uint16_t delay = 0;
// Buffer for test data (you can change this)
uint8_t tx[] = {0x01, 0x02, 0x03, 0x04, 0xAA, 0x55, 0xFF, 0x00};
uint8_t rx[sizeof(tx)] = {0};
printf("SPI Loopback Test on Raspberry Pi Zero\n");
printf("Using device: %s\n", device);
// Open the SPI device
fd = open(device, O_RDWR);
if (fd < 0) {
perror("Failed to open SPI device");
return 1;
}
// Set SPI mode
if (ioctl(fd, SPI_IOC_WR_MODE, &mode) < 0) {
perror("Failed to set SPI mode");
close(fd);
return 1;
}
// Set bits per word
if (ioctl(fd, SPI_IOC_WR_BITS_PER_WORD, &bits) < 0) {
perror("Failed to set bits per word");
close(fd);
return 1;
}
// Set max speed
if (ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed) < 0) {
perror("Failed to set speed");
close(fd);
return 1;
}
// Prepare the transfer structure
struct spi_ioc_transfer tr = {
.tx_buf = (unsigned long)tx,
.rx_buf = (unsigned long)rx,
.len = sizeof(tx),
.speed_hz = speed,
.delay_usecs = delay,
.bits_per_word = bits,
.cs_change = 0, // Keep CS active during transfer
};
// Perform the transfer
if (ioctl(fd, SPI_IOC_MESSAGE(1), &tr) < 0) {
perror("SPI transfer failed");
close(fd);
return 1;
}
// Display results
printf("Sent: ");
for (size_t i = 0; i < sizeof(tx); i++) {
printf("0x%02X ", tx[i]);
}
printf("\n");
printf("Received:");
for (size_t i = 0; i < sizeof(rx); i++) {
printf("0x%02X ", rx[i]);
}
printf("\n");
// Verify loopback
if (memcmp(tx, rx, sizeof(tx)) == 0) {
printf("\n✅ SUCCESS: Loopback test passed! SPI is working.\n");
} else {
printf("\n❌ FAILED: Received data does not match sent data.\n");
printf(" Make sure MOSI is connected to MISO for loopback testing.\n");
}
close(fd);
return 0;
}
How to Use It
- Loopback test (with jumper on MOSI↔MISO): You should see the received data exactly matching the sent data.
- Real device: Remove the jumper and connect your SPI slave device (e.g., ADC, sensor, display). Adjust
tx[]buffer and speed/mode as needed for your hardware. - Change the device path to
/dev/spidev0.1if you're using the second chip select (CE1).
Optional: Quick Compile & Run One-Liner
cat > spi_test.c << 'EOF'
[ paste the code above ]
EOF
gcc -o spi_test spi_test.c -Wall -O2 && sudo ./spi_test
This uses only standard Linux headers — no extra libraries like wiringPi or bcm2835 are required, making it reliable on modern Raspberry Pi OS (including on the Pi Zero).
Let me know if you want a version for a specific device (e.g., reading an MCP3008 ADC) or with continuous transfers!
With no Loopback
SPI Loopback Test on Raspberry Pi Zero
Using device: /dev/spidev0.0
Sent: 0x01 0x02 0x03 0x04 0xAA 0x55 0xFF 0x00
Received:0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
❌ FAILED: Received data does not match sent data.
Make sure MOSI is connected to MISO for loopback testing.One can have it monitor live with:
watch ./spi_test
How Does this Even Work?
We needed some explanation of how this atomically works, as was suggested
Great question! The code "just works" because of how the Linux SPI subsystem is designed — it's not magic, but it's cleverly layered. Let me break it down step by step, especially the part about where the data is buffered (and why /dev/spidev0.0 isn't a normal file with a big buffer inside it).
1. /dev/spidev0.0 is not a regular file that stores data
- It's a character device created by the kernel's
spidevdriver. - When you
open()it, you're getting a file descriptor that talks directly to the kernel driver — not to a disk file or a userspace buffer. - There is no persistent buffer sitting inside
/dev/spidev0.0waiting for data. The device node is just an entry point (like a doorbell) into the kernel.
2. What actually happens when you call ioctl(fd, SPI_IOC_MESSAGE(1), &tr)
This is the key part of your program.
Here's the flow from userspace → hardware:
Userspace (your C program):
- You fill
tx[]array in your process's memory. - You set up
struct spi_ioc_transferwith pointers to yourtxandrxbuffers. - You call
ioctl()withSPI_IOC_MESSAGE(1).
Kernel — spidev driver (drivers/spi/spidev.c):
- The
ioctlhandler receives your request. - It validates the parameters.
- It copies the transmit data from your userspace
txbuffer into a kernel bounce buffer (a temporary kernel-space buffer). This is done for safety and because DMA often can't access userspace memory directly. - It builds an internal
struct spi_message+struct spi_transferdescribing the job. - It then calls the lower-level SPI controller driver to execute the transfer synchronously (
spi_sync()).
Kernel — SPI controller driver (on Raspberry Pi Zero: spi-bcm2835.c):
- This driver knows the exact hardware registers of the BCM2835 SPI peripheral.
- It programs the SPI hardware:
- Sets clock speed, mode, bits per word, chip select (CE0), etc.
- For small transfers (like your 8 bytes), it usually uses PIO (programmed I/O — CPU writes/reads the FIFO registers directly).
- For larger transfers (> ~96 bytes on Pi), it switches to DMA automatically.
- The hardware has small FIFOs (First-In-First-Out buffers) inside the SPI block:
- TX FIFO: holds bytes waiting to be shifted out on MOSI.
- RX FIFO: holds bytes that have been shifted in from MISO.
- The driver pumps data into the TX FIFO and reads from the RX FIFO (either by polling or via interrupts).
Hardware level (the actual SPI peripheral on the Pi Zero SoC):
- The SPI controller generates the SCLK clock.
- It shifts bits out on MOSI (from TX FIFO) and simultaneously shifts bits in on MISO (into RX FIFO). This is why it's full-duplex by nature.
- In your loopback test, the wire connects MOSI → MISO, so whatever is sent is immediately received.
Back up the stack:
- Once the transfer finishes, the controller driver tells
spidev. spidevcopies the received data from its kernel bounce buffer back into your userspacerx[]array.ioctl()returns, and your program can now see the received bytes.
Summary — Where is the data buffered?
- Your userspace buffers (
tx[]andrx[]): These live in your program's RAM. You own them. - Kernel bounce buffers (in
spidev): Temporary copies used during the transfer (size limited by default to 4096 bytes — seespidev.bufsizmodule parameter). - Hardware FIFOs (inside the BCM2835 SPI block): Very small (usually 16–64 bytes depending on the exact hardware). The driver keeps them fed.
- No big software buffer inside
/dev/spidev0.0itself — everything is on-demand and synchronous.
That's why your tiny 8-byte transfer works instantly and reliably.
Bonus: Why it feels so simple
The Linux SPI framework hides all this complexity. Your 20-line program is really asking the kernel to do a full hardware transaction atomically. That's why SPI_IOC_MESSAGE is the recommended way — it's much more powerful and reliable than plain read()/write() for SPI.
If you want to dig deeper, you can look at the source:
drivers/spi/spidev.cdrivers/spi/spi-bcm2835.c
Would you like me to show you:
- How to make the transfer asynchronous (non-blocking)?
- How to use multiple transfers in one
ioctl(for command + data without dropping CS)? - Or how DMA is involved for bigger buffers?