Files
Snapintosh/docs/BOOT-CHAIN.md
T
Acelogic 1510d8c6ce Snapintosh public release
An EL2 monitor/hypervisor and toolchain that boots macOS 27 on the ASUS A16
(Snapdragon X2 Elite), with an Adreno Metal plug-in and IOGPU kext, and
air2ir3, an AIR to Mesa ir3 shader compiler.

MIT, with GPL-2.0 components listed in LICENSE and THIRD_PARTY_NOTICES.md.
No Apple, Qualcomm, ASUS or Microsoft binaries are included.
2026-10-21 21:26:36 -04:00

22 KiB

Booting stock XNU on the Snapdragon X2 Elite

This document explains how Snapintosh boots the unmodified macOS 27 kernelcache (build 26A428, board j700ap, SoC T8140, kernelcache.release.mac17p) on a Snapdragon X2 Elite (X2E-96-100). It covers the privilege layout, how the monitor stands in for SPTM and TXM, page-table translation, the boot structures and device tree, CPU topology, interrupts and timers. For the overall component map see ARCHITECTURE.md; for the list of opt-in kernel-code changes see KERNEL-PATCHES.md; for why the hardware needs any of this see FEASIBILITY.md.

No XNU is recompiled. Drivers for the Snapdragon's own hardware come from kexts, the way a Hackintosh does it. Apple's firmware set is read from a restore image you supply.

1. What Apple's boot chain assumes, and what this machine has

Apple silicon has no EL3. Its privilege stack is EL0-EL2 plus GXF (Guarded eXecution Feature), which adds a guarded shadow of each exception level, entered with genter and left with gexit:

Level Normal world Guarded world
EL2 / GL2 XNU (under VHE) SPTM: owns every page table
EL1 / GL1 - TXM: on Apple hardware, responsible for code-signing policy
EL0 / GL0 user space -

On Apple silicon iBoot hands over to SPTM, which builds the kernel's initial address space and enters XNU. The Snapdragon has no GXF, cannot walk a 16 KiB translation granule, and does not implement Apple's private instructions or system registers. Snapintosh therefore runs this stack instead:

Level Component Role
EL2 UEFI firmware, then q1n1 q1n1 is a UEFI EL2 proxy derived from Asahi Linux's m1n1; it receives the monitor and guest image over USB and starts them
EL2 Snapintosh monitor (probes/xnuboot.c) stands in for SPTM and TXM, emulates Apple devices, owns stage 2 and the shadow page tables
EL1 stock XNU believes it runs at EL2; FEAT_NV2 makes that work
EL0 macOS user space unchanged

The loader (probes/xnuboot.py) prepares everything on the host: the rebased kernelcache, XNU's initial 16 KiB tables, the boot structures and the device tree. The monitor then does what can only be done at EL2.

Apple mechanism On this silicon Replacement
16 KiB translation granule absent (measured) 4 KiB shadow tables built at EL2, both halves of the address space
genter into GL2/GL1 undefined encoding intercepted at EL2 (section 4)
SPTM / TXM no guarded world the monitor implements their call interfaces
Apple IMPDEF system registers undefined encodings HCR_EL2.TIDCP traps them to EL2
XNU expects to be EL2 EL2 belongs to the monitor FEAT_NV2 redirects its EL2 register accesses to memory

2. Inputs

tools/extract-firmware.py reads BuildManifest.plist for the board, so the components it extracts are the ones Apple ships together: the kernelcache (an MH_FILESET of 362 kexts), SPTM (sptm.t8140.release), TXM, and DeviceTree.j700ap. SPTM and TXM are never executed on the Snapdragon; the monitor implements them. Their binaries are used as references, for example tools/sptm-dispatch.py decodes SPTM's own endpoint table to map endpoint numbers to Apple's SPTM_FUNCTIONID_* names.

tools/kc-load.py lays the segments out and applies all 1,001,004 LC_DYLD_CHAINED_FIXUPS rebases. Without that pass every pointer in __DATA_CONST and __DATA is a packed rebase field, not an address. The kernelcache carries a __DATA_SPTM segment with no compile-time pointers: it is the window SPTM fills at boot, and here the loader fills it.

3. Exception levels: XNU at EL1, believing it is EL2

