Linux内核分析之基础知识-04

韩乔落

5.1 ARM64 架构概述 —— 寄存器、异常级别与系统寄存器

本节全面介绍 ARM64(AArch64)执行状态的核心编程模型,这是理解 Linux ARM64 内核代码的基础。我们将依次讨论寄存器文件、异常级别模型、关键系统寄存器、过程调用约定,以及与 x86_64 的本质差异。

1. ARM64 寄存器文件

ARM64 的寄存器文件设计体现了”简洁即力量”的哲学。与 x86_64 继承自 8086 时代的寄存器命名和语义不同,ARM64 的寄存器编号线性、语义统一。

1.1 通用寄存器(X0-X30 / W0-W30)

ARM64 提供 31 个 64 位通用寄存器,编号 X0 到 X30:

  • X0-X30:64 位(全量)访问。每个寄存器宽度为 64 位。
  • W0-W30:32 位访问,对应 X 寄存器的低 32 位。对 W 寄存器的写入会将对应 X 寄存器的高 32 位清零(而非像 x86 那样仅修改低 32 位而保留高位)。
1
2
3
4
5
6
63                                      0
+---------------------------------------+
| X[n] | 64-bit access
+-------------------+-------------------+
| (zeros) | W[n] | 32-bit access (write zeros upper)
+-------------------+-------------------+

注意是 31 个而非 32 个,因为 X31 被编码为零寄存器(XZR)而非通用寄存器。

1.2 零寄存器(XZR / WZR)

XZR(64 位)和 WZR(32 位)是硬连线零寄存器:

  • 读操作:始终返回 0
  • 写操作:被丢弃,无任何效果

零寄存器在内核代码中极为常见。例如,MOV X0, XZR 等价于将 X0 清零,但不需要一个立即数 0 的编码。在许多指令中,零寄存器充当”丢弃目标”——当你不需要指令的某个输出时,可以将目标设为 XZR。这比 x86 需要求助于 XOR EAX, EAXMOV EAX, 0 更加优雅。

零寄存器的设计消除了许多特殊指令变体的需要。例如,CMP X0, X1 实际上编码为 SUBS XZR, X0, X1——减法结果被丢弃,只影响条件标志。

1.3 栈指针(SP)

栈指针 SP 是一个独立的寄存器,不属于通用寄存器文件:

  • SP 不是 X31,尽管在某些指令编码中 X31 的位置可能映射到 SP 或 XZR(取决于具体指令)。
  • SP 始终指向当前异常级别的栈顶。ARM64 栈为满降序(full descending):SP 指向最后压入的数据,压栈时 SP 先递减再写入。
  • 对 SP 有严格的对齐要求:在 AArch64 状态下,SP 必须保持 16 字节对齐。违反此对齐规则会触发 SP 对齐异常(由 SCTLR_ELx.SA 控制)。
  • 大多数算术指令不能直接使用 SP 作为操作数,但 ADD/SUB 指令有专门的 SP 操作形式。

在 Linux 内核中,每个 CPU 的异常栈、内核栈、中断栈都通过 SP 进行管理。内核入口代码的第一件事通常就是将 SP 切换到对应的内核栈。

1.4 程序计数器(PC)

