Two address problems appear the moment an image loads. First, MH_PIE randomizes the load address, so every pointer the linker wrote to this image's own data is now off by the slide — those fixups are called rebases. Second, printf lives in libSystem, whose address nobody knows at link time — those fixups are called binds, and each one names the library ordinal you learned in Dynamic Libraries.
The classic answer, live since OS X 10.6, is one load command and five compressed byte streams in __LINKEDIT. loader.h notes the streams need "no endian swapping" — they are opcodes, not structs. This is Mach-O's equivalent of ELF's .rela.dyn plus .rela.plt, and of PE's import-address-table fixups, but expressed as a tiny bytecode dyld interprets.
loader.h defines both streams as tables, then compresses them. Rebase info is conceptually rows of:
<seg-index, seg-offset, type>
Bind info is conceptually rows of:
<seg-index, seg-offset, type, symbol-library-ordinal, symbol-name, addend>
"The opcodes are a compressed way to encode the table by only encoding when a column changes." So the stream is a little state machine: set opcodes update columns (segment, offset, ordinal, symbol), do opcodes emit one or a run of rows. Rebinding means adding the slide to a self-pointer; binding means writing a resolved foreign address into the slot.
The model's missing piece: a do-op also advances the offset by one pointer (8 bytes), and runs like "five rows at consecutive slots" cost one byte. Most of the stream is runs and repeated columns — that is where the compression comes from.
dyld_info_command is 48 bytes: cmd, cmdsize, then five (offset, size) pairs — rebase, bind, weak_bind, lazy_bind, export. Two constants exist: LC_DYLD_INFO = 0x22 and LC_DYLD_INFO_ONLY = 0x80000022. The high bit is LC_REQ_DYLD, which loader.h defines as: if dyld meets a required command it does not understand, it issues an "unknown load command required for execution" error and refuses the image. Unknown commands without the bit are simply ignored.
The rebase opcodes (high nibble = opcode, low nibble = immediate):
| Byte | Opcode | Effect |
|---|---|---|
| 0x00 | DONE | End of stream |
| 0x10 | SET_TYPE_IMM | Type = nibble (1 pointer, 2 text absolute, 3 text pcrel) |
| 0x20 | SET_SEGMENT_AND_OFFSET_ULEB | Segment = nibble (0-based), then uleb offset |
| 0x30 | ADD_ADDR_ULEB | Offset += uleb |
| 0x40 | ADD_ADDR_IMM_SCALED | Offset += nibble × 8 |
| 0x50 | DO_REBASE_IMM_TIMES | Emit nibble rows, each +8 |
| 0x60 | DO_REBASE_ULEB_TIMES | Emit uleb rows |
| 0x70 | DO_REBASE_ADD_ADDR_ULEB | Emit one row, then offset += 8 + uleb |
| 0x80 | DO_REBASE_ULEB_TIMES_SKIPPING_ULEB | Emit uleb rows, gap = uleb |
The bind stream uses the same shape with its own opcodes: DONE 0x00, SET_DYLIB_ORDINAL_IMM 0x10, SET_DYLIB_ORDINAL_ULEB 0x20, SET_DYLIB_SPECIAL_IMM 0x30, SET_SYMBOL_TRAILING_FLAGS_IMM 0x40 (nibble = symbol flags: weak-import 0x1, non-weak-definition 0x8), SET_TYPE_IMM 0x50, SET_ADDEND_SLEB 0x60, SET_SEGMENT_AND_OFFSET_ULEB 0x70, ADD_ADDR_ULEB 0x80, DO_BIND 0x90, then DO_BIND_ADD_ADDR_ULEB 0xA0, DO_BIND_ADD_ADDR_IMM_SCALED 0xB0, DO_BIND_ULEB_TIMES_SKIPPING_ULEB 0xC0. Special ordinals from 0x30 sign-extend the nibble: 0 is self, 0xF is −1 (main executable), 0xE is −2 (flat lookup), 0xD is −3 (weak lookup).
The third stream, weak_bind, is the same opcodes "sorted alphabetically by symbol name" so dyld can walk every image in order and detect collisions after binding — loader.h's example: calls to operator new first bind normally, then get rebound if some image overrides it. The fourth, lazy_bind, holds one DONE-terminated entry per lazy symbol: the stub helper pushes the entry's offset, dyld adds it to lazy_bind_off and binds on first call. The fifth stream, export_off, is the next lesson.
The classic-era sample: libasyncProfiler.dylib (x86_64 slice of a FAT file) has LC_DYLD_INFO_ONLY and no chained-fixups or exports-trie commands. otool prints the five streams:
cmd LC_DYLD_INFO_ONLY
cmdsize 48
rebase_off 573440
rebase_size 248
bind_off 573688
bind_size 2664
weak_bind_off 576352
weak_bind_size 1120
lazy_bind_off 577472
lazy_bind_size 4656
export_off 582128
export_size 1432The rebase stream starts exactly at __LINKEDIT's fileoff (573440). First bytes:
00090000: 1121 0055 4470 1854 4557 4470 1852 4260 .!.UDp.TEWDp.RB` 00090010: 1141 5741 5b30 e00c 5841 7008 5241 5841 .AWA[0..XAp.RAXA
Decode: 11 set type pointer; 21 00 segment 1 (__DATA_CONST, the second LC_SEGMENT_64), offset 0; 55 rebase five times — slots 0, 8, 16, 24, 32 — the front of (__DATA_CONST,__got), whose vmaddr is 0x7c000; 44 add 4 × 8 → offset 72; 70 18 rebase once at 72 then jump +8+24. Fully decoded: 644 rebase targets, all type 1, all in segments 1 and 2 — self-pointers in __got, __mod_init_func, and __data needing the slide.
The bind stream (file offset 573688):
000900f8: 1140 5f5f 5f73 7461 636b 5f63 686b 5f67 .@___stack_chk_g 00090108: 7561 7264 0051 7180 0290 405f 6578 6974 uard.Qq...@_exit
11 ordinal 1 (first LC_LOAD_DYLIB — /usr/lib/libSystem.B.dylib); 40 then the NUL-terminated name ___stack_chk_guard (nm confirms: U ___stack_chk_guard); 51 type pointer; 71 80 02 segment 1, uleb offset 256 — inside __got; 90 DO_BIND: dyld resolves the symbol in libSystem and writes it there. Then 40 _exit, 80 08 add 8, 90 bind the next eager import. 120 eager binds total; the lazy stream opens 72 00 11 40 __Unwind_Resume 00 90 00 — set segment 2 (__DATA), ordinal 1, name, bind, DONE — one entry per lazy stub, 166 in all; weak_bind (1120 bytes) opens directly with a symbol name, no ordinal preamble.
$ /usr/lib/llvm-21/bin/llvm-otool -arch x86_64 -l libasyncProfiler.dylib | grep -A12 LC_DYLD_INFO_ONLY
$ xxd -s $((0x4000 + 573440)) -l 32 libasyncProfiler.dylib
00090000: 1121 0055 4470 1854 4557 4470 1852 4260 .!.UDp.TEWDp.RB`What to look for: rebase_off equals __LINKEDIT's fileoff (the streams sit at the segment's front); the stream's first byte is an opcode, not a struct magic — start decoding at 11 21 00 and the rows should land in (__DATA_CONST,__got). Remember the 0x4000: this sample is a FAT file, so slice-relative offsets need the fat_arch offset added.
Without looking back: which of the five streams moves this image's own pointers after ASLR, and which one fills GOT slots with addresses from other images?
You are mid-decode of a bind stream and hit byte 0x12. What does it mean?
The pattern: read the high nibble as the opcode and the low nibble as its argument — then watch the five state columns, not the raw bytes.
Four of the five streams are consumed entirely by dyld at load time; the fifth (export_off, 1432 bytes here) is the image's outward face — every name other binaries will bind against.
Next: The Export Trie — the prefix-sharing tree dyld walks to answer "does this dylib export that symbol?", and the structure modern binaries promote into its own load command.