AArch64 Type-1 Hypervisor 入门手册:跟着 Xen 学,从零写到 C 世界

AArch64 Type-1 Hypervisor 入门手册:跟着 Xen 学,从零写到 C 世界

第一章:基础概念

1.1 Type-1 与 Type-2

  • Type-1(bare-metal hypervisor,裸机型) :hypervisor(虚拟机监控器)直接运行在硬件上,负责硬件资源管理和 guest 调度;guest OS(客户机操作系统)运行在 hypervisor 之上。Xen、Hyper-V 属于这一类。
  • Type-2(hosted hypervisor,托管型) :hypervisor 作为宿主机操作系统(host OS)中的程序或模块运行,借助 host OS 管理硬件。VMware Workstation 属于这一类。

本手册讨论 Type-1。你的代码是复位后第一批执行的代码,没有操作系统提供运行环境,因此从复位到进入 C 语言运行环境的初始化过程必须由你完成。

1.2 Arm 异常级 EL0–EL3

Armv8-A 定义了 4 个异常级(Exception level,EL);EL 编号越大,特权越高:

异常级 典型用途 典型软件
EL0 用户态 应用程序
EL1 操作系统内核态 guest OS 内核(如 Linux)
EL2 虚拟化层 hypervisor
EL3 安全监控层 TF-A(Trusted Firmware-A)、Secure Monitor
  1. 虚拟化扩展以 EL2 为中心:​VTTBR_EL2​(Stage-2 转换表基址)、​HCR_EL2​(Hypervisor 配置寄存器)、​VBAR_EL2(EL2 异常向量表基址)等寄存器由 EL2 配置。
  2. 从异常处理返回使用 ​eret​(Exception Return):目标异常级和返回地址由 ​SPSR_ELx​ / ​ELR_ELx​ 决定,通常返回较低异常级。较低异常级不能直接调用较高异常级;它通过执行 ​svc​ / ​hvc​ / ​smc​ 触发同步异常,硬件按异常级规则将异常送到更高异常级的异常向量。目标异常级由指令和当前配置决定:​svc​ 通常进入 EL1,​hvc​ 进入 EL2,​smc​ 进入 EL3(启用相应路由配置时可能不同)。guest 请求 hypervisor 服务时执行 ​hvc #0,由 EL2 的异常向量接管。
  3. EL2 有两种栈模式:EL2h / EL2t;​SPSel​ 决定使用 ​SP_EL2​(EL2h)还是 ​SP_EL0(EL2t)。

1.3 两段式地址转换:Stage-1 与 Stage-2

在非虚拟化系统中,虚拟地址(Virtual Address,VA)由 MMU(Memory Management Unit,内存管理单元)转换为物理地址(Physical Address,PA)。引入虚拟化后,地址转换分为两个阶段:

1
2
guest VA --(Stage-1,guest 页表,TTBR0_EL1)--> 中间物理地址(IPA)
IPA --(Stage-2,hypervisor 页表,VTTBR_EL2)--> 物理地址(PA)

guest 感知不到 Stage-2 的存在;hypervisor 通过 Stage-2 将每个 guest 限制在各自的内存范围内。Stage-2 是 Type-1 hypervisor 实现隔离的关键机制;后续实现 Stage-2 时会用到它,但在进入 C 语言运行环境之前不需要建立它。

1.4 陷入(trap)与敏感操作拦截

hypervisor 向 guest 呈现独占硬件的视图;当 guest 执行受陷获机制控制的敏感操作时,会触发异常并陷入 EL2,由 hypervisor 拦截。

  • guest 执行 ​hvc​ → 触发同步异常,硬件根据 ​VBAR_EL2 指向的向量表进入对应表项 → hypervisor 代码运行;
  • guest 访问未映射的内存(Stage-2 translation fault)→ 触发 Stage-2 Abort → 同样陷入 EL2;
  • guest 配置或访问中断、定时器等 → 由 ​HCR_EL2​(中断路由)、​CNTHCTL_EL2(定时器访问)等寄存器的陷获位决定是否陷入 EL2。

陷入 EL2 时,硬件将异常原因写入 ​​ESR_EL2​​(Exception Syndrome Register,异常综合征寄存器);hypervisor 的异常分发代码读取该寄存器并分派处理。Xen 中对应 ​guest_sync → do_trap_guest_sync 处理链。

1.5 两份核心文档

  • Arm ARM(Arm Architecture Reference Manual,Armv8-A 版,文档号 DDI 0487):寄存器位定义和异常模型以它为准;遇到不熟悉的字段,应查阅该文档。
  • Linux 内核 arm64 启动文档(​Documentation/arch/arm64/booting.rst):定义引导器加载内核时必须遵守的约定;遵循该约定,U-Boot、GRUB 等引导器才能正确加载。

第二章:Xen 启动的八个阶段

将从引导器把控制权交给 Xen 后的第一条指令,到进入 C 语言运行环境并完成 ​start_xen 早期初始化的过程,划分为八个阶段:

1
2
3
4
5
6
7
8
[0] Image header    start:  add x13, x18, #0x16  (opcode 即 "MZ",兼容 PE/COFF)
[1] 位置无关 计算 phys-offset = 实际加载地址 - 链接地址
[2] EL2 检查 check_cpu_mode:若非 EL2,则打印错误并停机
[3] CPU 配置 cpu_init:写 MAIR_EL2 / TCR_EL2 / SCTLR_EL2
[4] 开启 MMU 建立恒等映射 → enable_mmu(tlbi→TTBR0_EL2→isb→dsb→置 M/C→isb)
[5] 进入 C 代码 b start_xen(x0 = DTB 物理地址)
[6] 陷获配置 init_traps():VBAR_EL2 / MDCR_EL2 / HSTR_EL2 / CPTR_EL2 / HCR_EL2
[7] 后续初始化 按依赖顺序:解析 DTB → 页表重建 → … → alternatives 指令补丁置于最后

2.1 Image header与加载约定

