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, anddeviceTransmitfunctions inimports/spi.zigwrap the underlyingspi_*C functions and return!voidinstead ofesp_err_t. - Type Aliases: Raw ESP-IDF structs like
spi_bus_config_tandspi_device_interface_config_tare exposed asBusConfigandDeviceConfigfor cleaner Zig code. - Error Handling: Every wrapper function calls
errors.espCheckErrorto convertesp_err_tinto Zig errors, enabling standardtrypropagation.
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-samplerepository wraps the ESP-IDF SPI driver inimports/spi.zig, converting C error codes into Zig errors for idiomatic error handling. - Bus initialization requires a
BusConfigstruct andspi.busInitialize, while device configuration usesDeviceConfigwithspi.busAddDevice. - Data transmission occurs through
spi.deviceTransmitusingTransactionstructs, supporting both blocking and queued operations. - For complex peripherals, use higher-level drivers like
imports/led-strip.zigwhich abstract transaction details behind semantic APIs. - Always propagate errors with
tryand cleanup resources withspi.busRemoveDeviceandspi.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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →