内核页表隔离(Kernel Page Table Isolation,KPTI)既要在用户态运行期间收缩内核映射,又要保证下一次异常仍有可执行入口。这不是给 EL0 增加访问 EL1 的权限,而是减少相关处理器上瞬态执行与侧信道可能接触的内核地址转换环境。
这里对照 Linux v5.10 的 trampoline 与 Apple XNU 7195.60.75 的 TCR 范围切换。固定版本源码用于核对具体顺序;不同内核版本、CPU 修正和翻译粒度仍可能改变路径。重点不是数寄存器写入次数,而是证明每次切换后,下一条指令与下一次异常入口都仍有正确映射。
入口必须跨越两种地址视图
flowchart TD U["EL0 / 受限内核映射"] --> T["异常进入 EL1 / 最小入口"] T --> M["恢复完整内核视图"] M --> K["正式异常处理"] K --> R["进入始终可达的返回代码"] R --> V["准备受限向量与地址视图"] V --> E["ERET"] E --> U
异常切入 EL1 与恢复完整内核映射是两个步骤。处理器按 VBAR_EL1 选择异常向量;如果入口页自身也被撤掉,后续切换代码就没有执行位置。保存关键寄存器、变更地址视图与跳入正式向量的顺序必须构成闭环。
映射存在也不代表用户态享有架构上的读取或执行许可。分析 KPTI 时应同时记录地址转换范围与访问权限,避免把缓解瞬态执行风险误写成修复所有 CPU 上的直接 EL0 读取。
Linux 的页表根差值来自链接布局
delta = PAGE_SIZE + RESERVED_TTBR0_SIZE
enter:
TTBR1 = (TTBR1 + delta) & ~USER_ASID_FLAG
leave:
TTBR1 = (TTBR1 - delta) | USER_ASID_FLAG这是宏的符号化摘要,不是直接运行的汇编。tramp_pg_dir 后面是可选保留区,再接 swapper_pg_dir;因此 delta 来自该链接脚本的对象布局,而非“任意页表根加一页”。USER_ASID_FLAG 选择转换上下文,同样是转换的一部分。
在 CONFIG_UNMAP_KERNEL_AT_EL0 下,.entry.tramp.text 独立布局,链接断言限制该区大小为一页。实际执行还受运行时 alternative 选择影响;例如某些处理器修正会插入同步与 TLB 操作。只根据未展开的宏统计开销,会漏掉最终指令流。
Linux 入口保留异常种类与 X30
64 位 tramp_ventry 先把 X30 暂存到 TPIDRRO_EL0,然后恢复内核页表视图。正式 vectors 地址可来自与 trampoline 相邻的数据位置,或在未随机化的配置下直接使用符号;得到地址后还要保留当前向量项偏移。
| 异常种类 | 较低异常级 / AArch64 | 较低异常级 / AArch32 |
|---|---|---|
| 同步异常 | 0x400 |
0x600 |
| IRQ | 0x480 |
0x680 |
| FIQ | 0x500 |
0x700 |
| SError | 0x580 |
0x780 |
每个入口间隔 0x80。最前面的 0x400 空间不是“所有异常都跳过”,其后分别是来自较低异常级的 AArch64 与 AArch32 四类入口。跳转目标应是 vectors + 当前项偏移,不是把全部异常交给同步异常分支。
该版本通过 BL 与 RET 组合进入正式向量,前后还包含防止分支混淆及处理器修正的安排。这里的 RET 是受控的入口跳转,不是返回用户态;最终权限返回由 ERET 完成。
Linux 退出先登记下次异常入口
- 1
暂存最后的 X30
内核返回路径把
X30放入FAR_EL1,再跳到 trampoline 别名处的退出代码。 - 2
设置 VBAR
tramp_exit先将VBAR_EL1设为tramp_vectors,为受限视图中的下一次异常准备入口。 - 3
切换受限根
执行
tramp_unmap_kernel,撤下完整内核视图并选择用户侧 ASID。 - 4
恢复与返回
从
FAR_EL1恢复X30,执行ERET;后面还排列了推测执行约束序列。
TPIDRRO_EL0 与 FAR_EL1 分别用于这两侧的暂存协议,不应混为同一个保存槽。退出宏也不是可从任意位置单独调用的通用片段:它依赖进入 trampoline 之前的准备。
XNU 增大 T1SZ 来缩小高端范围
XNU 的相关路径不采用上面的 TTBR1 根差值,而是结合 TTBR0 中的 ASID 位与 TCR_EL1 的配置切换。T1SZ_USER = T1SZ_BOOT + 1,所以用户侧高端地址跨度更小:
T1SZ 增大一,跨度减半;进入内核恢复较小的 BOOT 值,范围扩大。对于 TTBR1 的高端范围,起点是 2^64 - S,因此缩小后保留高地址一侧,不是“较低的一半内核地址”。
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_SYNCHRONIZING摘要省略了配置条件:非 Apple 架构族路径在部分寄存器写入之间还有 ISB;Apple 路径依赖相应微架构的排序保证。进入时先选择内核 ASID 再扩大范围,退出时先缩小范围再选择用户 ASID,顺序并不对称。内核内部异常返回也不应套用 EL0 返回分支。
固定向量地址依靠两处索引安排
arm_vm_prepare_kernel_el0_mappings 遍历 ExceptionVectorsBase 到 ExceptionVectorsEnd 覆盖的页,为固定异常入口建立两种视图。arm_vm_kernel_el1_map 直接用目标虚拟地址索引;arm_vm_kernel_el0_map 则先减去根表覆盖跨度的一半,再向 cpu_tte 写入表项。
| 量 | 用途 |
|---|---|
| 原向量虚拟地址 | 取得原始代码对应的 PTE |
| 固定入口虚拟地址 | 作为 VBAR_EL1 的目标 |
| 表项写入使用的地址索引 | 配合不同 T1SZ 对根表的解释 |
| PTE 指向的物理页 | 保证两种视图到达同一份入口代码 |
相同固定入口地址并不意味着初始化只写一个表项;相同物理页也不要求所有虚拟索引相同。函数还会把 block 映射转换成等价页 PTE,并清除不适用于这组别名的 hint 位。
alloc_only 为真时,只准备表结构并使用空 PTE;为假时才复制实际映射并设置 VBAR_EL1。因此,观察到分配函数被调用,不足以证明向量已启用。
用最小模型检查切换不变量
下列向量偏移来自入口布局;T1SZ 与页表根数值是用于检查公式的示例,不宣称这些组合均受同一台硬件支持。ASID 标志采用示例 bit 48,与物理基址字段保持分离。
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")运行结果覆盖 8 个向量偏移、2 组范围关系、6 组符号化 TTBR 往返。它不执行系统寄存器写入,不模拟 TLB,也不测量 KPTI 性能。
完整验证应在固定内核与 CPU 上记录每个切换点的 PC、VBAR、TTBR、TCR 和 ASID,并展开实际 alternative 指令。只要下一条指令或下一次异常入口在任一中间状态失去正确映射,单看最终寄存器值仍解释不了一次成功往返。