Raspberry Pi Zero SPI Loopback Tester for C.

We go over a basic SPI diagnostic C program using the Raspberry Pi Zero!

Raspberry Pi Zero SPI Loopback Tester for C.

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=on to /boot/config.txt and reboot.
  • After reboot, you should see devices like /dev/spidev0.0 and /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.1

To 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.1 if 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
A raspberry Pi Zero w/ basic 3D printer holding frame!

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 spidev driver.
  • 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.0 waiting 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_transfer with pointers to your tx and rx buffers.
  • You call ioctl() with SPI_IOC_MESSAGE(1).

Kernel — spidev driver (drivers/spi/spidev.c):

  • The ioctl handler receives your request.
  • It validates the parameters.
  • It copies the transmit data from your userspace tx buffer 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_transfer describing 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.
  • spidev copies the received data from its kernel bounce buffer back into your userspace rx[] array.
  • ioctl() returns, and your program can now see the received bytes.

Summary — Where is the data buffered?

  • Your userspace buffers (tx[] and rx[]): 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 — see spidev.bufsiz module 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.0 itself — 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.c
  • drivers/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?
Linux Rocks Every Day