EL1, EL2, and Apple protected execution
Platform overview · EL0 to EL1 · Exception return · Hardware mappings
This checkout shows several privileged transitions with different implementations. Ordinary applications enter XNU through ARM64 exception vectors. A virtualized XNU kernel can issue hvc requests to its host. An SPTM-configured kernel calls a protected monitor through a separate interface. Legacy PPL code supplies another protected-call path. A diagram that labels all three simply “EL1 calls EL2” hides the distinctions needed to follow the source.
Which path is actually present?
| Path | Visible entry in this tree | Visible return | What is external or incomplete |
|---|---|---|---|
| Application ↔ XNU | ExceptionVectorsBase, lower-EL vectors, fleh_*, sleh_* |
Saved-state restore and ERET in locore.s |
Platform-specific register and protection configuration |
| Guest XNU ↔ hypervisor | hvc_5 / hvc32_4 under APPLEVIRTUALPLATFORM; PAC and power hypercalls |
Instructions after hvc collect result registers |
Host vector table, host saved context, dispatcher, and its exception-return instruction |
| XNU ↔ SPTM | SPTM_DOMAIN_ENTER at boot; sptm_* library calls; XNU pre-entry/post-exit hooks |
C/assembly continuation after the external call | Macro expansion, transport/library bodies, monitor vectors, monitor validation and return |
| XNU ↔ legacy PPL | Generated *_ppl wrapper, PPL dispatch/state/stack fragments |
PPL cleanup, AST handling, function epilogue | Protected crossing machinery is incomplete in the public assembly |
The build selects SPTM versus legacy ARM sources in files.arm64. APPLEVIRTUALPLATFORM, HAS_PARAVIRTUALIZED_PAC, XNU_MONITOR, and CONFIG_SPTM guard different code. The presence of all these files does not imply that one kernel executes all their branches.
Architectural EL1 → EL2 → EL1 contract
For an enabled AArch64 HVC taken from EL1 to EL2, hardware saves return PC in ELR_EL2, processor state in SPSR_EL2, and syndrome in ESR_EL2, then selects the lower-AArch64 synchronous vector at VBAR_EL2 + 0x400. Software must preserve the general registers it needs. EL2 restores its saved context and executes ERET; that instruction restores PC/state from ELR_EL2/SPSR_EL2. For a normally returning hypercall, the saved PC follows HVC. Configuration can disable or alter trapping, and host execution may defer a guest's resumption. This describes the architectural contract, not a host implementation found in XNU. Arm, AArch64 Exception Model, §§4.1.3 and 5.
Mermaid source
sequenceDiagram
participant X as Guest XNU at EL1
participant A as Architectural exception entry
participant H as External host at EL2
X->>X: Load function ID and arguments
X->>A: hvc
A->>H: EL2 vector and saved return state
H->>H: Preserve context and service request
H->>H: Restore context and ERET
H-->>X: Resume after hvc with result registers
X->>X: Store results and continue C caller
The middle of this diagram is deliberately an architectural/external boundary. There is no checked-in host exception frame to label arm_saved_state64: that structure belongs to the XNU exception paths documented in entry. Likewise, XNU's ret from a C wrapper is executed after the host has already returned to guest execution; it is not the instruction that lowered EL2 to EL1.
A concrete guest hypercall
hvg_hcall_get_mabs_offset() supplies VMAPPLE_GET_MABS_OFFSET and the local absolute-time offset. hvc_2() forwards two values through the five-register helper. The call's function ID is in a general register; the instruction immediate remains zero.
Source: arm64_hypercall.c, lines 254–266
hvg_hcall_return_t
hvg_hcall_get_mabs_offset(uint64_t *mabs_offset)
{
uint64_t fn = VMAPPLE_GET_MABS_OFFSET;
uint64_t x1 = ml_get_abstime_offset();
if (!hvc_2(&fn, &x1)) {
return HVG_HCALL_UNSUPPORTED;
}
*mabs_offset = x1;
return HVG_HCALL_SUCCESS;
}
The implementation copies inputs into x0–x4, executes the transition instruction, and copies the returned registers back to C storage:
Source: arm64_hypercall.c, lines 53–67
static inline bool
hvc_5(uint64_t *x0, uint64_t *x1, uint64_t *x2, uint64_t *x3, uint64_t *x4)
{
asm volatile (
"mov x0, %[i0]" "\n"
"mov x1, %[i1]" "\n"
"mov x2, %[i2]" "\n"
"mov x3, %[i3]" "\n"
"mov x4, %[i4]" "\n"
"hvc #0" "\n"
"str x0, %[o0]" "\n"
"str x1, %[o1]" "\n"
"str x2, %[o2]" "\n"
"str x3, %[o3]" "\n"
"str x4, %[o4]" "\n"
This is an excerpt of the inline-assembly statement; its remaining constraints/clobbers and success test are part of the function. The helper treats a negative returned x0 as failure for its normal status-return convention. UID queries have an explicit different sentinel rule, and hvc32_4() handles 32-bit register results. Do not extend that one success convention to every hypercall. hvg_get_uid().
hv_hvc.h composes IDs from a fast-call bit, width bit, service namespace, and function number. CPU services include paravirtualized pointer-authentication operations; OEM services include time offset, boot-session UUID, virtual-CPU wait and kick. hvg_is_hcall_available() queries host identity/version/features before selected use. The nonvirtual-platform implementations return unsupported rather than executing this helper.
For another concrete call, hvg_hc_kick_cpu() translates a logical CPU index to the topology's physical ID and calls VMAPPLE_VCPU_KICK. hvg_hc_wait_for_kick() passes its interrupt-enable argument to VMAPPLE_VCPU_WFK. These functions communicate with the external host; they are not ordinary Mach messages delivered to an ipc_port. virtual CPU calls.
Not every hvc has a normal returning contract. The virtual-platform branch of arm64_prepare_for_sleep() invokes PSCI CPU-off or system-suspend functions and places an infinite branch immediately afterward. That caller is not expecting the ordinary C-return sequence. power hypercalls. PAC assembly also issues hypercalls and checks their result before continuing: pac_asm.h.
SPTM has a distinct, partly external transition interface
For the expanded security and ownership model, see SPTM, TXM, and Exclaves execution. The entry/exit map distinguishes their ordinary calls, upcalls and interrupted-domain returns.
The SPTM boot source includes <sptm/sptm_xnu.h>. The local sptm.h includes the same external interface, and the build links sptm_xnu and TrustedExecutionMonitor_Kernel archives. Their implementations are not supplied here. Test mocks elsewhere in the tree are not the production monitor.
At cold boot XNU writes its exception-vector address, then invokes the external entry macro:
Source: start_sptm.s, lines 146–161
/**
* Now setup final XNU exception vectors. This is the closest we can do this
* in XNU because after sending SPTM SPTM_FUNCTIONID_FIXUPS_COMPLETE, VBAR
* will be validated and locked.
*/
adrp x9, EXT(ExceptionVectorsBase)@page
add x9, x9, EXT(ExceptionVectorsBase)@pageoff
msr VBAR_EL1, x9
isb
/**
* Call into the SPTM for the first time. This function traps to GL2 to
* signal the SPTM that the fixups phase has been completed.
*/
SPTM_LOAD_DISPATCH_ID SPTM_DOMAIN, SPTM_DISPATCH_TABLE_XNU_BOOTSTRAP, SPTM_FUNCTIONID_FIXUPS_COMPLETE
SPTM_DOMAIN_ENTER x16
The comment says GL2, Apple's guarded execution terminology. Preserve that qualification: this source does not show the macro becoming the guest's hvc #0, nor does it supply the monitor's complete register-bank semantics. CurrentG checks and KERNEL_MODE_* / GUARDED_MODE_* aliases are additional evidence that protected execution is more than an ordinary software call between two C functions. register aliases, pre-entry guard check.
The boot contract is concrete even without the external macro body: XNU finishes image fixups, supplies the final VBAR_EL1, reports fixup completion, and afterward expects the executable region to have its final monitor type. The source says the monitor validates and locks VBAR after that report. This is not an instruction-by-instruction account of the monitor.
XNU's entry and exit hooks protect its own execution state
_sptm_pre_entry_hook saves a frame, callee-saved temporaries, the dispatch ID in x16, and arguments x0–x7. When a current thread exists, it increments ACT_PREEMPT_CNT:
Source: locore.s, lines 2688–2700
/* Save arguments to SPTM function and SPTM function id. */
mov x20, x16
stp x0, x1, [sp, #-0x40]!
stp x2, x3, [sp, #0x10]
stp x4, x5, [sp, #0x20]
stp x6, x7, [sp, #0x30]
/* Increase the preemption count. */
mrs x9, TPIDR_EL1
cbz x9, Lskip_preemption_check_sptmhook
ldr w10, [x9, ACT_PREEMPT_CNT]
add w10, w10, #1
str w10, [x9, ACT_PREEMPT_CNT]
It performs optional scheduler-hygiene measurement, checks that the caller is not already in guarded mode, restores arguments, and returns to the library's entry sequence. This hook is not itself the missing protected transition instruction. Early boot can have no TPIDR_EL1 thread, which explains the explicit zero test. full pre-entry hook.
_sptm_post_exit_hook preserves returned x0–x7, decrements the current thread's preemption count with underflow detection, and checks whether urgent AST handling is now permitted. When it does call ast_taken_kernel, it masks exceptions for the call and restores DAIF afterward:
Source: locore.s, lines 2806–2815
/* IF there is no urgent AST, skip the AST. */
ldr x12, [x0, ACT_CPUDATAP]
ldr x14, [x12, CPU_PENDING_AST]
tst x14, AST_URGENT
b.eq Lsptm_skip_ast_taken_sptmhook
/* Handle the AST. This call requires interrupts to be disabled. */
msr DAIFSet, #(DAIFSC_ALL)
bl EXT(ast_taken_kernel)
msr DAIF, x20
The post-hook finally restores the monitor results and its stack frame. It can therefore perform XNU scheduler work after the protected call has returned, before the original caller continues. Its function epilogue is distinct from whatever return mechanism the monitor used. post-exit hook.
A mapping request shows the data crossing this interface
pmap_enter_pte() constructs/checks a mapping under the relevant reverse-map and epoch protections. It supplies the root-table physical address, virtual address, proposed PTE, and flags to sptm_map_page(). The ordinary status result and the per-CPU result area are different outputs. On success it reads the prior PTE and installed PTE pointer:
Source: pmap.c, lines 6471–6477
assert(!preemption_enabled());
const sptm_map_page_output_t *map_page_out = (const sptm_map_page_output_t *)
PERCPU_GET(pmap_sptm_percpu)->sptm_prev_ptes;
pt_entry_t *pte_p;
*old_pte = prev_pte = map_page_out->prev_pte;
*out_pte_p = pte_p = (pt_entry_t*)(map_page_out->ptep);
| Structure/value | Local role at the boundary |
|---|---|
pmap_t, especially ttep |
Identifies XNU's address-space/root-table association |
locked_pvh_t |
Carries protection for reverse mappings of the target physical page |
pmap_epoch_t |
Keeps concurrent table removal/unnesting from invalidating the operation's assumptions |
pmap_sptm_percpu_data_t.sptm_prev_ptes |
Per-CPU monitor output storage, populated from bootstrap-provided memory |
sptm_map_page_output_t |
External interface type interpreted as previous PTE plus PTE pointer |
thread.machine.preemption_count / assembly ACT_PREEMPT_CNT |
Prevents migration while the caller relies on that per-CPU storage; see machine_thread |
The local per-CPU layout is in pmap_data.h; initialization computes each CPU's result-area pointer from SPTMArgs. The call and failure cleanup are in pmap.c. These are source-visible ownership and concurrency contracts. They do not reveal how the monitor validates page types internally or programs its own privileged registers.
Resume a protected domain after XNU handles its interrupt
An interrupt can occur while a thread is executing in SPTM, TXM, or the secure kernel (SK). XNU handles the interrupt, then must resume the correct domain. During arm_init, XNU registers the stripped address of xnu_return_to_gl2 with sptm_register_xnu_exc_return. This is a return-dispatch address supplied to the monitor, distinct from the ordinary application's saved PC. registration.
The saved state visible to XNU does not necessarily contain the protected domain's real interrupted PC in its pc field. ml_get_backtrace_pc explicitly documents a fixed re-entry handler there and the actual interrupted PC in x14; it uses that value when ml_addr_in_non_xnu_stack recognizes the saved frame pointer. In the SPTM branch, that helper checks the bootstrap-supplied SPTM per-CPU and TXM thread-stack ranges; otherwise the accessor returns the ordinary saved pc. This lets a backtrace recover the interrupted location without confusing the resume handler with the interrupted instruction. stack-range checks, backtrace interpretation.
xnu_return_to_gl2 reads the current thread through TPIDR_EL1 and chooses the resume interface:
| Thread state | Resume destination |
|---|---|
thread.txm_thread_stack is nonzero |
Pass the associated stack in x0 to txm_resume |
Otherwise, CONFIG_EXCLAVES and th_exclaves_intstate & TH_EXCLAVES_EXECUTION |
Branch to sk_resume |
| Neither condition holds | Branch to sptm_resume_from_exception |
The first branch is short enough to read directly:
Source: locore.s, lines 1711–1717
mrs x8, TPIDR_EL1
ldr x8, [x8, TH_TXM_THREAD_STACK]
cbz x8, 1f
mov x0, x8
b EXT(txm_resume)
/* Unreachable */
b .
The remaining selector contains the SK and SPTM branches. All three use b, without setting a new link register, and mark the following infinite branch unreachable. The selector expects the external interface to resume protected execution rather than return to this dispatch site as an ordinary function. The actual implementations of these resume interfaces are external to this checkout.
The relevant fields and their synchronization contract are in thread.h. The interrupt-safe Exclaves flags may be modified only by the current thread with interrupts disabled; TH_EXCLAVES_EXECUTION includes execution interrupted or preempted while in the secure kernel or Exclaves userspace. flag definition. Consequently, interpreting only CurrentEL or only the saved pc misses state that this local return path explicitly consults.
Legacy PPL: read the state machine, recognize the missing crossing
The legacy PMAP_SUPPORT_PROTOTYPES_WITH_ASM_INTERNAL macro constructs a wrapper that loads a call index into x15 and branches to _aprr_ppl_enter. The checked-in code does not provide that entry body. wrapper generation. Likewise, ppl_return_to_kernel_mode is referenced without its implementation, and gxf_exceptions.s consists of includes rather than the complete protected-vector machinery.
The remaining assembly still exposes useful contracts. pmap_cpu_data stores ppl_kern_saved_sp, ppl_stack, an arm_context_t *save_area, ppl_state, and in-flight pmap state. The dispatch fragment checks the request against PMAP_COUNT, validates KERNEL/DISPATCH/EXCEPTION state transitions, selects the per-CPU PPL stack, and loads ppl_handler_table[index]. The handler runs through an indirect authenticated branch when pointer authentication is enabled. dispatch setup, handler invocation.
On an ordinary completion it restores the original stack, clears the in-flight pmap pointer and saved stack, changes DISPATCH back to KERNEL, then branches to the missing crossing routine. The later ppl_exit fragment distinguishes ordinary completion, bad call, panic, and an exception; it decreases preemption protection and can process an urgent kernel AST before its C-level return. completion, exit handling.
This path is not fully reconstructable from these fragments: locore.s contains explicit #error "XPRR configuration error" sites where protected entry/return support would be needed. The nearby panic code also explicitly distinguishes PPL running at GL2 from GL1 on a guest or pre-H13 platform; it checks CurrentEL before choosing legal TLB invalidation operations. The unconditional statement “PPL always runs at EL2” would contradict this source. configuration gap, GL1/GL2 distinction.
SMC and Mach exceptions are separate routes
The MONITOR-guarded monitor_call is explicitly described as an EL3 call and executes smc 0x11 before returning to its caller. It is not the source's EL2 guest hypercall helper. monitor_call.
A Mach exception message is a higher-level IPC operation constructed after XNU classifies a fault. It can go to a userspace debugger/server or the kernel's Unix exception handler. It does not itself mean entering EL2. Conversely, an hvc or protected mapping call need not create a Mach message at all. Follow Mach exception delivery for that object/port path, and EL0 entry and return for the fully visible XNU saved-state path.
What this page establishes
The guest's call sites, input/output registers, feature gates, SPTM hooks, mapping result storage, and available PPL state/stack transitions were read directly. The architectural EL2 paragraph is separately sourced from Arm. No host/monitor implementation, generated external entry macro, instruction-level protected return, or complete Apple guarded-register specification was available to inspect. Filling those boxes with invented assembly would obscure the strongest evidence this checkout actually provides.