Back to course

Debug Info

15 minadvancedModule 7: Integrity & DebugContinues from macho-code-signing
15 min · Module 7: Integrity & Debug

Why This Matters

A crash report hands you an address — 0x100000450, say. That tells a machine exactly where you died and tells a human nothing. Turning addresses into "main() at prog.c:8" is the whole point of debug info, and Mach-O makes a deliberate choice about where that mapping lives: shipping full DWARF inside every binary would bloat the file and expose internals, so the format splits it in half.

The split defines a workflow every macOS and iOS crash report assumes: the binary on the device and a dSYM bundle on the developer's disk are two halves of one artifact, joined by a 16-byte key the linker wrote into both. Get the join wrong and symbolication maps every address to the wrong source line — quietly, confidently, worse than having no symbols at all.

A Simple Model

Three pieces:

  1. The shipped binary. Code, the plain symbol table, and load commands — including LC_UUID and two small unwinding aids, LC_FUNCTION_STARTS and LC_DATA_IN_CODE. No DWARF.
  2. The dSYM bundle. A directory whose Contents/Info.plist names the target and whose Contents/Resources/DWARF/ holds a file carrying all the debug sections.
  3. The join.dsymutil builds the bundle from a linked binary; every consumer — dwarfdump, atos, lldb — trusts a bundle only when its UUID matches the binary's.

The model's missing piece: the UUID proves identity, not quality. It says "this bundle came from this exact link" — nothing more. A complete, honest dSYM from the wrong build is still useless, and the UUID is the only thing stopping tools from silently using it.

The Actual Detail

Three spec facts decide how the split works:

MechanismWhat the spec saysEffect
LC_UUID (0x1b)uuid_command: "a single 128-bit unique random number that identifies an object produced by the static link editor"The join key — identical in binary and dSYM, one UUID per thin slice
S_ATTR_DEBUG (0x02000000)"The static linker will not copy section contents from sections with this attribute into its output file. These sections generally contain DWARF debugging info."The main binary is designed never to carry DWARF sections — this is format policy, not disk-space thrift
N_STAB (0xE0)nlist.h: "if any of these bits set, a symbolic debugging entry" — the actual codes live in stab.hThe older path: debug entries stored inside the LC_SYMTAB symbol table itself, the data strip historically removed

What the shipped half keeps instead: LC_UUID, a plain LC_SYMTAB (no line tables), and two linkedit_data_command blobs whose payloads sit in __LINKEDIT — LC_FUNCTION_STARTS, a ULEB128 delta stream of function entry addresses, enough to walk frames without a single line of DWARF; and LC_DATA_IN_CODE, a DICE table marking non-instruction ranges inside __text (each entry: offset, size, kind).

Honest scope. Our samples were built without -g: llvm-dwarfdump reports an empty .debug_info in both prog64.macho and prog64.o. What we verify end-to-end here — UUIDs, the bundle structure dsymutil produces, the function-starts stream — is precisely the join mechanism a -g build fills with line tables.

A Real Example

The UUIDs of our samples, read from their load commands and from --uuid:

FileSliceUUID
prog64.machox86_644C4C44C9-5555-3144-A19F-1D5685FC622F
universal.machox86_644C4C44C9-5555-3144-A19F-1D5685FC622F
universal.machoarm644C4C44B1-5555-3144-A130-3397BCE0A53C
libasyncProfiler.dylibx86_64DA4AFC52-D48E-39AB-B574-FE3B9CC5232F

A universal file carries one LC_UUIDper thin Mach-O inside it — two UUIDs from one dump, because there are two complete linked binaries in the container, and each needs its own dSYM pairing.

The other half of crash recovery ships inside the binary. LC_FUNCTION_STARTS points at 8 bytes (dataoff 12440):

00003098: 9008 3010 0000 0000                      ..0.....

ULEB128 deltas from the image base: 0x410, 0x30, 0x10, then a zero terminator. Walked from 0x100000000 they land on 0x100000410 (_add), 0x100000440 (_helper), 0x100000450 (_main) — exactly what nm prints. Even with zero DWARF, an unwinder knows where every function starts.

Try It

Build the join yourself — on macOS the tools are dsymutil and dwarfdump; LLVM ships them as dsymutil and llvm-dwarfdump:

$ llvm-dwarfdump --uuid prog64.macho
UUID: 4C4C44C9-5555-3144-A19F-1D5685FC622F (x86_64) prog64.macho

$ dsymutil prog64.macho -o prog64.dSYM
warning: no debug symbols in executable (-arch x86_64)

$ llvm-dwarfdump --uuid prog64.dSYM
UUID: 4C4C44C9-5555-3144-A19F-1D5685FC622F (x86_64) prog64.dSYM/Contents/Resources/DWARF/prog64.macho

What to look for: the same UUID on both sides — that is the join. The warning is the honest part: this binary has no DWARF to copy, yet the bundle still gets its Contents/Info.plist and Contents/Resources/DWARF/ skeleton — the same skeleton a -g build fills with line tables. Then try --uuid universal.macho and count the UUIDs.

Check Your Understanding

Without looking back: in the shipped-app workflow, where does the DWARF actually live, and what joins it to the running binary?

Apply It

You run llvm-dwarfdump --uuid on a crash report's binary and on your stored dSYM. The UUIDs differ. What do you do?

The pattern: debug info is a two-artifact system. Identity first (UUID), content second (DWARF) — a debugger can only trust the second because it verified the first.

Connect

You can now join a crash address to a source line — when the join exists. The UUID also does humbler work: it is how Build Versions and UUID ties an exact link to its toolchain metadata, and how Code Signing pins a signature to the bytes it covers. Everything in this lesson protects the binary after it ships; the next concept moves to the moment it runs — the region at address zero.

Next: __PAGEZERO and Memory Hardening — the 4 GiB guard page, MH_PIE, and W^X, all declared in load commands you can read with otool.