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

> Learn to use SPI peripherals in Zig for ESP32 with this guide. Explore the zig-esp-idf-sample repository for idiomatic Zig error handling and easy data transmission.

- Repository: [Matheus C. França/zig-esp-idf-sample](https://github.com/kassane/zig-esp-idf-sample)
- Tags: how-to-guide
- Published: 2026-03-05

---

**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:

```zig
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:

```zig
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:

```zig
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`:

```zig
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.