Back to course

The Startup Sequence

15 minadvancedModule 8: Loading & ExecutionContinues from macho-memory-layout
15 min · Module 8: Loading & Execution

Why This Matters

main() feels like the first instruction your program executes. It is not. Between your shell and main sit the kernel, dyld, and — in a normal binary — a startup stub that builds the argument vector before anyone asked for it. Miss that sequence and whole bug classes are unexplainable: crashes before main, constructors firing in "random" order, argv empty in a debugger that broke at entry, an exit code that does not match your return statement.

It is also the payoff for the whole course. LC_MAIN is one load command; reading it correctly requires the header, the segments, the slide, and the entry-point arithmetic you have all met. One number, four concepts deep.

A Simple Model

The sequence, from the outside in:

  1. execve: the kernel maps the segments declared by LC_SEGMENT_64, reads LC_LOAD_DYLINKER, maps /usr/lib/dyld, and hands over control.
  2. dyld's pipeline runs to completion — closure, fixups, exports, initializers — exactly the seven stages from the previous concept. Your code cannot run "during" any of it.
  3. The jump: dyld computes __TEXT.vmaddr + LC_MAIN.entryoff + slide and jumps there.
  4. Startup glue: the jump target sets up the main thread's stack frame carrying argc, argv, envp — loader.h says of the thread-command tradition that "command arguments and environment variables are copied onto that stack" — and the platform's C runtime initializes before calling main.
  5. main runs; its return value becomes the process exit status. The sequence ends where every shell script reads it: the wait status.

The model's missing piece — the classic mistake — is stage 4's target. loader.h comments LC_MAIN's entryoff as "file (__TEXT) offset of main()", and our sample takes that comment literally: the offset lands on _main itself, startup stub skipped, because this tiny link contains no CRT. An ordinary clang-linked binary points entryoff at the startup stub in libsystem instead; only after that stub packs argc/argv/envp/apple does main() finally run. Same load command, two linker choices.

The Actual Detail

The command itself — entry_point_command, "a replacement for thread_command" (the old LC_UNIXTHREAD, which carried the initial stack). Its whole body for prog64.macho:

       cmd LC_MAIN
   cmdsize 24
  entryoff 1104
 stacksize 0

cmdsize 24, entryoff 1104 (0x450), stacksize 0 meaning "use the default" (the comment: "if not zero, initial stack size"). Now the three-number identity that proves what this value is:

FactValueSource
LC_MAIN.entryoff1104 (0x450)load command 11 of prog64.macho
__TEXT vmaddr / fileoff0x100000000 / 0LC_SEGMENT_64 — fileoff 0 means offset maps 1:1 onto the base
Computed preferred entry0x100000000 + 1104 = 0x100000450arithmetic
nm: _main0000000100000450 T _mainthe symbol table — exact match

Three independent readings agree to the byte: this binary's entry is main. Notice also where 1104 sits — past the load commands (which end at byte 999), inside __text (file 1040–1156): entryoff addresses code, never metadata. And 0x100000450 is already past __mh_execute_header at 0x100000000 — the image-base marker symbol, Mach-O's sibling of PE's ImageBase.

Before main, without main. Anything in stages 1–4 can fail: an unmapped dependency, a failed fixup, a refusal over an unknown LC_REQ_DYLD command — all reported as dyld errors with your binary's name but none of your code on the stack. "Crashes before main" is a load-time diagnosis, not a debugging session.

A Real Example

Follow one instruction pointer from file bytes to first opcode. The 24 bytes at file offset 0x450 — entryoff itself as a file position:

00000450: 5548 89e5 4883 ec10 c745 fc00 0000 00bf  UH..H....E......
00000460: 0100 0000 be02 0000                      ........

55 = push %rbp, 48 89 E5 = mov %rsp,%rbp, 48 83 EC 10 = sub $0x10,%rsp — the standard function prologue, main's own, not a startup stub's. There is no CRT shim between dyld's jump and this push, because this link has no CRT to put there. Disassembled with addresses:

100000450: 55                        pushq   %rbp
100000451: 48 89 e5                  movq    %rsp, %rbp
100000454: 48 83 ec 10               subq    $0x10, %rsp
100000458: c7 45 fc 00 00 00 00      movl    $0x0, -0x4(%rbp)
10000045f: bf 01 00 00 00            movl    $0x1, %edi

The last two lines are our source's main getting ready to call add(1, 2) — the program text you read in prog.c, at the address LC_MAIN predicted. Runtime corollary: with a slide of 0x4000 every one of these bytes lives at 0x100004450 instead; the file never changes, the jump target does.

Try It

Prove the identity on any executable — on macOS the linker tools are otool, nm, objdump; LLVM ships llvm- prefixed versions:

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

$ llvm-nm prog64.macho | grep -E "_main|__mh"
0000000100000000 T __mh_execute_header
0000000100000450 T _main

$ llvm-objdump -d prog64.macho | grep -A4 "^100000450"
100000450: 55                        pushq   %rbp
100000451: 48 89 e5                  movq    %rsp, %rbp
100000454: 48 83 ec 10               subq    $0x10, %rsp
100000458: c7 45 fc 00 00 00 00      movl    $0x0, -0x4(%rbp)
10000045f: bf 01 00 00 00            movl    $0x1, %edi

What to look for: python's sum lands on nm's _main address exactly, and the disassembly starts with a function prologue — entry is a callable function, not a table or a trampoline. On your own binaries, read entryoff from otool first: if it lands on a symbol named start rather than main, you are looking at the CRT-era behavior this lesson described.

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 0x4000. At what address does the process jump?

The pattern: entryoff is written once, at link time; the jump target is recomputed every launch — vmaddr + entryoff + slide — and everything before it must succeed first.

Connect

That is the full arc: 32 header bytes that choose the parser, load commands that map, protect, and fix; a signature and UUID that attest; a dSYM that remembers; dyld that performs; a layout that slides; and one offset that fires. The bytes are still on disk exactly as you first read them — every lesson in this course was a different projection of the same file.

Back to the course: all 24 concepts, the samples to practice on, and the spec sections each claim came from.