Back to course

Universal (FAT) Binaries

15 minintermediateModule 2: HeadersContinues from macho-cputypes
15 min · Module 2: Headers

Why This Matters

Apple transitioned Macs from Intel to Apple Silicon without splitting the ecosystem in two: one download, one .dylib, both architectures. The file format that made that possible is the FAT (or "universal") container — a small big-endian table up front pointing at two or more complete Mach-O slices inside the same file. file(1) on our samples prints it plainly: "Mach-O universal binary with 2 architectures."

Every tool in the chain speaks FAT: lipo creates and inspects them, the kernel reads the arch table to pick a slice before dyld ever runs, and code-signing signs each slice independently. If you have ever wondered where "does not contain a binary compatible with your CPU" comes from, it starts here.

A Simple Model

Think of a FAT file as a table of contents followed by stacked payloads:

  1. A fat_header — 8 bytes: magic 0xCAFEBABE, then nfat_arch, the number of table rows that follow.
  2. nfat_arch fat_arch rows — 20 bytes each: cputype, cpusubtype, offset, size, align. Row N says "a complete thin Mach-O lives at file offset X, Y bytes long, aligned to 2^align."
  3. The slices — each one a normal thin Mach-O (magic CF FA ED FE, mach_header_64, its own load commands), padded to its declared alignment. The gaps read as zeros — fat.h allows them to be filesystem holes.

The model's missing piece: every fat field is big-endian — the opposite regime from the little-endian slices they describe. fat.h: "All structures defined here are always written and read to/from disk in big-endian order." Read the table little-endian and every offset lands in the wrong place.

The Actual Detail

The two structs from <mach-o/fat.h>, all big-endian:

StructFieldSizeWhat it says
fat_headermagic40xCAFEBABE (FAT_MAGIC)
fat_headernfat_arch4number of fat_arch rows
fat_archcputype4slice architecture (e.g. 0x01000007)
fat_archcpusubtype4slice variant (e.g. 0x80000003)
fat_archoffset4file offset of the slice's mach magic
fat_archsize4byte length of the whole slice
fat_archalign4alignment exponent: offset must honor 2^align

Verified on our samples — universal.macho (66304 bytes, built with llvm-lipo -create):

Rowcputypecpusubtypeoffsetsizealign
arch 00x01000007 (x86_64)0x800000034096126162^12 = 4096
arch 10x0100000C (arm64)032768335362^14 = 16384

Slice 0 is byte-for-byte the 12616-byte prog64.macho, slice 1 the 33536-byte prog_arm64.macho — verify by reading offset 4096: CF FA ED FE again, a full little-endian thin file nested in the big-endian table. The signed production sample libasyncProfiler.dylib (1446144 bytes) follows the same shape: x86_64 slice at 0x4000 (size 0xB2890, align 2^14), arm64 at 0xB8000 (size 0xA9100, align 2^14), with the zero padding between the 48-byte arch table and offset 0x4000 there to honor that 2^14-byte alignment.

Common mistake. align is an exponent, not a byte count. align = 12 means 4096-byte alignment (2^12), not "aligned to 12 bytes." Reading it as a raw size is the fastest way to compute a wrong slice boundary.

A Real Example

The first 48 bytes of universal.macho — the header and both arch rows:

00000000: cafe babe 0000 0002 0100 0007 8000 0003  ................
00000010: 0000 1000 0000 3148 0000 000c 0100 000c  ......1H........
00000020: 0000 0000 0000 8000 0000 8300 0000 000e  ................
  1. Bytes 0–3: CA FE BA BE — FAT_MAGIC, read big-endian.
  2. Bytes 4–7: 00 00 00 02 — nfat_arch = 2 slices follow.
  3. Bytes 8–27 (arch 0): cputype 0x01000007, subtype 0x80000003, offset 00 00 10 00 = 4096, size 00 00 31 48 = 12616, align 00 00 00 0C = 12.
  4. Bytes 28–47 (arch 1): cputype 0x0100000C, subtype 0, offset 00 00 80 00 = 32768, size 00 00 83 00 = 33536, align 00 00 00 0E = 14.

Both rows fit in 48 bytes: 8-byte fat_header + 2 × 20-byte fat_arch. The table ends at offset 48; everything until 4096 is zero padding to arch 0's alignment.

Try It

Inspect any FAT file two ways — raw bytes and the tool built for the job:

$ xxd -l 48 universal.macho
00000000: cafe babe 0000 0002 0100 0007 8000 0003  ........

$ llvm-lipo -info universal.macho
Architectures in the fat file universal.macho are: x86_64 arm64

$ xxd -s 4096 -l 4 universal.macho
00001000: cffa edfe ....

What to look for: nfat_arch and lipo's architecture count agree (2), each fat_arch cputype matches the slice's own mach_header cputype at its offset, and the slice magic flips back to little-endian CF FA ED FE. Big-endian table, little-endian payloads — both facts visible in three commands.

Check Your Understanding

Without looking back: what byte order do fat_header and fat_arch use, how big is a fat_arch, and where does the first slice's magic live?

Apply It

fat_arch[0] of universal.macho reads offset 4096, size 12616, align 12. Which statement is true?

The pattern: table first (big-endian), payload at declared offset (little-endian inside), each slice self-contained down to its own magic.

Connect

You can now enter a Mach-O from either door: thin files at magic CF FA ED FE, containers at CA FE BA BE pointing into slices with that same thin magic. Once inside a slice, the 32-byte header hands you off to the region where the format's real instructions live — the flat array of load commands at byte 32.

Next: The Load Command Area — the cmd/cmdsize walk, the LC constants every tool knows, and the real 14-command list of prog64.macho.