ARM64 的程序计数器 PC 同样是独立寄存器,不像 x86 那样与 RIP 合并:

  • PC 不能作为大多数指令的操作数直接读写(这不是 x86 的 MOV EAX, EIP
  • PC 的修改只能通过以下方式:
    • 条件分支:B.condCBNZCBZTBNZTBZ
    • 无条件分支:BBL
    • 异常产生/返回:SVCHVCSMCERET
    • 间接分支:BRBLRRET
  • ADRADRP 指令可以基于 PC 计算地址,但不是直接读取 PC

1.5 处理器状态寄存器(PSTATE)

PSTATE 不是一个单一寄存器,而是处理器状态的抽象集合。在 AArch64 中,PSTATE 的各个字段独立访问(不像 x86 的 RFLAGS 那样是一个整体寄存器):

条件标志(NZCV)

  • N(Negative):运算结果的最高位为 1 时置位
  • Z(Zero):运算结果为零时置位
  • C(Carry):无符号溢出时置位
  • V(oVerflow):有符号溢出时置位

与 x86 不同,大多数 ARM64 算术指令不会自动设置条件标志。只有以 “S” 结尾的指令(如 ADDSSUBS)才会更新 NZCV。这使得编译器可以自由调度普通算术指令而不用担心标志被破坏。

中断屏蔽(DAIF)

  • D(Debug mask):屏蔽硬件调试异常
  • A(SError mask):屏蔽系统错误(SError)
  • I(IRQ mask):屏蔽普通中断
  • F(FIQ mask):屏蔽快速中断

Linux 内核通过 DAIF 字段控制中断使能。local_irq_enable() 本质上就是清除 DAIF.I 位,local_irq_disable() 则是设置该位。

安全与访问控制

  • PAN(Privileged Access Never):阻止 EL1 对用户空间内存的直接访问(防止内核直接读写用户缓冲区,强制使用 copy_to_user/copy_from_user 等安全接口)
  • UAO(User Access Override):临时覆盖 PAN,允许内核在 uaccess 操作期间访问用户空间
  • SSBS(Speculative Store Bypass Safe):控制推测性存储旁路行为
  • DIT(Data Independent Timing):启用数据无关时序模式,减少侧信道攻击面
  • TCO(Tag Check Override):控制 MTE 标签检查

当前状态

  • EL:当前异常级别(0-3)
  • SP:当前使用的栈指针选择(SP_EL0 或 SP_ELx)

1.6 NEON/SIMD 寄存器(V0-V31)

ARM64 的 SIMD/浮点寄存器组包含 32 个 128 位寄存器 V0-V31,每个可以按多种粒度访问:

1
2
3
4
5
6
7
8
9
10
11
12
127                                   0
+---------------------------------------+
| Q[n] | 128-bit (Quadword)
+-------------------+-------------------+
| | D[n] | 64-bit (Doubleword)
+-------------------+----------+--------+
| | | S[n] | 32-bit (Single word)
+-------------------+----------+--------+
| | | H[n] | | 16-bit (Halfword)
+-------------------+----------+--------+
| |B[n]| | 8-bit (Byte)
+-------------------+----------+--------+

这些寄存器在内核中主要用于:

  • 内核模式 NEON:某些内核路径(如加密算法)使用 NEON 指令加速。Linux 通过 kernel_neon_begin()/kernel_neon_end() 管理内核对 NEON 寄存器的借用。
  • 信号处理:保存和恢复用户空间的浮点/SIMD 状态。
  • fpsimd_state 结构体:用于管理浮点/SIMD 上下文的保存和恢复。

1.7 SVE 寄存器(Z0-Z31, P0-P15)

可伸缩向量扩展(Scalable Vector Extension, SVE)是 ARMv8-A 的可选扩展,引入了可变长度的向量寄存器:

  • Z0-Z31:可伸缩向量数据寄存器,每个宽度从 128 位到 2048 位(由实现决定),是 V0-V31 的超集(低 128 位即对应的 V 寄存器)
  • P0-P15:16 个谓词(Predicate)寄存器,每个宽度为 Z 寄存器的 1/8,用于逐元素条件控制

SVE 的核心理念是”编写一次,在不同向量长度的硬件上都能运行”。软件通过 RDVL 等指令在运行时查询向量长度,而不需要为特定长度硬编码。

SVE2 是 SVE 的增量扩展,增加了更多指令类别。Linux 内核通过 fpsimd 子系统统一管理 NEON 和 SVE 状态,在上下文切换时按需保存和恢复。

2. 异常级别(Exception Levels, EL0-EL3)

ARM64 采用四级异常级别模型,取代了 x86 的 Ring 0-3 保护环模型。异常级别在概念上类似特权级,但语义更加清晰和严格:

1
2
3
4
5
6
7
8
9
+-----------------------------------------------+
| EL3 Secure Monitor (ARM TrustZone) | 最高特权
+-----------------------------------------------+
| EL2 Hypervisor (虚拟化支持) |
+-----------------------------------------------+
| EL1 OS Kernel (Linux 运行于此) |
+-----------------------------------------------+
| EL0 User Applications (用户态) | 最低特权
+-----------------------------------------------+

2.1 各级别的角色

EL0 —— 用户应用层

  • 运行普通用户态应用程序
  • 无硬件特权,所有系统资源访问必须通过 SVC 系统调用陷入内核
  • 使用 TTBR0_EL1 指向的页表(用户空间地址)
  • 执行 AArch64 或 AArch32 指令集(可选)

EL1 —— 操作系统内核层

  • Linux 内核通常运行在此级别
  • 拥有完整的系统寄存器访问权限(EL1 级别的系统寄存器)
  • 管理页表、中断、设备驱动等
  • 可以控制 EL0 的行为(通过 SCTLR_EL1、TCR_EL1 等)

EL2 —— 虚拟机监控器层

  • 运行 Hypervisor(如 KVM)
  • 管理虚拟机的创建、调度和隔离
  • 控制第二阶段地址翻译(Stage-2 Translation)
  • VHE(Virtualization Host Extension)允许 Linux 内核在 EL2 运行,同时保持 EL1 的语义

EL3 —— 安全监控器层

  • ARM TrustZone 的核心,管理安全世界(Secure world)和非安全世界(Normal world)之间的切换
  • 最高特权级别,可以访问所有系统资源
  • 通常运行 ARM Trusted Firmware(ATF/TF-A)
  • Linux 内核不直接操作 EL3,但通过 SMC(Secure Monitor Call)指令请求 EL3 服务

2.2 Linux 运行的异常级别

Linux 内核通常运行在 EL1。但在启用了 VHE(Virtualization Host Extension)的系统上,为了支持更高效的虚拟化,内核会运行在 EL2。VHE 扩展通过修改 EL2 的行为使其”看起来像”EL1,这样内核代码无需大规模修改即可在 EL2 运行。

内核在启动时通过检查 ID_AA64MMFR1_EL1.VH 字段判断是否支持 VHE,并通过 __boot_cpu_mode 变量记录实际启动的异常级别。相关逻辑参见 arch/arm64/kernel/head.Sarch/arm64/kernel/hyp-stub.S

2.3 异常级别的关键系统寄存器

CurrentEL:读取当前异常级别:

1
2
MRS X0, CurrentEL
// X0[3:2] = 00 (EL0), 01 (EL1), 10 (EL2), 11 (EL3)

SPSR_ELx(Saved Program Status Register):异常发生时,处理器自动将 PSTATE 保存到目标异常级别的 SPSR 中。ERET 指令从 SPSR 恢复 PSTATE。

ESR_ELx(Exception Syndrome Register):包含异常的综合信息——异常类别(EC 字段)和具体条件码(ISS 字段)。内核的异常处理程序依赖此寄存器判断异常原因。例如:

  • EC=0x15:SVC 系统调用,ISS 中包含系统调用号
  • EC=0x24:数据中止(Data Abort,如缺页),ISS 中包含读写方向、访问大小等信息
  • EC=0x20:指令中止(Instruction Abort,如代码页缺失)

ELR_ELx(Exception Link Register):异常发生时保存返回地址。对于同步异常,返回到产生异常的指令;对于中断,返回到下一条待执行指令。ERET 从此寄存器恢复 PC。

FAR_ELx(Fault Address Register):对于访存引起的异常,FAR 保存触发异常的虚拟地址。缺页处理程序通过此寄存器获得故障地址。

3. 关键系统寄存器

ARM64 的系统寄存器通过 MRS(读)和 MSR(写)指令访问,命名遵循 <name>_<op0>_<op1>_<CRn>_<CRm>_<op2> 的编码格式,但 Linux 代码中通常使用助记符名称。

3.1 SCTLR_EL1 —— 系统控制寄存器

SCTLR_EL1 是最重要的系统控制寄存器之一,控制处理器的多项核心行为:

位域 名称 功能
0 M MMU 使能(1=开启地址翻译)
1 A 对齐检查使能
2 C 数据缓存使能
3 SA SP 对齐检查使能
4 SA0 EL0 的 SP 对齐检查
17 PAN 特权访问禁止(阻止 EL1 直接访问用户空间内存)
18 UAO 用户访问覆盖(配合 PAN 使用)
22 EE 数据字节序(0=小端,1=大端)
23 E0E EL0 数据字节序

在 Linux 内核启动的早期阶段(__primary_entry__cpu_setup),SCTLR 的配置是关键步骤之一。内核需要先关闭 MMU 和缓存,配置好页表后再重新启用。

3.2 TCR_EL1 —— 翻译控制寄存器

TCR_EL1 控制地址翻译的各个方面:

  • T0SZ / T1SZ:TTBR0/TTBR1 覆盖的地址空间大小。Linux 默认配置 T0SZ=25(用户空间 39 位地址)、T1SZ=25(内核空间 39 位地址),共构成 48 位虚拟地址空间。在支持 52 位 VA 的系统上(ARMv8.2-LVA),这些值可以更小。
  • TG0 / TG1:TTBR0/TTBR1 的页粒度(4KB/16KB/64KB)。Linux 允许在编译时选择页大小。
  • IR0/IR1, OR0/OR1:内部/外部缓存的回写属性(Normal Cacheable、Non-cacheable 等)
  • SH0/SH1:页表内存的共享属性(Inner Shareable、Outer Shareable 等)
  • E0PD0 / E0PD1:EL0 访问时对 TTBR0/TTBR1 区域的预防性拒绝(安全特性,防止推测性访问)

3.3 TTBR0_EL1 与 TTBR1_EL1 —— 页表基址寄存器

ARM64 使用双页表基址寄存器实现地址空间隔离:

  • TTBR0_EL1:用户空间页表基址,覆盖虚拟地址空间的低半部分(VA[63]=0)
  • TTBR1_EL1:内核空间页表基址,覆盖虚拟地址空间的高半部分(VA[63]=1)

这种设计的优势在于:内核页表和用户页表可以独立管理,切换进程时只需切换 TTBR0 即可,TTBR1 保持不变(内核页表在所有进程间共享)。这比 x86_64 的单一 CR3 寄存器更加高效。

在 Linux ARM64 中,mm_struct->pgd 指向进程的完整页表,其中 TTBR0 部分是进程私有的,TTBR1 部分指向共享的内核页表(init_mm.pgd)。KPTI(Kernel Page Table Isolation)通过为每个进程维护两套页表来隔离内核空间。

3.4 VBAR_EL1 —— 向量基址寄存器

VBAR_EL1 保存异常向量表的基地址。当异常发生时,处理器根据异常类型和当前状态(异常级别、栈指针选择)从向量表中取入口地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
VBAR_EL1 + 0x000  : EL0, SP0, 同步异常 (Current EL with SP0)
VBAR_EL1 + 0x080 : EL0, SP0, IRQ
VBAR_EL1 + 0x100 : EL0, SP0, FIQ
VBAR_EL1 + 0x180 : EL0, SP0, SError

VBAR_EL1 + 0x200 : EL0, SPx, 同步异常 (Current EL with SPx)
VBAR_EL1 + 0x280 : EL0, SPx, IRQ
VBAR_EL1 + 0x300 : EL0, SPx, FIQ
VBAR_EL1 + 0x380 : EL0, SPx, SError

VBAR_EL1 + 0x400 : EL1 (来自低级别), 同步异常
VBAR_EL1 + 0x480 : EL1 (来自低级别), IRQ
VBAR_EL1 + 0x500 : EL1 (来自低级别), FIQ
VBAR_EL1 + 0x580 : EL1 (来自低级别), SError

VBAR_EL1 + 0x600 : EL1 (来自同级), 同步异常
VBAR_EL1 + 0x680 : EL1 (来自同级), IRQ
VBAR_EL1 + 0x700 : EL1 (来自同级), FIQ
VBAR_EL1 + 0x780 : EL1 (来自同级), SError

Linux 内核的异常向量表定义在 arch/arm64/kernel/entry.S 中,每个入口占 128 字节(0x80),内含跳转到具体处理程序的指令。

3.5 MAIR_EL1 —— 内存属性间接寄存器

MAIR_EL1 定义了最多 8 种内存属性,供页表项中的 AttrIndex 字段引用:

1
2
3
4
5
AttrIndex 0 (000): Device-nGnRnE (最强设备属性,无聚合、无重排、无早期写确认)
AttrIndex 1 (001): Normal, Inner/Outer Non-cacheable
AttrIndex 2 (010): Normal, Inner/Outer Cacheable, R/W Allocate
AttrIndex 3 (011): Normal, Inner/Outer Cacheable, R Allocate (用于只读数据)
...

Linux ARM64 在 __cpu_setup 中配置 MAIR_EL1,将页表项的 AttrIndex 映射到具体的缓存属性。这与 x86 的 PAT(Page Attribute Table)在功能上类似,但编码方式不同。

3.6 ID_AA64* 系列 —— CPU 特性标识寄存器

ARM64 通过一组只读系统寄存器向软件报告处理器支持的功能特性:

  • ID_AA64ISAR0_EL1 / ID_AA64ISAR1_EL1 / ID_AA64ISAR2_EL1:指令集特性(AES、SHA、CRC32、Atomic、SME、MOPS 等)
  • ID_AA64MMFR0_EL1 ~ ID_AA64MMFR5_EL1:内存模型特性(物理地址大小、VA 大小、BIG_ENDIAN、SVE VL、EVC 等)
  • ID_AA64PFR0_EL1 ~ ID_AA64PFR2_EL1:处理器特性(EL 支持、FP/SIMD、SVE、SME、GCS、RME 等)
  • ID_AA64DFR0_EL1 / ID_AA64DFR1_EL1:调试特性(断点数量、PMU 版本、Trace 等)

Linux 内核通过 arm64_ftr_reg 框架解析这些寄存器,维护系统范围内各特性的最小公共能力(在多核系统中,特性能力取所有 CPU 的交集),并通过 cpu_featurearm64_kernel_features 机制在运行时检测和使能新特性。

4. 过程调用约定(AAPCS64)

ARM64 遵循 ARM Architecture Procedure Call Standard for 64-bit(AAPCS64)调用约定,这是所有 ARM64 软件必须遵守的二进制接口规范:

4.1 寄存器角色分配

寄存器 角色 调用者/被调用者保存
X0-X7 参数/返回值 调用者保存(caller-saved)
X8 间接结果位置 / 系统调用号 调用者保存
X9-X15 临时寄存器 调用者保存
X16-X17 IP0/IP1(链接器/PLT 临时) 调用者保存
X18 平台寄存器(Linux 未使用或特殊用途) 调用者保存
X19-X28 被调用者保存 被调用者保存(callee-saved)
X29 帧指针(FP) 被调用者保存
X30 链接寄存器(LR) 被调用者保存(通过保存到栈)

4.2 参数传递与返回值

  • 函数参数通过 X0-X7 按顺序传递,最多 8 个整数/指针参数通过寄存器传递
  • 超出 8 个的参数通过栈传递
  • 返回值通过 X0 传递(128 位返回值使用 X0:X1 对)
  • 浮点/向量参数通过 V0-V7 传递
  • 大于 16 字节的返回值由调用者分配内存,X8 传递指向该内存的指针

4.3 栈帧布局

ARM64 的栈为满降序,16 字节对齐:

1
2
3
4
5
6
7
8
9
10
低地址
+-------------------+
| 局部变量 |
| ... |
| 被调用者保存的 | <- FP (X29) 指向此处
SP ----> | X19, X20, ... |
| FP (X29) | <- 保存的帧指针
| LR (X30) | <- 保存的返回地址
+-------------------+
高地址

STP X29, X30, [SP, #-16]! 是典型的函数序言,同时保存帧指针和返回地址并递减 SP。LDP X29, X30, [SP], #16 是对应的花尾。

帧指针链(Frame Pointer Chain)通过每个栈帧中保存的 X29 形成链表,内核的栈回溯(stacktrace)机制依赖此链来追踪调用路径。内核编译时通常启用 -fno-omit-frame-pointer 以保证 X29 始终指向有效的帧指针。

5. ARM64 与 x86_64 的本质架构差异

5.1 Load/Store 架构

ARM64 是纯粹的 Load/Store 架构:只有专门的 Load(LDRLDP)和 Store(STRSTP)指令才能访问内存,算术和逻辑指令只能在寄存器之间操作。

x86_64 允许 ADD RAX, [RBX] 这样的内存+寄存器操作,但 ARM64 必须先 Load 再计算再 Store:

1
2
3
4
5
6
// x86_64: 单条指令
ADD RAX, [RBX]

// ARM64: 必须分两步
LDR X1, [X0] // 先从内存加载
ADD X0, X0, X1 // 再在寄存器间运算

这种设计简化了指令解码和流水线设计,但需要更多寄存器来暂存中间值(这也是 ARM64 有 31 个 GPR 的原因之一)。

5.2 固定长度指令

AArch64 所有指令都是 32 位固定长度,这带来了多个优势:

  • 指令解码简单高效,不需要 x86 那样复杂的长度前缀解析
  • 指令边界确定,有利于分支预测和指令预取
  • 不需要 IT(If-Then)块等复杂的指令编码机制
  • 但代价是编码空间有限,立即数范围受到约束(ARM64 使用复杂的位模式编码立即数)

5.3 条件执行模型

x86 的几乎所有算术指令都会设置 RFLAGS,随后通过条件跳转(JZJNZ 等)检查标志。ARM64 的模型则不同:

  • 大多数算术指令不设置标志(没有隐式副作用)
  • 只有带 S 后缀的指令(ADDSSUBSANDS)更新 NZCV
  • 提供条件选择指令,避免分支:CSEL Xd, Xn, Xm, cond(条件为真选 Xn,否则选 Xm)
  • 类似的还有 CSINCCSINVCSNEG

在内核代码中,CSEL 被广泛用于无分支的条件选择,减少分支预测失败的开销。

5.4 无分段机制

ARM64 完全没有 x86 的段描述符、段选择子、GDT/LDT 等分段概念。地址翻译直接从虚拟地址通过页表映射到物理地址,没有段级转换。这大大简化了内存管理模型,也消除了 x86 分段机制带来的历史复杂性。

5.5 异常级别 vs 保护环

x86 的 Ring 0-3 是平坦的四个特权级,但实际上只有 Ring 0(内核)和 Ring 3(用户)被广泛使用。ARM64 的 EL 模型则赋予了每个级别明确的角色:EL0 给应用、EL1 给内核、EL2 给虚拟化、EL3 给安全。EL 之间不能自由转移——只能通过异常(向上)和 ERET(向下返回)来切换,且只能逐级返回,不能跳级。

这种设计使得 ARM64 的特权模型比 x86 更加结构化,也为虚拟化和安全扩展提供了天然的硬件支持,而不需要像 x86 那样引入 VT-x/VT-d 等附加扩展来弥补模型缺陷。5.2 ARM64 虚拟内存:TTBR0/TTBR1 分割与多级页表

ARM64 架构在虚拟内存管理上采用了与 x86_64 截然不同的设计哲学。其中最核心的区别在于:ARM64 使用两个独立的页表基址寄存器(TTBR0 和 TTBR1),将整个虚拟地址空间一分为二,分别服务于用户空间和内核空间。这种设计从根本上改变了地址翻译的硬件行为,也深刻影响了 Linux 内核在 ARM64 上的内存管理实现。

5.2.1 TTBR0/TTBR1 地址空间分割

双寄存器机制

ARM64 架构定义了两个翻译表基址寄存器:

  • TTBR0_EL1:映射虚拟地址空间的低半部分(用户空间),当虚拟地址的第 63 位(VA[63])为 0 时,硬件自动选择此寄存器进行地址翻译。
  • TTBR1_EL1:映射虚拟地址空间的高半部分(内核空间),当虚拟地址的第 63 位(VA[63])为 1 时,硬件自动选择此寄存器进行地址翻译。

这种设计与 x86_64 形成鲜明对比。在 x86_64 上,单个 CR3 寄存器管理整个虚拟地址空间的页表,内核空间和用户空间共存于同一张页表中。而 ARM64 的双 TTBR 设计意味着用户空间和内核空间拥有完全独立的页表结构,硬件在每条指令访存时根据地址最高位自动选择对应的页表,无需软件干预。

TCR_EL1 的控制作用

翻译控制寄存器 TCR_EL1 负责配置地址翻译的各项参数,其中最关键的是 T0SZ 和 T1SZ 字段:

  • TCR_EL1.T0SZ:控制 TTBR0 管理的地址空间大小。有效地址位数为 64 - T0SZ,即 T0SZ 越大,用户空间越小。
  • TCR_EL1.T1SZ:控制 TTBR1 管理的地址空间大小。有效地址位数为 64 - T1SZ

默认配置下,两半空间大小相等,各占 VA_BITS 位地址宽度。以常见的 48 位虚拟地址为例,用户空间覆盖 0x00000000000000000x0000FFFFFFFFFFFF(256TB),内核空间覆盖 0xFFFF0000000000000xFFFFFFFFFFFFFFFF(256TB)。

TCR_EL1 还配置了中间物理地址宽度(IPS 字段)、页大小(TG0/TG1)、共享属性(SH0/SH1)、缓存属性(IRGN0/IRGN1、ORGN0/ORGN1)等关键参数。这些字段的 0/1 后缀分别对应 TTBR0 和 TTBR1,允许对两半地址空间使用不同的配置。

5.2.2 虚拟地址空间布局

以 48 位虚拟地址(4KB 页、4 级页表)为典型配置,ARM64 Linux 的虚拟地址空间布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
用户空间(TTBR0,256TB):
0x0000000000000000 - 0x0000FFFFFFFFFFFF 用户空间

内核空间(TTBR1,256TB):
0xFFFF000000000000 - 0xFFFF0000FFFFFFFF 模块区域(Module region,2GB)
0xFFFF001000000000 - ... vmalloc 区域
... IOREMAP 区域
... PCIe 配置空间映射
... 直接物理内存映射(线性映射区)
... vmemmap(struct page 数组)
... 固定映射区(fixmap)
... 内核代码段(__init_begin 到 _end)
0xFFFFFFFFFFFFFFFF 内核空间顶部

其中几个关键区域的意义如下:

  • 模块区域:位于内核空间最底部,紧挨着地址空间中点。内核模块(可加载模块 .ko 文件)加载到此处,因为它需要在内核虚拟地址空间中,但又靠近用户空间边界以便于地址转换。
  • vmalloc 区域:用于动态虚拟地址映射,包括 vmalloc()vmap() 等分配。此区域与线性映射区物理上可能不连续。
  • 线性映射区:物理内存的 1:1(加偏移)直接映射。大部分物理内存通过此区域访问,__pa()__va() 宏在此区域工作。
  • vmemmap 区域:所有物理页帧对应的 struct page 结构体数组。每个物理页帧对应一个 struct page,通过 pfn_to_page() 访问。
  • 内核代码段:内核镜像本身被映射到此区域,包含 .text.data.bss 等段。

5.2.3 页表层级(CONFIG_PGTABLE_LEVELS)

ARM64 支持灵活的页表层级配置,由编译时的 CONFIG_PGTABLE_LEVELS 决定。页大小和虚拟地址位数的组合决定了需要多少级页表:

页大小 VA 位数 页表级数 每级索引位数
4KB 39 3 9+9+9
4KB 48 4 9+9+9+9
4KB 52 5 需要扩展
16KB 47 3 11+11+11
16KB 48 4 11+11+11+..
64KB 42 2 16+13
64KB 48 3 16+16+16

最常见的配置是 4KB 页 + 48 位 VA = 4 级页表,对应的页表遍历路径为:

1
PGD(页全局目录)→ PUD(页上级目录)→ PMD(页中间目录)→ PTE(页表项)→ 物理页帧

每一级页表使用 9 位索引(因为 4KB 页能容纳 512 个 8 字节的页表项:4KB / 8B = 512 = 2^9),加上页内偏移 12 位(4KB = 2^12),共 4 x 9 + 12 = 48 位,恰好等于虚拟地址宽度。

当配置为 3 级页表时,PUD 层级被折叠(fold),PGD 直接指向 PMD 页表。这种折叠机制通过软件实现,硬件感知的页表级数由 TCR_EL1.TG0/TG1 和页大小共同决定。

5.2.4 页表项硬件格式

ARM64 的页表项是 64 位宽的描述符,其格式与 x86_64 有显著差异。ARM64 使用描述符类型字段而非单一的 Present 位来区分不同类型的页表项:

关键字段

  • Valid 位(bit 0):描述符是否有效。若为 0,硬件触发翻译故障(Translation Fault)。
  • 描述符类型(bit 1):区分表描述符(指向下一级页表)和块/页描述符(指向物理页帧)。
  • AF(Access Flag,bit 10):访问标志。硬件在首次访问时可以自动置位(由 TCR_EL1.HA 控制),也可由软件管理。Linux 通常启用硬件管理以减少页表错误。AF 必须被设置为 1,否则硬件会产生 Access Flag Fault。
  • SH[1:0](Shareability,bits 9:8):共享属性。00=Non-shareable,01=保留,10=Inner Shareable,11=Outer Shareable。内核通常使用 Inner Shareable 以确保多核间缓存一致性。
  • AP[2:1](Access Permissions,bits 7,6):访问权限。编码 EL0/EL1 访问权和读/写权限:
    • 00:EL1 可读写,EL0 不可访问
    • 01:EL0/EL1 均可读写
    • 10:EL1 只读,EL0 不可访问
    • 11:EL0/EL1 均只读
  • UXN(bit 54):User Execute Never,EL0 不可执行。
  • PXN(bit 53):Privileged Execute Never,EL1 不可执行。
  • nG(bit 11):non-Global 标志。置 1 表示此 TLB 项与 ASID 关联,切换地址空间时自动失效。用户空间页必须设置 nG=1,内核空间页通常 nG=0。
  • DBM(bit 51):Dirty Bit Modifier,硬件脏页跟踪。当 TCR_EL1.HD=1 时,硬件在写操作时自动设置此位,避免软件通过权限模拟脏页跟踪。
  • CONT(bit 52):Contiguous 提示位。告知 TLB 预取器连续的页表项属于同一连续区域,可用单一 TLB 项覆盖。
  • GP(bit 50):Guarded Page,用于分支目标识别(BTI/PAC)安全特性。
  • AttrIndx[2:0](bits 4:2):内存属性索引,指向 MAIR_EL1 寄存器中的 8 个属性字节之一。MAIR_EL1 预设了 Normal Memory、Device Memory 等属性组合,AttrIndx 选择使用哪种。

输出地址字段

页表项中的物理地址部分(Output Address)并非完整的物理地址,而是对齐到块/页大小的物理地址高位。例如在 4KB 页的 PTE 中,bits[47:12] 表示物理页帧号(假设物理地址宽度为 48 位)。

5.2.5 页大小支持

ARM64 支持三种页大小,均在编译时选定,运行时不可混用:

  • 4KB:默认配置,兼容性最好。页内偏移 12 位,每级页表索引 9 位。适合通用工作负载。
  • 16KB:页内偏移 14 位,每级页表索引 11 位。更大的页表项密度意味着更少的页表级数即可覆盖相同的地址空间,TLB 覆盖率提升。适合数据库等需要精细内存管理且 TLB 压力大的场景。缺点是内存开销略大(每个页表页容纳的项数不变但页更大)。
  • 64KB:页内偏移 16 位。默认即是大页,TLB 覆盖率极高。适合大内存系统(如数百 GB 以上),减少了页表遍历开销。但在内存碎片化环境中可能浪费更多物理内存。

5.2.6 内核虚拟内存布局常量

Linux 内核在 arch/arm64/include/asm/memory.h 中定义了虚拟内存布局的关键常量:

  • PAGE_OFFSET:线性映射区的起始虚拟地址,计算方式为 -(1UL << VA_BITS)。以 48 位 VA 为例,PAGE_OFFSET = 0xFFFF800000000000。线性映射区从此处开始,向上延伸覆盖所有物理内存。
  • MODULES_VADDR:模块区域的起始地址,位于内核空间最底部,紧邻地址空间中点。模块区域大小为 2GB(MODULES_VSIZE)。
  • VMALLOC_START / VMALLOC_END:vmalloc 区域的起止地址。此区域用于 vmalloc()vmap()ioremap() 等动态映射。其大小取决于线性映射区和 vmemmap 区域之后剩余的地址空间。
  • VMEMMAP_START:vmemmap 区域的起始地址。每个物理页帧对应一个 struct page 结构体,vmemmap 将这些结构体连续排列在虚拟地址空间中。起始地址通常为 PAGE_OFFSET - VMEMMAP_SIZE
  • KIMAGE_VOFFSET:内核镜像虚拟地址与物理地址之间的偏移量。由于内核镜像通过 kimage 方式加载,其虚拟地址并非简单的线性映射关系,需要此偏移量进行转换。

这些常量共同定义了 ARM64 内核的虚拟地址空间骨架,所有内核子系统都依赖此布局进行地址转换和内存分配。

5.2.7 ASID(地址空间标识符)

ARM64 的 ASID 机制是其 TLB 管理的核心优化之一:

  • 硬件 ASID 长度为 8 位或 16 位,由 TCR_EL1.AS 配置。8 位 ASID 提供 256 个硬件标识符,16 位 ASID 提供 65536 个。
  • ASID 编码在 TTBR0_EL1 的高位中(bits[63:48] 或 bits[63:56]),与页表基址共存于同一寄存器。
  • 当 nG=1 的页表项被加载到 TLB 时,TLB 项被标记为与当前 ASID 关联。后续查找时,硬件自动比对 ASID,仅当 ASID 匹配时才使用该 TLB 项。

这与 x86_64 的做法形成对比。x86_64 在每次上下文切换时必须重载 CR3 并刷新非全局 TLB 项,而 ARM64 只需切换 TTBR0(其中编码了新进程的 ASID),若新旧进程 ASID 不同,硬件自动隔离 TLB 项,无需刷新。这极大地减少了上下文切换的开销。

Linux 内核的 ARM64 ASID 分配器(arch/arm64/mm/context.c)管理着硬件 ASID 到软件进程上下文的映射。当硬件 ASID 耗尽时(进程数超过 ASID 容量),分配器会执行全局 TLB 无效化并重新分配 ASID,这一过程称为 ASID 版本翻转(version rollover)。

5.2.8 大页支持

ARM64 Linux 支持两种大页机制:

透明大页(块映射)

在页表遍历过程中,如果某一层级的描述符直接指向一个大的物理连续块而非下一级页表,就形成了块映射(block mapping),即大页:

页大小 PMD 级大页 PUD 级大页
4KB 2MB 1GB
16KB 32MB 16GB
64KB 512MB

例如在 4KB 页 + 4 级页表配置下,PMD 层级的块映射覆盖 2MB 连续物理内存(512 个 4KB 页),PUD 层级的块映射覆盖 1GB 连续物理内存(512 个 2MB 块)。

连续页(Contiguous Pages)

ARM64 还支持 Contiguous 页机制:16 个连续的 PTE 或 PMD 可以被标记为 Contiguous(设置 CONT 位),TLB 硬件将其作为单个 TLB 项处理。这等效于将 TLB 覆盖率提升 16 倍:

  • 4KB 页 + CONT:16 x 4KB = 64KB 的 TLB 覆盖
  • 2MB 大页 + CONT:16 x 2MB = 32MB 的 TLB 覆盖

Contiguous 机制对软件透明:应用程序无需特殊系统调用,内核在分配连续物理页时自动设置 CONT 位。这提供了一种介于普通页和透明大页之间的轻量级 TLB 优化手段。


ARM64 的虚拟内存架构通过 TTBR0/TTBR1 分割实现了内核与用户空间的硬件级隔离,通过灵活的多级页表配置适配从嵌入式到大服务器的广泛场景,通过 ASID 机制优化了上下文切换性能,通过大页和 Contiguous 页减少了 TLB 压力。这些设计共同构成了 ARM64 Linux 内存管理的硬件基础。

5.3 ARM64 异常向量与中断处理

ARM64 架构采用精简的异常向量表设计,仅包含 16 个入口,通过硬件向量基址寄存器(VBAR_EL1)与软件宏配合完成从异常进入到返回的全流程。本章以 Linux 7.0 内核源码为主线,逐一剖析异常向量表、异常入口保存、系统调用、中断处理、GIC 中断控制器、异常返回、KPTI 页表切换以及 SDEI 机制。

5.3.1 异常向量表

异常向量表定义于 arch/arm64/kernel/entry.S。CPU 通过系统寄存器 VBAR_EL1(Vector Base Address Register)获取向量表的基地址。内核在启动阶段将向量表的地址写入该寄存器,此后所有异常均由硬件自动跳转到表中对应位置。

向量表共 16 个表项,按照目标异常级别(EL)和栈指针选择分为 4 组,每组 4 种异常类型:

组别 Sync IRQ FIQ SError
EL1t(使用 SP_EL0) el1t_sync el1t_irq el1t_fiq el1t_error
EL1h(使用 SP_EL1) el1h_sync el1h_irq el1h_fiq el1h_error
EL0(64 位用户态) el0t_64_sync el0t_64_irq el0t_64_fiq el0t_64_error
EL0(32 位兼容态) el0t_32_sync el0t_32_irq el0t_32_fiq el0t_32_error

内核运行在 EL1 并使用 SP_EL1,因此 EL1h 组是内核自身异常的主路径;EL0_64 和 EL0_32 分别服务于 64 位和 32 位兼容用户态程序。每个表项占用 128 字节(0x80 对齐),保证向量之间有足够空间嵌入跳转指令。

与 x86 架构对比:x86 定义了 256 个 IDT(中断描述符表)表项,覆盖大量硬件中断向量与异常号;ARM64 仅通过 16 个向量入口,将中断号的解析工作交给软件(主要是 GIC 驱动)完成,体现了精简指令集的设计哲学。

5.3.2 异常类型

ARM64 定义了四种异常类型:

  • 同步异常(Synchronous):由当前执行的指令直接触发。典型来源包括:SVC(用户态系统调用)、HVC(虚拟化管理调用)、SMC(安全监控调用)、指令/数据中止(page fault)、对齐错误等。同步异常的关键特征是 ESR_EL1(Exception Syndrome Register)中记录了触发原因,便于软件精确诊断。
  • IRQ(普通中断请求):由外部设备通过中断控制器投递,是常规设备中断的标准通道。
  • FIQ(快速中断请求):高优先级中断,在 Linux 中通常留给安全世界(TrustZone)使用。GICv3 可以利用优先级机制将 FIQ 映射为伪 NMI(Non-Maskable Interrupt)。
  • SError(系统错误):异步异常,通常来自 RAS(Reliability, Availability, Serviceability)子系统,表示硬件检测到不可恢复的错误。由于是异步的,SError 可能在指令执行很久之后才被报告。

5.3.3 异常入口(kernel_entry 宏)

当异常发生时,CPU 自动完成少量工作:将 PSTATE 保存到 SPSR_EL1,将返回地址保存到 ELR_EL1,切换到对应异常级别的栈指针,然后跳转到向量表项。此后软件接管,执行 kernel_entry 宏完成上下文保存。

kernel_entry 的核心流程:

  1. 将 SP 减去 PT_REGS_SIZE(即 struct pt_regs 的大小),在内核栈上分配保存区域。
  2. 依次将 30 个通用寄存器(X0-X29)保存到 pt_regs 中。由于异常发生时 X29(FP)和 X30(LR)具有特殊语义,它们在栈帧布局中占据固定位置。
  3. 保存 ELR_EL1(返回地址,即异常前的 PC)和 SPSR_EL1(异常前的 PSTATE)。
  4. 若为 EL0 入口,加载当前任务指针(通过 current_thread_info() 获取),并检查 MTE(Memory Tagging Extension)异步错误。
  5. 对于系统调用入口,用户态参数通过 X0-X5 传递,系统调用号存放在 X8 中。

与 x86 的 PUSH_AND_CLEAR_REGS 对比:x86 通过 push 指令逐个压栈,而 ARM64 采用预先计算偏移量后以 STP(Store Pair)指令批量写入,效率更高。

5.3.4 系统调用(SVC #0)

系统调用是用户态请求内核服务的标准机制。ARM64 上的调用流程如下:

  1. 用户程序将系统调用号写入 X8,参数放入 X0-X5。
  2. 执行 SVC #0 指令,CPU 从 EL0 切换到 EL1,跳转到向量表中 EL0_64_sync 入口。
  3. 向量处理代码分发到 el0t_64_sync_handler(),该函数根据 ESR_EL1 判断异常来源为 SVC,调用 el0_svc()
  4. el0_svc() 调用 do_el0_svc(),进而通过 el0_svc_common() 查找系统调用表(sys_call_table),执行对应的系统调用实现函数。
  5. 返回值写入 X0,通过 kernel_exit 宏恢复上下文并执行 ERET 返回用户态。

x86 使用 SYSCALL 指令配合 MSR_LSTAR 寄存器实现快速系统调用入口;ARM64 的 SVC 路径统一经过向量表分发,代码路径略长,但设计更加一致。Linux 通过可选的 syscall 扩展优化减少分支开销。

5.3.5 中断处理(IRQ)

硬件中断的处理流程以 GIC 投递 IRQ 为起点:

  1. GIC 向 CPU 发送中断信号,CPU 在当前指令完成后响应,跳转到 EL1h_irq 向量。
  2. kernel_entry 保存完整上下文。
  3. 中断处理函数调用 GIC 驱动,读取 IAR(Interrupt Acknowledge Register,中断确认寄存器),获得硬件中断号(IRQ number)。此读操作同时表示 CPU 已确认该中断。
  4. 根据中断号查找对应的设备驱动处理程序(通过内核通用 IRQ 框架 generic_handle_irq),执行设备特定的中断服务程序。
  5. 中断处理完成后,向 GIC 写入 EOI(End of Interrupt),通知中断控制器本次处理结束。
  6. kernel_exit 恢复上下文并返回被中断的代码。

若中断发生在内核态(EL1),返回后继续执行被中断的内核代码;若发生在用户态(EL0),返回前还需检查是否需要重新调度(need_resched)和是否有待处理信号。

5.3.6 GIC 通用中断控制器

ARM 平台的中断控制器为 GIC(Generic Interrupt Controller),其发展经历了多个版本:

  • GICv2:由 Distributor(分发器)和 CPU Interface(CPU 接口)两部分组成。Distributor 负责中断路由和优先级仲裁,CPU Interface 负责与单个 CPU 核心交互。GICv2 最多支持 8 个 CPU 核心。
  • GICv3/GICv4:引入了可扩展架构,支持数千个 CPU 核心。新增 LPI(Locality-specific Peripheral Interrupts,局部外设中断),通过 ITS(Interrupt Translation Service,中断转换服务)实现基于消息的中断(MSI)路由。GICv4 在 GICv3 基础上增加了直接虚拟中断注入,减少虚拟化场景下的 VM Exit 开销。
  • 优先级机制:GIC 支持多级中断优先级(至少 16 级,GICv3 可达 256 级)。Linux 利用优先级差异实现伪 NMI:将部分中断配置为最高优先级,使其在内核关闭普通中断(DAIF.I 位)时仍能被响应,用于硬锁检测(lockdep)和 kgdb 等调试功能。

5.3.7 异常返回(kernel_exit 宏)

异常返回是异常入口的逆过程。kernel_exit 宏负责:

  1. 从 pt_regs 中恢复所有通用寄存器(X0-X29)。
  2. 恢复 ELR_EL1(返回地址)和 SPSR_EL1(处理器状态)。
  3. 执行 ERET(Exception Return)指令。该指令由硬件原子完成:将 ELR_EL1 写入 PC,将 SPSR_EL1 写入 PSTATE,同时完成异常级别切换。

若返回目标是 EL0(用户态),还需额外处理:

  • 切换到用户态栈指针。
  • 应用 SSBD(Speculative Store Bypass Disable)缓解措施(若硬件需要)。
  • 执行 KPTI 页表切换,将内核页表替换为用户页表。

5.3.8 KPTI 蹦床页

KPTI(Kernel Page Table Isolation,内核页表隔离)是针对 Meltdown 类侧信道攻击的缓解措施,通过在用户态运行时移除内核地址空间映射来防止推测执行泄露内核数据。

CONFIG_UNMAP_KERNEL_AT_EL0 启用时,内核为每个进程维护两套页表:一套完整映射内核空间(内核态使用),一套仅映射少量蹦床代码(用户态使用)。蹦床页同时映射在两张页表中,充当切换桥梁。

  • 异常入口:CPU 从用户态进入内核态时,在蹦床页中切换 TTBR0_EL1,从用户页表切换到内核页表,刷新 TLB 后跳转到内核主体代码。
  • 异常出口:返回用户态前,再次通过蹦床页将 TTBR0_EL1 切回用户页表。

x86 使用 CR3 寄存器切换实现类似功能(KAISER/PAGE_TABLE_ISOLATION),原理相同但 ARM64 的 TTBR0/TTBR1 硬件分离设计使得地址空间隔离更为自然。

5.3.9 SDEI(软件委托异常接口)

SDEI(Software Delegated Exception Interface)是 ARM 定义的固件到操作系统的事件通知机制。固件通过 SMC(Secure Monitor Call)或 HVC(Hypervisor Call)向操作系统投递事件,操作系统在独立的栈上执行预注册的处理函数。

SDEI 的典型应用场景包括:

  • RAS 事件:固件检测到硬件错误,通过 SDEI 通知操作系统执行错误恢复或记录日志。
  • PMU 事件:性能监控单元的固件级事件通知。
  • 固件事件:如固件更新完成、安全状态变更等。

SDEI 定义了两种优先级:普通(Normal)和关键(Critical)。关键优先级事件可以在普通优先级事件处理过程中抢占执行。每种优先级拥有独立的专用栈,确保事件处理不会破坏被中断上下文的栈空间。

SDEI 在概念上与 x86 的 NMI(Non-Maskable Interrupt)最为接近——二者都提供了一种不可屏蔽的高优先级通知通道,但 SDEI 基于 ARM 固件调用机制(SMC/HVC),而 x86 NMI 是由硬件中断控制器直接投递的。

5.4 ARM64 内核启动:从 primary_entry 到 start_kernel

ARM64 架构的内核启动流程与 x86_64 截然不同。它没有实模式、没有保护模式过渡、没有 16 位代码,整个启动路径干净利落。从引导加载程序跳转到 primary_entry 开始,经过身份映射建立、异常级别切换、MMU 使能,最终进入架构无关的 start_kernel

5.4.1 启动入口前提条件

ARM64 Linux 内核对引导加载程序(UEFI 或 U-Boot)提出了一组严格的入场要求:

  • MMU 必须关闭(MMU must be OFF)。内核在启动早期需要以物理地址直接运行,自行建立页表后再开启 MMU。
  • D-cache 必须关闭(D-cache must be OFF)。数据缓存若处于开启状态,可能导致内核映像的内存视图与实际物理内存不一致。
  • I-cache 可以开启也可以关闭。指令缓存的状态不影响启动正确性。
  • X0 = 设备树(DTB/FDT)的物理地址。ARM64 不像 x86 那样通过低内存传递启动参数,而是将设备树 blob 的地址放在寄存器 X0 中传递。
  • X1、X2、X3 保留,必须为 0
  • CPU 必须处于 EL1 或 EL2。通常引导加载程序运行在 EL2(Hypervisor 异常级别),内核会在启动早期将其降级到 EL1。

5.4.2 primary_entry —— 启动第一站

primary_entry 是内核映像的入口点,定义在 arch/arm64/kernel/head.S 中。它按顺序调用一系列子程序,逐步完成启动环境初始化:

1
2
3
4
5
6
7
8
9
primary_entry:
bl record_mmu_state // 检测 MMU 状态,结果保存到 x19
bl preserve_boot_args // 保存 x0(DTB指针) 到 x21,x0-x3 存入 boot_args[]
bl __pi_create_init_idmap // 创建初始身份映射页表
mov x0, x20 // 传递启动标志
bl init_kernel_el // 检测异常级别,若在 EL2 则配置并降级到 EL1
mov x0, x20
bl __cpu_setup // 配置 SCTLR、TCR、MAIR 等系统寄存器
b __primary_switch // 使能 MMU,重定位内核

各步骤的职责:

  1. record_mmu_state:读取当前 MMU 状态(虽然前提要求 MMU 关闭,但内核仍做防御性检查),将结果记录在 x19 中供后续使用。

  2. preserve_boot_args:将 X0 中的 DTB 物理地址保存到 X21(整个启动过程中 X21 始终持有该指针),同时将 X0-X3 写入 boot_args[] 数组,供后续 C 代码查询。

  3. __pi_create_init_idmap:创建初始身份映射(identity mapping),使物理地址与虚拟地址形成一对一映射。这是 MMU 开启前必须完成的准备工作——没有页表就无法启用 MMU。

5.4.3 init_kernel_el —— 异常级别检测与切换

ARM64 的异常级别(Exception Level)决定了 CPU 的特权等级。引导加载程序通常在 EL2 启动内核,而内核主体运行在 EL1。init_kernel_el 负责检测当前级别并在必要时执行降级:

流程如下:

  1. 读取 CurrentEL 寄存器获取当前异常级别。
  2. 若处于 EL2
    • 配置 HCR_EL2(Hypervisor Configuration Register),设置虚拟化相关控制位。
    • 将 hyp stub 向量表地址写入 VBAR_EL2
    • 清除 SCTLR_EL2 的高 32 位。
    • 将目标 EL1 入口地址写入 ELR_EL1,并设置 SPSR_EL1 记录返回后的处理器状态。
    • 执行 ERET 指令,CPU 从 EL2 安全降级到 EL1。
  3. 若处于 EL1:直接继续执行。
  4. 返回值(X0)携带启动模式标志:BOOT_CPU_MODE_EL1BOOT_CPU_MODE_EL2

这个过程体现了 ARM64 的设计哲学:异常级别切换通过系统寄存器配置 + ERET 指令完成,而非 x86 中通过 far jump 切换段描述符的复杂方式。

5.4.4 __cpu_setup —— 处理器配置

在 MMU 开启之前,__cpu_setup 对 CPU 的关键系统寄存器进行配置:

  • SCTLR_EL1(System Control Register):设置各种控制位,如对齐检查、缓存策略等。
  • TCR_EL1(Translation Control Register):配置页表 walks 的属性,包括物理地址大小、虚拟地址空间划分(TTBR0/TTBR1)等。
  • MAIR_EL1(Memory Attribute Indirection Register):定义内存属性(如 Normal Cacheable、Device nGnRnE 等),供页表项中的属性索引引用。

这些寄存器的值将在 MMU 使能后立即生效,因此必须在 MMU 开启前完成配置。

5.4.5 __primary_switch —— 使能 MMU

这是启动流程中最关键的时刻:MMU 从关闭状态切换到开启状态,内核从物理地址运行切换到虚拟地址运行。

1
2
3
4
5
__primary_switch:
// 将 reserved_pg_dir 加载到 TTBR0/TTBR1
// 通过设置 SCTLR_EL1.M 位使能 MMU
bl __pi_early_map_kernel // 映射并重定位内核
b __primary_switched
  • 使用 reserved_pg_dir 作为初始页表基址。这是编译时预留的页表目录,包含内核映像的身份映射和虚拟地址映射。
  • 将 SCTLR_EL1 的 M 位置 1,MMU 正式使能。从此刻起,所有地址访问都经过 MMU 转换。
  • __pi_early_map_kernel 完成内核的正式映射和重定位(如果需要的话,处理 KASLR 偏移)。

5.4.6 __primary_switched —— 汇编阶段的收尾

MMU 使能后,内核运行在虚拟地址空间中。__primary_switched 完成最后的汇编级初始化工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
__primary_switched:
// 设置 init_task 为当前任务
adr_l x0, init_task
msr sp_el0, x0 // current 宏依赖 sp_el0 指向当前 task_struct

// 加载异常向量表
adr_l x0, vectors
msr vbar_el1, x0 // 异常/中断从此进入内核向量表

// 保存 FDT 指针
str x21, [x0] // x21 → __fdt_pointer

// 计算 kimage_voffset(内核虚拟地址与物理地址的偏移量)
// 调用 kasan_early_init(地址消毒器早期初始化)
// 调用 finalise_el2(完成 EL2 相关的最终配置)

// 跳转到架构无关的通用启动入口
b start_kernel

关键操作解读:

  • 设置 init_task:ARM64 巧妙地利用 sp_el0 寄存器保存当前进程的 task_struct 指针。此处将 init_task(0 号进程的 task_struct)写入 sp_el0,使得 current 宏能正确工作。
  • 加载异常向量表vectors 是内核的异常向量表基地址,包含同步异常、IRQ、FIQ、SError 等各类异常的处理入口。写入 VBAR_EL1 后,CPU 遇到异常时会跳转到正确的处理程序。
  • 保存 FDT 指针:将 X21 中持有(自 preserve_boot_args 起)的设备树物理地址存储到全局变量 __fdt_pointer,供后续设备树解析使用。

完成这一切后,执行 b start_kernel 跳转到架构无关的通用启动入口。从此,ARM64 特定的汇编启动阶段宣告结束。

5.4.7 完整启动流程图

1
2
3
4
5
6
7
8
9
10
11
12
Bootloader (UEFI / U-Boot)
→ 将内核 Image 加载到 2MB 对齐的物理地址
→ 设置 X0 = DTB 物理地址, X1-X3 = 0
→ 跳转到 primary_entry
→ record_mmu_state (检测并记录 MMU 状态)
→ preserve_boot_args (保存 DTB 指针到 x21)
→ create_init_idmap (创建初始身份映射页表)
→ init_kernel_el (EL2 → EL1 降级,若需要)
→ __cpu_setup (配置 SCTLR/TCR/MAIR)
→ __primary_switch (使能 MMU,重定位内核)
→ __primary_switched (设置 init_task、向量表、FDT 指针)
→ start_kernel (进入架构无关的通用启动流程)

5.4.8 ARM64 与 x86_64 启动流程对比

ARM64 与 x86_64 的内核启动路径差异巨大,根本原因在于两种架构的历史包袱和设计理念不同:

特性 ARM64 x86_64
启动模式 直接从 EL1/EL2 开始 从 16 位实模式起步,经历保护模式、长模式
实模式/16 位代码 必须处理(real-mode header、setup code)
A20 门 无概念 需要使能 A20 地址线
遗留 BIOS 依赖 BIOS 调用获取内存等信息
启动参数传递 X0 寄存器(DTB 物理地址) 低内存区域的 boot params / cmdline
内核解压 引导加载程序负责 内核内置解压器(bzImage)
内核映像头 64 字节扁平头部 PE 头 + 16 位 setup 代码,结构复杂
内核映像格式 单一扁平二进制(Image) bzImage(含实模式部分 + 保护模式部分)
MMU 开启方式 配置系统寄存器后置位 SCTLR.M 设置 CR0.PG,需先建立临时页表
异常/中断向量 VBAR_EL1 指向向量表 IDT(Interrupt Descriptor Table)

ARM64 的启动路径更简洁的核心原因:它是一块干净的 64 位架构,没有 x86 数十年积累的向后兼容负担。从加电到内核入口,ARM64 只需做”正确的事”——建立映射、切换异常级别、开启 MMU,然后进入 C 代码世界。5.5 ARM64 UEFI 启动与内核映像格式

ARM64 内核 Image 头部

ARM64 内核映像(arch/arm64/boot/Image)的头部为 64 字节,定义在
arch/arm64/include/asm/image.h 中。该头部同时兼容裸机启动和 UEFI PE 加载,
其布局如下:

偏移 大小 字段 说明
0 4 字节 code0 MZ 魔数(UEFI PE 头)或跳转指令(裸机)
4 4 字节 code1 跳转到 primary_entry 的分支指令
8 8 字节 text_offset 内核加载地址相对于物理内存起始的偏移
16 8 字节 image_size 内核映像的总大小(含 BSS)
24 8 字节 flags 标志位:字节序、页大小、物理基址等
32 8 字节 res2 保留
40 8 字节 res3 保留
48 8 字节 res4 保留
56 4 字节 magic 魔数 "ARM\x64"(0x644d5241)
60 4 字节 pe_offset PE/COFF 头部的偏移量

code0 字段的设计尤为巧妙:当 UEFI 固件的 PE 加载器解析该文件时,它识别开头的 MZ 魔数并按照 PE/COFF 格式加载;当裸机引导程序(如 U-Boot)加载时,该字段被解释为一条 b 分支指令,直接跳转到 code1,进而跳转到primary_entry——内核的主入口点。这种双重身份设计使得同一个内核映像可以同时支持两种启动方式。

flags 字段(偏移 24)包含以下关键信息:

  • 字节序标志(bit 0):0 = 小端,1 = 大端
  • 页大小(bits 1-2):4KB、16KB 或 64KB
  • 物理基址标志(bit 3):text_offset 是否相对于 2MB 对齐的物理基址
  • 镜像放置(bits 4-5):image_size 是否包含 BSS 段

UEFI 启动流程:EFI Stub

ARM64 内核内嵌了一个 EFI Stub(drivers/firmware/efi/libstub/arm64-stub.c),它作为 UEFI 应用程序运行,负责在 UEFI 环境中完成内核的加载和重定位。

UEFI 启动流程如下:

1
2
3
4
5
6
7
UEFI 固件
→ efi_pe_entry() // EFI Stub 入口,解析 PE 头
→ handle_kernel_image() // 定位并重定位内核映像
→ primary_entry() // 跳转到内核主入口
→ init_kernel_el() // 确认当前异常级别
→ preserve_boot_args() // 保存 bootloader 传递的参数
→ create_idmap() // 创建初始页表映射

efi_pe_entry() 是 PE/COFF 格式的入口点,UEFI 固件将控制权直接移交此处。该函数利用 UEFI Boot Services 分配内存、加载内核映像,然后跳转到内核的实际入口 primary_entry。EFI Stub 还负责:

  • 从 UEFI 获取内存映射信息
  • 处理内核的物理地址随机化(KASLR)
  • 将 UEFI 的内存映射转换为 Linux 的 memmap 格式
  • 设置初始的 FDT(如果 UEFI 提供了 DTB)

与 x86_64 不同,ARM64 没有实模式(real mode)阶段,内核从第一条指令开始就运行在 64 位模式下,这大大简化了启动流程。

内核映像格式

ARM64 支持以下内核映像格式:

  • Image:原始未压缩的二进制映像,是最基本的格式
  • Image.gz:gzip 压缩的映像,需要引导程序具备解压能力
  • Image.lz4:LZ4 压缩的映像
  • Image.lzma:LZMA 压缩的映像

重要区别:ARM64 没有 zImage。32 位 ARM(ARMv7)支持 zImage——一种内核自带解压器的自解压映像。ARM64 移除了这一机制,解压责任交由引导程序
(U-Boot、GRUB 等)承担。这一设计决策简化了内核代码,但要求引导程序具备更强的能力。

对于纯 UEFI 固件环境,内核提供了 EFI zboot 格式(make Image.gz 配合CONFIG_EFI_ZBOOT)。该格式将压缩的内核映像封装为 EFI 应用程序,内含
一个微型解压器,由 UEFI 固件直接加载执行。

DTB(Device Tree Blob)

DTB 是 ARM64 平台上描述硬件信息的主要机制之一。它是一棵结构化的二进制树,描述了系统的 CPU、内存布局、总线、设备及其属性。DTB 通常由引导程序传递给内核,存放于寄存器 x0x21 所指向的物理地址。

DTB 的典型内容包括:

  • /cpus:CPU 核心数量、频率、启用方法
  • /memory:物理内存范围
  • /chosen:内核命令行、initrd 地址
  • 各设备的兼容字符串(compatible)、寄存器地址(reg)、中断号(interrupts)

内核在 setup_arch() 中调用 setup_machine_fdt() 解析 DTB:

1
2
3
4
5
6
7
// arch/arm64/kernel/setup.c
void __init setup_arch(char **cmdline_p)
{
// ...
setup_machine_fdt(__fdt_pointer); // 解析 DTB,匹配机器型号
// ...
}

DTB 与 ACPI 是 ARM64 上两种并存的硬件发现机制。DTB 在嵌入式和服务器领域广泛使用,而 ACPI 则在遵循 SBSA/SBBR 标准的服务器平台上使用。内核通过CONFIG_EFI_STUBCONFIG_ACPI 同时支持两种机制。

setup_arch() 初始化流程

setup_arch() 是 ARM64 架构特定初始化的核心函数,定义在arch/arm64/kernel/setup.c 中。其调用序列为:

1
2
3
4
5
6
7
8
9
setup_arch()
→ kaslr_init() // 初始化内核地址随机化种子
→ setup_machine_fdt() // 解析设备树,建立硬件拓扑
→ arm64_memblock_init() // 初始化 memblock 分配器,建立内存区域
→ paging_init() // 设置内核页表,映射整个物理内存
→ acpi_boot_table_init() // 初始化 ACPI(如果存在)
→ acpi_numa_init() // 初始化 NUMA 拓扑(基于 ACPI SRAT)
→ zone_sizes_init() // 初始化内存管理区
-> cpu_read_bootcpu_ops() // 读取启动 CPU 的特性

kaslr_init() 从 DTB 的 /chosen/kaslr-seed 属性或 EFI 的 RNG 协议获取随机种子,为后续的内核地址空间布局随机化提供熵源。

arm64_memblock_init() 根据 DTB 或 EFI 提供的内存映射,将可用内存区域注册到 memblock 分配器,同时保留内核映像、DTB、initrd 占用的区域。

paging_init() 是最关键的步骤之一,它创建内核的最终页表映射,将虚拟地址空间划分为 TTBR0(用户空间)和 TTBR1(内核空间)两部分,并为内核的线性映射(linear mapping)、vmalloc 区和 fixmap 建立页表项。该函数完成后,内核的虚拟内存子系统即可正常运作。

5.6 ARM64 多处理器启动 — PSCI 与 Spin Table

概述

ARM64 系统通常是多核(SMP)系统。启动时只有主 CPU(boot CPU,也称 CPU 0)被固件唤醒并执行内核代码。其余的从 CPU(secondary CPU)需要由内核在适当的时机显式启动。ARM64 提供了两种从 CPU 启动机制:PSCISpin Table

方法一:PSCI(Power State Coordination Interface)

PSCI 是 ARM 标准化的电源状态协调接口,由固件(如 ARM Trusted Firmware,即 ATF/TF-A)实现,运行在最高的异常级别 EL3。PSCI 是现代 ARM64 系统
(包括所有服务器平台)的标准从 CPU 启动方式。

PSCI 的启动流程如下:

1
2
3
4
5
6
7
8
9
内核 (EL1)
→ cpu_psci_cpu_boot() // smp_ops 回调
→ psci_ops.cpu_on(target_cpu, secondary_entry)
→ SMC #0 // 陷入 EL3(TrustZone 固件)
→ ATF/TFA 处理 PSCI_CPU_ON
→ 为目标 CPU 分配电源和时钟
→ 将 secondary_entry 写入目标 CPU 的入口寄存器
→ 向目标 CPU 发出电源启动信号
→ 目标 CPU 从 secondary_entry 开始执行

关键函数 cpu_psci_cpu_boot() 定义在 arch/arm64/kernel/psci.c 中,它调用 psci_ops.cpu_on(),后者通过 SMC(Secure Monitor Call)指令陷入 EL3 的 TrustZone 固件。固件负责为目标 CPU 上电、设置入口地址并释放其执行。SMC 调用的参数包括:

  • 功能 ID:PSCI_CPU_ON(0xC4000003)
  • 目标 CPU 的 MPIDR(Multiprocessor Affinity Register)
  • 入点地址:secondary_entry 的物理地址
  • 上下文 ID:传递给目标 CPU的额外参数

PSCI 的优势在于它是标准化的、跨厂商的接口,所有 ARM 服务器平台都必须实现。固件在 EL3 层面管理 CPU 电源状态,内核无需了解底层电源控制的具体实现。

方法二:Spin Table

Spin Table 是一种较简单的从 CPU 启动方式,主要用于嵌入式系统。它在设备树中通过 enable-method = "spin-table" 声明。其工作原理如下:

1
2
3
4
5
6
7
8
9
10
11
固件阶段:
1. 固件启动所有 CPU
2. 从 CPU 在 secondary_holding_pen 处自旋等待
3. 从 CPU 不断轮询 cpu-release-addr 指向的内存位置

内核阶段:
smp_spin_table_cpu_boot()
→ 将 secondary_entry 写入 cpu-release-addr
→ 清除数据缓存(DC CVAU)
→ 执行 SEV 指令(唤醒自旋等待的从 CPU)
→ 从 CPU 检测到地址更新,跳转到 secondary_entry

secondary_holding_pen 是一段简单的自旋循环代码(定义在arch/arm64/kernel/head.S 中),从 CPU 在此不断检查一个内存地址是否被写入了有效的入口地址。当主 CPU 调用 smp_spin_table_cpu_boot()secondary_entry 的地址写入 cpu-release-addr 指向的内存后,从 CPU检测到变化并跳转执行。Spin Table 的缺点是:所有从 CPU 在内核启动前就已上电并自旋,消耗额外功耗;且它不是标准化的接口,兼容性不如 PSCI。

从 CPU 启动流程

无论使用 PSCI 还是 Spin Table,从 CPU 的入口点都是 secondary_entry
定义在 arch/arm64/kernel/head.S 中。其执行路径为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
secondary_entry                         // 汇编入口
→ init_kernel_el() // 确认当前异常级别
→ __cpu_setup() // 配置 CPU 控制寄存器
→ 清除 TLB
→ 设置 SCTLR_EL1(关闭 MMU 等)
→ 设置 TCR_EL1(页表配置)
→ secondary_startup // 建立页表映射
→ __enable_mmu() // 启用 MMU
→ 将 TTBR1 指向 swapper_pg_dir
→ 设置 SCTLR_EL1.M = 1
→ __secondary_switched // 切换到 C 语言环境
→ 设置 SP(任务栈)
→ 保存启动参数
→ secondary_start_kernel() // 跳转到 C 函数

secondary_start_kernel() — C 语言入口

secondary_start_kernel() 定义在 arch/arm64/kernel/smp.c 中,是从 CPU
进入 C 语言运行环境后的主函数:

1
2
3
4
5
6
7
8
// 简化的调用流程
secondary_start_kernel()
→ cpuinfo_store_boot_cpu() // 读取并存储 CPU 特性
→ check_local_cpu_errata() // 检查 CPU 勘误
→ check_local_cpu_caps() // 验证 CPU 特性与启动 CPU 一致
→ enable_cpu_capabilities() // 启用从 CPU 的特性
→ gic_cpu_init() // 初始化 GIC(中断控制器)接口
→ cpu_startup_entry() // 进入调度器

该函数的关键职责包括:

  1. CPU 特性验证:从 CPU 必须与主 CPU 拥有兼容的特性集,否则系统可能不稳定。例如,如果主 CPU 支持某个可选指令集扩展而从 CPU 不支持,内核会禁用该扩展或直接拒绝启动该从 CPU。

  2. GIC 初始化:每个 CPU 核心都有自己的 GIC(Generic Interrupt Controller)CPU 接口。gic_cpu_init() 为当前 CPU 配置中断优先级掩码、中断分组等。

  3. 系统寄存器配置:设置 VBAR_EL1(异常向量基址)、TCR_EL1页表控制)、SCTLR_EL1(系统控制)等关键寄存器。

  4. 进入调度器:最终调用 cpu_startup_entry(CPUHP_AP_ONLINE_IDLE),将 CPU 标记为在线并进入 idle 调度循环,等待任务分配。

ARM64 vs x86_64:多核启动对比

方面 x86_64 ARM64
启动协议 SIPI(Start-Up IPI) PSCI SMC / Spin Table
固件角色 BIOS/UEFI,启动后不再参与 ATF(EL3)持续参与电源管理
初始模式 实模式(16 位),需转入长模式 直接 64 位(EL1)
从 CPU 等待 通过 APIC 唤醒,无自旋等待 Spin Table 需自旋,PSCI 不需要
入口地址 APIC 提供的低 1MB 内地址 secondary_entry 任意物理地址
CPU 拓扑 APIC ID MPIDR 寄存器

x86_64 使用 SIPI(Startup Inter-Processor Interrupt)通过 APIC 向从 CPU发送启动向量,从 CPU 从实模式开始执行一段 trampoline 代码(位于低 1MB 内存),然后切换到保护模式和长模式。相比之下,ARM64 的 PSCI 方案更为简洁:固件在 EL3 管理电源,内核通过 SMC 调用请求启动,从 CPU 直接在 64 位 EL1模式下进入内核入口点,无需经历复杂的模式转换。

5.7 ARM64 安全特性 — PAC、BTI、MTE、KPTI

ARM64 架构在近年引入了一系列硬件级安全特性,为内核和用户空间提供了比纯软件方案更高效、更强力的防护。这些特性从 ARMv8.3 到 ARMv8.5 逐步引入,Linux 内核通过相应的 CONFIG 选项启用。以下逐一分析。

1. 指针认证(Pointer Authentication, PAC)

配置选项CONFIG_ARM64_PTR_AUTHCONFIG_ARM64_PTR_AUTH_KERNEL

指针认证是 ARMv8.3 引入的特性。其核心思想是在指针的空闲高位(用户空间通常是高 16 位,内核空间是高几位)嵌入一个加密签名(PAC,Pointer Authentication Code)。PAC 由指针值、上下文和密钥共同计算得出,使用 QARMA算法。ARM64 定义了五把密钥:

密钥 用途
APIAKey 指令地址签名(签名代码指针、返回地址)
APIBKey 指令地址备用密钥
APDAKey 数据地址签名(签名数据指针)
APDBKey 数据地址备用密钥
APGAKey 通用签名

在函数调用中,PAC 的工作方式如下:

1
2
3
4
5
6
7
8
9
10
11
函数入口(prologue):
PACIASP // 用 APIAKey + SP 作为上下文签名 LR(返回地址)
// 签名后的返回地址存入 LR

函数出口(epilogue):
AUTIASP // 验证 LR 中的 PAC,若有效则还原原始地址
RET // 返回

若攻击者覆写了栈上的返回地址,AUTIASP 验证失败:
→ 若 CPU 支持 FPAC(ARMv8.6+):直接触发异常,内核崩溃
→ 否则:PAC 被破坏但返回错误地址,后续访问触发异常

内核模式 PAC(CONFIG_ARM64_PTR_AUTH_KERNEL)对内核代码指针进行签名,防止内核返回地址被覆写。编译器在编译内核时自动插入 PACIASP/AUTIASP指令对。每把密钥在进程切换时通过 keys->apia 等字段保存和恢复,确保不同进程使用不同密钥。

对比 x86_64:x86 没有直接的硬件指针认证等价物。x86 的控制流完整性(CFI)方案是编译器级别的(如 KCFI、LLVM CFI),通过在间接跳转前检查类型哈希来验证跳转目标。PAC 的优势在于它是硬件实现的,性能开销极低,且密钥由硬件管理,攻击者无法从 EL0/EL1 读取。

2. 分支目标识别(Branch Target Identification, BTI)

配置选项CONFIG_ARM64_BTICONFIG_ARM64_BTI_KERNEL

BTI 是 ARMv8.5 引入的控制流完整性特性,用于防止间接分支(BRBLR)跳转到非法目标地址。其机制如下:

  • 在每个合法的间接分支目标位置放置一条 BTI 指令(landing pad),如 BTI c(call target)或 BTI j(jump target)
  • CPU 在执行 BR/BLR 时检查目标地址是否为 BTI 指令
  • 若目标不是 BTI 指令或 BTI 类型不匹配,CPU 触发 Branch Target Exception

BTI 的启用需要在页表项中设置 PTE_GP(Guarded Page)位。该位告诉 CPU对此页中的间接分支目标进行 BTI 检查。内核通过 BTI_KERNEL 选项为内核代码启用 BTI,编译器在编译时自动在函数入口插入适当的 BTI 指令。

1
2
3
4
5
6
7
合法跳转:
BLR x8 // 间接调用
→ 目标地址处有 "BTI c" → 正常执行

非法跳转(攻击):
BLR x8 // 攻击者控制 x8
→ 目标地址处无 BTI 指令 → 触发 Branch Target Exception → 内核崩溃

对比 x86_64:Intel CET IBT(Indirect Branch Tracking)的机制几乎完全相同——在合法目标处放置 ENDBR64 指令,CPU 检查间接跳转目标是否以ENDBR64 开头。两者在概念上是对等的。

3. 内存标签扩展(Memory Tagging Extension, MTE)

配置选项CONFIG_ARM64_MTE

MTE 是 ARMv8.5 引入的硬件内存安全特性,用于检测内存安全漏洞(缓冲区溢出、释放后使用等)。其原理如下:

  • 物理内存按 16 字节粒度(granule)划分,每个粒度关联一个 4 位标签(共 16 种标签值,0-15)
  • 指针的高位字节(利用 TBI——Top Byte Ignore 特性)中存储一个 4 位指针标签
  • 每次内存访问时,CPU 自动比较指针标签与内存标签:
    • 匹配:正常访问
    • 不匹配:触发标签检查故障(Tag Check Fault)
1
2
3
4
5
6
7
8
9
10
正常访问:
指针标签 = 5, 内存标签 = 5 → 匹配 → 正常读写

缓冲区溢出:
指针标签 = 5, 越界后的内存标签 = 3 → 不匹配 → 触发故障

释放后使用:
分配时:指针标签 = 7, 内存标签 = 7
释放时:内存标签改为 0(或随机值)
再次访问:指针标签 = 7, 内存标签 = 0 → 不匹配 → 触发故障

MTE 支持两种检查模式:

模式 精度 性能 用途
同步(Synchronous) 高,精确报告出错指令 开销较大(约 5-10%) 安全敏感场景
异步(Asynchronous) 低,延迟报告出错 开销较小(约 1-3%) 性能敏感场景

GCR_EL1(Tag Control Register)控制标签生成策略,内核可以通过设置GCR_EL1.EXCLUDE 位掩码来排除某些标签值,使随机生成的标签集中在特定范围内,从而提高检测概率。内核还通过 CONFIG_KASAN_SW_TAGSCONFIG_KASAN_HW_TAGS 将 MTE 集成到 KASAN(Kernel Address SANitizer)中,用于内核自身的内存安全检测。

对比 x86_64:x86 没有硬件内存标签的等价特性。x86 上的内存安全检测主要依赖软件方案:KASAN(基于影子内存)、KFENCE(基于采样检测)、ASAN(用户空间)。MTE 的硬件实现使其性能开销远低于软件方案。

4. KPTI(Kernel Page Table Isolation)

配置选项CONFIG_UNMAP_KERNEL_AT_EL0

ARM64 的 KPTI 机制与 x86_64 的 KPTI 目标相同:防止 Meltdown 类旁路攻击在用户态读取内核内存。ARM64 的实现利用了其双 TTBR(Translation Table Base Register)架构:

1
2
3
4
5
6
7
8
返回用户空间(EL0)时:
→ 将 TTBR0_EL1 指向一块预分配的"零页"页表
→ 该页表仅映射了 trampoline 页和用户空间
→ 内核映射完全不可访问

进入内核(EL1)时:
→ 将 TTBR0_EL1 恢复为完整的内核页表
→ trampoline 代码负责切换 TTBR0 并刷新 TLB

关键设计是 trampoline 页:这是一页同时映射在用户页表和内核页表中的代码,包含异常入口/出口的 TTBR 切换逻辑。当用户态触发系统调用或中断时,CPU 先进入 trampoline 页(在用户页表中有映射),trampoline 代码切换 TTBR0 到内核页表,然后跳转到内核的实际异常处理代码。

对比 x86_64:x86 使用单一 CR3 寄存器,KPTI 通过在 sysret/iret返回用户态时将 CR3 切换到用户页表实现。ARM64 使用双 TTBR0/TTBR1架构,只需切换 TTBR0 即可隔离内核映射(TTBR1 始终指向内核页表,但在 EL0 时不被使用),因此 ARM64 的 KPTI 开销通常低于 x86。

5. E0PD(E0 Prevent Data)

配置选项CONFIG_ARM64_E0PDE0PD 是 ARMv8.5 引入的特性,作为 KPTI 的轻量级替代方案。它不真正取消内核映射,而是让 EL0 对通过 TTBR1(内核地址空间)的访问产生 恒定时间的故障,防止攻击者通过测量访问延迟来探测内核地址映射。TCR_EL1E0PD0E0PD1 位分别控制 TTBR0TTBR1 地址空间的 E0PD 行为。当 E0PD 启用时,无论目标地址是否映射,EL0 的访问都返回故障,且响应时间相同,从而消除了基于时序的旁路攻击面。

6. PAN(Privileged Access Never)与 UAO(User Access Override)

PAN 通过 SCTLR_EL1.PAN 位实现:当该位置 1 时,EL1(内核)对 EL0(用户空间)页面的数据访问被阻止,触发权限故障。这防止内核意外访问用户空间内存(如通过悬挂指针)。

UAO(User Access Override)提供临时豁免机制:SCTLR_EL1.PAN 置位时,copy_to_user()/copy_from_user() 等合法的内核-用户数据拷贝操作通过设置 PSTATE.UAO 位临时允许访问,操作完成后立即清除。对于不支持 PAN 硬件的 CPU,内核通过 CONFIG_ARM64_SW_TTBR0_PAN 提供软件模拟:在进入内核时将 TTBR0 切换为空页表,copy_from_user() 时临时恢复原始 TTBR0,达到类似的隔离效果。

对比 x86_64:x86 的 SMAP(Supervisor Mode Access Prevention,通过 CR4.SMAP 启用)与 PAN 功能等价。x86 通过 RFLAGS.AC 标志位的临时置位(stac/clac 指令)来允许特定的用户空间访问,对应 ARM64 的 UAO。

7. 影子调用栈(Shadow Call Stack)

配置选项CONFIG_SHADOW_CALL_STACK

ARM64 的影子调用栈使用 x18 寄存器作为影子栈指针。函数调用时:

1
2
3
4
5
6
7
8
9
函数入口(prologue):
STR LR, [X18], #8 // 将返回地址压入影子栈,x18 递增

函数出口(epilogue):
LDR X9, [X18, #-8]! // 从影子栈弹出返回地址
CMP X9, LR // 比较影子栈值与当前 LR
B.NE __scs_check_failed // 不匹配则崩溃
MOV LR, X9
RET

影子调用栈与 PAC 互补:PAC 保护单个返回地址不被篡改,影子调用栈保护整个调用链。即使攻击者绕过了 PAC 修改了栈上的返回地址,影子栈上的副本仍可用于检测篡改。

对比 x86_64:Intel CET 的影子栈是硬件实现的(CPU 自动将返回地址压入影子栈),而 ARM64 的影子调用栈是软件实现的(编译器插入指令),但利用了x18 寄存器不会被常规函数调用修改的特性,性能开销较低。

5.8 ARM64 与 x86_64 内核对比

ARM64 与 x86_64 是当前 Linux 内核支持的两大主流 64 位架构。它们在设计哲学上截然不同:x86_64 继承了 CISC 的复杂历史,ARM64 则是基于 RISC 原则的全新设计。本章从多个维度系统对比两种架构在 Linux 内核中的实现差异。

1. 架构基础对比

特性 x86_64 ARM64
指令集 CISC,变长编码(1-15 字节) RISC,定长 32 位编码
通用寄存器 16 个(RAX-R15) 31 个(X0-X30)+ SP
内存模型 Load + Operate(ADD [mem], 1 Load/Store 严格分离
特权模型 Ring 0-3(4 级) EL0-EL3(4 级)
页表基址 CR3(单寄存器) TTBR0 + TTBR1(双寄存器)
系统调用 SYSCALL/SYSRET(MSR 寄存器) SVC(异常向量)
中断控制器 APIC/IOAPIC GIC(Generic Interrupt Controller)
固件接口 BIOS/UEFI UEFI + DTB
端序 仅小端 小端/大端(可配置)

x86_64 的变长指令编码(1-15 字节)允许高密度的指令编码,但译码逻辑复杂。ARM64 的定长 32 位编码简化了译码流水线,有利于提高指令译码吞吐量。在
寄存器方面,ARM64 提供了 31 个通用寄存器(x86_64 仅 16 个),减少了函数调用中的栈溢出(spill)次数。ARM64 的 Load/Store 架构要求所有运算在寄存器间完成,内存操作只能通过专用的 Load/Store 指令,这与 x86_64 的”内存操作数 + 运算”合一模式形成鲜明对比。

2. 启动流程对比

阶段 x86_64 ARM64
CPU 复位 实模式,从 0xFFFFFFF0 执行 EL3/EL2,从固件入口执行
引导程序 GRUB2 加载 bzImage U-Boot/GRUB 加载 Image
16 位设置代码 有(setup_header 无(直接 64 位入口)
模式转换 实模式→保护模式→长模式 EL2→EL1(或直接 EL1)
自解压 内核内嵌解压器 引导程序负责解压
硬件描述 E820 内存映射 DTB 或 ACPI
内核入口 startup_64(压缩内核) primary_entry

x86_64 的启动流程承载了从 16 位实时代到 64 位长模式的历史包袱。CPU 复位后从实模式(16 位)开始,需要经过实模式→保护模式(32 位)→长模式(64 位)两次模式转换。内核映像内嵌了 16 位设置代码和自解压器,GRUB2 需要配合setup_header 协议加载内核。

ARM64 的启动流程则干净得多:CPU 复位后直接在最高异常级别(通常 EL3 或 EL2)以 64 位模式运行,固件(如 ATF)完成初始化后,将异常级别降至 EL1 并跳转到内核入口。内核映像没有自解压器,解压由引导程序完成。

3. 内存管理对比

特性 x86_64 ARM64
虚拟地址位数 48 位(4 级)或 57 位(5 级) 39-52 位(2-5 级,可配置)
页大小 4KB、2MB、1GB 4KB、16KB、64KB
页表分裂 单 CR3 指向统一页表 TTBR0(用户)+ TTBR1(内核)
地址空间标识 PCID(CR3 低 12 位) ASID(TTBR0 低 16 位)
不可执行位 Bit 63(XD/NX) UXN/PXN 位(分离用户/特权)
脏位(Dirty) 硬件自动设置(Bit 6) DBM 位(可选,ARMv8.1)
访问标志 硬件自动设置(Bit 5) AF 位(硬件或软件设置)

ARM64 的双 TTBR 架构是一个显著优势:TTBR0 专用于用户空间(低地址),TTBR1 专用于内核空间(高地址),两者完全独立。这意味着 switch_mm()(进程切换)只需更新 TTBR0,而内核映射通过 TTBR1 保持不变。x86_64使用单一 CR3,每次 switch_mm() 都会同时影响用户和内核映射。

ARM64 将不可执行位分为 UXN(User Execute Never)和 PXN(PrivilegedExecute Never)两个独立位,提供了比 x86_64 单一 NX 位更细粒度的控制。例如,内核可以将一个页面设置为用户不可执行但特权可执行,或反之。

在脏页管理方面,x86_64 的硬件脏位(PTE bit 6)由 CPU 在写入时自动设置,ARM64 则通过 DBM(Dirty Bit Modifier,ARMv8.1)位实现类似功能,但也可以选择软件方式管理。

4. 安全特性对比

特性 x86_64 ARM64
控制流完整性 KCFI(编译器,类型哈希) PAC(硬件加密签名)+ BTI(硬件)
栈保护 Canary(软件) SCS(x18 影子栈)+ PAC
内存标签 无(使用 KFENCE 软件方案) MTE(硬件 4 位标签)
内核页表隔离 CR3 切换 TTBR0 切换
特权访问保护 SMAP(CR4.SMAP) PAN(SCTLR_EL1.PAN)/ TTBR0 交换
执行防止 SMEP(CR4.SMEP) UXN/PXN 位
影子栈 Intel CET(硬件自动) 软件影子栈(x18 寄存器)

ARM64 在安全特性的硬件化程度上领先于 x86_64。PAC 提供的加密签名保护比KCFI 的类型哈希检查更难伪造;MTE 提供的硬件内存标签是 x86 完全没有的能力;BTI 与 Intel CET IBT 概念对等但实现细节不同。

x86_64 在影子栈方面有一定优势:Intel CET 的硬件影子栈由 CPU 自动管理返回地址的压栈和出栈,性能开销极低。ARM64 的软件影子栈需要编译器插入额外的 Load/Store 指令,开销略高。

5. 性能考量

两种架构的性能特征源于其设计哲学差异:

指令密度与吞吐量

  • x86_64 的变长编码在代码密度上通常占优,常用指令可以用更少的字节表示
  • ARM64 的定长编码牺牲了代码密度,但换来了更高的译码吞吐量——译码器无需确定指令边界,可以并行译码多条指令

内存序模型

  • x86_64 采用 TSO(Total Store Order),保证较强的内存序:Store 操作对所有 CPU 按序可见,Load 操作可以重排序但不越过 Store
  • ARM64 采用 弱内存序(Weakly Ordered),CPU 可以自由重排 Load 和 Store操作以获得更高性能。内核必须使用显式内存屏障:
    • DMB(Data Memory Barrier):确保屏障前的内存操作在屏障后可见
    • DSB(Data Synchronization Barrier):等待所有内存操作完成
    • ISB(Instruction Synchronization Barrier):刷新流水线,确保后续
      指令看到之前的系统寄存器修改

这意味着 ARM64 内核代码中需要插入更多的内存屏障指令(通过 smp_wmb()smp_rmb()smp_mb() 等宏),而 x86_64 上这些宏大部分是空操作(NOP)。ARM64 的弱序模型在无竞争场景下性能更优,但增加了编程的复杂性。

功耗效率
ARM64 的简洁指令集和较低的控制逻辑复杂度使其在功耗效率上天然优于 x86_64。这是 ARM 在移动和嵌入式领域占据主导地位的根本原因,也是 ARM 服务器(如AWS Graviton、Ampere Altra)在数据中心获得竞争力的关键因素之一。

  • Title: Linux内核分析之基础知识-04
  • Author: 韩乔落
  • Created at : 2026-05-28 18:15:43
  • Updated at : 2026-05-28 18:40:00
  • Link: https://jelasin.github.io/2026/05/28/Linux内核分析之基础知识-04/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
Linux内核分析之基础知识-04