A complete guide to building ESP32 firmware using Zig and ESP-IDF.
- Prerequisites
- Installation
- Quick Start
- Project Structure
- Building Your First Project
- Working with Components
- Examples
- Troubleshooting
- Next Steps
- Python 3.12+ (for ESP-IDF tools)
- Git (for cloning repositories)
- CMake 3.16+ (bundled with ESP-IDF)
- Ninja or Make (bundled with ESP-IDF)
- Zig compiler (optional - will be auto-downloaded if not found)
- Linux (Ubuntu 20.04+, Debian, Fedora, Arch)
- macOS (10.15+)
- Windows (10/11 with PowerShell or WSL2)
- Nix/NixOS (via
flake.nix)
| Architecture | Targets |
|---|---|
| RISC-V | ESP32-C2, C3, C5, C6, C61, H2, H21, H4, P4 |
| Xtensa | ESP32, ESP32-S2, ESP32-S3 |
Option A: Standard Installation
# Clone ESP-IDF
git clone --recursive https://github.com/espressif/esp-idf.git
cd esp-idf
# Linux/macOS:
./install.sh
# Windows (PowerShell):
.\install.ps1
# Windows (Command Prompt):
install.batOption B: Using Nix Flakes (Linux/macOS)
# Enter development environment with all dependencies
nix develop
# Or use direnv for automatic activation
echo "use flake" > .envrc
direnv allowOption C: ESP-IDF installer (Windows/macOS)
Download from: https://dl.espressif.com/dl/esp-idf/
Every time you open a new terminal, activate ESP-IDF:
# Linux/macOS:
. $HOME/esp/esp-idf/export.sh
# Windows (PowerShell):
. $HOME/esp/esp-idf/export.ps1
# Windows (Command Prompt):
%USERPROFILE%\esp\esp-idf\export.batgit clone https://github.com/kassane/zig-esp-idf-sample.git
cd zig-esp-idf-sampleidf.py --version
# Expected output: ESP-IDF v5.x.x or v6.x.x# For ESP32-C6 (RISC-V)
idf.py set-target esp32c6
# For ESP32-S3 (Xtensa)
idf.py set-target esp32s3
# For ESP32 (original, Xtensa)
idf.py set-target esp32idf.py buildWhat happens during build:
- ✅ CMake detects your target and configures the build
- ✅ Zig toolchain is automatically downloaded (if needed)
- ✅ C bindings are generated from ESP-IDF headers
- ✅ Target-specific patches are applied
- ✅ Zig code is compiled to object files
- ✅ Everything is linked with ESP-IDF libraries
- ✅ Firmware binaries are generated
# Connect your ESP32 via USB, then:
idf.py -p PORT flash
# Find your port:
# Linux: /dev/ttyUSB0 or /dev/ttyACM0
# macOS: /dev/cu.usbserial-*
# Windows: COM3, COM4, etc.
# Example:
idf.py -p /dev/ttyUSB0 flashidf.py -p PORT monitor
# Or combine flash + monitor:
idf.py -p PORT flash monitor
# Exit monitor: Ctrl+]zig-esp-idf-sample/
├── build.zig # Zig build script (compiles Zig code)
├── build.zig.zon # Zig package manifest
├── CMakeLists.txt # Root CMake config
├── sdkconfig # ESP-IDF configuration (generated)
├── sdkconfig.defaults # Default SDK config
├── sdkconfig.defaults.esp32 # ESP32-specific defaults
├── partitions_matter.csv # Custom partition table for Matter (3 MB app, 4 MB flash)
├── dependencies.lock # Component version lock
├── wokwi.toml # Wokwi simulator config
│
├── main/
│ ├── CMakeLists.txt # Main component config
│ ├── placeholder.c # Minimal C entry point (required by CMake)
│ ├── matter_wrappers.cpp # C++ shims for esp_matter C++ API (activated when component present)
│ ├── app.zig # Main Zig application entry
│ ├── idf_component.yml # Component dependencies
│ ├── Kconfig.projbuild # Project configuration options
│ └── examples/
│ ├── gpio-blink.zig # Toggle LED on GPIO2
│ ├── uart-echo.zig # UART echo
│ ├── i2c-scan.zig # I2C bus scan
│ ├── wifi-station.zig # WiFi station
│ ├── http-server.zig # HTTP server
│ ├── ble-gatt-server.zig # BLE GATT server
│ ├── smartled-rgb.zig # WS2812B LED strip
│ ├── dsp-math.zig # DSP/FFT operations
│ └── matter-light.zig # Matter On/Off Light
│
├── imports/ # Zig API wrappers and bindings
│ ├── idf.zig # Main ESP-IDF facade module
│ ├── idf-sys.zig # Generated C bindings (auto-generated)
│ ├── sys.zig # Re-exports idf-sys
│ ├── error.zig # esp_err_t → Zig error mapping
│ ├── logger.zig # std.log integration (espLogFn)
│ ├── version.zig # ESP-IDF version info (ver)
│ ├── heap.zig # HeapCapsAllocator, MultiHeapAllocator
│ ├── bootloader.zig # Partition/bootloader control
│ ├── gpio.zig # GPIO wrapper
│ ├── wifi.zig # WiFi station/AP/scan
│ ├── uart.zig # UART driver
│ ├── i2c.zig # I2C master
│ ├── spi.zig # SPI master (+ SDSPI)
│ ├── i2s.zig # I2S audio (STD, PDM, TDM)
│ ├── http.zig # HTTP server + client
│ ├── mqtt.zig # MQTT client
│ ├── lwip.zig # lwIP sockets, DNS, SNTP
│ ├── crc.zig # ESP-ROM CRC-8/16/32
│ ├── bluetooth.zig # Bluedroid BLE
│ ├── nimble.zig # NimBLE BLE (compile-time guarded)
│ ├── now.zig # ESP-NOW protocol
│ ├── nvs.zig # NVS flash key-value storage
│ ├── partition.zig # Partition table operations
│ ├── sleep.zig # Deep/light sleep + wakeup
│ ├── event.zig # ESP event loop
│ ├── wdt.zig # Task watchdog timer
│ ├── rtos.zig # FreeRTOS tasks/queues/semaphores/timers
│ ├── pcnt.zig # Pulse counter (pulse)
│ ├── phy.zig # Wireless PHY / RF calibration
│ ├── segger.zig # Segger SystemView profiling
│ ├── led-strip.zig # LED strip — requires espressif/led_strip
│ ├── dsp.zig # DSP/FFT — requires espressif/esp-dsp
│ ├── hosted.zig # ESP-Hosted coexistence — requires espressif/esp_hosted
│ ├── wifi_remote.zig # WiFi remote — requires espressif/esp_wifi_remote
│ ├── timer.zig # High-resolution esp_timer (one-shot + periodic)
│ ├── ledc.zig # LED PWM controller (duty, fade)
│ ├── twai.zig # TWAI/CAN bus driver
│ ├── pm.zig # Power management locks
│ ├── pthread.zig # POSIX threads (FreeRTOS-backed)
│ └── panic.zig # Zig panic handler
│
├── include/ # C headers for binding generation
│ ├── stubs.h # Core ESP-IDF headers (input to zig translate-c)
│ ├── wifi_stubs.h # WiFi macro wrappers
│ ├── bt_stubs.h # Bluetooth macro wrappers
│ ├── matter_stubs.h # C wrapper interface for esp_matter (C++ component)
│ ├── matter_closure_patch.h # GCC 14 C++23 fix for closure-control cluster
│ └── bindings.h # Additional bindings
│
├── cmake/ # CMake build system scripts
│ ├── zig-config.cmake # Main Zig configuration
│ ├── zig-download.cmake # Auto-download Zig toolchain
│ ├── zig-runner.cmake # Helper functions for Zig
│ ├── bindings.cmake # Binding generation
│ ├── extra-components.cmake # Managed component detection
│ └── patch.cmake # Post-processing patches
│
├── patches/ # Binding fixes for translate-c issues
│ ├── *.zig
│
├── docs/ # Documentation
│ ├── build-internals.md # Build system details
│ ├── build-scheme.png # Build flow diagram
│ └── zig-xtensa.md # Xtensa toolchain info
│
└── flake.nix # Nix development environment
const std = @import("std");
const idf = @import("esp_idf");
comptime {
@export(&main, .{ .name = "app_main" });
}
fn main() callconv(.c) void {
log.info("Hello from Zig on ESP32!", .{});
log.info("Zig version: {s}", .{@import("builtin").zig_version_string});
// Show memory info
var heap = idf.heap.HeapCapsAllocator.init(null); // default: MALLOC_CAP_DEFAULT
log.info("Free heap: {} bytes", .{heap.freeSize()});
// Sleep forever
while (true) {
idf.rtos.Task.delayMs(1000);
}
}
// overwrite zig std_options config
pub const std_options: std.Options = .{
.logFn = idf.log.espLogFn,
};
// rename log instance
const log = std.log.scoped(.@"esp-idf");
// overwrite std panic-handler
pub const panic = idf.esp_panic.panic;Build and run:
idf.py build flash monitorCreate main/examples/blink.zig:
const std = @import("std");
const idf = @import("esp_idf");
const LED_PIN = .@"18"; // Change to your LED pin
comptime {
@export(&main, .{ .name = "app_main" });
}
fn main() callconv(.c) void {
// Configure GPIO as output
idf.gpio.Direction.set(LED_PIN, .output) catch {
log.err("Failed to configure GPIO", .{});
return;
};
log.info("Blinking LED on GPIO {d}", .{LED_PIN});
while (true) {
// LED ON
idf.gpio.Level.set(LED_PIN, 1) catch {};
log.info("LED ON", .{});
idf.rtos.Task.delayMs(1000);
// LED OFF
idf.gpio.Level.set(LED_PIN, 0) catch {};
log.info("LED OFF", .{});
idf.rtos.Task.delayMs(1000);
}
}
// overwrite zig std_options config
pub const std_options: std.Options = .{
.logFn = idf.log.espLogFn,
};
// rename log instance
const log = std.log.scoped(.blink);
// overwrite std panic-handler
pub const panic = idf.esp_panic.panic;Components are specified in main/idf_component.yml:
Add a component:
# Use idf.py command:
idf.py add-dependency espressif/led_strip
# then
idf.py reconfigureThe build system automatically:
- ✅ Detects components in
dependencies.lock - ✅ Adds include paths to binding generation
- ✅ Generates
HAS_*defines (e.g.,HAS_LED_STRIP=1) - ✅ Includes headers in
include/stubs.hconditionally - ✅ Makes APIs available in Zig via
idf.*modules
1. Ensure LED strip is in main/idf_component.yml:
dependencies:
espressif/led_strip: "*"2. Reconfigure:
idf.py reconfigure3. Check provided example:
The example in examples/smartled-rgb.zig demonstrates:
- Configuring WS2812B LED strip on GPIO 2
- Setting individual pixel colors
- Refreshing the display
- Blinking pattern
- Generates a sine tone (freq = 0.2 normalized)
- Applies Hann window
- Does FFT (1024 points, float32, radix-2)
- Computes power spectrum in dB
- Prints ASCII plot of the spectrum (64×10 chars, -120 to +40 dB)
Use the WiFi station example: examples/wifi-station.zig
Configure WiFi credentials:
idf.py menuconfig
# Navigate to: Example Configuration
# Set WiFi SSID and PasswordOr edit main/Kconfig.projbuild to change default values.
The project provides comprehensive Zig wrappers in imports/:
| Module | Description | Notes |
|---|---|---|
idf.gpio |
GPIO control | Any target |
idf.wifi |
WiFi station/AP/scan | Not on H2/H4/P4 |
idf.bt |
Bluedroid BLE | Requires CONFIG_BT_ENABLED |
idf.nimble |
NimBLE BLE | Requires CONFIG_BT_NIMBLE_ENABLED |
idf.heap |
HeapCapsAllocator, MultiHeapAllocator | Any target |
idf.rtos |
FreeRTOS tasks/queues/semaphores/timers | Any target |
idf.nvs |
NVS flash key-value storage | Any target |
idf.partition |
Partition table operations | Any target |
idf.sleep |
Deep/light sleep + wakeup sources | Any target |
idf.event |
ESP event loop | Any target |
idf.wdt |
Task watchdog timer | Any target |
idf.bl |
Bootloader/partition control | Any target |
idf.i2c |
I2C master | Any target |
idf.spi |
SPI master + SDSPI | Any target |
idf.uart |
UART driver | Any target |
idf.i2s |
I2S audio (STD, PDM, TDM) | Any target |
idf.http |
HTTP server (httpd) + client | Any target |
idf.mqtt |
MQTT client | Any target |
idf.lwip |
lwIP sockets, DNS, SNTP | Any target |
idf.crc |
ESP-ROM CRC-8/16/32 | Any target |
idf.esp_now |
ESP-NOW protocol | Any target |
idf.pulse |
Pulse counter (PCNT) | Any target |
idf.phy |
Wireless PHY / RF calibration | Any target |
idf.segger |
Segger SystemView profiling | Any target |
idf.ver |
ESP-IDF version info | Any target |
idf.led |
LED strip WS2812B | Requires espressif/led_strip |
idf.dsp |
DSP/FFT operations | Requires espressif/esp-dsp |
idf.esp_hosted |
ESP-Hosted coexistence | Requires espressif/esp_hosted |
idf.wifi_remote |
WiFi via slave MCU | Requires espressif/esp_wifi_remote |
idf.timer |
High-resolution esp_timer | Any target |
idf.ledc |
LED PWM controller | Any target |
idf.twai |
TWAI/CAN bus driver | Any target |
idf.pm |
Power management locks | Any target |
idf.pthread |
POSIX threads (FreeRTOS-backed) | Any target |
idf.matter |
Matter/CHIP protocol | Requires espressif/esp_matter |
idf.log |
std.log integration (espLogFn) |
Any target |
idf.err |
esp_err_t → Zig error mapping | Any target |
idf.sys |
Raw C bindings | Direct ESP-IDF API access |
const idf = @import("esp_idf");
const std = @import("std");
// overwrite std panic-handler
pub const panic = idf.esp_panic.panic;
// overwrite std.log
pub const std_options: std.Options = .{
.logFn = idf.log.espLogFn,
};
export fn myTask(_: ?*anyopaque) callconv(.c) void {
const log = std.log.scoped(.task);
while (true) {
log.info("Task running!", .{});
idf.rtos.Task.delayMs(1000);
}
}
export fn app_main() callconv(.c) void {
_ = idf.rtos.Task.create(myTask, "my_task", 2048, null, 5) catch @panic("Failed to create task");
// Main task continues...
while (true) {
idf.rtos.Task.delayMs(1000);
}
}const idf = @import("esp_idf");
const std = @import("std");
export fn app_main() callconv(.c) void {
var arena = std.heap.ArenaAllocator.init(std.heap.c_allocator);
defer arena.deinit();
const allocator = arena.allocator();
var list: std.ArrayList(u32) = .empty;
defer list.deinit(allocator);
list.append(allocator, 10) catch {};
list.append(allocator, 20) catch {};
list.append(allocator, 30) catch {};
// ...
}When wrappers aren't available, use raw bindings:
const idf = @import("esp_idf");
const std = @import("std");
const sys = idf.sys;
const log = std.log.scoped(.@"esp-idf");
export fn app_main() callconv(.c) void {
var mac_addr: [6]u8 = undefined;
// Direct ESP-IDF C API call
const result = sys.esp_efuse_mac_get_default(&mac_addr);
if (result != sys.ESP_OK) {
std.log.err("Failed to get MAC address", .{});
return;
}
log.info("MAC: {X:0>2}:{X:0>2}:{X:0>2}:{X:0>2}:{X:0>2}:{X:0>2}", .{
mac_addr[0], mac_addr[1], mac_addr[2],
mac_addr[3], mac_addr[4], mac_addr[5],
});
}
pub const std_options: std.Options = .{
.logFn = idf.log.espLogFn,
};"zig: command not found"
- The Zig toolchain should auto-download. Check
build/zig-relsafe-*directory - Or install manually: https://ziglang.org/download/
- For Nix users:
nix develop
"Component not found: espressif__led_strip"
# Check idf_component.yml
cat main/idf_component.yml
# Reconfigure to download components
idf.py reconfigure"translate-c failed"
- Check
include/stubs.handinclude/wifi_stubs.hfor syntax errors - Ensure managed components exist in
managed_components/ - Check that
HAS_*defines match components incmake/extra-components.cmake
Binding generation errors
- The
patches/directory contains fixes for commontranslate-cissues - If you see struct layout errors, a patch likely needs updating
- Check
cmake/patch.cmakefor how patches are applied
"Serial port not found"
# Linux: Add user to dialout group
sudo usermod -a -G dialout $USER
# Log out and back in
# Check available ports
ls /dev/tty*
# Windows: Check Device Manager for COM port"Failed to connect to ESP32"
- Hold BOOT button while connecting
- Try different USB cable/port (must be data cable, not power-only)
- Verify target matches your device:
idf.py set-target esp32c6 - Check USB drivers are installed (CP210x or CH340)
"Guru Meditation Error: Core panic"
- Check stack size (increase in
xTaskCreate, default 2048 often too small) - Verify GPIO pins match your hardware
- Enable debug build:
idf.py menuconfig→ Compiler options → Debug (-Og) - Check
sdkconfigfor panic handler settings
"Out of memory" / Heap errors
- Use arena allocators:
std.heap.ArenaAllocator - Check available heap:
heap.freeSize() - Reduce allocations in hot paths
- Consider enabling PSRAM if available
- Check
idf.py size-componentsfor memory usage
WiFi not connecting
- Verify SSID and password in menuconfig
- Check WiFi country code matches your region
- Ensure 2.4GHz WiFi (5GHz not supported)
- Check WiFi credentials don't contain special characters
Matter example: "app partition is too small"
The Matter binary is ~2.2 MB and requires a 4 MB flash chip and custom partition table:
# Ensure sdkconfig has 4 MB flash and custom partition:
# CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
# CONFIG_PARTITION_TABLE_CUSTOM=y
# CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_matter.csv"
# If sdkconfig doesn't reflect sdkconfig.defaults changes, update it:
idf.py reconfigure
idf.py build -DCONFIG_ZIG_EXAMPLE_MATTER_LIGHT=yMatter example: IDF version incompatibility
espressif/esp_matter1.4.x requires IDF v5.x (tested with v5.5)- Not compatible with IDF v6.x (depends on
json/mqttcomponents removed in v6) - Check your IDF version:
idf.py --version
- Zig Language: https://ziglang.org/documentation/master/
- ESP-IDF Docs: https://docs.espressif.com/projects/esp-idf/
- Project Wiki:
All examples are in main/examples/:
gpio-blink.zig- Toggle an LED on GPIO2 (any target)uart-echo.zig- Read UART1 and echo backi2c-scan.zig- Scan I2C bus for deviceswifi-station.zig- Connect to a WiFi APhttp-server.zig- Serve a web page over WiFible-gatt-server.zig- BLE peripheral with GATT notifications (requiresCONFIG_BT_ENABLED)smartled-rgb.zig- WS2812B LED strip control (requiresespressif/led_strip)dsp-math.zig- FFT + power spectrum via DSP (requiresespressif/esp-dsp)matter-light.zig- Matter On/Off Light device (requiresespressif/esp_matter, IDF v5.x, 4 MB flash)
# Interactive configuration menu
idf.py menuconfig
# Key settings to explore:
# - Component config → FreeRTOS → Tick rate (Hz)
# - Component config → ESP System Settings → Panic handler behavior
# - Partition Table → Choose partition scheme
# - Example Configuration → WiFi credentials
# - Compiler options → Optimization levelThe project includes wokwi.toml for simulation:
- Install Wokwi CLI: https://docs.wokwi.com/wokwi-ci/idf-wokwi-usage
- Edit
wokwi.tomlto match your project - Run:
idf.py wokwi --timeout 30000
or use VSCode Extension
- Custom Components: Create reusable Zig modules in
imports/ - WiFi & Networking: HTTP servers, WebSockets, mDNS
- OTA Updates: Over-the-air firmware updates
- Bluetooth: BLE advertising, GATT services
- Deep Sleep: Ultra-low power modes
- File Systems: SPIFFS, FAT, LittleFS
- Cryptography: Hardware-accelerated crypto
Use sdkconfig.defaults for version control:
- Base config:
sdkconfig.defaults - Target-specific:
sdkconfig.defaults.esp32 - Don't commit
sdkconfig(it's generated)
Debugging:
# Monitor with timestamps
idf.py monitor --timestamps
# Filter logs by tag
idf.py monitor --print-filter "tag:app"
# Save logs to file
idf.py monitor | tee output.logClean builds when needed:
# Clean Zig cache
rm -rf .zig-cache
# Full CMake clean
idf.py fullclean
# Regenerate bindings
idf.py reconfigureFound a bug or want to contribute?
- GitHub: https://github.com/kassane/zig-esp-idf-sample
- Issues: https://github.com/kassane/zig-esp-idf-sample/issues
- Pull Requests: See CONTRIBUTING.md
# Setup
idf.py set-target esp32c6 # Set target device
idf.py reconfigure # Regenerate build config / update deps
# Build
idf.py build # Build project
idf.py clean # Clean build files
idf.py fullclean # Full clean (including config)
# Flash
idf.py -p PORT flash # Flash firmware
idf.py -p PORT monitor # Monitor serial output
idf.py -p PORT flash monitor # Flash and monitor
idf.py -p PORT app-flash # Flash app only (faster)
# Config
idf.py menuconfig # Interactive configuration
idf.py size # Show binary sizes
idf.py size-components # Component size breakdown
# Dependencies
idf.py add-dependency PKG # Add managed component
# Help
idf.py --help # Show all commands
idf.py --list-targets # List supported targets- Main app:
main/app.zig - Examples:
main/examples/*.zig - Dependencies:
main/idf_component.yml - Configuration:
sdkconfig,sdkconfig.defaults* - Bindings:
imports/idf-sys.zig(auto-generated) - Wrappers:
imports/*.zig - Build scripts:
cmake/*.cmake
Check your board's pinout:
- GPIO Pins: Varies by model (ESP32: 0-39, ESP32-C6: 0-30)
- Built-in LED: Often GPIO 2, 8, or 18
- UART: Usually GPIO 1 (TX), GPIO 3 (RX)
- I2C: SDA/SCL pins vary by board
- SPI: MOSI/MISO/CLK pins vary by board
- ESP32 Development Board (any supported variant)
- USB Cable (must be data cable, not power-only)
- LEDs/Components (optional, for examples)
- LED Strip WS2812B (optional, for smartled-rgb example)
- Documentation: docs/
- Issues: https://github.com/kassane/zig-esp-idf-sample/issues
- Discussions: https://github.com/kassane/zig-esp-idf-sample/discussions