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:
- Registers real type formatters (no Python)
- Rewrites expressions so Zig syntax works
- 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.
The Zen of Coding Newsletter
Join the newsletter to receive the latest updates in your inbox.