Skip to content

Hacking LLDB for a great Zig debugging experience

Joel Reymont
Joel Reymont
4 min read
Hacking LLDB for a great Zig debugging experience

Discuss it on Twitter, Reddit or Hacker News.

Zig is better than C. And that’s great — until you try to debug it.

If you’ve ever dropped into LLDB while debugging Zig, you already know the pain: slices look like random structs, optionals are unreadable, error unions feel hostile, and expressions like slice[0] just… don’t work.

This post introduces zdb, an LLDB plugin that makes debugging Zig feel normal — without rebuilding LLDB or messing with Python scripts.

The Problem

Here’s a simple Zig program (from test/test_types.zig in the zdb repo):

const std = @import("std");

const Color = enum { red, green, blue, yellow };

const TestStruct = struct {
    name: []const u8,
    values: []i32,
    optional_value: ?i32,
    error_result: MyError!i32,
    shape: Shape,
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Test slices
    const string_slice: []const u8 = "Hello, zdb debugger!";
    const int_slice: []const i32 = &[_]i32{ 1, 2, 3, 4, 5 };

    // Test optionals
    const some_value: ?i32 = 42;

    // Test std library
    var list: std.ArrayListUnmanaged(i32) = .empty;
    try list.appendSlice(allocator, &.{ 10, 20, 30 });

    std.debug.print("Breakpoint here!\n", .{});  // breakpoint here
}

Now stop at the breakpoint and inspect things in stock LLDB:

(lldb) frame variable string_slice int_slice color test_struct list
([]u8) string_slice = (ptr = "Hello, zdb debugger!", len = 20)
([]i32) int_slice = {
  ptr = 0x00000001000da244
  len = 5
}
(test_types.Color) color = blue
(test_types.TestStruct) test_struct = {
  name = (ptr = "test object", len = 11)
  values = {
    ptr = 0x000000016fdfe588
    len = 3
  }
  optional_value = (data = 42, some = '\x01')
  error_result = (value = 100, tag = 0)
  shape = {
    payload = { ... }
    tag = circle
  }
}
(array_list.Aligned(i32,null)) list = {
  items = {
    ptr = 0x0000000100180000
    len = 3
  }
  capacity = 32
}

This is technically correct — but not very useful.

And if you try to write Zig-like expressions:

(lldb) p int_slice[0]
                  ^
                  error: type '[]i32' does not provide a subscript operator

LLDB has no idea that a slice is really (ptr, len). It just sees a struct.

Existing Solutions (And Why They’re Not Great)

LLDB lets you register Python-based type summaries:

# zig_formatters.py
import lldb

def slice_summary(valobj, internal_dict):
    ptr = valobj.GetChildMemberWithName('ptr')
    length = valobj.GetChildMemberWithName('len').GetValueAsUnsigned()
    return f'len={length} ptr={ptr.GetValue()}'

def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand(
        'type summary add -F zig_formatters.slice_summary -x "\\[\\].*"'
    )
    print('[python] Registered slice formatter')

That gets you nicer output:

(lldb) frame variable string_slice int_slice
([]u8) string_slice = len=20 ptr=0x00000001000e3fde
([]i32) int_slice = len=5 ptr=0x00000001000da244

But expressions still don’t work:

(lldb) p int_slice[0]
error: type '[]i32' does not provide a subscript operator

Problems:

  • You’re writing Python to debug systems code
  • Crossing Python ↔ C++ boundaries is slow
  • No expression support
  • No string content display

zig-lldb (The Nuclear Option)

Jacob Young’s zig-lldb fork adds a full TypeSystemZig to LLDB.

It’s excellent. It’s also a full LLDB fork.

Upsides

  • Real Zig semantics
  • Expressions work
  • Variables view works

Downsides

  • Rebuild LLDB (bring snacks!)
  • Track upstream changes forever
  • Not usable with system or Homebrew LLDB

Enter zdb

zdb takes a different approach: how far can we go without rebuilding LLDB?

