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 |
- 虚拟化扩展以 EL2 为中心:
VTTBR_EL2(Stage-2 转换表基址)、HCR_EL2(Hypervisor 配置寄存器)、VBAR_EL2(EL2 异常向量表基址)等寄存器由 EL2 配置。 - 从异常处理返回使用
eret(Exception Return):目标异常级和返回地址由 SPSR_ELx / ELR_ELx 决定,通常返回较低异常级。较低异常级不能直接调用较高异常级;它通过执行 svc / hvc / smc 触发同步异常,硬件按异常级规则将异常送到更高异常级的异常向量。目标异常级由指令和当前配置决定:svc 通常进入 EL1,hvc 进入 EL2,smc 进入 EL3(启用相应路由配置时可能不同)。guest 请求 hypervisor 服务时执行 hvc #0,由 EL2 的异常向量接管。 - 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 | guest VA --(Stage-1,guest 页表,TTBR0_EL1)--> 中间物理地址(IPA) |
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 | [0] Image header start: add x13, x18, #0x16 (opcode 即 "MZ",兼容 PE/COFF) |
2.1 Image header与加载约定
Xen 的做法(xen/arch/arm/arm64/head.S 开头,节选):
1 | FUNC(start) |
- 引导器将控制权交给 Xen 后执行的第一条指令是
add x13, x18, #0x16。这条指令本身没有实际作用,其意义在于机器码:前两个字节恰好是 4D 5A,即 ASCII "MZ";UEFI 要求 PE/COFF 可执行文件以 "MZ"开头。因此同一个文件头可同时满足两种加载器。 b real_start才是实际跳转到启动代码的指令。- 后续的
.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 | /* Find out where we are */ |
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 | FUNC_LOCAL(check_cpu_mode) |
mrs x5, CurrentEL:读取当前异常级。CurrentEL[3:2] 是 EL 编号,低两位 RES0,所以 EL2 的读数是 0x8。0x9 是处理器状态(PSTATE)中 EL2h 的模式编码(PSR_MODE_EL2h),不是 CurrentEL的读数。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)才跳去报错。
- 对
失败时不会静默停机,而是通过 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 | FUNC_LOCAL(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 | ubfm x1, x1, #0, #3 // 取 PARange 低 4 位(当前 CPU 支持的物理地址位宽) |
为什么不将 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 | FUNC_LOCAL(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 | primary_switched: |
- 将 DTB(Device Tree Blob,设备树二进制)的物理地址放入
x0 并传入 C 代码。这是 hypervisor 与引导器之间的主要交接信息:内存布局、设备列表和中断信息都由它描述。 - 进入 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 | void init_traps(void) |
五个寄存器的作用:
| 寄存器 | 作用 | 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 | .align 11 /* 2 KB 对齐,VBAR 要求 */ |
向量表为什么是 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 | mrs x0, elr_el2 /* 保存当前 ELR */ |
它利用硬件行为:解除 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 | aarch64-none-elf-gcc -nostdlib -ffreestanding -c boot.S -o boot.o |
预期输出:
1 | AB |
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 | QEMU 加载器 |
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 | mrs x5, CurrentEL |
CurrentEL[3:2] 是当前异常级编号。右移 2 位后:
- 1 → EL1
- 2 → EL2
- 3 → EL3
HCR_EL2、VTTBR_EL2 等虚拟化寄存器由 EL2 管理,因此首先确认当前异常级为 EL2。QEMU 命令行中的 virtualization=on 才会让 CPU 从 EL2 启动;否则代码会打印 N 后停机。
1 | msr SPSel, #1 |
SPSel=1 表示当前使用 SP_EL2,即 EL2h 模式。_stack_top 指向 .bss中 16 KB 栈的栈顶,栈向下增长。- 必须先建立栈,后续 C 函数和异常处理才能使用栈。
1 | adrp x0, hyp_vectors |
VBAR_EL2 是异常向量表基址,要求 2 KB 对齐。这里在开启 MMU 之前就设置好,原因是:如果页表有错,开启 MMU 后的第一条取指就可能异常;如果此时 VBAR_EL2 仍为 0,CPU 会从 0x200 一类的无效地址取指,连错误原因都无法获取。
3.2.4 MAIR_EL2、TCR_EL2、SCTLR_EL2
MAIR_EL2:
1 | mov x0, #0x44ff |
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 | movz x0, #0x3f19 |
写入值 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 | movz x0, #0x0800 |
第一次写入 0x30D00800:
- 保持
M=0(MMU 关闭)、C=0(D-cache 关闭)、I=0(I-cache 关闭) - 把架构规定为 RES1 的位先置 1,避免写 0 造成不可预测行为
待页表和 TTBR0_EL2 都准备好后:
1 | movz x0, #0x1805 |
第二次写入 0x30D01805,在 RES1 基础上使能:
| 位 | 名称 | 作用 |
|---|---|---|
| 0 | M |
MMU |
| 2 | C |
D-cache |
| 12 | I |
I-cache |
isb 确保后续指令观察到新的系统控制状态。
3.2.5 恒等映射与开启 MMU
1 | adrp x0, boot_pgtable |
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]) |
这里有两个容易写错的位域:
- AF 必须置 1。如果
AF=0,第一次访问就会触发 Access Flag fault;只设置 bit0=1 会漏掉 AF。 - AttrIndx 在
[4:2] ,不是单独某一位。0x701 和 0x705才是正确的 Normal/Device 描述符。
运行时内存布局:
1 | 0x00000000 - 0x3FFFFFFF Device |
页表准备好后,按顺序写入 TTBR0_EL2、失效 TLB:
1 | msr TTBR0_EL2, x0 |
dsb sy:确保页表写入对所有观察者可见isb:确保后续指令观察到新的 TTBR0_EL2tlbi alle2:失效 EL2 Stage-1 的全部 TLB 表项- 再
dsb/isb:保证 TLB 失效完成
最后写 SCTLR_EL2 使能 M/C/I。从这条指令开始,取指和访存都经过 MMU;能够打印 B 说明整条恒等映射链路工作正常。
3.2.6 进入 C 语言运行环境
1 | mov x0, x22 /* DTB */ |
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 | .balign 2048 |
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 | exc_sync: |
发生异常时,硬件会设置:
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 |
#r 将参数转换为寄存器名字符串。例如:
1 | WRITE_SYSREG(hyp_vectors, VBAR_EL2); |
展开为 msr VBAR_EL2, xN。
HCR_EL2:
1 |
| 位 | 名称 | 作用 |
|---|---|---|
| 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 |
volatile 防止编译器优化连续的 UART 写操作。putc_() 直接写数据寄存器,puts_() 循环输出字符串,puthex() 将 64 位值格式化为 16 个十六进制字符。
这是简单的轮询输出,不检查 PL011 的 TX FIFO 是否满。QEMU 的设备模型可以直接接受;真实 UART 通常要先读 UARTFR 的 TXFF 位,等 FIFO 有空位再写入。
hv_main() :
1 | void hv_main(uint64_t dtb) |
流程:
- 打印
C,表示已进入 C 语言运行环境 - 打印
Hello, world! - 打印 QEMU 传进来的 DTB 地址
- 再设置一次
VBAR_EL2(汇编里已经设置过,重复设置无害) - 配置
HCR_EL2 - 打印
D 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 等模块。
第四章:十个常见错误
- 将
ldr x0, =symbol 的结果当作运行地址——该值是链接地址。MMU 开启前应使用 adr/adrp。能在 QEMU 上运行,不代表能在真实硬件上运行。 - 开启 MMU 前遗漏
dsb sy——MMU 硬件读到未完成的页表项,症状是随机取指异常,极难调试。 - 写完
SCTLR 后遗漏 isb——流水线中预取的旧指令继续执行,表现为偶发异常。 - 恒等映射未覆盖“下一条指令” ——
msr SCTLR_EL2一旦生效,PC 所在的 VA 必须能转换为同一 PA。 -
VBAR_EL2 未按 2 KB 对齐——写入值的低位会被硬件忽略,发生异常时会从无效地址取指。使用 .balign 2048(或 .align 11)。 - 进入 C 代码前没有栈——C 函数序言的第一条
stp就会写入无效内存。 - EL2 检查失败后静默停机——先配置好 earlyprintk,打印
CurrentEL后再停机。 - TCR 的
PS 字段写死——更换物理地址位宽不同的 SoC 就会出问题。应读取 ID_AA64MMFR0_EL1.PARange 并按实际值配置(参考 Xen 的 ubfm/csel/bfi)。 -
HCR_EL2 一次配置过多陷获位——先验证 AMO/FMO/IMO,其余陷获位等 vCPU 机制实现后再配置;配置过多时,guest 一开始运行就会进入尚未实现的陷获处理分支。 - 遗漏
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 实现可供对照精读:
- 完整的向量表与同步异常处理:完成 16 项向量表和
do_trap_sync 的初始实现,能在异常时打印 ESR_EL2 和调用栈回溯(backtrace,对应 Xen 的 entry.S 与 traps.c)。 - Stage-2 页表(p2m) :为第一个 guest 建立 Stage-2 页表并写入
VTTBR_EL2(对应 Xen 的 mmu/p2m.c;注意 break-before-make:修改表项前先失效旧表项、失效 TLB,再写入新值)。 - 创建第一个 vCPU,并用
eret 从 EL2 返回到 EL1(guest) :设置初始 ELR_EL2/SPSR_EL2/SP_EL1,运行一段 guest 的裸机代码(bare-metal code,对应 Xen 的 domain.c)。 - Hypercall 与陷入分发:实现
hvc #0 通道和 ESR_EL2.EC 的 switch 分发(对应 do_trap_guest_sync / do_trap_hypercall;其中用 0xDEADBEEF 填充寄存器作为哨兵值,以及通过 regs->pc -= 4重放指令,这两个技巧值得留意)。 - 中断虚拟化:GICv3 与 vGIC(virtual GIC),通过
LR(List Register)注入虚拟中断(对应 gic-v3.c / vgic.c)。 - SMCCC 与 PSCI:实现 SMCCC(Secure Monitor Call Calling Convention)调用约定和 PSCI(Power State Coordination Interface)服务,使 guest 能够启动、关闭并调用固件服务(对应
vsmc.c)。 - CPU errata 与 Spectre 缓解:建立 MIDR 表并部署针对 Spectre 的硬化向量(对应
cpuerrata.c / bpi.S)。 - 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 | .section .text.head, "ax" |
A.2 main.c
1 |
|
本手册基于 Xen 源码(xen.git @ 6b30eca0e388,xen/arch/arm 下真实代码引用;指令逐字,注释有中文改写)与 Arm ARM(DDI 0487)编写,仅供学习与研究。