How to Use SPI Peripherals in Zig for ESP32: A Complete Guide

The kassane/zig-esp-idf-sample repository provides a thin Zig wrapper in imports/spi.zig that converts ESP-IDF SPI driver calls into idiomatic Zig errors, allowing you to initialize buses, add devices, and transmit data using try statements instead of manual error code checking.

The kassane/zig-esp-idf-sample project demonstrates how to use SPI peripherals in Zig for ESP32 by wrapping the official ESP-IDF C driver with safe, composable Zig bindings. This approach lets you leverage Zig's error handling and type safety while maintaining full access to the underlying hardware capabilities.

Architecture of the Zig SPI Wrapper

The SPI implementation follows a layered architecture that bridges the ESP-IDF C API with Zig idioms. In imports/spi.zig, the wrapper re-exports ESP-IDF symbols under convenient Zig names and translates return codes through errors.espCheckError.

Core Components

  • Zig Bindings: The busInitialize, busAddDevice, and deviceTransmit functions in imports/spi.zig wrap the underlying spi_* C functions and return !void instead of esp_err_t.
  • Type Aliases: Raw ESP-IDF structs like spi_bus_config_t and spi_device_interface_config_t are exposed as BusConfig and DeviceConfig for cleaner Zig code.
  • Error Handling: Every wrapper function calls errors.espCheckError to convert esp_err_t into Zig errors, enabling standard try propagation.

Initializing SPI Buses and Devices in Zig

Before transmitting data, you must initialize the SPI bus and add your specific device configuration. This two-step process mirrors the ESP-IDF pattern but uses Zig's error handling.

Configuring the SPI Bus

Create a BusConfig (aliased to spi_bus_config_t) to define GPIO pins and transfer limits:

const sys = @import("sys");
const spi = @import("spi");

var bus_cfg = sys.spi_bus_config_t{
    .mosi_io_num = 23,
    .miso_io_num = 19,
    .sclk_io_num = 18,
    .quadwp_io_num = -1,
    .quadhd_io_num = -1,
    .max_transfer_sz = 4096,
    .flags = 0,
    .intr_flags = sys.ESP_INTR_FLAG_LEVEL1,
};

try spi.busInitialize(sys.SPI2_HOST, &bus_cfg, 0);

Adding a Device to the Bus

After bus initialization, configure your specific SPI device with clock speed, mode, and chip select pin:

var dev_cfg = sys.spi_device_interface_config_t{
    .command_bits = 0,
    .address_bits = 0,
    .dummy_bits = 0,
    .mode = 0,                     // SPI mode 0
    .duty_cycle_pos = 0,
    .cs_ena_pretrans = 0,
    .cs_ena_posttrans = 0,
    .clock_speed_hz = 1_000_000,   // 1 MHz
    .spics_io_num = 5,             // CS pin
    .flags = sys.SPI_DEVICE_HALFDUPLEX,
    .queue_size = 1,
    .pre_cb = null,
    .post_cb = null,
};

var device: spi.Device = undefined;
try spi.busAddDevice(sys.SPI2_HOST, &dev_cfg, &device);

Transmitting Data with SPI Peripherals in Zig for ESP32

Once the device handle is obtained, you can perform transactions using the Transaction struct (aliased to spi_transaction_t).

Blocking Transfers

For simple synchronous communication, use deviceTransmit to block until the transfer completes:

var tx_buf = [_]u8{ 'H', 'E', 'L', 'L', 'O' };
var rx_buf = [_]u8{0} ** 5;
var trans = sys.spi_transaction_t{
    .length = 8 * @sizeOf(tx_buf),   // bits
    .tx_buffer = &tx_buf,
    .rx_buffer = &rx_buf,
    .flags = 0,
};

try spi.deviceTransmit(device, &trans);

const stdout = std.io.getStdOut().writer();
try stdout.print("Received: {s}\n", .{rx_buf});

Transaction Configuration

The spi_transaction_t struct supports complex configurations including command and address phases, dummy bits, and DMA buffers. Set the .length field in bits, not bytes, and provide both .tx_buffer and .rx_buffer for full-duplex operations.

Higher-Level Abstractions for SPI Peripherals in Zig

