How to Use GPIO Pins with Zig and ESP-IDF: A Complete Guide to the Type-Safe Wrapper
The zig-esp-idf-sample repository provides a three-layer GPIO wrapper that converts ESP-IDF C calls into compile-time pin enums and Zig error unions, allowing you to control hardware with try idf.gpio.Level.set(pin, 1) instead of raw C interop.
Working with GPIO pins on ESP32 microcontrollers traditionally requires verbose C code and manual error checking. The kassane/zig-esp-idf-sample project solves this by wrapping the ESP-IDF GPIO driver in idiomatic Zig, providing type-safe pin enumeration, automatic error conversion, and high-level helpers for common operations like digital I/O, interrupts, and pull-up configuration.
Understanding the Three-Layer GPIO Architecture
The GPIO wrapper in imports/gpio.zig organizes functionality into distinct layers that bridge Zig's compile-time features with the ESP-IDF C API.
Compile-Time Pin Enumeration
At build time, the wrapper inspects the ESP-IDF system bindings and generates an exhaustive enum containing only the pins available on your selected target. This prevents compile-time errors when referencing non-existent pins.
In imports/gpio.zig, the Num() function returns @Enum(sys.gpio_num_t, .exhaustive, …), exposing a GpioNum type where specific pins are accessed as .@"2" for GPIO2 or .@"13" for GPIO13.
Low-Level C Interop
The numToC(gpio_num: GpioNum) function in imports/gpio.zig (lines 44-47) performs a simple cast from the Zig enum to the raw C gpio_num_t required by ESP-IDF functions. This conversion happens automatically in higher-level APIs, keeping your application code free of manual casting.
High-Level Type-Safe Operations
The wrapper groups related GPIO operations into ergonomic structs that return Zig errors (!void) instead of raw esp_err_t codes. Every call uses try errors.espCheckError(…) to convert C error codes into Zig's error handling model, allowing you to use catch or if (err) |...| for flow control.
Basic GPIO Operations with Zig
Configuring Pin Direction
Use idf.gpio.Direction.set() to configure a pin as input, output, or open-drain. This wraps the ESP-IDF gpio_set_direction() call with automatic error conversion.
const idf = @import("esp_idf");
// Configure GPIO2 as output
try idf.gpio.Direction.set(.@"2", .output);
// Configure GPIO4 as input
try idf.gpio.Direction.set(.@"4", .input);
The implementation in imports/gpio.zig (lines 70-74) handles the conversion from the Zig Mode enum to the C gpio_mode_t constant.
Reading and Writing Digital Levels
The Level struct provides methods to write digital values and read pin states. Writing returns an error union while reading returns a boolean representing the logic level.
// Set GPIO2 high
try idf.gpio.Level.set(.@"2", 1);
// Set GPIO2 low
try idf.gpio.Level.set(.@"2", 0);
// Read GPIO4 state
const is_high = idf.gpio.Level.get(.@"4");
These functions are implemented in imports/gpio.zig (lines 60-68), wrapping gpio_set_level() and gpio_get_level().
Enabling Internal Pull-Up and Pull-Down Resistors
Configure internal resistors using the PULL struct methods. These wrap the ESP-IDF pull-up and pull-down enable/disable functions.
// Enable pull-up on GPIO4
try idf.gpio.PULL.upEn(.@"4");
// Enable pull-down on GPIO5
try idf.gpio.PULL.downEn(.@"5");
// Disable pull-up on GPIO4
try idf.gpio.PULL.upDis(.@"4");
The implementation in imports/gpio.zig (lines 77-102) provides upEn, upDis, downEn, and downDis methods that map to the corresponding ESP-IDF calls.
Advanced GPIO Features
Handling GPIO Interrupts in Zig
The wrapper provides type-safe interrupt handling through the installISRService, isrRegister, and intrEnable functions. These manage the ESP-IDF GPIO ISR service with Zig error handling.
const std = @import("std");
const idf = @import("esp_idf");
const sys = @import("sys");
fn button_isr(arg: ?*anyopaque) callconv(.c) void {
// ISR context - keep this minimal
// Use arg to access shared state if needed
_ = arg;
}
pub fn setupButtonInterrupt(pin: idf.gpio.Num()) !void {
// Configure pin as input with pull-up
try idf.gpio.Direction.set(pin, .input);
try idf.gpio.PULL.upEn(pin);
// Set interrupt type (falling edge)
try idf.gpio.setIntrType(pin, .negedge);
// Install ISR service (call once per application)
try idf.gpio.installISRService(0);
// Register the ISR handler
var handle: sys.gpio_isr_handle_t = undefined;
try idf.gpio.isrRegister(button_isr, null, 0, &handle);
// Enable interrupt on the pin
try idf.gpio.intrEnable(pin);
}
These functions are implemented in imports/gpio.zig (lines 31-44), wrapping gpio_install_isr_service, gpio_isr_handler_add, and related ESP-IDF interrupt management calls.
Configuring Drive Strength and Pin Hold
For power-sensitive applications, the wrapper exposes drive strength configuration and pin hold functionality. These features control output current capability and maintain pin states during deep sleep.
pub fn configurePowerPin(pin: idf.gpio.Num()) !void {
// Set as open-drain output
try idf.gpio.Direction.set(pin, .output_od);
// Set drive capability (cap_3 = strongest)
try idf.gpio.setDriveCapability(pin, .cap_3);
// Enable pin hold (maintains state in deep sleep)
try idf.gpio.holdEn(pin);
}
The setDriveCapability and holdEn functions in imports/gpio.zig wrap the ESP-IDF gpio_set_drive_capability and gpio_hold_en calls, converting error codes to Zig errors.
Complete Working Examples
Minimal LED Blink Example
This example from main/examples/gpio-blink.zig demonstrates the essential GPIO workflow: pin configuration, level toggling, and RTOS integration.
const std = @import("std");
const idf = @import("esp_idf");
const LED_PIN: idf.gpio.Num() = .@"2";
export fn app_main() callconv(.c) void {
// Configure GPIO2 as output
idf.gpio.Direction.set(LED_PIN, .output) catch return;
var on: u1 = 0;
while (true) {
on ^= 1;
// Toggle LED state
idf.gpio.Level.set(LED_PIN, on) catch {};
// Delay 1 second
idf.rtos.Task.delayMs(1000);
}
}
This example uses idf.gpio.Num() to access the compile-time pin enumeration, configures direction with error handling via catch, and toggles the LED level in an infinite loop.
Input Reading with Pull-Up Configuration
For reading button states or external signals, combine direction configuration with pull-up resistors and level reading.
const idf = @import("esp_idf");
pub fn readButtonState() !bool {
const BUTTON_PIN = idf.gpio.Num().@"4";
// Configure as input with internal pull-up
try idf.gpio.Direction.set(BUTTON_PIN, .input);
try idf.gpio.PULL.upEn(BUTTON_PIN);
// Read current logic level (true = high, false = low)
return idf.gpio.Level.get(BUTTON_PIN);
}
This pattern ensures the pin is properly biased when the external circuit is open, preventing floating inputs.
Summary
- Type-safe pin enumeration: The
Num()function inimports/gpio.ziggenerates a compile-time enum of available GPIO pins for your specific ESP32 target, preventing invalid pin references. - Automatic error conversion: All GPIO operations return Zig error unions (
!void) instead of rawesp_err_tcodes, enabling idiomatictryandcatcherror handling viaespCheckError. - Layered API design: The wrapper provides low-level conversion (
numToC), mid-level configuration (Direction,Level,PULL), and advanced features (interrupts, drive strength, pin hold) inimports/gpio.zig. - Complete ESP-IDF coverage: All major GPIO features including digital I/O, pull-up/down resistors, interrupt service routines, drive capability configuration, and deep-sleep hold functionality are exposed through type-safe Zig functions.
Frequently Asked Questions
How do I configure a GPIO pin as output in Zig?
Use idf.gpio.Direction.set() with the target pin and .output mode. This function in imports/gpio.zig wraps the ESP-IDF gpio_set_direction() call and converts any error to a Zig error union. For example: try idf.gpio.Direction.set(.@"2", .output); configures GPIO2 as an output.
What is the difference between idf.gpio.Num() and raw integer pin numbers?
idf.gpio.Num() returns a compile-time generated enum that contains only the valid GPIO pins for your specific ESP32 target, defined in imports/gpio.zig. This prevents you from referencing non-existent pins (like GPIO20 on chips that lack it) at compile time, whereas raw integers would only fail at runtime.
How do I handle GPIO interrupts in Zig without writing C code?
The wrapper provides installISRService(), isrRegister(), and intrEnable() in imports/gpio.zig. You write the ISR handler as a normal Zig function with callconv(.c), register it with isrRegister(), and enable interrupts with intrEnable(). All error handling uses Zig's try keyword, converting ESP-IDF error codes automatically.
Can I configure drive strength and deep-sleep hold using this wrapper?
Yes. The imports/gpio.zig file exposes setDriveCapability() for configuring output current strength (.cap_0 through .cap_3) and holdEn()/holdDis() for maintaining pin states during deep sleep. These wrap the ESP-IDF gpio_set_drive_capability() and gpio_hold_en() functions while returning Zig error unions.
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 →