Back to course

Mach-O File Layout

15 minbeginnerModule 1: FundamentalsContinues from macho-intro
15 min · Module 1: Fundamentals

Why This Matters

The intro showed you the first 32 bytes of a Mach-O file. That is the entry point into the format — not the format itself. Before decoding any single field, you need the whole map: which regions exist, in what order they sit on disk, and where one ends and the next begins.

Everyone who touches a Mach-O needs this map. dyld walks it to map segments and find the entry point. A debugger walks it to find symbols. A code-signing tool rewrites part of it. Get one size wrong — trust a stale sizeofcmds, misread a fileoff — and every region after it is garbage and the file will not load.

A Simple Model

Think of a thin Mach-O file as four zones front to back:

  1. A fixed 32-byte header — magic, CPU type, file type, and the two counts that bound everything else: ncmds and sizeofcmds.
  2. The load command area — exactly sizeofcmds bytes starting at offset 32, a flat array of variable-length commands telling the loader what to do.
  3. Segment payload — the code and data itself, carved up by segments, each declaring its own fileoff and filesize.
  4. __LINKEDIT — always last: symbol tables, strings, rebase/bind info, export trie, code signature.

The model's missing piece: there is no master pointer to any of this. The header carries counts, not addresses. The only fixed fact is that load commands begin at byte 32 — every other region is discovered by walking outward from there. (ELF at least stores e_phoff and e_shoff pointers; Mach-O makes you earn it.)

The Actual Detail

Here is the exact byte map of prog64.macho (12616 bytes, MH_EXECUTE, x86_64), read from its four LC_SEGMENT_64 commands:

RegionFile rangeSizeWhat it is
mach_header_640x0000–0x001F32magic, cputype, filetype 2 (EXECUTE), ncmds 14, sizeofcmds 968, flags 0x00200085
Load commands0x0020–0x03E796814 commands; area ends at byte 1000 (32 + 968)
__PAGEZERO—0vmaddr 0, vmsize 0x100000000, filesize 0 — memory only, no file bytes
__TEXT0x0000–0x1FFF8192fileoff 0 — the header and load commands live inside this segment, plus __text, __unwind_info, __eh_frame
__DATA0x2000–0x2FFF4096fileoff 8192 — __data, __common
__LINKEDIT0x3000–0x32D7328fileoff 12288 — chained fixups, exports, symbols, strings; 12288 + 328 = 12616 = file size

Notice two things. First, __TEXT maps from file offset 0, so the header itself is part of the first segment — loader.h says the first segment of MH_EXECUTE and MH_DYLIB files contains the mach_header and load commands. Second, __PAGEZERO occupies the low 4 GiB of memory with filesize 0: a segment can be pure address space with no file behind it.

Object files play by different rules. MH_OBJECT files are compact: loader.h says all sections sit in "one unnamed segment with no segment padding." In prog64.o (992 bytes) the header is 32 bytes, the four load commands span 512, so the payload starts at exactly 544 = 32 + 512 — and otool prints the segment's segname as blank, while each section still carries its own __TEXT/__DATA segname.

A Real Example

Three cuts through prog64.macho, one per zone boundary:

00000000: cffa edfe 0700 0001 0300 0080 0200 0000  ................
00000010: 0e00 0000 c803 0000 8500 2000 0000 0000  .......... .....

Bytes 16–23: ncmds = 14, sizeofcmds = 0x3C8 = 968 — so the load command area is bytes 32 through 999, and the flags word 0x00200085 unpacks to NOUNDEFS | DYLDLINK | TWOLEVEL | PIE.

00002000: 2a00 0000 0000 0000 0000 0000 0000 0000  *...............

At file offset 8192 — the fileoff of __DATA — the first word is 2A 00 00 00 = 42. That is the source file's int global_counter = 42;, which nm reports as D _global_counter at vmaddr 0x100002000. Segment vmaddr 0x100002000 maps file byte 8192: the disk-to-memory contract in one line.

00003000: 0000 0000 2000 0000 3800 0000 3800 0000  .... ...8...8...
00003010: 0000 0000 0100 0000 0000 0000 0000 0000  ................

At file offset 12288 — __LINKEDIT's fileoff — metadata begins. The first blob is the LC_DYLD_CHAINED_FIXUPS payload (dataoff 12288, datasize 56): fixups_version = 0, starts_offset = 0x20. Symbols, strings, and the code signature all live further inside this same segment, each located by its own load command.

Try It

Any Mach-O works — on macOS use otool, on Linux llvm-otool:

$ llvm-otool -l prog64.macho | grep -E "segname|fileoff|filesize"
  segname __PAGEZERO ... fileoff 0      filesize 0
  segname __TEXT     ... fileoff 0      filesize 8192
  segname __DATA     ... fileoff 8192   filesize 4096
  segname __LINKEDIT ... fileoff 12288  filesize 328

What to look for: the four fileoffs tile the file with no gaps and no overlaps (0, 8192, 12288, end 12616), and the last segment's fileoff + filesize equals the file size exactly — __LINKEDIT always finishes the job. If your numbers do not tile, you are reading the wrong fields.

Check Your Understanding

Without looking back: where does the load command area start and end, and which header fields bound it?

Apply It

In prog64.macho, __LINKEDIT has fileoff 12288 and filesize 328. The file is 12616 bytes. What does that tell you?

The pattern: segments tile the file through their fileoff/filesize pairs, the last one landing exactly on EOF. Header, commands, payload, __LINKEDIT — in that order, every time.

Connect

You have the map now: fixed header at 0, load commands from 32, payload located per segment, __LINKEDIT last. The header region starts with four bytes that decide how every subsequent field is read — they are not the same four bytes in every Mach-O file.

Next: Magic Numbers and Byte Order — why a thin file opens CF FA ED FE, a universal file opens CA FE BA BE, and only one of them is little-endian.