Back to course

The Dynamic Linker and Entry Point

15 minintermediateModule 6: Modern Linking & EntryContinues from macho-chained-fixups
15 min · Module 6: Modern Linking & Entry

Why This Matters

The kernel maps your segments and then stops — it does not resolve a single symbol. Before any of your code runs, someone must load the dylibs, walk the fixups from the last lesson, and run initializers. That someone is dyld, and the executable has to say where it lives: this is Mach-O's PT_INTERP, the interpreter path ELF carries in a program header. And when dyld finishes, it needs a target to jump to — Mach-O's answer to ELF's e_entry and PE's AddressOfEntryPoint.

Two load commands answer both questions. Get either wrong and nothing runs: no dylinker name means dyld is never found; no entry command means dyld has nowhere to transfer control.

A Simple Model

Two commands, two directions of the same handshake:

  1. LC_LOAD_DYLINKER (0xe) — one per executable, never in a dylib: "the dynamic linker's path is /usr/lib/dyld." loader.h: "A file can have at most one of these."
  2. LC_MAIN (0x80000028) — "replacement for LC_UNIXTHREAD": a 24-byte entry_point_command with entryoff, "file (__TEXT) offset of main()", and stacksize, "if not zero, initial stack size for the main thread".

The runtime target is arithmetic, never a stored address:

jump target = image vmaddr + entryoff + slide

vmaddr is fixed by the linker, entryoff is fixed by the linker, and only slide varies per launch — which is why MH_PIE binaries recompute this every time and why dyld, not the kernel, performs the jump. Legacy binaries pre-dating LC_MAIN used LC_UNIXTHREAD (0x5) instead, embedding the full thread-state registers (including the starting RIP) directly in the load commands.

The model's missing piece: entryoff is a file offset, and only equals a __TEXT-relative position because __TEXT.fileoff is 0 in practice — the arithmetic adds vmaddr, never fileoff.

The Actual Detail

dylinker_command = {cmd, cmdsize, name}: an lc_str offset from the command's start, then the path inside the command — the same in-command string layout LC_LOAD_DYLIB uses, so the name offset is 12 (cmd 4 + cmdsize 4 + the offset field 4), not a file offset. entry_point_command = {cmd, cmdsize 24, entryoff u64, stacksize u64}. Both commands carry LC_REQ_DYLD on LC_MAIN, so an old dyld refuses rather than skips.

The header flags of our sample decode the policy around the jump — prog64.macho: ncmds 14, sizeofcmds 968, flags 0x00200085:

BitValueloader.h says
MH_NOUNDEFS0x1no undefined references (all binds resolved internally or empty)
MH_DYLDLINK0x4input for the dynamic linker — must be loaded by dyld
MH_TWOLEVEL0x80two-level namespace bindings (ordinals, from Module 5)
MH_PIE0x200000"the OS will load the main executable at a random address" — ASLR, MH_EXECUTE only
Common mistake. Treating entryoff as a runtime address or as nm's raw number. On disk it is 1104 (0x450); nm prints 0x100000450 because nm shows the preferred vmaddr. At runtime you must add the slide as well — and loader.h's comment says the field is main()'s file offset, which our samples satisfy exactly, but the guarantee you should rely on is "the offset dyld jumps to", verified against nm, not assumed.

A Real Example

Both commands from prog64.macho, as otool prints them:

     cmd LC_LOAD_DYLINKER
      cmdsize 32
         name /usr/lib/dyld (offset 12)

      cmd LC_MAIN
   cmdsize 24
  entryoff 1104
 stacksize 0

Check the arithmetic: __TEXT vmaddr 0x100000000 + entryoff 1104 = 0x100000450, and nm agrees — 0000000100000450 T _main. The arm64 twin of the same program carries entryoff 1052 against the same preferred base: 0x100000000 + 1052 = 0x10000041C, and nm -arch arm64 prints 000000010000041c T _main. Different slices, different code generation, same contract — the entry command is per-arch, which is why FAT files store complete load-command sets per slice.

Note the LCs that are absent: no LC_UNIXTHREAD (LC_MAIN replaced it), and — because prog.c defines no library calls and links no dylibs — LC_LOAD_DYLINKER is the only loader-related command. The dylib sample shows the inverse: libraries never name the dylinker ("they are loaded by dyld"), so libasyncProfiler.dylib has neither command.

Try It

$ /usr/lib/llvm-21/bin/llvm-otool -arch x86_64 -l prog64.macho | grep -A3 -E "LC_LOAD_DYLINKER|LC_MAIN"
$ /usr/lib/llvm-21/bin/llvm-nm prog64.macho | grep " T _main"
0000000100000450 T _main

$ python3 -c "print(hex(0x100000000 + 1104))"
0x100000450

What to look for: name offset is always 12 for LC_LOAD_DYLINKER (in-command layout, same as dylib names); stacksize 0 means the linker did not request a specific stack — the platform default applies; and python's sum lands on nm's _main exactly. Then subtract: if you ever get a number nm does not print, you added fileoff or forgot the vmaddr base.

Check Your Understanding

Without looking back: what kind of value is LC_MAIN's entryoff — a file position, a load-command index, or a runtime address?

Apply It

entryoff is 1104, __TEXT's vmaddr is 0x100000000, and at runtime dyld slid the image by 0xAB00000. Where does the process jump?

The pattern: entryoff is written once at link time; the jump target is recomputed every launch — vmaddr + entryoff + slide — after fixups and initializers have all succeeded.

Connect

Control transfer is the last thing dyld does with load commands before your code runs — but the commands themselves still carry the metadata macOS uses to decide whether this image may run at all: which OS floor it declares, which SDK built it, and the UUID that ties it to its dSYM.

Next: Build Versions and UUID — platform, minos, sdk, the tools that produced the file, and the 16 bytes every crash report is keyed by.