# Debugging Zig Code on ESP32 with ESP-IDF: Practical Guide

> Debug Zig code on ESP32 with ESP-IDF effectively. Learn practical steps for GDB, OpenOCD, and memory debugging with this comprehensive guide.

- 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

---

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

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

```zig
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`](https://github.com/kassane/zig-esp-idf-sample/blob/main/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:

```bash
idf.py menuconfig  # → Component config → Log output → Default log level → Debug

```

Alternatively, set the environment variable before building:

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

```bash
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`](https://github.com/kassane/zig-esp-idf-sample/blob/main/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:

```bash
openocd -f interface/ftdi/esp32_devkitj_v1.cfg -f target/esp32.cfg &

```

Attach the debugger:

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

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

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

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

```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`**: The `imports/logger.zig` module forwards Zig logs to ESP-IDF's subsystem without runtime overhead.
- **Retain symbols in Debug builds**: Set `-Dmode=Debug` and `strip_debug_info = false` (as configured in [`docs/zig-xtensa.md`](https://github.com/kassane/zig-esp-idf-sample/blob/main/docs/zig-xtensa.md)) to enable GDB source-level debugging.
- **Use GDB/OpenOCD**: Attach `xtensa-esp32-elf-gdb` to the OpenOCD server on port 3333 to step through Zig code in `main/app.zig`.
- **Monitor memory with custom allocators**: Wrap ESP-IDF heap functions in Zig allocators and use `std.debug.assert` for 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`](https://github.com/kassane/zig-esp-idf-sample/blob/main/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`](https://github.com/kassane/zig-esp-idf-sample/blob/main/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.