zephyr-comunicacion-spi

SPI Communication in Zephyr: Buffers and Chip Select

  • 5 min

The SPI is a synchronous, full-duplex serial bus designed for fast transfers between a controller and one or more peripherals.

When we need to move data quickly — for example, painting pixels on a TFT display, reading an SD card, or communicating with a LoRa radio chip — I2C is too slow. SPI can easily reach speeds of 8, 20, or even 80 MHz.

The Zephyr SPI API has a bit more ceremony than the I2C one. It requires handling buffers and chip select (CS) configuration explicitly; let’s break it down.

Differences between I2C and SPI

Before writing code, we need to understand how Zephyr views these buses:

  1. Full Duplex: SPI sends and receives simultaneously. While a byte goes out on MOSI, another byte comes in on MISO.
  2. Chip Select (CS): In I2C, we select by address (software). In SPI, we select by pulling a physical pin low. Zephyr manages this pin automatically if configured correctly.
  3. Buffers: The SPI API is designed for DMA. We don’t send “a byte”; we send “blocks of memory”.

Configuring the Hardware in the Overlay

When the chip select is controlled via GPIO, the cs-gpios property is defined in the SPI controller, and the peripheral’s reg selects its index.

Let’s say we have an LCD display connected to the spi1 port. We want to use pin 10 of port 0 as CS and run at 8 MHz.

/* app.overlay */
#include <zephyr/dt-bindings/gpio/gpio.h>

&spi1 {
    status = "okay";
    
    /* Define the CS pins managed by software (GPIO) */
    /* <&controller pin flags> */
    cs-gpios = <&gpio0 10 GPIO_ACTIVE_LOW>;

    /* Our device (e.g., Display) */
    my_display: display@0 {
        compatible = "my-company,my-display"; /* Example compatible */
        reg = <0>; /* Index in the cs-gpios array (0 = pin 10) */
        
        /* SPI configuration */
        spi-max-frequency = <8000000>; /* 8 MHz */
    };
};
Copied!

The compatible is a placeholder for the example. In a real project, you must use the one from an existing driver or create a custom binding that inherits the common properties of an SPI peripheral.

Using cs-gpios is more flexible than using the microcontroller’s native “Hardware CS”. It allows using any pin as Chip Select, and Zephyr handles pulling it low and high just before and after the transmission.

The SPI API: Buffers and Sets

To send data, Zephyr uses two structures: spi_buf and spi_buf_set.

This might seem like too much bureaucracy for sending a single piece of data, but there’s a reason: it allows sending data that is scattered in RAM as if it were a single continuous frame, without having to copy it to an intermediate buffer. It’s pure efficiency.

Example: Writing to a Display

Let’s say you want to send a one-byte command followed by one hundred bytes of image data.

#include <zephyr/kernel.h>
#include <zephyr/drivers/spi.h>
#include <errno.h>

/* Get the node */
#define SPI_OP  SPI_OP_MODE_MASTER | SPI_WORD_SET(8) | SPI_LINES_SINGLE
static const struct spi_dt_spec dev_spi = SPI_DT_SPEC_GET(DT_NODELABEL(my_display), SPI_OP, 0);

int main(void) {
    if (!spi_is_ready_dt(&dev_spi)) {
        printk("SPI not ready\n");
        return -ENODEV;
    }

    /* Data to send */
    uint8_t command = 0x2C; // Write RAM
    uint8_t data[] = {0xFF, 0x00, 0xFF, 0x00}; // Pixels...

    /* 1. Define the individual buffers */
    struct spi_buf tx_bufs[] = {
        { .buf = &command, .len = 1 },
        { .buf = data,    .len = sizeof(data) }
    };

    /* 2. Package them into a Set */
    /* This tells it: "Send all of this consecutively, without raising CS in between" */
    struct spi_buf_set tx = {
        .buffers = tx_bufs,
        .count = 2
    };

    /* 3. Send (Write) */
    /* Since it's write-only, pass NULL for the receive set */
    int ret = spi_write_dt(&dev_spi, &tx);

    if (ret != 0) {
        printk("SPI error: %d\n", ret);
    }

    return ret;
}
Copied!

Notice the power of this: We sent a command and a data array in a single transaction. The Chip Select went low at the beginning and high at the end. If we had made two separate calls to spi_write_dt, the CS would have gone high in between, breaking communication with the display.

Sending and Receiving Simultaneously with transceive

If you are reading an SPI sensor, you need to send (usually an address) and receive simultaneously.

uint8_t tx_data[] = {0x80 | 0x10, 0x00}; /* Command + dummy byte */
uint8_t rx_data[2];

struct spi_buf tx_b = { .buf = tx_data, .len = sizeof(tx_data) };
struct spi_buf_set tx = { .buffers = &tx_b, .count = 1 };

struct spi_buf rx_b = { .buf = rx_data, .len = sizeof(rx_data) };
struct spi_buf_set rx = { .buffers = &rx_b, .count = 1 };

/* Full Duplex Transaction */
/* Note: In SPI, to read N bytes, you typically have to send N "junk" (dummy) bytes to generate the clock */
spi_transceive_dt(&dev_spi, &tx, &rx);

/* In this example protocol, rx_data[0] matches the command,
   and the useful data arrives during the dummy byte in rx_data[1]. */
Copied!