Back to course

The Export Trie

18 minintermediateModule 5: Dynamic LinkingContinues from macho-dyld-info
18 min · Module 5: Dynamic Linking

Why This Matters

Bind opcodes name the symbols this image needs; something must answer the reverse question — "what does this image provide?" — when dyld resolves another binary's binds, when dlsym runs, and when a re-export fans a dylib's names through a second library. ELF answers with a sorted .dynsym, PE with three parallel export arrays; both are lookup tables over flat names.

Apple chose a trie instead, and loader.h says why: it "factors out common prefixes" and "reduces LINKEDIT pages in RAM because it encodes all information (name, address, flags) in one small, contiguous range." C++ mangling makes the case extreme — __ZNSo… and __ZNS… share long runs of bytes that a flat table would repeat per symbol. The trie is also load-bearing at runtime: it is the index dyld walks for every bind-by-name across every loaded image.

A Simple Model

Think of a tree whose edges carry string fragments, rooted at byte offset 0 of the export area. Each node is:

  1. A uleb128 terminal size — 0 means "no exported symbol here, just a branch point"; otherwise it counts the payload bytes that follow.
  2. The terminal payload — a uleb flags value, then (normally) a uleb address: the symbol's location "from the mach_header for the image". REEXPORT swaps the address for an ordinal plus a string; STUB_AND_RESOLVER stores two ulebs (stub, resolver).
  3. A child count byte (0–255), then each edge: a NUL-terminated UTF-8 fragment plus a uleb offset for the child node.

Lookup walks the tree one character at a time against the requested name — prefixes are stored exactly once, so _malloc and _main share their _m edge. The model's missing piece: that child uleb is measured from the start of the trie, not from the current node — the header comment never says so, so it must be pinned by example below.

The Actual Detail

Two ways to reach the same payload. Older binaries carry it inside dyld_info's export_off/export_size pair (our dylib: offset 582128, size 1432 — no separate command). Modern binaries promote it to LC_DYLD_EXPORTS_TRIE = 0x80000033, a 16-byte linkedit_data_command {cmd, cmdsize, dataoff, datasize} — the same struct that carries code signatures and chained fixups, because all of them are "a blob at a file offset with a size".

The terminal flags, straight from loader.h:

FlagValueMeaning
KIND_MASK0x030 regular, 1 thread-local, 2 absolute
WEAK_DEFINITION0x04This export may be overridden
REEXPORT0x08Payload is ordinal + string, not an address — re-export from that dylib
STUB_AND_RESOLVER0x10Payload is two ulebs: lazy stub offset, resolver offset

And the address rule, again from the header: the uleb after flags is "the location of the content named by the symbol from the mach_header" — an offset from image base, not a vmaddr and not a file offset (they coincide here only because __TEXT.fileoff is 0 and you add the vmaddr).

Common mistakes. Child offsets are absolute within the trie — ld64's export reader resolves them as start + childOffset where start is the trie's base, and walking our sample desyncs under any other rule. Second trap: xxd's ASCII column lies — the bytes 43 47 4C 51 56 you will see printed as "C G L Q V" are uleb child offsets 67/71/76/81/86, not letters in a label.

A Real Example

prog64.macho declares it as its own command — a modern-era binary with no LC_DYLD_INFO at all:

      cmd LC_DYLD_EXPORTS_TRIE
  cmdsize 16
  dataoff 12344
 datasize 96

All 96 bytes (5 exports, one root, one shared _ edge):

00003038: 0001 5f00 0500 055f 6d68 5f65 7865 6375  .._...._mh_execu
00003048: 7465 5f68 6561 6465 7200 4362 7373 5f63  te_header.Cbss_c
00003058: 6f75 6e74 6572 0047 6164 6400 4c6d 6169  ounter.Gadd.Lmai
00003068: 6e00 5167 6c6f 6261 6c5f 636f 756e 7465  n.Qglobal_counte
00003078: 7200 5602 0000 0003 0084 4000 0300 9008  r.V.......@.....
00003088: 0003 00d0 0800 0300 8040 0000 0000 0000  .........@......

Walk it: byte 0 00 — root has no terminal. Byte 1 01 — one child; edge 5f 00 = "_", child uleb 05. Node 5: terminal size 00, then 05 children, five edges — _mh_execute_header\0 43 (child 67), bss_counter\0 47 (71), add\0 4c (76), main\0 51 (81), global_counter\0 56 (86). Each leaf: terminal size 2, 00 flags, then the address uleb — and then 00 children. The five results:

'__mh_execute_header' flags 0 addr 0x0
'_bss_counter'        flags 0 addr 0x2004
'_add'                flags 0 addr 0x410
'_main'               flags 0 addr 0x450
'_global_counter'     flags 0 addr 0x2000

nm -g agrees symbol-for-symbol and address-for-address once you add __TEXT's vmaddr 0x100000000: _main trie address 0x450 = nm's 0x100000450, _bss_counter 0x2004 = 0x1000002004's low half (the zero-fill int bss_counter at (__DATA,__bss)+4), _global_counter 0x2000 = the 42 in (__DATA,__data). Five external-defined symbols in the symbol table, five terminals — a dylib's export trie simply is the public face of its dysymtab range.

Try It

$ /usr/lib/llvm-21/bin/llvm-nm -g prog64.macho
0000000100000000 T __mh_execute_header
0000000100000410 T _add
0000000100002004 S _bss_counter
0000000100002000 D _global_counter
0000000100000450 T _main

$ xxd -s 12344 -l 96 prog64.macho

What to look for: exactly five nm lines and five trie terminals; strip the top 32 bits of each nm address and you have the trie's ulebs (0, 0x410, 0x2004, 0x2000, 0x450). In the hex dump, the lone 5f 00 near the front is the only shared prefix edge — everything else hangs off node 5.

Check Your Understanding

Without looking back: an edge in the export trie stores the uleb value 81. What is 81 measured from?

Apply It

A terminal reads: size 2, then bytes 08 00. What has the linker just advertised?

The pattern: size first, flags decide the payload's shape, child count byte closes the node — and every offset inside is measured from the trie's byte 0.

Connect

The trie is the one structure from the dyld_info era that survives intact into modern binaries — prog64 ships it as its own LC. The other four streams do not survive: their job moves into the pointer slots themselves.

Next: Chained Fixups — how rebase and bind rows get threaded through the data they fix, so the kernel can apply them while paging faults in, replacing 644+120 opcodes with a linked list in the GOT.