Xen 的做法(​xen/arch/arm/arm64/head.S 开头,节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FUNC(start)
/*
* DO NOT MODIFY. Image header expected by Linux boot-loaders.
*/
efi_head:
/*
* This add instruction has no meaningful effect except that
* its opcode forms the magic "MZ" signature of a PE/COFF file
* that is required for UEFI applications.
*/
add x13, x18, #0x16
b real_start /* branch to kernel start */
END(start)
.quad 0 /* Image load offset from start of RAM */
.quad _end - start /* Effective size of kernel image */
.quad __HEAD_FLAGS /* Informative flags */
...
.byte 0x41, 0x52, 0x4d, 0x64 /* Magic number, "ARM\x64" */
.long pe_header - efi_head /* 0x3c 处:PE 头偏移(EFI 时) */
  1. 引导器将控制权交给 Xen 后执行的第一条指令是 ​​add x13, x18, #0x16​​。这条指令本身没有实际作用,其意义在于机器码:前两个字节恰好是 ​4D 5A​,即 ASCII ​"MZ"​;UEFI 要求 PE/COFF 可执行文件以 ​"MZ" 开头。因此同一个文件头可同时满足两种加载器。
  2. b real_start 才是实际跳转到启动代码的指令。
  3. 后续的 ​.quad​/​.byte​ 序列构成 Linux arm64 Image header:向引导器描述镜像格式、加载偏移和大小。​.byte 0x41,0x52,0x4d,0x64​ 组成 ​"ARM\x64" magic number,引导器据此确认文件格式。

你要学的:不要自定义私有镜像格式。遵循 Linux arm64 Image header 布局,U-Boot 的 ​booti​、GRUB 和 QEMU 的 ​-kernel 都能识别该格式。

你的骨架:参照 Xen 的这段头部实现改写(可以省略 EFI 相关部分,保留 ​"ARM\x64"​ magic number 和必要的 ​.quad 字段即可;完整写法见附录 A)。

2.2 位置无关代码与 phys-offset

问题:链接器按链接地址(例如 ​0x40200000)生成代码,但引导器可能将镜像加载到任意物理地址。MMU 尚未开启,代码必须在任意加载地址下都能正确执行(位置无关)。

Xen 的做法(​real_start 内):

1
2
3
4
/* Find out where we are */
ldr x0, =start // x0 = start 的链接地址(链接时固定)
adr x19, start // x19 = start 当前的真实物理地址(PC 相对)
sub x20, x19, x0 // x20 := phys-offset

ldr​ 与 ​adr 的区别:

  • ldr x0, =start​ 是伪指令:汇编器将 ​start​ 的链接时地址作为立即数放入常量池,​ldr 读取该值。这个值在链接时固定。
  • adr x19, start​ 是 程序计数器(PC)相对寻址:将当前 PC 到 ​start​ 的相对偏移加到 PC 上,得到 ​start 当前的运行地址。无论代码加载到哪里,都能计算出正确地址。
  • 两者之差 ​phys-offset​ 即加载地址相对链接地址的偏移。之后任何需要绝对地址的场合(例如写页表项),都使用 ​链接地址 + phys-offset 换算。

你要学的:MMU 开启前,取地址应使用 ​adr​、​adrp​ 或 ​adr_l​,跳转只使用相对跳转(​b​/​bl​ 本身是相对跳转)。如果把 ​ldr x0, =某符号 读出的链接地址当作运行地址,在加载地址与链接地址不一致的机器上就会产生取指异常。这类缺陷在 QEMU 上通常无法暴露,因为 QEMU 通常按链接地址加载;在真实硬件上才会出现。

2.3 EL2 检查(check_cpu_mode)

Xen 的做法(​arm64/head.S,逐字):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FUNC_LOCAL(check_cpu_mode)
PRINT_ID("- Current EL ")
mrs x5, CurrentEL
print_reg x5
PRINT_ID(" -\r\n")
/* Are we in EL2 */
cmp x5, #PSR_MODE_EL2t
ccmp x5, #PSR_MODE_EL2h, #0x4, ne
b.ne 1f /* No */
ret
1:
/* OK, we're boned. */
PRINT_ID("- Xen must be entered in NS EL2 mode -\r\n")
PRINT_ID("- Please update the bootloader -\r\n")
b fail
END(check_cpu_mode)
  1. mrs x5, CurrentEL​:读取当前异常级。​CurrentEL[3:2]​ 是 EL 编号,低两位 RES0,所以 EL2 的读数是 ​0x8​。​0x9​ 是处理器状态(PSTATE)中 ​EL2h​ 的模式编码(​PSR_MODE_EL2h​),不是 ​CurrentEL 的读数。

  2. ccmp x5, #PSR_MODE_EL2h, #0x4, ne​ 是条件比较。上一条 ​cmp​ 已经把 ​x5​ 和 ​PSR_MODE_EL2t​(​0x8​)比较过;只有结果不等(​ne​)时,​ccmp​ 才继续和 ​PSR_MODE_EL2h​(​0x9​)比较。若条件不成立,​ccmp​ 直接把 NZCV 设为 ​0x4​,即 ​Z=1。

    • 对 ​CurrentEL​ 来说,EL2 的 ​0x8 已经让第一个比较相等;第二个比较针对 PSTATE 编码,在当前架构下不会命中。
    • 紧跟 ​b.ne 1f​:两次比较都不相等(​Z=0)才跳去报错。
  3. 失败时不会静默停机,而是通过 earlyprintk 输出两行提示,再执行 ​b fail。检查失败时必须能够打印原因,否则只能根据现象推断问题。

你要学的:hypervisor 必须运行在 EL2,否则无法访问 ​HCR_EL2​ 等寄存器,因此进入后应首先确认当前异常级。另一个工程实践是:从第一行汇编就配置好 earlyprintk(直接向 UART 的数据寄存器写入字符即可,几十行代码;UART 即 Universal Asynchronous Receiver/Transmitter,通用异步收发器),否则前面任一阶段出错时,都只能根据无输出状态推断。

2.4 MMU 开启前的寄存器准备(cpu_init)

Xen 的做法(​arm64/head.S​ 的 ​cpu_init,节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
FUNC_LOCAL(cpu_init)
PRINT_ID("- Initialize CPU -\r\n")
/* Set up memory attribute type tables */
ldr x0, =MAIRVAL
msr mair_el2, x0
/*
* Set up TCR_EL2:
* PS -- Based on ID_AA64MMFR0_EL1.PARange
*/
ldr x0, =(TCR_RES1|TCR_SH0_IS|TCR_ORGN0_WBWA|TCR_IRGN0_WBWA|TCR_T0SZ(64-48))
/* ID_AA64MMFR0_EL1[3:0] (PARange) corresponds to TCR_EL2[18:16] (PS) */
mrs x1, ID_AA64MMFR0_EL1
/* Limit to 48 bits, 256TB PA range (#5) */
ubfm x1, x1, #0, #3
mov x2, #5
cmp x1, x2
csel x1, x1, x2, lt
bfi x0, x1, #16, #3
msr tcr_el2, x0
ldr x0, =SCTLR_EL2_SET
msr SCTLR_EL2, x0
isb
msr spsel, #1
ret
END(cpu_init)

三个寄存器的作用:

寄存器 作用 Xen 设置了什么
MAIR_EL2 内存属性间接寄存器:页表项中 3 位的 ​AttrIndx 各代表“普通可缓存/Device-nGnRE…” 写入 Xen 定义好的 ​MAIRVAL,之后页表项通过索引引用属性
TCR_EL2 EL2 的地址转换控制:地址位宽(PS/T0SZ)、缓存策略(IRGN/ORGN)、共享性(SH) 48 位 VA、Inner/Outer Write-Back、Inner Shareable;PS 字段根据硬件寄存器 ​ID_AA64MMFR0_EL1 的 PARange 字段配置(见下文)
SCTLR_EL2 系统控制寄存器:MMU 使能位 ​M​、缓存位 ​C 等 先写一个保守的 ​SCTLR_EL2_SET(关闭 MMU 的安全配置),MMU 留到下一阶段开启

三条无分支位操作:

1
2
3
4
5
ubfm  x1, x1, #0, #3     // 取 PARange 低 4 位(当前 CPU 支持的物理地址位宽)
mov x2, #5
cmp x1, x2
csel x1, x1, x2, lt // 超过 48 位(编码 5)时钳位到 5(条件选择,无分支)
bfi x0, x1, #16, #3 // 将钳位后的 3 位插入 TCR 的 PS 字段 [18:16]

为什么不将 ​PS​ 固定为 5?不同 SoC 支持的物理地址位宽不同(32/36/40/42/44/48/52 位),因此需要从 ​ID_AA64MMFR0_EL1.PARange​ 读取实际值再配置。​csel​/​bfi 全程无分支:MMU 和 cache 尚未开启,分支预测和取指都有开销,应尽量减少分支。

2.5 恒等映射与开启 MMU(enable_mmu)

问题:将 ​SCTLR_EL2.M​ 置 1 后,取指立即开始经过 MMU 转换。如果此时下一条指令的虚拟地址无法转换为正确的物理地址,CPU 会立即产生取指异常。解法是恒等映射(identity mapping) :先建立 ​VA == PA 的页表,开启 MMU 后程序仍按原地址运行,之后再切换到最终的虚拟地址布局。

Xen 的使能序列(​arm64/mmu/head.S,节选,顺序如下):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FUNC_LOCAL(enable_mmu)
mov x4, x0 /* 页表物理地址 */
mov x5, x1 /* 额外 SCTLR 标志 */
flush_xen_tlb_local /* dsb nshst; tlbi alle2; dsb nsh; isb */
/* Write Xen's PT's paddr into TTBR0_EL2 */
msr TTBR0_EL2, x4
isb
mrs x0, SCTLR_EL2
orr x0, x0, #SCTLR_Axx_ELx_M /* Enable MMU */
orr x0, x0, #SCTLR_Axx_ELx_C /* Enable D-cache */
orr x0, x0, x5 /* Enable extra flags */
dsb sy /* Flush PTE writes and finish reads */
msr SCTLR_EL2, x0 /* now paging is enabled */
isb /* Now, flush the icache */
ret
END(enable_mmu)

屏障顺序的理由:

步骤 指令 理由
1 tlbi alle2​ 位于两条 ​dsb 之间 失效 TLB(Translation Lookaside Buffer,转译后备缓冲器)中可能存在的陈旧转换项,确保 MMU 开启后使用新页表
2 msr TTBR0_EL2​ + ​isb 转换表基址写入后,​isb 确保后续指令观察到新基址
3 dsb sy​ 在写 SCTLR 之前 页表是内存中的数据,必须先确保写入对所有观察者可见,否则 MMU 硬件可能读到尚未完成的页表项
4 msr SCTLR_EL2​(将 ​M 位设为 1) 从此时起 MMU 生效
5 isb​ 紧跟其后 丢弃流水线中按旧状态预取的指令

概括来说:​​dsb​​ 保证数据访问完成, ​​isb​​ 保证指令流同步;两者都不能省略,顺序也不能交换。

你要学的:先建立 1:1 恒等映射页表(即使只映射当前代码所在的内存区域),确认 MMU 开启后程序仍能正常执行,再设计虚拟地址布局。Xen 的最终虚拟内存布局(高半区映射、拆除恒等映射)在 C 代码的 ​setup_pagetables() 中完成,不必在汇编阶段一次完成。

2.6 进入 C 语言运行环境

MMU 开启、BSS(Block Started by Symbol,未初始化数据段)清零后,Xen 执行最后几个步骤(​arm64/head.S​ 的 ​real_start 尾部):

1
2
3
4
5
6
7
primary_switched:
bl zero_bss
PRINT("- Ready -\r\n")
/* Setup the arguments for start_xen and jump to C world */
mov x0, x21 /* x0 := paddr(FDT) */
ldr x1, =start_xen
b launch /* 设置好 SP 等之后 blr x1 */
  1. 将 DTB(Device Tree Blob,设备树二进制)的物理地址放入 ​​x0​​ 并传入 C 代码。这是 hypervisor 与引导器之间的主要交接信息:内存布局、设备列表和中断信息都由它描述。
  2. 进入 C 代码前必须准备好足够的栈空间(Xen 使用 per-CPU 的 boot stack)。C 函数会使用栈;没有栈,​start_xen 的第一条使用栈的指令就可能失败。

至此,汇编阶段结束。

2.7 init_traps 与陷获配置

进入 ​start_xen​ 后,Xen 首先执行 ​​init_traps()​ ​,而不是解析设备树或建立最终页表。源码注释说明了原因(​arm/setup.c):

/* Initialize traps early allow us to get backtrace when an error occurred */

先建立异常处理,后续任一步骤出错时才能获得调用栈回溯(backtrace),而不是静默停机。

init_traps()​ 全文(​arm/traps.c,逐字)。这段代码写五个寄存器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void init_traps(void)
{
/* Setup Hyp vector base. Note they might get updated with the
* branch predictor hardening. */
WRITE_SYSREG((vaddr_t)hyp_traps_vector, VBAR_EL2);

/* Trap Debug and Performance Monitor accesses */
WRITE_SYSREG(HDCR_TDRA|HDCR_TDOSA|HDCR_TDA|HDCR_TPM|HDCR_TPMCR, MDCR_EL2);

/* Trap CP15 c15 used for implementation defined registers, and ThumbEE */
WRITE_SYSREG(HSTR_T(15) | (cpu_has_thumbee ? HSTR_TTEE : 0), HSTR_EL2);

WRITE_SYSREG(get_default_cptr_flags(), CPTR_EL2);

/* Configure HCR_EL2 with the bare minimum to run Xen until a guest
* is scheduled. {A,I,F}MO bits are set to allow EL2 receiving interrupts. */
WRITE_SYSREG(HCR_AMO | HCR_FMO | HCR_IMO, HCR_EL2);
isb();
}

五个寄存器的作用:

寄存器 作用 Xen 的设置与意图
VBAR_EL2 EL2 异常向量表基址 指向 ​hyp_traps_vector​——16 项 × 128 字节、2 KB 对齐的向量表。从此异常由 hypervisor 接管,不再由引导器处理
MDCR_EL2 调试/性能监控陷获 全部置位:guest 不得访问调试寄存器和 PMU(Performance Monitoring Unit,性能监控单元)——既防止信息泄漏,也防止 guest 干扰 hypervisor
HSTR_EL2 协处理器陷获 陷获实现定义的 c15 寄存器访问;guest 访问即陷入 EL2
CPTR_EL2 FP/SVE(Scalable Vector Extension)陷获 默认陷获浮点:guest 首次使用 FPU(Floating-Point Unit,浮点单元)时会陷入 EL2,hypervisor 才能确定应保存或切换哪个 vCPU 的 FP 上下文(惰性上下文切换的前提)
HCR_EL2 Hypervisor 配置寄存器 初始仅置位 ​AMO​/​FMO​/​IMO​:将 SError、FIQ、IRQ 全部路由到 EL2。其余位(例如用于陷获 WFI 的 ​TWI)留到每个 vCPU(虚拟 CPU)创建时按需配置,遵循最小配置原则

你要学的:Type-1 hypervisor 必须在其他初始化之前接管异常与陷获配置。顺序错误的典型后果是:解析 DTB 时出现缺页或对齐错误,异常向量仍指向引导器,机器直接复位,且无法获取故障原因。

向量表的结构如下(​arm64/entry.S):

1
2
3
4
5
6
7
8
9
10
11
12
        .align  11                  /* 2 KB 对齐,VBAR 要求 */
FUNC(hyp_traps_vector)
ventry hyp_sync_invalid /* Synchronous EL2t */
ventry hyp_irq_invalid /* IRQ EL2t */
ventry hyp_fiq_invalid /* FIQ EL2t */
ventry hyp_error_invalid /* Error EL2t */
ventry hyp_sync /* Synchronous EL2h */
ventry hyp_irq /* IRQ EL2h */
...
ventry guest_sync /* Synchronous 64-bit EL0/EL1 ← guest 陷入入口 */
ventry guest_irq /* IRQ 64-bit EL0/EL1 */
...

向量表为什么是 16 项 × 128 字节:Arm ARM 规定向量表必须 2 KB 对齐,并按“异常来源(当前 EL 使用的 SP,以及较低 EL 是 AArch64 还是 AArch32)× 异常类型(Sync/IRQ/FIQ/SError)”排列成 4×4。每个表项只有 128 字节,无法容纳完整处理代码,因此仅放置一条跳转到实际处理函数的 ​b 指令。

guest 陷入 EL2 后的完整处理链路是:​guest_sync​(汇编代码保存现场)→ ​do_trap_guest_sync(regs)​(C 代码读取 ​ESR_EL2​ 的 ​EC​ 字段,并用 ​switch 分发)→ 具体处理函数。这条处理链是后续支持 guest 的主要工作,属于进入 C 语言运行环境之后的内容。

2.8 后续初始化顺序与依赖

start_xen 包含数十个初始化步骤,顺序由依赖关系决定。Xen 源码中给出了几条明确的约束:

顺序约束 原因(Xen 源码注释的原话逻辑)
llc_coloring_init()​ → ​setup_pagetables() “Page tables must be setup after LLC coloring initialization because coloring info are required in order to create colored mappings”——页表要用缓存着色信息
IOMMU(I/O Memory Management Unit)初始化 → guest Stage-2 页表体系 guest 的 DMA(Direct Memory Access)设备经 IOMMU 访问的地址空间必须与 Stage-2 一致;必须先初始化 IOMMU,再建立 p2m(physical-to-machine)映射,否则 DMA 会绕过隔离
alternatives(指令动态补丁)置于最后 所有 CPU 特性探测完成后,统一将通用指令替换为本 CPU 的最优指令;过早应用会被后续初始化覆盖

另一条贯穿全程的纪律:所有 CPU 特性与勘误(errata)通过声明式表和 MIDR 匹配管理。Xen 用 ​MIDR_RANGE(model, min_rev, max_rev)​ 将“哪款 CPU 的哪个修订版本存在哪个缺陷、需要哪种 workaround”集中登记成表,启动时按当前 CPU 的 MIDR(Main ID Register)自动套用。该表应在项目初期建立,避免分散的 ​if (midr == ...) 判断遍布代码库。

再看 guest 返回前的一段 SError 检查代码(​entry.S):

1
2
3
4
5
6
7
8
9
10
        mrs     x0, elr_el2          /* 保存当前 ELR */
dsb sy /* 等在途访存完成 */
msr daifclr, #IFLAGS__A__ /* 解开 SError 屏蔽 */
LABEL(abort_guest_exit_start)
isb /* ← 挂起的 SError 在该窗口内被接收 */
LABEL(abort_guest_exit_end)
msr daifset, #IFLAGS__A__ /* 重新屏蔽 */
mrs x1, elr_el2
cmp x0, x1
cset x19, ne /* 不等 → 窗口内收到 SError */

它利用硬件行为:解除 SError 屏蔽后,若有挂起的异步错误,会在 ​daifclr​ 与 ​daifset​ 之间的窗口被接收;代码用夹在两个标签之间的 ​isb​ 标出该窗口。硬件会将 ​ELR_EL2​ 改写为异常返回地址,比较前后两个 ELR 即可判断窗口内是否发生了 SError——用一个指令窗口将异步错误转化为可检测事件。这种方法当前无需实现,但说明了一个原则:阅读 Xen 汇编时,几乎每个看似多余的屏障背后都对应一条 Arm ARM 的硬件行为规则。


第三章:最小骨架的构建与代码解读

完整代码见附录 A,正文只保留讲解所需的片段。该骨架在 QEMU ​virt​ 机器、​virtualization=on​ 配置下从 EL2 启动,完成 earlyprintk、恒等映射、MMU/cache 配置和异常向量表设置,进入 C 语言运行环境后打印 ​Hello, world!​ 与 DTB 地址,最后停在 ​wfi。

关键设计选择:Image header 的 magic number 位于偏移 56;​T0SZ=25​(39 位 VA)配合 L1 的 1 GB block;描述符包含 ​AF​/​SH​/​AttrIndx​ 字段;​VBAR_EL2 在开启 MMU 之前设置。

它与第二章各阶段的对应关系:

手册小节 在本骨架中的位置
2.1 Image header _start 起始的 64 字节 Linux arm64 Image header
2.2 位置无关 使用 ​adrp​/​add 获取符号的运行地址;QEMU 按固定地址加载时不会暴露偏移问题,但代码仍保持位置无关
2.3 EL2 检查 mrs x5, CurrentEL 检查 EL2
2.4 CPU 配置 MAIR_EL2​ / ​TCR_EL2​ / ​SCTLR_EL2
2.5 恒等映射与开启 MMU 512 个 1 GB block、​TTBR0_EL2​、​tlbi alle2​、置 ​SCTLR_EL2.M/C/I
2.6 进入 C 代码 x0 = DTB​,​bl hv_main
2.7 init_traps 2 KB 向量表、​VBAR_EL2​、​HCR_EL2、异常打印
2.8 后续初始化 本骨架未展开,留待 Stage-2 和 vCPU 阶段

3.1 构建与运行

需要 ​aarch64-none-elf-gcc​(含 binutils)和 ​qemu-system-aarch64。

1
2
3
4
5
aarch64-none-elf-gcc -nostdlib -ffreestanding -c boot.S -o boot.o
aarch64-none-elf-gcc -nostdlib -ffreestanding -c main.c -o main.o
aarch64-none-elf-ld -Ttext 0x40080000 boot.o main.o -o hv.elf
aarch64-none-elf-objcopy -O binary hv.elf hv.bin
qemu-system-aarch64 -M virt,virtualization=on -cpu max -nographic -kernel hv.bin

预期输出:

1
2
3
4
5
AB
C
Hello, world!
DTB=0000000044000000
D
  • A:earlyprintk 已工作
  • B:MMU 已成功开启
  • C:已进入 C 语言运行环境
  • Hello, world!​:C 代码、栈、​.rodata、UART 的 Device 映射全部正常
  • DTB=...​:QEMU 启动时置于 ​x0 的设备树地址被成功保存并打印(具体数值随 RAM 大小和 QEMU 版本变化)
  • D​:​VBAR_EL2​ / ​HCR_EL2 已配置

程序最终在 ​wfi​ 循环中等待,QEMU 不会自动退出;按 ​Ctrl-A X 结束。

3.2 代码解读

3.2.1 启动链路总览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
QEMU 加载器
→ _start(EL2,MMU 关闭)
→ 保存 DTB、检查 EL2
→ 设置 SP_EL2 和 VBAR_EL2
→ 打印 'A'
→ 配置 MAIR_EL2 / TCR_EL2 / SCTLR_EL2
→ 建立 512 项 1 GB block 恒等映射
→ 写入 TTBR0_EL2、失效 TLB
→ 开启 MMU / D-cache / I-cache
→ 打印 'B'
→ hv_main(C 语言运行环境)
→ 打印 C、Hello, world! 和 DTB
→ 配置 HCR_EL2
→ 打印 'D'
→ wfi 循环

3.2.2 Image header 与加载约定

_start 起始的 64 字节为 Linux arm64 Image header:

偏移 字段 本代码的值 含义
0 code0 b 1f 跳过整个头,跳到实际入口
4 code1 0 保留
8 text_offset 0 加载偏移;QEMU 的加载器将内核加载基址设为 ​0x40080000
16 image_size _edata - _start 文件镜像大小,不含 ​.bss
24 flags 0 小端、4 KB 页
32–55 res2..res4 0 保留
56 magic 0x644d5241 "ARM\x64"
60 res5 0 保留

这里的 magic number 必须位于偏移 56。如果写在偏移 52,QEMU 会认为它不是合法的 Image header,退化为裸二进制加载。该镜像在 QEMU 上可能仍能运行,但在 U-Boot ​booti 或其他加载器上会失败。

QEMU 的 ​-kernel​ 加载器将镜像加载到 ​0x40080000​,启动 CPU 时将 DTB 地址放入 ​x0​,PC 指向 ​0x40080000。因此代码首先执行:

1
mov x22, x0

将 DTB 指针保存到 callee-saved 寄存器 ​x22​,进入 C 代码前再写回 ​x0。DTB 是 hypervisor 与引导器之间的主要交接信息,内存大小、设备和中断控制器等信息都由它描述。

3.2.3 EL2 检查、栈和向量表

1
2
3
4
mrs x5, CurrentEL
lsr x5, x5, #2
cmp x5, #2
b.ne hang_no_el2

CurrentEL[3:2] 是当前异常级编号。右移 2 位后:

  • 1 → EL1
  • 2 → EL2
  • 3 → EL3

HCR_EL2​、​VTTBR_EL2​ 等虚拟化寄存器由 EL2 管理,因此首先确认当前异常级为 EL2。QEMU 命令行中的 ​virtualization=on​ 才会让 CPU 从 EL2 启动;否则代码会打印 ​N 后停机。

1
2
3
4
msr SPSel, #1
adrp x0, _stack_top
add x0, x0, :lo12:_stack_top
mov sp, x0
  • SPSel=1​ 表示当前使用 ​SP_EL2,即 EL2h 模式。
  • _stack_top​ 指向 ​.bss 中 16 KB 栈的栈顶,栈向下增长。
  • 必须先建立栈,后续 C 函数和异常处理才能使用栈。
1
2
3
4
adrp x0, hyp_vectors
add x0, x0, :lo12:hyp_vectors
msr VBAR_EL2, x0
isb

VBAR_EL2​ 是异常向量表基址,要求 2 KB 对齐。这里在开启 MMU 之前就设置好,原因是:如果页表有错,开启 MMU 后的第一条取指就可能异常;如果此时 ​VBAR_EL2​ 仍为 0,CPU 会从 ​0x200 一类的无效地址取指,连错误原因都无法获取。

3.2.4 MAIR_EL2、TCR_EL2、SCTLR_EL2

MAIR_EL2:

1
2
mov x0, #0x44ff
msr MAIR_EL2, x0

0x44FF 的低 16 位定义了两个属性:

Attr 值 含义
Attr0 0xFF Normal,Inner/Outer Write-Back Write-Allocate
Attr1 0x44 Device-nGnRE

页表描述符里的 ​AttrIndx 会索引这两个属性。普通 RAM 使用 Attr0;UART、GIC(Generic Interrupt Controller,通用中断控制器)等 MMIO(Memory-Mapped I/O)区域使用 Attr1。

TCR_EL2:

1
2
3
movz x0, #0x3f19
movk x0, #0x2, lsl #16
msr TCR_EL2, x0

写入值 ​0x0000000000023F19:

字段 值 含义
T0SZ[5:0] 25 VA 位数 = 64 - 25 = 39 位
IRGN0[9:8] 3 页表遍历 Inner Write-Back
ORGN0[11:10] 3 页表遍历 Outer Write-Back
SH0[13:12] 3 Inner Shareable
TG0[15:14] 0 4 KB 粒度
PS[18:16] 2 40 位物理地址

PS=2​ 对应 QEMU ​virt​ 的 40 位物理地址。更换平台时应像 Xen 那样读取 ​ID_AA64MMFR0_EL1.PARange 后再配置,不要沿用这个固定值(见 2.4 节)。

关键点:4 KB 粒度与 39 位 VA 意味着从 level 1 开始查表。

  • L1 每项覆盖 1 GB
  • L2 每项覆盖 2 MB
  • L3 每项覆盖 4 KB

因此只需要一张 4 KB 的 L1 表,用 1 GB block 即可完成低 512 GB 的恒等映射。

如果按 48 位 VA 配置(​T0SZ=16​),首级变为 L0。L0 表项只能是 table 描述符,不允许 block 描述符;如果将 L1 的 1 GB block 或 L2 的 2 MB block 直接写入 L0,开启 MMU 后必然触发 translation fault。

SCTLR_EL2:

1
2
3
movz x0, #0x0800
movk x0, #0x30d0, lsl #16
msr SCTLR_EL2, x0

第一次写入 ​0x30D00800:

  • 保持 ​M=0​(MMU 关闭)、​C=0​(D-cache 关闭)、​I=0(I-cache 关闭)
  • 把架构规定为 RES1 的位先置 1,避免写 0 造成不可预测行为

待页表和 ​TTBR0_EL2 都准备好后:

1
2
3
movz x0, #0x1805
movk x0, #0x30d0, lsl #16
msr SCTLR_EL2, x0

第二次写入 ​0x30D01805,在 RES1 基础上使能:

位 名称 作用
0 M MMU
2 C D-cache
12 I I-cache

isb 确保后续指令观察到新的系统控制状态。

3.2.5 恒等映射与开启 MMU

1
2
3
4
5
6
7
8
9
10
11
12
13
14
adrp x0, boot_pgtable
add x0, x0, :lo12:boot_pgtable
mov x1, #0
2:
lsl x2, x1, #30 /* PA = index * 1 GB */
mov x3, #0x701 /* Normal */
cbnz x1, 3f
mov x3, #0x705 /* entry 0: Device */
3:
orr x2, x2, x3
str x2, [x0, x1, lsl #3]
add x1, x1, #1
cmp x1, #512
b.lt 2b

boot_pgtable 是 4 KB 对齐的 L1 表,共 512 项。每项描述 1 GB:

  • entry[0]​ 覆盖 ​0x00000000–0x3FFFFFFF​,映射为 Device,包括 GIC(​0x08000000​)、PL011 UART(​0x09000000​)、virtio-mmio(​0x0A000000)等。
  • entry[1]​ 覆盖 ​0x40000000–0x7FFFFFFF​,映射为 Normal,包括 QEMU RAM、内核镜像、页表、栈。
  • 其余项也映射为 Normal,覆盖未使用的地址空洞。

将未使用的地址空洞也映射为 Normal 只是骨架的简化做法;真实 hypervisor 只映射实际使用的区域,使非法访问继续触发 fault。

描述符位域:

位域 0x701 Normal 0x705 Device 含义
[1:0] 01 01 有效 block
[4:2] 000 001 AttrIndx:0=Normal,1=Device
[7:6] 00 00 AP:读写,EL0 不可访问
[9:8] 11 11 Inner Shareable
[10] 1 1 AF(Access Flag)
[47:30] index << 30 index << 30 1 GB block 的输出地址位(PA[47:30])

这里有两个容易写错的位域:

  1. AF 必须置 1。如果 ​AF=0​,第一次访问就会触发 Access Flag fault;只设置 ​bit0=1​ 会漏掉 ​AF。
  2. AttrIndx 在 ​ ​[4:2]​ ​,不是单独某一位。​0x701​ 和 ​0x705 才是正确的 Normal/Device 描述符。

运行时内存布局:

1
2
3
4
5
6
7
8
9
10
11
12
0x00000000 - 0x3FFFFFFF   Device
0x08000000 GIC
0x09000000 PL011 UART
0x0A000000 virtio-mmio

0x40000000 - 0x7FFFFFFF Normal
0x40000000 RAM 基址
0x40080000 _start / 镜像
0x40094000 boot_pgtable(4 KB)
0x40098000 _stack(16 KB)
0x4009C000 _end
0x44000000 QEMU DTB(地址随 RAM 大小变化)

页表准备好后,按顺序写入 ​TTBR0_EL2、失效 TLB:

1
2
3
4
5
6
msr TTBR0_EL2, x0
dsb sy
isb
tlbi alle2
dsb sy
isb
  • dsb sy:确保页表写入对所有观察者可见
  • isb​:确保后续指令观察到新的 ​TTBR0_EL2
  • tlbi alle2:失效 EL2 Stage-1 的全部 TLB 表项
  • 再 ​dsb/isb:保证 TLB 失效完成

最后写 ​SCTLR_EL2​ 使能 ​M​/​C​/​I​。从这条指令开始,取指和访存都经过 MMU;能够打印 ​B 说明整条恒等映射链路工作正常。

3.2.6 进入 C 语言运行环境

1
2
mov x0, x22     /* DTB */
bl hv_main

AAPCS64(Arm Architecture Procedure Call Standard for AArch64)规定第一个参数放入 ​x0​。​x22​ 是被调用者保存(callee-saved)寄存器,前面保存的 DTB 地址不会被中间的汇编破坏。​bl​ 将返回地址写入 ​x30,然后跳转到 C 函数。

C 代码能够正常执行,说明:

  • SP_EL2 栈有效
  • 代码段、​.rodata、栈所在的 RAM 都已映射
  • UART 所在的 Device 区间已映射
  • MMU 和 cache 配置没有破坏取指与访存

3.2.7 异常向量表

1
2
3
.balign 2048
hyp_vectors:
/* 16 × 128 字节 */

AArch64 向量表固定 16 项、每项 128 字节,按“异常来源 × 异常类型”排列:

偏移 来源 异常类型
0x000 Current EL,SP0 Sync / IRQ / FIQ / SError
0x200 Current EL,SPx Sync / IRQ / FIQ / SError
0x400 Lower EL,AArch64 Sync / IRQ / FIQ / SError
0x600 Lower EL,AArch32 Sync / IRQ / FIQ / SError

每组 4 项,顺序固定为 Sync、IRQ、FIQ、SError。

当前代码设置 ​SPSel=1​,因此 EL2 自身的同步异常使用 ​0x200​ 这一组表项。每个表项只有 128 字节,无法容纳完整处理代码,因此仅放置一条 ​b 指令跳转到实际处理函数。

1
2
3
4
5
6
7
8
exc_sync:
mov x0, #0 /* kind: 0=SYNC */
exc_common:
mrs x1, ESR_EL2
mrs x2, FAR_EL2
mrs x3, ELR_EL2
bl exc_report
b hang

发生异常时,硬件会设置:

  • ESR_EL2​:异常类型和原因(​EC​/​ISS)
  • FAR_EL2:出错的虚拟地址(地址异常时有效)
  • ELR_EL2:异常返回地址
  • SPSR_EL2:发生异常时的 PSTATE

向量表入口先将 ​kind​(0=SYNC)放入 ​x0​,​exc_common​ 再将 ​ESR​/​FAR​/​ELR​ 放入 ​x1​–​x3​,然后按 AAPCS64 调用 C 函数 ​exc_report(),打印后停机。故意访问未映射地址时,可观察到类似输出:

1
[EXC SYNC] ESR=0000000096000004 FAR=0001000000000000 ELR=0000000040080108

这说明向量表、栈和 C 异常处理均工作正常。

注意:当前仅实现诊断用异常处理,不进行现场保存和恢复。以后要运行 guest,需要在 ​Lower EL​ 的表项中保存 guest 寄存器、读取 ​ESR_EL2​ 的 ​EC​ 字段并分发、处理 Stage-2 fault,再通过 ​eret 返回 EL1(guest)。

3.2.8 main.c:C 语言运行环境的初始化

系统寄存器写宏:

1
2
#define WRITE_SYSREG(v, r) \
__asm__ volatile("msr " #r ", %0" :: "r"((uint64_t)(v)))

#r 将参数转换为寄存器名字符串。例如:

1
WRITE_SYSREG(hyp_vectors, VBAR_EL2);

展开为 ​msr VBAR_EL2, xN。

HCR_EL2:

1
2
3
4
#define HCR_RW   (1u << 31)
#define HCR_AMO (1u << 5)
#define HCR_FMO (1u << 4)
#define HCR_IMO (1u << 3)
位 名称 作用
31 RW EL1 使用 AArch64(0 则 AArch32)
5 AMO 物理 SError 路由到 EL2
4 FMO 物理 FIQ 路由到 EL2
3 IMO 物理 IRQ 路由到 EL2

这是 hypervisor 的基本配置:较低异常级的中断先陷入 EL2,由 hypervisor 决定是否注入 guest。当前还没有 guest,也没有配置 GIC,因此这些位仅表示已配置,不会有实际中断。

UART 输出:

1
2
#define UART_BASE 0x09000000UL
#define UART_DR (*(volatile uint8_t *)(UART_BASE + 0x00))

volatile​ 防止编译器优化连续的 UART 写操作。​putc_()​ 直接写数据寄存器,​puts_()​ 循环输出字符串,​puthex() 将 64 位值格式化为 16 个十六进制字符。

这是简单的轮询输出,不检查 PL011 的 TX FIFO 是否满。QEMU 的设备模型可以直接接受;真实 UART 通常要先读 ​UARTFR​ 的 ​TXFF 位,等 FIFO 有空位再写入。

​hv_main()​ :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void hv_main(uint64_t dtb)
{
puts_("\r\nC\r\n");
puts_("Hello, world!\r\n");
puts_("DTB=");
puthex(dtb);
puts_("\r\n");

WRITE_SYSREG(hyp_vectors, VBAR_EL2);
WRITE_SYSREG(HCR_RW | HCR_AMO | HCR_FMO | HCR_IMO, HCR_EL2);
__asm__ volatile("isb");

puts_("D\r\n");

for (;;)
__asm__ volatile("wfi");
}

流程:

  1. 打印 ​C,表示已进入 C 语言运行环境
  2. 打印 ​Hello, world!
  3. 打印 QEMU 传进来的 DTB 地址
  4. 再设置一次 ​VBAR_EL2(汇编里已经设置过,重复设置无害)
  5. 配置 ​HCR_EL2
  6. 打印 ​D
  7. wfi 循环

wfi 在没有任何中断源时会使 vCPU 进入 WFI 等待状态,QEMU 保持静默。这是预期行为,不是停止响应。

3.2.9 当前边界与下一步

该骨架已经具备:

  • EL2 启动
  • earlyprintk
  • 恒等映射的 MMU 配置
  • D-cache / I-cache
  • 2 KB 异常向量表和异常诊断
  • C 语言运行环境(栈、只读数据、MMIO)

但还不是完整的 Type-1 hypervisor:

  • 没有配置 Stage-2 转换(​VTCR_EL2​、​VTTBR_EL2)
  • 没有创建并启动 EL1 guest
  • 没有 GIC、定时器、虚拟中断注入
  • 没有保存与恢复 guest 上下文
  • 异常处理目前只打印后停机,不恢复执行
  • 没有主动清零 BSS(QEMU 启动时 RAM 默认清零;真实硬件上需要由软件清零)
  • UART 输出没有检查 TX FIFO(QEMU 可用,真实硬件上建议补充)

下一步通常按以下顺序推进:配置 Stage-2 页表 → 准备 guest 镜像和 DTB → 初始化 GIC → 设置 ​ELR_EL2​/​SPSR_EL2​/​SP_EL1​ → 执行 ​eret​,从 EL2 返回到 EL1(guest)。对应到 Xen,即 ​p2m​、​domain​、​vgic 等模块。


第四章:十个常见错误

  1. 将 ​​ldr x0, =symbol​​ 的结果当作运行地址——该值是链接地址。MMU 开启前应使用 ​adr​/​adrp。能在 QEMU 上运行,不代表能在真实硬件上运行。
  2. 开启 MMU 前遗漏 ​​dsb sy​——MMU 硬件读到未完成的页表项,症状是随机取指异常,极难调试。
  3. 写完 ​​SCTLR​​ 后遗漏 ​​isb​——流水线中预取的旧指令继续执行,表现为偶发异常。
  4. 恒等映射未覆盖“下一条指令” ——​msr SCTLR_EL2 一旦生效,PC 所在的 VA 必须能转换为同一 PA。
  5. ​VBAR_EL2​​ 未按 2 KB 对齐——写入值的低位会被硬件忽略,发生异常时会从无效地址取指。使用 ​.balign 2048​(或 ​.align 11)。
  6. 进入 C 代码前没有栈——C 函数序言的第一条 ​stp 就会写入无效内存。
  7. EL2 检查失败后静默停机——先配置好 earlyprintk,打印 ​CurrentEL 后再停机。
  8. TCR 的 ​​PS​​ 字段写死——更换物理地址位宽不同的 SoC 就会出问题。应读取 ​ID_AA64MMFR0_EL1.PARange​ 并按实际值配置(参考 Xen 的 ​ubfm​/​csel​/​bfi)。
  9. ​HCR_EL2​​ 一次配置过多陷获位——先验证 ​AMO​/​FMO​/​IMO,其余陷获位等 vCPU 机制实现后再配置;配置过多时,guest 一开始运行就会进入尚未实现的陷获处理分支。
  10. 遗漏 ​​msr SPSEL, #1​​ 导致 ​​SP​​ 的含义改变——EL2t/EL2h 下 ​SP 指向不同的寄存器,混用会导致栈错误。

第五章:术语速查表

术语 说明
EL0/1/2/3 Arm 异常级(Exception level);编号越大特权越高,hypervisor 运行在 EL2
EL2h / EL2t EL2 的两种栈模式;​SPSel​ 选择 ​SP_EL2​(EL2h)或 ​SP_EL0(EL2t)
MMU Memory Management Unit,内存管理单元,负责 VA→PA 转换
TLB Translation Lookaside Buffer,转译后备缓冲器,缓存地址转换结果
Stage-1 / Stage-2 Stage-1 将 guest 的 VA 转换为 IPA;Stage-2 将 IPA 转换为 PA
VA / IPA / PA Virtual Address / Intermediate Physical Address / Physical Address,虚拟地址/中间物理地址/物理地址
TTBR0_EL2 EL2 的 Stage-1 转换表基址寄存器
VTTBR_EL2 Stage-2 转换表基址寄存器(含 VMID,Virtual Machine Identifier)
MAIR_EL2 Memory Attribute Indirection Register,内存属性间接寄存器
TCR_EL2 Translation Control Register,转换控制寄存器
SCTLR_EL2 System Control Register,系统控制寄存器;​M​ 位使能 MMU,​C 位使能 D-cache
HCR_EL2 Hypervisor Configuration Register,Hypervisor 配置寄存器;控制陷获和中断路由
VBAR_EL2 Vector Base Address Register,EL2 向量基址寄存器;2 KB 对齐,16 × 128 字节
ESR_EL2 Exception Syndrome Register,异常综合征寄存器;​EC 字段说明陷入原因
ELR_EL2 Exception Link Register,异常链接寄存器,保存异常返回地址
hvc​ / ​smc HVC(Hypervisor Call)/ SMC(Secure Monitor Call)同步异常指令,分别陷入 EL2 / EL3
eret Exception Return,异常返回;从 ​SPSR_ELx​/​ELR_ELx 恢复 PSTATE/PC
dsb​ / ​isb Data / Instruction Synchronization Barrier,数据/指令同步屏障
tlbi alle2 失效 EL2 Stage-1 的全部 TLB 表项
DTB / FDT Device Tree Blob / Flattened Device Tree,设备树二进制与扁平设备树
p2m physical-to-machine,Xen 中 guest 物理地址到机器地址的映射
VMID Virtual Machine Identifier,Stage-2 地址空间标识
vCPU virtual CPU,虚拟 CPU
PMU Performance Monitoring Unit,性能监控单元
FPU / SVE Floating-Point Unit / Scalable Vector Extension,浮点单元与可伸缩向量扩展
GIC / vGIC Generic Interrupt Controller / virtual GIC,通用中断控制器与虚拟 GIC
LR List Register,vGIC 中用于注入虚拟中断的寄存器
UART Universal Asynchronous Receiver/Transmitter,通用异步收发器
MMIO Memory-Mapped I/O,内存映射 I/O
IOMMU I/O Memory Management Unit,I/O 内存管理单元
SMP Symmetric Multiprocessing,对称多处理
SMCCC / PSCI Secure Monitor Call Calling Convention / Power State Coordination Interface
MIDR Main ID Register,CPU 型号/修订版本寄存器
AAPCS64 Arm Architecture Procedure Call Standard for AArch64,AArch64 过程调用标准
Image header / magic number Linux arm64 镜像头及其格式标识字段
BSS Block Started by Symbol,未初始化数据段
backtrace 调用栈回溯
phys-offset 加载地址与链接地址之差
恒等映射 identity mapping,​VA == PA 的映射
errata 芯片硬件缺陷及对应 workaround
earlyprintk 直接操作 UART 的早期打印机制

第六章:后续学习路线

在运行验证本手册的骨架(观察到 A/B/C/D 标记)后,可以按以下顺序扩展 hypervisor;每一步都有对应的 Xen 实现可供对照精读:

  1. 完整的向量表与同步异常处理:完成 16 项向量表和 ​do_trap_sync​ 的初始实现,能在异常时打印 ​ESR_EL2​ 和调用栈回溯(backtrace,对应 Xen 的 ​entry.S​ 与 ​traps.c)。
  2. Stage-2 页表(p2m) :为第一个 guest 建立 Stage-2 页表并写入 ​VTTBR_EL2​(对应 Xen 的 ​mmu/p2m.c;注意 break-before-make:修改表项前先失效旧表项、失效 TLB,再写入新值)。
  3. 创建第一个 vCPU,并用 ​​eret​​ 从 EL2 返回到 EL1(guest) :设置初始 ​ELR_EL2​/​SPSR_EL2​/​SP_EL1​,运行一段 guest 的裸机代码(bare-metal code,对应 Xen 的 ​domain.c)。
  4. Hypercall 与陷入分发:实现 ​hvc #0​ 通道和 ​ESR_EL2.EC​ 的 ​switch​ 分发(对应 ​do_trap_guest_sync​ / ​do_trap_hypercall​;其中用 ​0xDEADBEEF​ 填充寄存器作为哨兵值,以及通过 ​regs->pc -= 4 重放指令,这两个技巧值得留意)。
  5. 中断虚拟化:GICv3 与 vGIC(virtual GIC),通过 ​LR​(List Register)注入虚拟中断(对应 ​gic-v3.c​ / ​vgic.c)。
  6. SMCCC 与 PSCI:实现 SMCCC(Secure Monitor Call Calling Convention)调用约定和 PSCI(Power State Coordination Interface)服务,使 guest 能够启动、关闭并调用固件服务(对应 ​vsmc.c)。
  7. CPU errata 与 Spectre 缓解:建立 MIDR 表并部署针对 Spectre 的硬化向量(对应 ​cpuerrata.c​ / ​bpi.S)。
  8. SMP 多核启动:SMP(Symmetric Multiprocessing)下,secondary CPU(从 CPU)执行与 boot CPU(主 CPU)相同的启动流程,只是各自的栈与 per-CPU 数据不同(对应 ​smpboot.c)。

每完成一步,回到 Xen 对应文件对照精读——本手册配套的《Xen-AArch64-ARMv8-A 实现深度分析》报告包含这些函数的逐行讲解和交互式调用关系图。


附录 A:最小骨架完整代码

下面两个文件与第三章的解读对应,可分别保存后使用 3.1 节的命令编译。

A.1 boot.S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
    .section .text.head, "ax"
.globl _start
.globl hyp_vectors
.globl exc_report

/*
* ARM64 Linux Image 头(QEMU -kernel 会识别它)。
* 字段必须按 ABI 摆正:magic 在偏移 56,而不是 52。
*/
_start:
b 1f
.word 0 /* code1 */
.quad 0 /* text_offset:QEMU 的加载基址已是 0x40080000 */
.quad _edata - _start /* image_size:只算加载到文件里的内容,不含 .bss */
.quad 0 /* flags:小端,4KB 页 */
.quad 0, 0, 0 /* res2..res4 */
.word 0x644d5241 /* "ARM\x64" */
.word 0 /* res5 */
1:
mov x22, x0 /* QEMU 启动桩通过 x0 传入 DTB 地址 */
mov x21, #0x09000000 /* PL011 UART */

/* ---- EL2 门禁 ---- */
mrs x5, CurrentEL
lsr x5, x5, #2
cmp x5, #2
b.ne hang_no_el2

/* ---- 先建立栈,异常处理才有地方可落 ---- */
msr SPSel, #1 /* 使用 SP_EL2 */
adrp x0, _stack_top
add x0, x0, :lo12:_stack_top
mov sp, x0

/* ---- 尽早装向量表:MMU 一开若有映射错误,至少能打印 ESR/FAR ---- */
adrp x0, hyp_vectors
add x0, x0, :lo12:hyp_vectors
msr VBAR_EL2, x0
isb

/* ---- earlyprintk: PL011 UART @ 0x09000000 ---- */
mov w10, #'A'
strb w10, [x21]

/* ---- MAIR:Attr0=Normal WB,Attr1=Device-nGnRE ---- */
mov x0, #0x44ff
msr MAIR_EL2, x0

/*
* TCR_EL2:T0SZ=25 (39-bit VA),4KB 粒度,从 level 1 开始走表;
* IRGN0/ORGN0=3 (Normal WB),SH0=3,PS=2 (40-bit PA)。
*/
movz x0, #0x3f19
movk x0, #0x2, lsl #16
msr TCR_EL2, x0

/* SCTLR_EL2:先保持 MMU/D-cache/I-cache 关闭(RES1 位按规范置 1) */
movz x0, #0x0800
movk x0, #0x30d0, lsl #16
msr SCTLR_EL2, x0
isb

/*
* 恒等映射低 512GB,用 1GB block 描述符。
* entry[0] 覆盖 0x00000000-0x3fffffff(UART/GIC 等设备)→ Device。
* 其余 entry 覆盖 RAM 及空洞 → Normal,1GB block 的 AF/SH/Attr 需置对。
* 旧代码只置了 bit0(valid),AF=0,且 T0SZ=16 时首级是 L0,
* L0 不允许 block 描述符,所以开 MMU 立刻 translation fault。
*/
adrp x0, boot_pgtable
add x0, x0, :lo12:boot_pgtable
mov x1, #0
2:
lsl x2, x1, #30 /* PA = index * 1GB */
mov x3, #0x701 /* Normal, AttrIdx=0, SH=3, AF=1, block */
cbnz x1, 3f
mov x3, #0x705 /* entry 0: Device, AttrIdx=1 */
3:
orr x2, x2, x3
str x2, [x0, x1, lsl #3]
add x1, x1, #1
cmp x1, #512
b.lt 2b

msr TTBR0_EL2, x0
dsb sy
isb
tlbi alle2
dsb sy
isb

/* 开 MMU、D-cache、I-cache */
movz x0, #0x1805 /* RES1 | M | C | I */
movk x0, #0x30d0, lsl #16
msr SCTLR_EL2, x0
isb

mov w10, #'B'
strb w10, [x21]

/* ---- 进 C ---- */
mov x0, x22 /* DTB */
bl hv_main /* 永不返回 */

hang_no_el2:
mov w10, #'N'
strb w10, [x21]

hang:
wfe
b hang

/*
* 异常向量表,16 × 128B,必须 2KB 对齐。
* 现在运行在 EL2h(SPSel=1),同步异常走 0x200。
*/
.balign 2048
hyp_vectors:
/* Current EL with SP0 */
.balign 128
b exc_sync
.balign 128
b exc_irq
.balign 128
b exc_fiq
.balign 128
b exc_serr

/* Current EL with SPx */
.balign 128
b exc_sync
.balign 128
b exc_irq
.balign 128
b exc_fiq
.balign 128
b exc_serr

/* Lower EL using AArch64 */
.balign 128
b exc_sync
.balign 128
b exc_irq
.balign 128
b exc_fiq
.balign 128
b exc_serr

/* Lower EL using AArch32 */
.balign 128
b exc_sync
.balign 128
b exc_irq
.balign 128
b exc_fiq
.balign 128
b exc_serr

/* kind: 0=SYNC 1=IRQ 2=FIQ 3=SError */
exc_sync:
mov x0, #0
b exc_common
exc_irq:
mov x0, #1
b exc_common
exc_fiq:
mov x0, #2
b exc_common
exc_serr:
mov x0, #3
exc_common:
mrs x1, ESR_EL2
mrs x2, FAR_EL2
mrs x3, ELR_EL2
bl exc_report
b hang

.section .bss
.align 12
boot_pgtable:
.space 4096
.align 14
_stack:
.space 16384
_stack_top:
_end:

A.2 main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <stdint.h>

#define WRITE_SYSREG(v, r) __asm__ volatile("msr " #r ", %0" :: "r"((uint64_t)(v)))

#define HCR_RW (1u << 31) /* EL1 运行在 AArch64 */
#define HCR_AMO (1u << 5) /* SError 路由到 EL2 */
#define HCR_FMO (1u << 4) /* FIQ 路由到 EL2 */
#define HCR_IMO (1u << 3) /* IRQ 路由到 EL2 */

#define UART_BASE 0x09000000UL
#define UART_DR (*(volatile uint8_t *)(UART_BASE + 0x00))

extern char hyp_vectors[]; /* 2KB 对齐向量表(16 × 128B) */

static void putc_(char c)
{
UART_DR = (uint8_t)c;
}

static void puts_(const char *s)
{
while (*s)
putc_(*s++);
}

static void puthex(uint64_t v)
{
static const char hex[] = "0123456789abcdef";
char buf[16];
int i;

for (i = 0; i < 16; i++) {
buf[15 - i] = hex[v & 0xf];
v >>= 4;
}
for (i = 0; i < 16; i++)
putc_(buf[i]);
}

/*
* 从向量表调用。ESR/FAR/ELR 已经由汇编取好。
* 这里只打印,然后停住,方便用 QEMU -d int 对照。
*/
void exc_report(uint64_t kind, uint64_t esr, uint64_t far, uint64_t elr)
{
const char *name;

switch (kind) {
case 0: name = "SYNC"; break;
case 1: name = "IRQ"; break;
case 2: name = "FIQ"; break;
default: name = "SERR"; break;
}

puts_("\r\n[EXC ");
puts_(name);
puts_("] ESR=");
puthex(esr);
puts_(" FAR=");
puthex(far);
puts_(" ELR=");
puthex(elr);
puts_("\r\n");

for (;;)
__asm__ volatile("wfi");
}

void hv_main(uint64_t dtb)
{
puts_("\r\nC\r\n");
puts_("Hello, world!\r\n");
puts_("DTB=");
puthex(dtb);
puts_("\r\n");

/* ---- init_traps ---- */
WRITE_SYSREG(hyp_vectors, VBAR_EL2);
WRITE_SYSREG(HCR_RW | HCR_AMO | HCR_FMO | HCR_IMO, HCR_EL2);
__asm__ volatile("isb");

puts_("D\r\n"); /* 陷阱装好 */

for (;;)
__asm__ volatile("wfi");
}

本手册基于 Xen 源码(​xen.git​ @ ​6b30eca0e388​,​xen/arch/arm 下真实代码引用;指令逐字,注释有中文改写)与 Arm ARM(DDI 0487)编写,仅供学习与研究。