Debugging Zig Code on ESP32 with ESP-IDF: Practical Guide
You can debug Zig applications on ESP32 by bridging std.log to ESP-IDF's logging subsystem, building with -Dmode=Debug to retain DWARF symbols, and attaching GDB via OpenOCD, while leveraging compile-time assertions and custom allocators for memory debugging.
The zig-esp-idf-sample repository demonstrates how to write firmware for ESP32-family chips in Zig while reusing the full ESP-IDF ecosystem. Debugging Zig code on ESP32 with ESP-IDF requires configuring the build system to preserve debug symbols, routing logs through the ESP-IDF logging subsystem, and understanding the bridge between Zig's standard library and the underlying C APIs.
Architecture of Zig Logging on ESP-IDF
Bridging std.log to ESP-IDF
All Zig code calls std.log (e.g., std.log.debug("msg", .{})). The logger import in imports/logger.zig intercepts these calls and forwards them to the ESP-IDF log subsystem:
pub fn espLogFn(
comptime level: std.log.Level,
comptime scope: @TypeOf(.EnumLiteral),
comptime format: []const u8,
args: anytype,
) void {
// Maps Zig levels to ESP-IDF levels via levelToEsp (lines 33-39)
// Forwards to ESP_LOG which calls sys.esp_log_write
}
The function maps Zig log levels to ESP-IDF levels using levelToEsp (see lines 33-39 in imports/logger.zig). It adds a colorized prefix and forwards the formatted string to ESP_LOG, which ultimately calls sys.esp_log_write. Because this mapping occurs at compile-time, the translation incurs no runtime overhead.
Debug vs. Release Build Modes
Zig's built-in builtin.mode drives the default log level in ESP-IDF:
pub const default_level: sys.esp_log_level_t = switch (@import("builtin").mode) {
.Debug => sys.ESP_LOG_DEBUG,
.ReleaseSafe => sys.ESP_LOG_INFO,
.ReleaseFast, .ReleaseSmall => sys.ESP_LOG_ERROR,
};
In Debug mode, the ESP-IDF log level is set to DEBUG, exposing all log.debug statements. The build configuration in build.zig disables DWARF stripping (strip_debug_info = false) for the Debug target (see docs/zig-xtensa.md lines 180-182). This ensures the resulting ELF file contains full symbol information for source-level debugging.
Memory Allocation Debugging
Zig code can use std.heap.ArenaAllocator backed by ESP-IDF allocators. The repository documents several ESP-IDF allocators available in Zig (e.g., idf.heap.HeapCapsAllocator) in the README (lines 29-43). By swapping the backing allocator to imports/heap.zig implementations, you can:
- Verify heap capabilities via Zig assertions (
std.debug.assert) - Detect out-of-memory conditions early during debugging
- Monitor allocation statistics through custom wrappers
Practical Debugging Workflow
Enable Verbose Logging
Configure the log level through menuconfig:
idf.py menuconfig # → Component config → Log output → Default log level → Debug
Alternatively, set the environment variable before building:
export ESP_IDF_LOG_LEVEL=DEBUG
idf.py build
All log.debug calls from Zig's std.log.debug now appear on the UART monitor.
Build Debug Firmware
Generate a binary with full debug symbols:
idf.py set-target esp32
idf.py -Dmode=Debug build
The -Dmode=Debug flag forces Zig's builtin.mode to .Debug (see docs/getting-started.md line 619). The resulting ELF retains DWARF symbols, allowing GDB to step through Zig source files such as main/app.zig.
Attach GDB via OpenOCD
Start the OpenOCD server provided by ESP-IDF:
openocd -f interface/ftdi/esp32_devkitj_v1.cfg -f target/esp32.cfg &
Attach the debugger:
xtensa-esp32-elf-gdb build/your_project.elf
(gdb) target remote :3333
(gdb) monitor reset halt
(gdb) break main.main
(gdb) continue
Because the binary retains DWARF info, breakpoints map directly to Zig source lines rather than assembly addresses.
Inspect Heap State
Monitor memory usage programmatically:
const heap = std.heap.ArenaAllocator.init(std.heap.c_allocator);
defer heap.deinit();
log.debug("Arena used: {d} bytes", .{heap.state().used});
This prints current arena usage to the monitor, helping identify memory leaks in long-running applications.
Code Examples
Minimal Debug Logging Example
This pattern from main/app.zig demonstrates basic logging and deep sleep:
const std = @import("std");
const log = std.log;
const sys = @import("sys");
pub export fn app_main() void {
log.debug("Starting Zig app on ESP32", .{});
log.info("System info: {}", .{sys.esp_chip_info()});
const delay = std.time.milliTimestamp;
const start = delay();
while (delay() - start < 5000) {} // 5 seconds
log.info("Finished, entering deep sleep", .{});
sys.esp_deep_sleep(0);
}
UART Echo with Debug Output
The main/examples/uart-echo.zig file shows comprehensive error handling with debug tracing:
pub fn appMain() void {
const uart = uart.UART0;
uart.init(.{
.baud_rate = 115200,
.parity = .none,
.stop_bits = .one,
}) catch |err| {
log.err("UART init failed: {}", .{err});
return;
};
log.debug("UART initialized, start echo loop", .{});
while (true) {
const maybe_byte = uart.readByte() catch continue;
uart.writeByte(maybe_byte) catch {};
log.debug("Echoed byte: 0x{X}", .{maybe_byte});
}
}
Custom Heap Caps Allocator
Wrap ESP-IDF's heap capabilities allocator for Zig:
const std = @import("std");
const idf = @cImport(@cInclude("esp_heap_caps.h"));
pub const HeapCaps = struct {
allocator: std.mem.Allocator,
pub fn init() HeapCaps {
const caps = idf.HEAP_CAP_8BIT;
const ptr = idf.heap_caps_malloc(1024, caps);
return .{
.allocator = std.heap.FixedBufferAllocator.init(ptr, 1024).allocator
};
}
};
pub fn example() void {
var heap = HeapCaps.init();
const buf = heap.allocator.alloc(u8, 64) catch unreachable;
defer heap.allocator.free(buf);
log.debug("Allocated 64-byte buffer", .{});
}
This mirrors the allocator patterns documented in the repository README (lines 29-43).
Summary
- Bridge logging through
std.log: Theimports/logger.zigmodule forwards Zig logs to ESP-IDF's subsystem without runtime overhead. - Retain symbols in Debug builds: Set
-Dmode=Debugandstrip_debug_info = false(as configured indocs/zig-xtensa.md) to enable GDB source-level debugging. - Use GDB/OpenOCD: Attach
xtensa-esp32-elf-gdbto the OpenOCD server on port 3333 to step through Zig code inmain/app.zig. - Monitor memory with custom allocators: Wrap ESP-IDF heap functions in Zig allocators and use
std.debug.assertfor compile-time and runtime checks.
Frequently Asked Questions
How do I enable debug logging in Zig for ESP32?
Build your project with idf.py -Dmode=Debug build according to docs/getting-started.md. This sets Zig's builtin.mode to .Debug, which configures the default log level to ESP_LOG_DEBUG and ensures std.log.debug statements appear on the UART console.
Why aren't my breakpoints working in GDB when debugging Zig?
Ensure you are building with strip_debug_info = false in your build.zig configuration. As documented in docs/zig-xtensa.md (lines 180-182), Debug mode must preserve DWARF symbols so that GDB can map machine code addresses back to Zig source lines in files like main/app.zig.
How does Zig's std.log integrate with ESP-IDF?
The integration occurs through imports/logger.zig, which defines an espLogFn that maps Zig's std.log.Level values to ESP-IDF log levels via the levelToEsp function (lines 33-39). This function calls sys.esp_log_write, forwarding formatted messages to the ESP-IDF logging subsystem that outputs to UART0 by default.
Can I use standard Zig allocators on ESP32?
Yes. You can use std.heap.ArenaAllocator or std.heap.GeneralPurposeAllocator backed by ESP-IDF's heap functions. The repository provides examples in imports/heap.zig showing how to wrap heap_caps_malloc for specific memory capabilities, allowing you to use familiar Zig allocation patterns while adhering to ESP32 memory constraints.
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 →