For specific peripherals, the repository provides higher-level wrappers that handle transaction details internally. The imports/led-strip.zig file demonstrates this pattern with an SPI-backed LED strip driver.

LED Strip Implementation

Instead of manually building transactions, you initialize the peripheral with configuration structs and call semantic methods like setPixel and refresh:

const led = @import("led-strip");

// LED strip configuration
const led_cfg = led.LedStripConfig.ws2812(4, 30); // GPIO4, 30 LEDs

// SPI configuration for the strip
const spi_cfg = led.LedStripSpiConfig{
    .clk_src = sys.SPI_CLK_SRC_DEFAULT,
    .spi_bus = sys.SPI2_HOST,
    .flags = 0,
};

// Create the device
var strip_handle: led.LedStripHandle = undefined;
try led.newSpiDevice(&led_cfg, &spi_cfg, &strip_handle);

// Set pixel colors
try led.setPixel(strip_handle, 0, 255, 0, 0);   // red
try led.setPixel(strip_handle, 1, 0, 255, 0);   // green
try led.setPixel(strip_handle, 2, 0, 0, 255);   // blue

// Refresh the strip
try led.refresh(strip_handle);

// Cleanup
try led.deinit(strip_handle);
try spi.busFree(sys.SPI2_HOST);

This approach encapsulates the spi.busAddDevice and spi.deviceTransmit calls within the driver, providing a type-safe interface specific to the hardware.

Summary

  • The kassane/zig-esp-idf-sample repository wraps the ESP-IDF SPI driver in imports/spi.zig, converting C error codes into Zig errors for idiomatic error handling.
  • Bus initialization requires a BusConfig struct and spi.busInitialize, while device configuration uses DeviceConfig with spi.busAddDevice.
  • Data transmission occurs through spi.deviceTransmit using Transaction structs, supporting both blocking and queued operations.
  • For complex peripherals, use higher-level drivers like imports/led-strip.zig which abstract transaction details behind semantic APIs.
  • Always propagate errors with try and cleanup resources with spi.busRemoveDevice and spi.busFree.

Frequently Asked Questions

How do I handle SPI errors in Zig compared to C?

In C, the ESP-IDF SPI driver returns esp_err_t integer codes that you must manually check. The Zig wrapper in imports/spi.zig automatically converts these codes through errors.espCheckError, returning !void or !T instead. This allows you to use Zig's try keyword for automatic error propagation and handle failures with catch blocks or errdefer cleanup.

What is the difference between SPI2_HOST and SPI3_HOST in the ESP32 Zig wrapper?

SPI2_HOST and SPI3_HOST correspond to the ESP32's SPI peripheral controllers (also historically referred to as HSPI and VSPI). According to the kassane/zig-esp-idf-sample source code, you pass these constants to spi.busInitialize as the first parameter to select which hardware SPI controller to configure. SPI2_HOST is commonly used for general-purpose SPI devices, while SPI3_HOST provides an additional independent bus when you need to communicate with multiple SPI devices on separate buses simultaneously.

Can I use DMA with the Zig SPI wrapper?

Yes, the Zig SPI wrapper supports DMA transfers because it exposes the underlying ESP-IDF structures directly. When configuring the bus in imports/spi.zig, set the .max_transfer_sz field in your BusConfig (aliased to spi_bus_config_t) to a value larger than 32 bytes to enable DMA allocation. The Transaction struct (aliased to spi_transaction_t) accepts .tx_buffer and .rx_buffer pointers that can reference DMA-capable memory regions, allowing high-speed transfers without CPU intervention.

How do I share an SPI bus between multiple devices in Zig?

To share an SPI bus between multiple devices, initialize the bus once with spi.busInitialize using a configuration that accommodates the highest clock speed and largest transfer size required by any device. Then, call spi.busAddDevice multiple times with different DeviceConfig structs (varying .spics_io_num for chip select pins and .clock_speed_hz for per-device speeds). The ESP-IDF driver automatically handles bus arbitration, and you can use spi.deviceAcquireBus and spi.deviceReleaseBus in imports/spi.zig for critical sections where you need exclusive access during a sequence of transactions.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →