Kernel Page Table Isolation (KPTI) must reduce kernel mappings during user execution without removing the code needed for the next exception. It does not grant EL0 access to EL1. Its purpose is to reduce kernel translation exposure relevant to transient execution and side channels on affected processors.
The comparison here uses Linux v5.10's trampoline and Apple XNU 7195.60.75's TCR-based range switch. Fixed source versions establish concrete ordering; other releases, CPU workarounds, and translation granules may change it. The useful invariant is not a register-write count: the next instruction and the next exception entry must remain correctly mapped after every transition.
Entry code must bridge two address views
flowchart TD U["EL0 / restricted kernel mappings"] --> T["Exception into EL1 / minimal entry"] T --> M["Restore full kernel view"] M --> K["Normal exception handling"] K --> R["Reach continuously mapped return code"] R --> V["Prepare restricted vectors and view"] V --> E["ERET"] E --> U
Taking an exception into EL1 and restoring the full kernel mapping are distinct steps. The processor selects vectors through VBAR_EL1. If the entry page has also disappeared, the code that restores the mapping has nowhere to execute. Register preservation, translation changes, and the jump into normal vectors must form a complete round trip.
A mapping's presence does not grant architectural user-mode read or execute permission. Track both translation coverage and access permissions rather than describing a transient-execution mitigation as a fix for direct EL0 reads on every CPU.
Linux derives the root delta from linker layout
delta = PAGE_SIZE + RESERVED_TTBR0_SIZE
enter:
TTBR1 = (TTBR1 + delta) & ~USER_ASID_FLAG
leave:
TTBR1 = (TTBR1 - delta) | USER_ASID_FLAGThis is a symbolic macro summary, not executable assembly. tramp_pg_dir is followed by an optional reserved area and then swapper_pg_dir. The delta therefore comes from object placement in this linker script, not a universal rule for adding one page to a root. USER_ASID_FLAG selects a translation context and is part of the transformation.
With CONFIG_UNMAP_KERNEL_AT_EL0, .entry.tramp.text has separate placement and a link-time assertion constraining it to one page. Runtime alternatives also affect the executed sequence; some CPU workarounds introduce synchronization and TLB operations. Counting instructions in an unexpanded macro misses that final sequence.
Linux preserves the vector slot and X30
The 64-bit tramp_ventry first saves X30 in TPIDRRO_EL0, then restores the kernel page-table view. It obtains the normal vectors address from nearby trampoline data or, without randomization, from the symbol. The current vector-slot offset still has to be retained.
| Exception | Lower EL / AArch64 | Lower EL / AArch32 |
|---|---|---|
| Synchronous | 0x400 |
0x600 |
| IRQ | 0x480 |
0x680 |
| FIQ | 0x500 |
0x700 |
| SError | 0x580 |
0x780 |
Each entry occupies a 0x80 slot. The initial 0x400 reservation does not mean all exceptions are skipped: it precedes four lower-EL AArch64 entries and four lower-EL AArch32 entries. Dispatch uses vectors + current slot offset, not the synchronous handler for every exception.
This version combines BL and RET to reach the normal vectors, with branch-aliasing defenses and CPU workarounds around the transition. That RET is controlled entry dispatch, not a return to user mode. The eventual privilege return uses ERET.
Linux installs the next entry before unmapping
- 1
Preserve the final X30
The kernel return path stores
X30inFAR_EL1before branching to the trampoline exit alias. - 2
Set VBAR
tramp_exitfirst pointsVBAR_EL1attramp_vectors, preparing the next exception entry under the restricted view. - 3
Select the restricted root
tramp_unmap_kernelremoves the full kernel view and selects the user-side ASID. - 4
Restore and return
Reload
X30fromFAR_EL1and executeERET. A speculation-constraining sequence follows it.
TPIDRRO_EL0 and FAR_EL1 serve different sides of the preservation protocol. They are not a single interchangeable slot. Nor is the exit macro a generic fragment callable from an arbitrary context: it depends on preparation before entering the trampoline.
XNU increases T1SZ to shrink the high range
XNU's relevant path does not use the TTBR1 root delta above. It combines an ASID bit in TTBR0 with TCR_EL1 configuration. Since T1SZ_USER = T1SZ_BOOT + 1, the user-side high-address span is smaller:
Increasing T1SZ by one halves the span; restoring the smaller BOOT value expands it. The TTBR1 high-range base is 2^64 - S, so shrinking retains the high-address portion, not the lower half of kernel addresses.
enter EL1:
TTBR0.ASID.low_bit = 1
TCR = TCR_EL1_BOOT
ISB
return to EL0:
TCR = TCR_EL1_USER
TTBR0.ASID.low_bit = 0
ERET_CONTEXT_SYNCHRONIZINGThis summary omits configuration-specific barriers. Non-Apple architecture-family paths include an additional ISB between some writes; Apple paths rely on the corresponding microarchitectural ordering. Entry selects the kernel ASID before expanding the range, whereas exit shrinks the range before selecting the user ASID. These orders are not symmetric, and an EL1-to-EL1 exception return should not be mistaken for the EL0 return branch.
A fixed vector address needs two indexing arrangements
arm_vm_prepare_kernel_el0_mappings walks the pages covering ExceptionVectorsBase through ExceptionVectorsEnd and prepares both views of the fixed entry. arm_vm_kernel_el1_map indexes directly with the target virtual address. arm_vm_kernel_el0_map subtracts half the root table's coverage before writing through cpu_tte.
| Quantity | Purpose |
|---|---|
| Original vector virtual address | Obtain the PTE for the original code |
| Fixed entry virtual address | Supply the VBAR_EL1 target |
| Address index used while writing entries | Account for root interpretation under each T1SZ |
| Physical page referenced by the PTE | Reach the same entry code through both views |
One fixed entry address does not imply that initialization writes only one entry. The same physical page does not require identical virtual indices. The helper also derives an equivalent page PTE from a block mapping and clears a hint bit inappropriate for these aliases.
When alloc_only is true, the function prepares table structures using empty PTEs. It copies actual mappings and sets VBAR_EL1 only when that argument is false. Observing the allocation call alone does not establish that the new vectors are active.
Check transition invariants with a small model
Vector offsets below come from the entry layout. T1SZ and root values are illustrative formula checks, not a claim that one machine supports all these combinations. The model uses bit 48 as an illustrative ASID flag, separate from its physical-base field.
KINDS = ("sync", "irq", "fiq", "serror")
low64 = {k: 0x400 + i * 0x80 for i, k in enumerate(KINDS)}
low32 = {k: 0x600 + i * 0x80 for i, k in enumerate(KINDS)}
assert list(low64.values()) == [0x400, 0x480, 0x500, 0x580]
assert list(low32.values()) == [0x600, 0x680, 0x700, 0x780]
for boot in (25, 26):
full = 1 << (64 - boot)
reduced = 1 << (64 - (boot + 1))
full_base = (1 << 64) - full
reduced_base = (1 << 64) - reduced
assert reduced * 2 == full
assert reduced_base > full_base
assert reduced_base + reduced == 1 << 64
for page_size in (4096, 16384, 65536):
for reserved in (0, page_size):
flag = 1 << 48
base = page_size * 32
restricted = base | flag
full = (restricted + page_size + reserved) & ~flag
restored = (full - page_size - reserved) | flag
assert restored == restricted
print("PASS: 8 vector offsets; 2 range cases; 6 illustrative TTBR round trips")The run covers eight vector offsets, two range relationships, and six symbolic TTBR round trips. It performs no system-register writes, models no TLB behavior, and measures no KPTI performance.
Full validation needs a fixed kernel and CPU, the actual alternative-instruction expansion, and PC, VBAR, TTBR, TCR, and ASID observations at each transition. If the next instruction or exception entry loses its mapping in an intermediate state, final register values alone do not explain a successful round trip.