The dyld_info streams work, but they sit in __LINKEDIT while the pointers they describe sit in __DATA_CONST — far apart, and dyld must read them before anything runs. fixup-chains.h states the modern goal outright: the per-segment starts are "passed down to the kernel for page-in linking", so a page's fixups can be applied the moment that page faults in, without hunting through a side table. ELF's answer to the same pressure was DT_RELR bitmaps; Apple's answer is to stop treating fixup locations as data at all — the locations are the pointer slots, linked into chains.
This is also the structural break between the course's two sample eras: libasyncProfiler.dylib carries four separate streams and no chain command; prog64.macho carries chains and a trie and no LC_DYLD_INFO. One load command, LC_DYLD_CHAINED_FIXUPS, replaces rebase, bind, weak_bind, and lazy_bind.
Three regions, all offsets from the start of one blob:
Then the trick: every pointer slot packs a next distance to the following slot on its page plus a one-bit bind tag. Walk the page following next-hops; at each slot read bind — 0 means "this is a rebase: the other bits hold a target", 1 means "this is an import: the other bits hold an ordinal into the imports table". One pointer, two interpretations, selected by one bit.
The model's missing piece: next is a small distance, not an address — chains die at the end of the page (0 = last element), and slots that need no fixup are simply not linked.
The blob opens with dyld_chained_fixups_header — seven u32s, verbatim from the header: fixups_version (0), starts_offset, imports_offset, symbols_offset, imports_count, imports_format (1 = DYLD_CHAINED_IMPORT, 2 = +addend s32, 3 = +addend64), symbols_format (0 uncompressed, 1 zlib). Then dyld_chained_starts_in_image {seg_count, seg_info_offset[]}, where each non-zero entry points at a dyld_chained_starts_in_segment: size, page_size (u16, 0x1000 or 0x4000), pointer_format (u16), segment_offset (u64), max_valid_pointer, page_count, then page_start[] — each entry the first chain's offset within that page, or 0xFFFF (DYLD_CHAINED_PTR_START_NONE) for a page with no fixups.
pointer_format picks the slot bit layout. The header's enum includes DYLD_CHAINED_PTR_ARM64E = 1 (stride 8, arm64e pointer authentication), DYLD_CHAINED_PTR_64 = 2 ("target is vmaddr" — plain 64-bit userland, this course's samples' format family), DYLD_CHAINED_PTR_32 = 3, DYLD_CHAINED_PTR_64_OFFSET = 6 (target is a vm offset), and DYLD_CHAINED_PTR_ARM64E_USERLAND24 = 12 (24-bit bind ordinals). For PTR_64, the two overlay structs from fixup-chains.h:
dyld_chained_ptr_64_rebase: target:36, high8:8, reserved:7, next:12, bind:1 — bind == 0
dyld_chained_ptr_64_bind: ordinal:24, addend:8, reserved:19, next:12, bind:1 — bind == 1
Both carry "4-byte stride" next fields per the header's comments; the rebase target is a 36-bit vmaddr (64 GiB image ceiling), the bind ordinal indexes imports_format 1's record — dyld_chained_import {lib_ordinal:8, weak_import:1, name_offset:23} — the same ordinal space as the bind opcodes, now in a struct instead of a stream.
prog64.macho: LC_DYLD_CHAINED_FIXUPS at dataoff 12288, datasize 56 — the entire payload:
00003000: 0000 0000 2000 0000 3800 0000 3800 0000 .... ...8...8... 00003010: 0000 0000 0100 0000 0000 0000 0000 0000 ................ 00003020: 0400 0000 0000 0000 0000 0000 0000 0000 ................ 00003030: 0000 0000 0000 0000 ........
Little-endian u32s: fixups_version 0, starts_offset 32, imports_offset 56, symbols_offset 56, imports_count 0, imports_format 1, symbols_format 0. At byte 32: seg_count 4, then seg_info_offset [0, 0, 0, 0] — every segment reports no chains, so no pointer_format or page_start arrays exist at all. imports_offset = symbols_offset = datasize = 56: both tables start exactly where the blob ends — zero records, zero strings. The four zero u32s past the starts table are alignment padding to the declared size.
Why empty? Look at prog.c: one initialized int, one zero-fill int, one static helper, arithmetic between them — every reference is RIP-relative or intra-image, zero undefined symbols (the header carries MH_NOUNDEFS), zero binds, and self-pointers that x86-64's PC-relative code never creates. ld64 still emits the command — modern toolchains always do — with a valid header describing nothing to fix. The arm64 twin repeats the trick: dataoff 32768, datasize 56, same skeleton. Contrast the dylib, which has 644 rebases and 120 binds worth of chains on a real application — its equivalent blob would be full of seg_info_offset values and per-page starts.
$ xxd -s 12288 -l 56 prog64.macho
00003000: 0000 0000 2000 0000 3800 0000 3800 0000 .... ...8...8...
$ python3 -c "import struct; d=open('prog64.macho','rb').read(); print(struct.unpack_from('<7I', d, 12288))"
(0, 32, 56, 56, 0, 1, 0)What to look for: unpacking '<7I' gives the header in field order — three of the five middle values are the numbers 32/56/56 you can see repeated as 20 00 … 38 00 … 38 00 in the dump. Then repeat both commands against a dylib built with -fixup_chains on your own machine: imports_count will be non-zero, imports_offset will point inside the blob, and seg_info_offset entries will no longer all be zero.
Without looking back: dyld walks a chain and finds a slot whose lowest bit is 1. What are the other bits?
A chained-fixups header reports imports_offset = 56, symbols_offset = 56, and datasize = 56. What do you conclude?
The pattern: every offset in this blob is relative to the blob; equal offsets mean empty regions; imports_count is the count that makes them matter.
With fixups (rebase and bind) and exports (the trie) resolved, dyld's remaining job on a linked executable is administrative: find its own code path, decide where the process starts, and jump.
Next: The Dynamic Linker and Entry Point — LC_LOAD_DYLINKER names /usr/lib/dyld, LC_MAIN names the entry file offset, and the slide arithmetic turns both into runtime addresses.