On Apple silicon HCR_EL2.E2H is RES1 and the kernel runs at EL2 under VHE, with SPTM in the guarded shadow of EL2. The kernelcache accordingly writes EL2 registers such as SMPRIMAP_EL2. At EL1 those accesses are undefined, and an undefined instruction at EL1 is delivered to EL1, where the hypervisor never sees it.

The X2 Elite implements FEAT_NV2 (ID_AA64MMFR2_EL1.NV = 2). With HCR_EL2.{NV, NV2} set and a VNCR_EL2 page per CPU, EL1 accesses to EL2 registers become loads and stores to that page. XNU runs unmodified and its view of "EL2 state" lands in memory the monitor can read. VNCR_EL2 takes an address in EL2's own translation regime, not the guest's.

Other identity choices:

  • The ID register group is passed through from the hardware with features the monitor cannot stand behind cleared (probes/idregs.h). Apple IMPDEF registers trap through HCR_EL2.TIDCP; unknown ones read as zero and drop writes, and are logged.
  • AMXIDR_EL1 must advertise a version or XNU panics; the monitor reports one while the AMX enable register stays zero, so XNU never uses AMX. See APPLE-ENCODINGS.md.
  • VMPIDR_EL2 gives each vCPU the affinity of its cpu node in the device tree. MIDR_EL1 is left as Qualcomm's: the kernel reads it once.
  • The monitor manages the guest's pointer-authentication keys across the EL1/EL2 boundary.

4. Monitor calls: standing in for SPTM and TXM

The interface

XNU reaches its monitors with genter (0x00201420). Every call site is a uniform stub that builds a routing word in x16 from immediates - domain[55:48], table[39:32], endpoint[31:0] - marshals eight arguments, and executes either genter or a paired alternate encoding, 0xd4e00000. A byte in __DATA_CONST chooses between them, and XNU copies that byte from a feature bit in SPTM's boot structure (section 6). Because the monitor builds that structure, it keeps XNU on genter.

tools/kc-analyze.py replays each stub's writes to x16 and lists the whole interface. All 348 call sites sit in five 4 KiB pages:

Domain Table Name Stubs
0 0 XNU_BOOTSTRAP (endpoint from x0) 56
0 3 T8110_DART_XNU 19
0 5 SART 3
0 6 NVME 10
0 7 UAT (GPU MMU) 14
0 9 CPUTRACE 13
0 10 HIB 6
0 11 GEN3_DART_XNU 19
0 13 T6000_DART_XNU 16
0 14 unnamed 16
2 0 TXM_XNU 1
3 0 cL4 ringgate (exclaves) 1

Only domain 0 matters for a first boot. Exclaves are deferred.

How a call reaches EL2

An undefined instruction at EL1 cannot be routed to EL2 by any HCR_EL2 bit. The monitor has used three mechanisms, each cheaper than the last:

  1. Discovery mode. The five call pages are execute-never at stage 2, so the fetch faults to EL2 (ESR_EL2.EC = 0x20). A fetch at a known call site is serviced and returned from; any other instruction on those pages is run by lifting the protection and single-stepping one instruction (MDCR_EL2.TDE, MDSCR_EL1.SS). This needs no knowledge of the stub layout and was used to find it.
  2. Native-call mode (stock image, no code changes; compat/native-call.h, probes/nativecalls.py). The call instruction executes and raises an undefined-instruction exception to XNU's own vector. XNU's vector page is execute-never at stage 2, so the vector fetch traps to EL2. The monitor identifies the call by the faulting PC against a pinned inventory of call sites, services it, and restores EL1's exception registers. The few kernel instructions that write ELR_EL1/SPSR_EL1 directly are handled on their own guarded pages. No call page is single-stepped.
  3. HVC call entry (opt-in kernel patch 1). The 348 call words are replaced by HVC #0x5350, one instruction each, so a call is a single direct exit to EL2 with no EL1 exception state to repair.

The image is pinned by digest; a different kernelcache is refused rather than patched.

What the monitor implements

