Back to course

Build Versions and UUID

12 minintermediateModule 6: Modern Linking & EntryContinues from macho-entry
12 min · Module 6: Modern Linking & Entry

Why This Matters

The same bytes ship to machines running different OS releases, get debugged months later, and may be rejected before they run at all. Two small load commands answer the three questions that follow: where is this allowed to run (platform + minimum OS), what was it built against (SDK, plus the tool versions that produced it), and which exact build is this (UUID). dyld consults the first to refuse old systems cleanly; crash reporters and dsymutil use the UUID to join a crashed binary to its debug file — without a matching UUID there are no symbols, only addresses.

If you have ever seen "built with SDK … requires macOS …" in an error dialog, or a crash log keyed by a 36-character hex string, these two commands are the source.

A Simple Model

Think of LC_BUILD_VERSION as a passport stamp and LC_UUID as a fingerprint:

  1. platform — which OS family (1 = macOS, 2 = iOS, … through 10 = DriverKit). One number disambiguates binaries that otherwise look identical.
  2. minos — the floor: dyld refuses the image below this OS version. sdk — the SDK it was compiled against: tells tools what APIs were available, not what runs.
  3. tools — a list of (tool, version) pairs: which clang/ld/swift produced the file.

LC_UUID is simpler still: cmd, cmdsize, and 16 raw bytes. It is not derived from content (rebuild with identical sources and it changes) — it is minted per link, per architecture slice.

The model's missing piece: minos and sdk answer different questions. A binary can carry sdk 13.3 and minos 10.15 — built today against the 13.3 SDK, still runnable on four-year-old systems — and only minos gates execution.

The Actual Detail

build_version_command = {cmd 0x32, cmdsize, platform, minos, sdk, ntools} followed by ntools × build_tool_version {tool, version} — so cmdsize = 32 + 8 × ntools for the usual ntools = 1. Versions pack three parts into one u32, loader.h's comment: "X.Y.Z is encoded in nibbles xxxx.yy.zz" — major shift 16, minor shift 8, patch in the low byte (13.3 → 0x000D0300). Tool values from the same header: TOOL_CLANG 1, TOOL_SWIFT 2, TOOL_LD 3. Platforms: PLATFORM_MACOS 1, IOS 2, TVOS 3, WATCHOS 4, BRIDGEOS 5, MACCATALYST 6, then simulators 7–9, DRIVERKIT 10.

The older form, LC_VERSION_MIN_MACOSX (0x24), is a 16-byte version_min_command {version, sdk} with no platform field — one command per OS family (LC_VERSION_MIN_IPHONEOS etc.). LC_BUILD_VERSION (0x32) replaced it when one binary could target platforms the old commands could not name. LC_UUID is 0x1b, cmdsize 24, always exactly one per slice.

Common mistake. Reading sdk as the runtime requirement. sdk records the toolchain's headers; minos records the floor dyld enforces. Second trap: expecting one UUID for a FAT file — every slice links separately, so x86_64 and arm64 of the same program carry different UUIDs (our samples do: see below), and the debugger must match the crashed architecture's UUID, not the file's first bytes.

A Real Example

The dylib sample — classic era, macOS-only, built by the linker itself:

     cmd LC_UUID
 cmdsize 24
    uuid DA4AFC52-D48E-39AB-B574-FE3B9CC5232F

       cmd LC_BUILD_VERSION
   cmdsize 32
  platform 1
       sdk 13.3
     minos 10.15
    ntools 1
      tool 3
   version 857.1

Decode: platform 1 = macOS; sdk 13.3 = 0x000D0300; minos 10.15 = 0x000A0F00; tool 3 = TOOL_LD — this stamp says ld 857.1 built the file against the macOS 13.3 SDK with a 10.15 floor. Now the executable sample — modern era, both slices from the same link:

    uuid 4C4C44C9-5555-3144-A19F-1D5685FC622F   (x86_64)
    uuid 4C4C44B1-5555-3144-A130-3397BCE0A53C   (arm64)
  platform 1   sdk 13.0   minos 13.0   ntools 1
      tool 4   version 22.1.2

sdk and minos both 13.0 — a single-platform, single-floor build. tool 4 is where honesty matters: this course's loader.h revision names only tools 1–3, so the value 4 (version 22.1.2) is recorded here as observed, without a name we can cite from the header. And the two UUIDs differ despite identical sources — per-slice identity, exactly as the model says.

Try It

$ /usr/lib/llvm-21/bin/llvm-otool -arch x86_64 -l prog64.macho | grep -A8 LC_BUILD_VERSION

$ python3 -c "v=0x000D0300; print((v>>16)&0xffff, (v>>8)&0xff, v&0xff)"
13 3 0

$ /usr/lib/llvm-21/bin/llvm-otool -arch x86_64 -l prog64.macho | grep -A2 LC_UUID

What to look for: the python unpack is the inverse of loader.h's xxxx.yy.zz rule — three numbers, one u32; otool prints sdk/minos already decoded. Then compare UUIDs across arch flags on a FAT binary: two slices, two stamps, and a crash log only makes sense once you know which one it cites.

Check Your Understanding

Without looking back: a load command's minos field reads 0x000D0300. What version is that?

Apply It

A binary reports platform 1, sdk 13.3, minos 10.15. A user runs it on macOS 11. What decides whether dyld loads it?

The pattern: platform must match, minos must be satisfied, sdk is provenance — and the UUID rides along untouched until a debugger needs it.

Connect

Knowing which build this is invites the next question: is it also which bytes the developer signed? The UUID identifies a build to tools; macOS wants a cryptographic statement identifying it to a developer — and the load command that carries it is next.

Next: Code Signing — LC_CODE_SIGNATURE, the big-endian superblob, page hashes in the CodeDirectory, and the team identity embedded at byte 101 of ours.