zdb is a native C++ LLDB plugin that:

  1. Registers real type formatters (no Python)
  2. Rewrites expressions so Zig syntax works
  3. Runs on stock LLDB

Load it:

(lldb) plugin load libzdb.dylib
[zdb] Loaded 19 formatters + expression syntax

Now things look… sane:

(lldb) frame variable string_slice int_slice
([]u8) string_slice = "Hello, zdb debugger!" {
  ptr = 0x00000001000e3fde "Hello, zdb debugger!"
  len = 20
}
([]i32) int_slice = len=5 ptr=0x1000da244 {
  ptr = 0x00000001000da244
  len = 5
}

And expressions finally behave:

(lldb) p int_slice[0]
(int) $0 = 1

(lldb) p list[0]
(int) $1 = 10

(lldb) p test_struct.optional_value.?
(int) $2 = 42

(lldb) p test_struct.error_result catch 0
(int) $3 = 100

That alone makes day-to-day debugging dramatically better.

How This Works (a.k.a. The Dark Arts)

LLDB has two APIs:

  • Public API (SBTarget, SBValue, etc.)
  • Internal API (TypeCategoryImpl, FormatManager, etc.)

The public API is… polite but limited.

The internal API does the real work — and isn’t exported.

So zdb cheats!

Step 1: Find Internal Symbols

They’re not exported, but they’re there:

nm -C liblldb.dylib | grep AddTypeSummary
0000000000360f38 t lldb_private::TypeCategoryImpl::AddTypeSummary(...)

Step 2: Compute the Base Address

We anchor off a known exported symbol:

void* ref = dlsym(handle, "_ZN4lldb10SBDebugger10InitializeEv");
uintptr_t base = (uintptr_t)ref - reference_offset;

Now every internal symbol is just base + offset.

Step 3: Call It with the Right ABI

ARM64 calling conventions matter. shared_ptr gets passed indirectly:

using AddTypeSummaryFn = void (*) (
    void* this_ptr,
    const char* name_ptr,
    size_t name_len,
    int match_type,
    SharedPtrLayout* sp
);

Mess this up and you get instant crashes.

Step 4: Rewrite Expressions

zdb intercepts expressions and rewrites them:

Zig Becomes
slice[n] slice.ptr[n]
list[n] list.items.ptr[n]
opt.? opt.data
err catch x (err.tag == 0 ? err.value : x)

Users never see this. They just type Zig.

Offset Tables

Offsets change between LLDB versions, so zdb uses JSON tables:

{
  "version": "21.1.7",
  "reference_symbol": "_ZN4lldb10SBDebugger10InitializeEv",
  "reference_offset": "0x4c670",
  "symbols": {
    "TypeCategoryImpl::AddTypeSummary": { "offset": "0x35ff38" }
  }
}

There’s a script to generate these automatically:

python3 tools/dump_offsets.py liblldb.dylib > lldb-21.1.7.json

Limitations (Yes, There Are Some)

The big missing piece is Variables View expansion.

That requires registering C++ synthetic children via std::function. Unfortunately, ABI mismatches between plugin and LLDB builds make this extremely fragile.

So:

  • CLI expressions work
  • GUI tree expansion does not

For most Zig debugging, this turns out to be fine.

Comparison

Feature Stock LLDB Python zig-lldb zdb
Install Built-in Script Rebuild Plugin
Summaries
slice[n]
Optionals
Variables view Partial
Performance Slow Fast Fast

Final Thoughts

zdb is intentionally pragmatic.

It’s not as pure as zig-lldb, and it’s definitely more cursed than Python formatters — but it hits a sweet spot:

  • Works with stock LLDB
  • Makes Zig readable
  • Makes expressions usable

For everyday Zig debugging, that’s usually all you want.

Your contributions are welcome — especially offset tables for new LLDB releases!

Thanks to Jacob Young for zig-lldb, which proved that proper Zig debugging support is possible and inspired this lighter-weight alternative.