The endpoint implementations live in compat/sptm.c and the compat/sptm-*.c files (mapping, unmapping, updates, references, slides, DART), with compat/txm.c for the TXM domain. The main groups:

  • Bootstrap: FIXUPS_COMPLETE (the loader already applied them), REGISTER_CPU, REGISTER_EXC_RETURN, CPU_ID (a 16-bit per-CPU slot), SERIAL_PUTC and SERIAL_DISABLE (early console through the monitor), LOCKDOWN (acknowledged; the monitor does not enforce CTRR-style text immutability).
  • Page tables: MAP_PAGE, UNMAP, UPDATE_REGION, UPDATE_DISJOINT, SLIDE_REGION and retype operations, which keep XNU's 16 KiB view and the 4 KiB shadow consistent as one transaction (section 5).
  • Frame table: one entry per physical page holding a type and reference counts; zero (untyped) is the honest initial state.
  • Per-CPU output area (OUTPUT_AREA, endpoint 0x32): returns the virtual address of a per-CPU scratch page into which MAP_PAGE writes the replaced entry and the entry's address. Zero is the one value Apple's SPTM refuses to return, and the address must be one the guest can translate.
  • DART, NVMe and UAT tables, so XNU's IOMMU and storage drivers can program their translation through the monitor as they would on Apple hardware.
  • TXM domain: answered by the monitor at EL2. Boot-policy and signing questions are outside the scope of this document.

Behaviour that cannot be derived from XNU alone is checked against Apple's real SPTM running under QEMU: tools/qemu-sptm-oracle/setup.sh builds the jprx/qemu-sptm oracle and tools/qemu-sptm-oracle/run.sh records an operation x frame-type x PTE matrix that tools/test-sptm-oracle.c keeps the monitor honest against. See DEVICE-REFERENCES.md.

An unknown endpoint is never answered with a silent success. A call that succeeds without doing its work lets XNU proceed on state that does not exist and fail somewhere unrelated.

5. Page tables: a 16 KiB guest on a 4 KiB MMU

XNU is built for 16 KiB pages and the hardware walker only supports 4 KiB and 64 KiB granules. Virtualization does not route around that: stage 1 is walked by the same MMU, and a walk raises no exception to trap.

On an SPTM system XNU does not write page tables; it asks SPTM to, with arguments. That removes the expensive part of classic shadow paging (trapping and chasing stores into guest tables). But XNU still reads its tables directly (pmap_pte and friends dereference PTEs), so the tables it sees must really be 16 KiB tables. The monitor keeps both:

Layer Granule Written by Walked by
XNU's view 16 KiB the monitor, on each SPTM call XNU's software walker only
Shadow 4 KiB the monitor, derived from XNU's view the hardware (TTBR0_EL1/TTBR1_EL1)
Stage 2 4 KiB the monitor the hardware

shadow/mmu.c walks the 16 KiB tables in software and shadow/shadow.c emits an equivalent 4 KiB translation: a 16 KiB page becomes four 4 KiB pages, a 32 MiB block becomes sixteen 2 MiB blocks, always using the largest descriptor alignment allows. The property it is built around is that walking the shadow gives the same answer as walking the guest for every address; tools/test-shadow.c checks exactly that. After MAP_PAGE writes a guest PTE, the monitor re-reads the leaf through the guest's own tables before shadowing it, so a decoding error shows up as a disagreement rather than as two consistent wrong answers.

