Chapter 52Debug And Valgrind

Debug and Valgrind

Overview

After building slice tooling and lightweight reflection in the previous chapter, we now turn to what happens when things go wrong. Zig’s diagnostics pipeline lives in std.debug, which controls panic strategies, offers stack unwinding, and exposes helpers for printing structured data. debug.zig For memory instrumentation you have std.valgrind, a thin veneer over Valgrind’s client request protocol that keeps your custom allocators visible to Memcheck without ruining portability. valgrind.zigmemcheck.zig

Learning Goals

  • Configure panic behavior and collect stack information with std.debug.
  • Use stderr-aware writers and stack capture APIs without leaking unstable addresses into logs.
  • Annotate custom allocations for Valgrind Memcheck and safely query leak counters at runtime.

Diagnostics with

std.debug is the standard library’s staging ground for assertions, panic hooks, and stack unwinding. The module keeps the default panic bridge (std.debug.simple_panic) alongside a configurable FullPanic helper that funnels every safety check into your own handler. simple_panic.zig Whether you are instrumenting tests or tightening release builds, this is the layer that decides what happens when unreachable executes.

Panic strategies and safety modes

By default, a failed std.debug.assert or unreachable results in a call to @panic, which delegates to the active panic handler. You can override this globally by defining a root-level pub fn panic(message: []const u8, trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn, or compose a bespoke handler via std.debug.FullPanic(custom) to preserve Zig’s rich error messages while swapping termination semantics. This is especially useful in embedded or service-mode binaries where you prefer logging and clean shutdowns over aborting the process. Remember that safety features are mode-dependent—std.debug.runtime_safety evaluates to false in ReleaseFast and ReleaseSmall, so instrumentation should check that flag before assuming invariants are enforced.

Capturing stack frames and managing stderr

The following program demonstrates several std.debug primitives: printing to stderr, locking stderr for multi-line output, capturing a stack trace without exposing raw addresses, and reporting build parameters.

Zig
const std = @import("std");
const builtin = @import("builtin");

pub fn main() !void {
    // Emit a quick note to stderr using the convenience helper.
    std.debug.print("[stderr] staged diagnostics\n", .{});

    // Lock stderr explicitly for a multi-line message.
    {
        const writer = std.debug.lockStderrWriter(&.{});
        defer std.debug.unlockStderrWriter();
        writer.writeAll("[stderr] stack capture incoming\n") catch {};
    }

    var stdout_buffer: [256]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const out = &stdout_writer.interface;

    // Capture a trimmed stack trace without printing raw addresses.
    var frame_storage: [8]usize = undefined;
    var trace = std.builtin.StackTrace{
        .index = 0,
        .instruction_addresses = frame_storage[0..],
    };
    std.debug.captureStackTrace(null, &trace);
    try out.print("frames captured -> {d}\n", .{trace.index});

    // Guard a sentinel with the debug assertions that participate in safety mode.
    const marker = "panic probe";
    std.debug.assert(marker.len == 11);

    var buffer = [_]u8{ 0x41, 0x42, 0x43, 0x44 };
    std.debug.assertReadable(buffer[0..]);
    std.debug.assertAligned(&buffer, .@"1");

    // Report build configuration facts gathered from std.debug.
    try out.print(
        "runtime_safety -> {s}\n",
        .{if (std.debug.runtime_safety) "enabled" else "disabled"},
    );
    try out.print(
        "optimize_mode -> {s}\n",
        .{@tagName(builtin.mode)},
    );

    // Show manual formatting against a fixed buffer, useful when stderr is locked.
    var scratch: [96]u8 = undefined;
    var stream = std.io.fixedBufferStream(&scratch);
    try stream.writer().print("captured slice -> {s}\n", .{marker});
    try out.print("{s}", .{stream.getWritten()});
    try out.flush();
}
Run
Shell
$ zig run debug_diagnostics_station.zig
Output
Shell
[stderr] staged diagnostics
[stderr] stack capture incoming
frames captured -> 4
runtime_safety -> enabled
optimize_mode -> Debug
captured slice -> panic probe

A few callouts:

  • std.debug.print always targets stderr, so it remains separate from any structured stdout reporting.
  • Use std.debug.lockStderrWriter when you need atomic multi-line diagnostics; the helper temporarily clears std.Progress overlays.
  • std.debug.captureStackTrace writes to a std.builtin.StackTrace buffer. Emitting only the frame count avoids leaking ASLR-sensitive addresses and keeps log output deterministic. builtin.zig
  • Formatter access comes from the writer interface returned by std.fs.File.stdout().writer(), which mirrors the approach from earlier chapters.

Introspecting symbols and binaries

std.debug.getSelfDebugInfo() opens the current binary’s DWARF or PDB tables on demand and caches them for subsequent lookups. With that handle you can resolve instruction addresses to std.debug.Symbol records that include function names, compilation units, and optional source locations. SelfInfo.zig You do not need to pay that cost in hot paths: store addresses (or stack snapshots) first, then resolve them lazily in telemetry tools or when generating a bug report. On platforms where debug info is stripped or unavailable, the API returns error.MissingDebugInfo, so wrap the lookup in a fallback that prints module names only.

Instrumenting with

std.valgrind mirrors Valgrind’s client requests while compiling down to no-ops when builtin.valgrind_support is false, keeping your binaries portable. You can detect Valgrind at runtime via std.valgrind.runningOnValgrind() (useful for suppressing self-tests that spawn massive workloads) and query accumulated error counts with std.valgrind.countErrors().

Marking custom allocations for Memcheck

When you roll your own allocator, Memcheck cannot infer which buffers are live unless you annotate them. The following example shows the canonical pattern: announce a block, adjust its definedness, run a quick leak check, and free the block when done.

Zig
const std = @import("std");

pub fn main() !void {
    var stdout_buffer: [256]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const out = &stdout_writer.interface;
    const on_valgrind = std.valgrind.runningOnValgrind() != 0;
    try out.print("running_on_valgrind -> {s}\n", .{if (on_valgrind) "yes" else "no"});

    var arena_storage: [96]u8 = undefined;
    var arena = std.heap.FixedBufferAllocator.init(&arena_storage);
    const allocator = arena.allocator();

    var span = try allocator.alloc(u8, 48);
    defer {
        std.valgrind.freeLikeBlock(span.ptr, 0);
        allocator.free(span);
    }

    // Announce a custom allocation to Valgrind so leak reports point at our call site.
    std.valgrind.mallocLikeBlock(span, 0, true);

    const label: [:0]const u8 = "workspace-span\x00";
    const block_id = std.valgrind.memcheck.createBlock(span, label);
    defer _ = std.valgrind.memcheck.discard(block_id);

    std.valgrind.memcheck.makeMemDefined(span);
    std.valgrind.memcheck.makeMemNoAccess(span[32..]);
    std.valgrind.memcheck.makeMemDefinedIfAddressable(span[32..]);

    const leak_bytes = std.valgrind.memcheck.countLeaks();
    try out.print("leaks_bytes -> {d}\n", .{leak_bytes.leaked});

    std.valgrind.memcheck.doQuickLeakCheck();

    const error_total = std.valgrind.countErrors();
    try out.print("errors_seen -> {d}\n", .{error_total});
    try out.flush();
}
Run
Shell
$ zig run valgrind_integration_probe.zig
Output
Shell
running_on_valgrind -> no
leaks_bytes -> 0
errors_seen -> 0

Even outside Valgrind the calls succeed—every request degrades to a stub when client support is absent—so you can leave the instrumentation in release binaries without gating on build flags. The sequence worth memorizing is:

  1. std.valgrind.mallocLikeBlock immediately after you obtain memory from a custom allocator.

  2. std.valgrind.memcheck.createBlock with a zero-terminated label so Memcheck reports use the name you expect.

  3. Optional range adjustments such as makeMemNoAccess and makeMemDefinedIfAddressable when you deliberately poison or unpoison guard bytes.

  4. A matching std.valgrind.freeLikeBlock (and memcheck.discard) before the underlying allocator releases the memory.

Notes & Caveats

  • Stack capture relies on debug info; in stripped builds or unsupported targets, std.debug.captureStackTrace falls back to empty results, so wrap diagnostics with graceful degradation.
  • std.debug.FullPanic executes on every safety violation. Ensure the handler performs only async-signal-safe operations if you plan to log from multiple executor threads.
  • Valgrind annotations are cheap in native runs but do not cover sanitizer-based tooling—prefer compiler sanitizers (ASan/TSan) when you need deterministic CI coverage. 37

Exercises

  • Implement a custom panic handler that logs to a ring buffer using std.debug.FullPanic, then forwards to the default handler in debug mode.
  • Extend debug_diagnostics_station.zig so that stack captures are resolved to symbol names via std.debug.getSelfDebugInfo(), caching results to avoid repeated lookups.
  • Modify valgrind_integration_probe.zig to wrap a bump allocator: record every active span in a table, and call std.valgrind.memcheck.doQuickLeakCheck() only when the process shuts down. 10

Caveats, alternatives, edge cases

  • std.debug.dumpCurrentStackTrace prints absolute addresses and source paths that vary per run because of ASLR; capture to an in-memory buffer and redact volatile fields before shipping telemetry.
  • Valgrind’s client requests depend on the xchg-based handshake and are no-ops on architectures that Valgrind does not support—runningOnValgrind() will always return zero there.
  • Memcheck annotations do not replace structured testing; combine them with Zig’s leak detection (zig test --detect-leaks) for deterministic regression coverage. 13

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.