Back to course

CPU Types and Subtypes

12 minintermediateModule 2: HeadersContinues from macho-header
12 min · Module 2: Headers

Why This Matters

Try to launch an Intel-only binary on Apple Silicon (or the reverse) and macOS refuses with "Bad CPU type in executable." That verdict comes from two 4-byte header fields: cputype and cpusubtype. They are also how lipo, the FAT parser, and dyld's slice picker choose which code in a universal file is allowed to run on the machine in front of them.

Misread these values and you will "run" x86 code on arm64 — or decide a slice is compatible when its capability bits say otherwise. The encoding is simple once you see that both fields pack a base value plus flags.

A Simple Model

Two questions, two fields:

  1. cputype — which architecture family and ABI width? A small base number (7 = x86, 12 = ARM) with capability bits ORed into the high byte: 0x01000000 means "64-bit ABI." So x86_64 is not a new number — it is 7 with the ABI64 bit: 0x01000007.
  2. cpusubtype — which variant within the family? A low value selecting the instruction-level baseline (3 = any x86_64, 0 = any arm64, 2 = arm64e) plus its own capability bits in the top byte, masked by CPU_SUBTYPE_MASK = 0xFF000000. CPU_SUBTYPE_LIB64 = 0x80000000 is the one you will see on x86_64 executables.

The model's missing piece: the loader masks off the capability byte before choosing a slice — "ALL" subtypes mean "runs on any hardware of this type," while capability bits annotate how the binary was built (64-bit libraries, pointer authentication).

The Actual Detail

Apple's <mach/machine.h> defines the constants (cputype is integer_t, 4 bytes, stored little-endian in thin files):

ConstantValueHow it is formed
CPU_TYPE_X867base family number
CPU_TYPE_X86_640x01000007 (16777223)CPU_TYPE_X86 | CPU_ARCH_ABI64 (0x01000000)
CPU_TYPE_ARM12base family number
CPU_TYPE_ARM640x0100000C (16777228)CPU_TYPE_ARM | CPU_ARCH_ABI64
CPU_SUBTYPE_X86_64_ALL3runs on any x86_64 hardware
CPU_SUBTYPE_ARM64_ALL0runs on any arm64 hardware
CPU_SUBTYPE_ARM64E2arm64e — pointer-authenticated ABI
CPU_SUBTYPE_LIB640x80000000capability bit ORed into the subtype's high byte

In real headers: prog64.macho stores cpusubtype bytes 03 00 00 80 = 0x80000003 — subtype 3 (X86_64_ALL) with the LIB64 capability bit, which otool helpfully splits into subtype ALL and caps 0x80. prog_arm64.macho stores subtype 0 (ARM64_ALL), caps 0x00. In the FAT tables, universal.macho's x86_64 arch carries 0x80000003 while its arm64 arch carries plain 0.

Common mistake. Do not treat cpusubtype as a strict equality check. CPU_SUBTYPE_X86_64_ALL (3) means "any x86_64 CPU," so the loader compares subtypes with masking rules from machine.h — capability bits live above CPU_SUBTYPE_MASK and the low bits express a baseline, not a SKU.

A Real Example

The same twelve bytes from our two thin executables — only cputype and subtype differ:

prog64.macho (x86_64):
00000000: cffa edfe 0700 0001 0300 0080 0200 0000  ........

prog_arm64.macho (arm64):
00000000: cffa edfe 0c00 0001 0000 0000 0200 0000  ........
  1. Both: CF FA ED FE — same magic, same 64-bit little-endian regime.
  2. Bytes 4–7: 07 00 00 01 = 0x01000007 (x86_64) vs 0C 00 00 01 = 0x0100000C (arm64). Same base-plus-ABI64 construction, different base (7 vs 12).
  3. Bytes 8–11: 03 00 00 80 = 0x80000003 (X86_64_ALL | LIB64) vs 00 00 00 00 = 0 (ARM64_ALL).
  4. Byte 12 onward: filetype 2, ncmds 14 vs 15 — headers otherwise follow the same shape.

These are exactly the two slices inside universal.macho — the FAT arch entries store the same pairs (0x01000007/0x80000003 and 0x0100000C/0) in big-endian.

Try It

Compare architectures across every sample at once:

$ for f in *.macho *.o; do echo "== $f"; xxd -l 12 "$f" | head -1; done
== prog64.o
00000000: cffa edfe 0700 0001 0300 0000  ....
== prog_arm64.o
00000000: cffa edfe 0c00 0001 0000 0000  ....

$ llvm-readobj --file-headers prog_arm64.macho | grep Cpu
  CpuType: Arm64 (0x100000C)
  CpuSubType: CPU_SUBTYPE_ARM64_ALL (0x0)

What to look for: bytes 4–7 are the whole architecture story — 07 00 00 01 versus 0C 00 00 01, little-endian, identical position in every 64-bit Mach-O ever made. Bytes 8–11 then refine the variant and carry the capability byte.

Check Your Understanding

Without looking back: how is CPU_TYPE_ARM64 (0x0100000C) constructed, and what does the high byte of a cpusubtype like 0x80000003 carry?

Apply It

A FAT header entry reads cputype 0x01000007, cpusubtype 0x80000003. Which slice is this, and how would a tool display the subtype?

The pattern: cputype = family | ABI-width bits; cpusubtype = variant | capability bits in the top byte. Mask before you compare.

Connect

cputype and cpusubtype are not only header decoration — they are the selection keys of a container format. When one file must carry both an x86_64 slice (0x01000007) and an arm64 slice (0x0100000C), those pairs are written into a big-endian table up front, and the loader picks by them.

Next: Universal (FAT) Binaries — fat_header, fat_arch, slice offsets and alignment, walked through the real universal.macho and libasyncProfiler.dylib.