Details that matter:

  • TCR_EL1.TG1 is encoded differently from TG0 (TG1: 01=16K, 10=4K; TG0: 00=4K, 10=16K). One lookup table for both fields gives a shadow that is right in one half and silently wrong in the other.
  • The kernel regime is 47 bits (T1SZ = 17, as XNU's own level descriptors state), even though the kernelcache is linked at 0xfffffe0000000000. A narrower regime places top-level entries at indices the kernel never reads; see APERTURE-FAULT.md.
  • XNU also uses the low regime. It walks and then accesses an identity mapped bootstrap reserve above declared DRAM, so TTBR0_EL1 carries a second shadow built from the same guest tables.
  • The shadow's output size comes from the hardware. XNU ships TCR_EL1.IPS = 0; the monitor sets IPS from ID_AA64MMFR0_EL1.PARange and hands XNU back its own TCR value on reads (HCR_EL2.TRVM).
  • Stage 2 maps only what exists (shadow/stage2.c): declared DRAM and the reserve, the panel when handed over, and nothing else. Any other touch is a stage-2 fault at EL2 carrying the address.
  • Permissions. Apple's kernels express page permissions through SPRR permission classes rather than plain AP bits. shadow/sprr-view.c turns the classes into concrete 4 KiB permissions, with private copies of the tables for the few paths whose permissions depend on the current SPRR state.
  • Physical aperture. XNU translates physical to virtual addresses with a region table SPTM publishes (base, virtual base, page count, 24 bytes per entry). The loader publishes it, plus SPTM's own data region at its fixed virtual address.

There is also an opt-in kernel patch that creates user processes with 4 KiB pmaps, using Apple's own 4K-pages switch, so user tables need no granule translation (KERNEL-PATCHES.md).

6. Boot structures and the device tree

XNU's entry is the first word of __TEXT_BOOT_EXEC. The entry contract, read off its first instructions:

Register Meaning
x0 entry mode / CPU index (0 = boot CPU)
x1 boot_args (virtual address)
x2 SPTM boot structure (virtual address)

XNU locates its own Mach-O header from a stored link-time offset, so any slide works. It never writes SCTLR_EL1: it must be entered with translation on and the kernel already mapped at its link addresses, which on Apple hardware is SPTM's job and here is the loader's.

boot_args. x1 points at the classic 1,152-byte structure: revision, virtual and physical base, memory size, top of kernel data, video, machine type, device-tree pointer and length at +0x60/+0x68, and the 1,024-byte command line at +0x6c. The command line is the one place a stock kernel accepts new behaviour without code changes; serial=3 sends both kprintf and the console to the monitor's serial endpoint.

SPTM boot structure. x2 points at an 856-byte structure whose first quadword must be the magic 0xd00f... (a wrong value spins forever). A versioned table at +0x1a0 is copied field by field into __DATA_CONST, with the version gating how many fields are read. It carries the physical aperture table, the frame-table root, the per-CPU scratch base and the feature bit that selects the alternate call encoding. The last word is a build agreement (ARM_LARGE_MEMORY must match what the kernel was compiled with).

Device tree. The tree is Apple's DeviceTree.j700ap, edited by probes/appledt.py, which parses and re-serialises it so added properties do not invalidate later offsets. The stock tree is unfinished by design; iBoot completes it. The loader does the same:

  • 51 properties ship with a placeholder flag (bit 31 of the length). XNU does not mask it and would walk 0x80000000 bytes too far; they are resolved.
  • clock-frequency / timebase-frequency are filled in (section 9).
  • /chosen/dram-base and /chosen/dram-size do not exist in the shipped tree and are created.
  • Every cpu node ships as state = "waiting". Exactly one must say "running", or ml_parse_cpu_topology never finds the boot CPU and a release-build kernel dereferences NULL.
  • Device nodes are placed and validated against the device models (probes/devicemodel.py); nodes for hardware this machine does not have are removed or quieted.

7. CPU topology

The guest keeps the J700 six-core topology: four efficiency cores at affinities 0x0-0x3 and two performance cores at 0x100-0x101. Each vCPU is a fixed Snapdragon core; the boot CPU is started by q1n1 and the secondaries are started with PSCI CPU_ON (probes/secondary.c), each with its own VNCR_EL2 page, stack and report. VMPIDR_EL2 presents the Apple affinity.

XNU brings secondaries up through PMGR register writes. Those land in the monitor's PMGR models (compat/device.c), which release the corresponding vCPU (compat/cpu.c) rather than touching real power controllers. Reducing the topology with cpus=1 is not an option for this kernel: it trips a PMGR CPU-complex assertion earlier in boot.

Guest CPU identities describe the compatibility interface. They do not make an Oryon core an Apple core, and the pairing of Apple core classes to Snapdragon core tiers is a placement choice, not a measured equivalence.

8. Interrupts: a virtual AIC

The guest never sees the Snapdragon's GIC. It sees an Apple Interrupt Controller modelled at EL2 (compat/aic.c):

  • AIC3 layout at the address the J700 tree declares, with flat register banks, one die and one delivery cluster. The model reports revision 10 and 1,280 interrupt lines; these are deliberate virtual choices, not measured T8140 values, and are recorded in the guest profile.
  • Reading EVENT returns the highest pending source and auto-masks it. Darwin's handler completes delivery by writing MASK_CLR, not a separate EOI, so the model follows that contract.
  • Interrupts reach the guest as virtual IRQs (HCR_EL2.VI). The timer and IPIs use virtual FIQ (HCR_EL2.VF), matching Apple's convention.

Physical devices that the guest drives directly (for example the Adreno GPU) raise level-triggered GIC SPIs. compat/irq-forward.c takes the SPI at EL2, masks it at the distributor, completes it, and raises the mapped AIC line. The guest's MASK_CLR is the resample point: if the line is still pending the level stays up, otherwise the SPI is re-enabled. The model is pure and tested natively by tools/test-irq-forward.c.

IPIs. Apple's fast-IPI system registers are emulated (compat/ipi.c): local and global requests route to the target vCPU's pending state, and the guest's write-one-to-clear acknowledgement consumes it. There is no physical kick between cores, so every vCPU's EL2 timer is capped at a 1 ms poll to notice pending IPIs. The fast path for that tick (compat/fast-timer-irq.h) handles a secondary CPU's timer interrupt without the monitor's global lock.

9. Timers

The Snapdragon's generic timer runs at 19.2 MHz. The guest sees a 24 MHz counter, matching Apple hardware; timebase-frequency in the tree says 24 MHz, and the monitor scales every counter read and deadline between the two domains (compat/clock.c, integer quotient/remainder arithmetic so the freestanding monitor needs no 128-bit division). The 24 MHz choice is forced by the watchdog: its checked arithmetic overflows with the 19.2 MHz nanosecond ratio.

  • Apple's deadline timer programming is mapped onto the EL2 hypervisor timer (CNTHP_*_EL2); expiry is delivered as a virtual FIQ.
  • FEAT_ECV and CNTHCTL_EL2 trap controls keep the physical rate from leaking through the architectural virtual counter.
  • The CNTKCTL event stream is emulated in software: WFE traps to EL2, and events coalesce into one pending bit.
  • The WD1 watchdog is a timed model (compat/watchdog.c) in the guest counter domain; expiry latches a guest reset request.
  • With the root clock-frequency left at zero, XNU computes an event-stream bit index of -2 and panics in _enable_timebase_event_stream; this was the first device-tree dependency found.

10. Lessons from bring-up

Bring-up was driven by what the kernel asked for, one stop per run: each fault named the next missing piece. A few lessons generalise.

One field, two consumers. Several times a field's meaning was read correctly from one consumer and then generalised, while a second consumer had a different contract: a routing field with a fourth sub-field, a count that one validator accepts as zero while a walker dereferences the pointer anyway, a random-seed length one check accepts and another rejects, one status code returned from six places. Each failure was silent. The habits that came out of it: sweep the kernelcache for every reader of a global before concluding, make shared error answers carry which cause produced them, and check the built image rather than the code that builds it.

Hardware traps. HPFAR_EL2 reads back zero on this silicon for a stage-2 permission fault on an instruction fetch, so the address comes from AT S1E1R on FAR_EL2. The firmware leaves the OS Lock set, which silently disables single-step; the monitor clears it before arming steps. EL1's fault registers survive between runs and must be cleared before entry, or a stale fault looks current.

Read the machine after a stop. The guest is gone when a run returns but its memory is not, and every kernel virtual address in the image maps to a known physical address. Reading structures back off the target answers in one round trip what static reading takes much longer to narrow down.

11. Running without the hardware

probes/run.py xnuboot --qemu runs the identical monitor and image on QEMU -cpu max, which has a 16 KiB granule, FEAT_NV2 and working debug. It began as the control that separates "this machine lacks the feature" from "this probe is wrong". The physical layout is relative to whatever free memory the target reports, so the same build places itself on either machine.

12. Re-running the evidence

tools/extract-firmware.py <restore image> j700ap
tools/kc-load.py <kernelcache> --out build/xnu.img
tools/kc-analyze.py <kernelcache>          # call sites, pages, x16 routing
python3 tools/sptm-dispatch.py <sptm image> --verify   # endpoint names

SNAP_STAGE=6 python3 probes/run.py shadowmmu   # a call answered by EL2
SNAP_STAGE=7 python3 probes/run.py shadowmmu   # the rest of the page still runs
SNAP_STAGE=8 python3 probes/run.py shadowmmu   # upper half, at kernel addresses

python3 probes/xnuboot.py --check               # build the address space offline
python3 probes/xnuboot.py --profile             # firmware hashes, ABI offsets, layout
python3 probes/run.py xnuboot --qemu            # whole entry path, no hardware

For installed boots on the laptop, see RUNNING.md.