1. HSR 寄存器到底在解决什么问题
刚接触 ARM 虚拟化扩展的人,几乎都遇到过这样的困扰:程序运行到某条指令,CPU 突然切到了 Host 侧,查看打印结果只能看到一串十六进制数字。你能确定“它进入了异常”,却完全无法知道“它为什么进入异常”。HSR 寄存器就是用来回答这个问题的那个寄存器,它的全称是 Hypervisor Syndrome Register,直译过来叫“虚拟化异常记录寄存器”。它不属于通用寄存器,也不参与运算,它的唯一职责是:当客户机(Guest)的某条指令触发陷入(trap)行为时,把这条指令的“现场记录”留在寄存器中,交给 Hypervisor 去判断接下来该模拟什么、该注入什么、还是该直接放行。
这个寄存器解决的问题看似单一,但牵扯面极广。一个能跑起来的 ARM 虚拟化监控程序,至少要在以下几个场景中读取它:客户机访问了未映射的 MMIO 地址、客户机执行了 WFI 想进入低功耗、客户机读了某个受保护的系统寄存器、客户机执行了 HVC 想主动陷入宿主、客户机触发了一次外部中止。这些场景全都走同一条异常向量入口,而区分它们的唯一依据,就是 HSR(在 AArch64 下对应 ESR_EL2)里的异常分类字段。
这篇文章适合三类人看。第一类是刚上手 ARM 虚拟化、能跑通一个最小 Hypervisor 但卡在“异常分发写不对”阶段的开发者;第二类是做嵌入式安全隔离、需要在两个执行环境之间做指令级拦截的工程师;第三类是已经能跑通基本流程,但发现 MMIO 模拟偶发失败、vCPU 偶发挂死、性能莫名下降,想回头把 HSR 的每个位都弄清楚的老手。我会从寄存器位域开始,一层一层拆到实际代码,中间穿插我自己踩过的坑,尽量做到看完能直接抄进项目。
1.1 从异常级别说起:ARM 虚拟化扩展的四个环
要理解 HSR 存在的必要性,得先理清 ARMv7 之后引入的异常级别模型。传统的 ARM 只有 User 和 Privileged 两个大层级,虚拟化扩展把它拆成了 EL0 到 EL3 四层:EL0 是普通应用态,EL1 是操作系统内核态,EL2 是 Hypervisor 态,EL3 是安全监控态。客户机操作系统运行在 EL1,Hypervisor 运行在 EL2,两者之间不是靠软件约定来隔离,而是靠硬件强制隔离。
关键在于,硬件给 EL2 提供了一套“拦截开关”。Hypervisor 可以告诉 CPU:如果 EL1 执行 WFI,就把它拦下来交给我;如果 EL1 读某个系统寄存器,也拦下来交给我;如果 EL1 访问某段内存触发了第二阶段翻译失败,同样拦下来。这些拦截开关分散在 HCR_EL2、VTCR_EL2 等一系列控制寄存器里,编号密密麻麻,初次接触容易把人绕晕。
拦截一旦发生,CPU 做的事情是:保存客户机的现场(PC、PSTATE、通用寄存器),把异常级别从 EL1 提升到 EL2,跳到 EL2 的异常向量表。问题来了,EL2 的向量表是固定的几个入口,所有类型的拦截都跳到相近的位置,Hypervisor 站在入口处,手里只有一堆保存下来的寄存器值,它凭什么判断“这次是 WFI 还是 MMIO”?答案就是硬件在陷入发生时,把原因编码进了一个专用寄存器,也就是 HSR。没有这个寄存器,Hypervisor 就只能靠猜测或者靠额外的软件标记来区分,那整个模型的可靠性和性能都没法看。
1.2 陷入不是目的,拿到“现场信息”才是
很多人对陷入机制的理解停在“拦下来”这个层面,觉得只要拦住就万事大吉,接下来随便处理一下再返回就行。实际做下来会发现,拦住只是第一步,真正的工作量在于“还原现场”。举个具体的例子:客户机执行了一条str w0, [x1],其中 x1 指向一个模拟设备的寄存器地址。这条指令在第二阶段翻译时失败,CPU 陷入到 EL2。此时你要完成模拟,需要知道四件事:写的是哪个地址、写的是哪个寄存器、写的是多宽的数据、写进去的值是多少。
地址可以从 FAR_EL2 和 HPFAR_EL2 组合得到,数据宽度和寄存器编号从 HSR 的 ISS 字段里来,数据值本身要回到保存的通用寄存器现场去取。这四件事缺一不可,任何一项拿错,模拟就会出错,而且往往错得很隐蔽——比如宽度判断错了,你可能只写了低 8 位,剩下的字节保持原值,客户机读回来就是一个诡异的数值,排查起来能耗掉一整天。
所以我把 HSR 的定位说成“现场记录单”而不是“原因寄存器”,是因为它承载的信息量远大于一个原因码。它同时告诉你异常类别、指令长度、以及一组随异常类别变化的附加信息。这三层结构是理解它的主线,后面所有细节都挂在这条主线上。
1.3 HSR 与 ESR_EL2:同一个思想的两代实现
这里必须澄清一个容易混淆的点。HSR 这个名字属于 AArch32 时代的命名,它是一个 32 位寄存器,配合 ARMv7 的虚拟化扩展使用。到了 AArch64,对应的寄存器改名叫 ESR_EL2(Exception Syndrome Register,异常记录寄存器,EL2 版本),位宽从 32 位扩展到 64 位,字段位置做了平移,但整体设计思想完全一致。
两者的对应关系可以这样记:ESR_EL2 的低 32 位结构基本沿用了 HSR 的布局,EC 在 [31:26],IL 在 [25],ISS 在 [24:0],高 32 位里新增了 ISS2 等扩展字段。所以在 AArch64 环境下读 ESR_EL2 的低 32 位,得到的语义和读 HSR 是一样的。这也意味着,一份写得好好的位域解析代码,只要把寄存器读取接口换掉,就能在两种架构之间迁移。
注意:AArch32 客户机跑在 AArch64 Hypervisor 上时,客户机侧的异常同样由 ESR_EL2 记录,但部分 EC 编码是按 AArch32 语义定义的(比如 CP15 访问陷入的 EC 是 0x03,而不是 AArch64 系统寄存器访问的 0x18)。解析时必须结合“当前客户机是 AArch32 还是 AArch64”这个上下文,否则会把编码含义搞反。
我在实际项目中见过一种错误写法:一套代码同时支持 32 位和 64 位客户机,作者偷懒没有做架构分支,直接用 AArch64 的 EC 表去查 AArch32 的陷入,结果 CP15 访问全被当成未知异常注入回去,客户机启动到一半就崩。这种问题在日志里表现为“大量 unknown exception”,看着像硬件问题,其实是查表查错了。
2. 位域拆解:一个 32 位寄存器里的三层结构
把 HSR 摊开来看,结构其实非常规整,32 位被切成三段,每一段承担不同职责。理解这个分层,比死记每个位的位置重要得多。
| 字段 | 位范围 | 宽度 | 职责 |
|---|---|---|---|
| EC | [31:26] | 6 位 | 异常类别,决定“为什么进来” |
| IL | [25] | 1 位 | 指令长度,决定“出错指令多长” |
| ISS | [24:0] | 25 位 | 附加信息,含义随 EC 变化 |
这个设计的巧妙之处在于,ISS 是一个“复用容器”。硬件不打算为每种异常都准备一个独立寄存器,而是共用一个 25 位的空间,由 EC 来决定怎么解读这块空间。好处是寄存器数量省下来了,代价是解析代码必须写成两级:先看 EC,再按 EC 去解码 ISS。这跟网络协议里的“类型 + 负载”是一个思路,你可以把它类比成一个信封,EC 是信封上贴的标签,ISS 是信封里的信,标签不同,信的格式就不同。
2.1 EC:先判断“为什么进来”
EC 是 6 位字段,理论上能表达 64 种异常类别,实际定义了几十种。写分派代码时,最省事的写法是拿一张表把常见值列出来,其余归到“未知”。但这里有个陷阱:未知类别不能简单地当成“不应该发生”然后忽略,因为一旦忽略,客户机就会卡死在异常向量里,表现为无故挂死,而且没有任何日志线索。
下面这张表是我平时用得最多的一批 EC 值,基本上覆盖了 90% 以上的实际场景。
| EC 值 | 含义 | 常见处理方式 |
|---|---|---|
| 0x00 | 未知原因 | 记录日志,注入未定义异常 |
| 0x01 | WFI/WFE 被陷入 | 让出 CPU 或直接返回 |
| 0x03 | AArch32 CP15 的 MCR/MRC 访问 | 模拟协处理器寄存器 |
| 0x07 | AArch32 SMC 指令 | 转发给安全监控层或模拟 |
| 0x0B | AArch32 的 LDREX/STREX | 模拟独占访问 |
| 0x0D | AArch64 BRK 指令 | 当作断点处理或注入调试异常 |
| 0x11 | AArch32 SVC | 转发或注入 |
| 0x12 | AArch32 HVC | 客户机主动陷入 |
| 0x13 | AArch32 SMC | 同 0x07 |
| 0x15 | AArch64 SVC | 转发或注入 |
| 0x16 | AArch64 HVC | 客户机主动陷入 |
| 0x17 | AArch64 SMC | 转发或模拟 |
| 0x18 | AArch64 系统寄存器访问被陷入 | 按 Op0/Op1/CRn/CRm/Op2 模拟 |
| 0x20 | 来自低异常级别的指令中止 | 处理取指故障 |
| 0x21 | 同级别内的指令中止 | 同上,注意 ELR 语义 |
| 0x22 | PC 对齐故障 | 注入对齐异常 |
| 0x24 | 来自低异常级别的数据中止 | MMIO 模拟的主战场 |
| 0x25 | 同级别内的数据中止 | 处理 Hypervisor 自身故障 |
| 0x26 | SP 对齐故障 | 注入对齐异常 |
| 0x2C | AArch64 浮点异常被陷入 | 按需模拟或放行 |
| 0x32 | SError 中断 | 通常注入客户机 |
| 0x34 | AArch32 断点 | 调试相关 |
| 0x35 | AArch64 断点 | 调试相关 |
| 0x3C | AArch32 BKPT | 调试相关 |
| 0x3D | AArch64 BRK | 调试相关 |
实际写代码时,我会把这张表做成 switch 语句,每个 case 调一个独立处理函数。这样做的理由很实在:陷入处理是性能敏感路径,用函数指针数组虽然看着优雅,但会引入一次间接跳转和一次缓存访问,在每秒几十万次陷入的场景下,这点开销会实打实体现在总吞吐上。而 switch 编译出来通常是跳转表,直接命中,代价低得多。
2.2 IL:被绝大多数人忽略的一位
IL 表示“出错指令的长度”,1 表示 32 位指令,0 表示 16 位指令。在 AArch64 下,指令固定 4 字节,IL 恒为 1,所以很多人写代码时直接把它当常量跳过。这个习惯在纯 AArch64 环境里没问题,一旦要支持 AArch32 客户机就会出大问题。
AArch32 支持 ARM 和 Thumb 两种指令集,Thumb 里还有 16 位和 32 位混合编码。当客户机在 Thumb 状态下执行了一条 16 位指令并触发陷入,IL 就是 0。此时如果你要计算“返回到哪条指令之后”,或者要回读指令字节做解码,指令长度算错就意味着返回地址算错,客户机会跳到一条指令的中间去执行,接下来发生什么完全不可预测。
提示:AArch32 客户机的返回地址语义要按异常类型区分。对于中止类异常,ELR 指向出错指令本身,需要由 Hypervisor 决定是重试还是跳过;对于 HVC、SMC 这类主动陷入指令,ELR 已经指向下一条指令,不需要再加偏移。这两类如果混淆,轻则客户机行为诡异,重则陷入死循环。
我自己就栽过一次:处理 CP15 访问陷入时,为了让客户机“跳过这条访问指令”,我无条件给 ELR 加了 4。在 ARM 状态下一切正常,切到 Thumb 状态后客户机立刻跑飞,因为那条指令只有 2 字节,加 4 直接跳过了半条指令。后来改成elr + (il ? 4 : 2)才恢复正常。这个教训让我在之后所有涉及 ELR 调整的地方,都强制要求先读 IL。
另外还有个小细节:IL 位在某些实现相关的异常类别下可能不可靠,此时架构允许它返回 0 或 1 而语义未定义。所以当 EC 是保留值或者你自己没把握的类别时,不要拿 IL 去做地址计算,宁可把客户机停下来报错,也不要盲目往前推。
2.3 ISS:条件解码的典型陷阱
ISS 是 25 位,看起来不大,但因为含义随 EC 变化,它是整个寄存器里最容易读错的部分。常见的错误有两类。第一类是把某个 EC 下的字段位置套用到另一个 EC 上,比如把数据中止的 SRT 位置当成系统寄存器访问的 Rt 位置去读,位置看起来都在中间偏上,实际完全不一样。第二类是忘记检查“字段有效性标志”,比如数据中止里的 ISV 位,它明确告诉你“其余字段能不能用”,如果不看这一位就直接解码,读到的是无意义的残留值。
正确的阅读顺序应该是三步:第一步读 EC 确定解码方案,第二步读该方案下的有效性标志位,第三步只在标志位允许时才去读具体字段。这个顺序不能颠倒,因为有效性标志本身也在这个方案里定义。
为了把这件事讲透,下一节我会把四类最常用的 ISS 布局拆开,一类一类说清楚每个字段的位置、含义,以及我在实际代码里怎么用它们。
3. ISS 在不同 EC 下的真实含义
3.1 数据中止:ISV、SAS、SRT、SF 四兄弟
数据中止是 Hypervisor 打交道最多的异常,MMIO 模拟、设备直通、内存热插拔全都依赖它。当 EC 是 0x24 或 0x25 时,ISS 的布局大致如下。
| 位 | 字段 | 含义 |
|---|---|---|
| [24] | ISV | 指令记录有效,1 表示下面这些字段可用 |
| [23:22] | SAS | 访问宽度,0=字节,1=半字,2=字,3=双字 |
| [21] | SSE | 符号扩展标志,1 表示需要符号扩展 |
| [20:16] | SRT | 传输寄存器编号,0 到 30 |
| [15] | SF | 寄存器位宽,1 表示 64 位寄存器,0 表示 32 位 |
| [14] | AR | 获取释放语义标志,用于独占类指令 |
| [10] | FnV | FAR 无效标志 |
| [9] | EA | 外部中止标志 |
| [8] | CM | 缓存维护指令标志 |
| [7] | S1PTW | 第一阶段页表遍历标志 |
| [6:0] | DFSC | 数据故障状态码 |
这组字段的价值在于,只要 ISV 是 1,你就能在不读客户机指令的前提下,完整还原“哪个寄存器、多宽、是否符号扩展”这三件事。这条路径我称之为快路径,它的性能优势非常明显:省掉了读客户机内存、解码指令、处理指令集差异这一整套操作,在 MMIO 密集的场景下能省下相当可观的 CPU 时间。
但 ISV 并不总是 1。架构规定,当出错的指令不是“单寄存器加载存储”时,ISV 必须为 0。典型情况包括:多寄存器加载存储指令、SIMD 与浮点加载存储指令、独占访问指令、缓存维护指令,以及故障发生在页表遍历过程中。这些情况下 SRT、SAS、SF 全部无效,Hypervisor 只能走慢路径:从 ELR 拿到出错指令的地址,主动读客户机内存把那几条指令取出来,自己解码。
注意:ISV 为 0 不等于不能模拟,只等于不能靠硬件帮忙模拟。慢路径必须实现,而且必须实现对,否则客户机一旦用上那几条指令,行为就会出问题。我在早期版本里只实现了快路径,结果客户机内核在启动后期用了一条多寄存器存储指令去访问 MMIO,直接触发未知异常,卡在那里不动。加上慢路径之后才彻底解决。
3.2 故障状态码 DFSC/IFSC:定位到页表和权限
低 7 位的状态码是排查内存问题的金钥匙。它告诉你故障发生在第几级页表、是翻译失败还是权限失败还是对齐失败。下表是我最常遇到的一批编码。
| 编码 | 含义 |
|---|---|
| 0x04 到 0x07 | 翻译失败,分别对应页表第 0 到第 3 级 |
| 0x09 到 0x0B | 访问标志失败,对应第 1 到第 3 级 |
| 0x0D 到 0x0F | 权限失败,对应第 1 到第 3 级 |
| 0x10 | 同步外部中止 |
| 0x21 | 对齐故障 |
| 0x2C | 同步奇偶校验或纠错码错误 |
这几个编码在实际排查中的价值极高。举两个真实场景。场景一:客户机访问设备时大量报 0x0D 到 0x0F,说明第二阶段页表项存在但权限不对,多半是映射时把设备内存标成了只读,而客户机在做写操作。场景二:报 0x04 到 0x07,说明页表项根本不存在,是漏映射,通常因为设备地址范围比预想的大,只映射了前一段。
区分这两类问题的意义在于排查方向完全不同:权限问题要去查映射属性,翻译问题要去查地址范围。如果不看状态码,只知道“数据中止了”,就得两头都查,效率差好几倍。我现在写日志时,一定会把 DFSC 单独打出来,就是为了省这一步。
指令中止的字段布局更简单,没有 ISV、SAS 这些,只有 FnV、EA、S1PTW 和低 7 位的 IFSC,结构与数据中止相似,判断逻辑也相似。
3.3 系统寄存器访问:AArch64 的 Op0/Op1/CRn/CRm/Op2
当 EC 是 0x18 时,说明客户机读写了某个被陷入开关拦下来的系统寄存器。AArch64 的系统寄存器编码由五个字段组成,ISS 里把这五个字段原样带过来了。
| 位 | 字段 | 含义 |
|---|---|---|
| [24:22] | Op0 | 操作码 0 |
| [21:20] | Op2 | 操作码 2 |
| [19:17] | Op1 | 操作码 1 |
| [16:14] | CRn | 控制寄存器编号 |
| [13:10] | Rt | 目标或源寄存器编号 |
| [9] | Direction | 0 表示写,1 表示读 |
| [7:5] | CRm | 控制寄存器扩展 |
这五元组正好对应汇编语法MRS Xt, S3_<op0>_<op1>_<CRn>_<CRm>_<Op2>里的那几个数字。拿到这五个字段,你就精确知道客户机要访问哪个寄存器。Direction 告诉你是读还是写,Rt 告诉你数据在哪个通用寄存器里。
写操作的处理相对简单:从保存的通用寄存器现场里取出 Rt 对应的值,按寄存器语义存到你维护的软件影子状态里,然后把 ELR 往前推进一条指令。读操作则要复杂一些,你得先算出正确的值写回 Rt,再推进 ELR。有些寄存器是只读常量,有些是读写但需要做位域屏蔽,有些读操作带副作用,这些都得逐个查手册。
提示:不要图省事把一大批系统寄存器都设成陷入。每一个陷入都是一次完整的世界切换,开销远高于普通函数调用。我见过一个实现为了“保险”,把 ID 寄存器组全部打开陷入,结果客户机内核启动时的寄存器探测阶段产生了几万次陷入,启动时间直接翻倍。正确做法是只在你确实需要干预的那几个寄存器上开陷入,其余的交给硬件直接访问。
3.4 最简 ISS:WFx 与 HVC/SMC
剩下两类陷入的 ISS 结构简单到几乎不用解释。
WFI/WFE 陷入(EC=0x01)只需要看最低位:[0] 为 0 表示 WFI,为 1 表示 WFE。另外 [24] 是条件有效标志,配合 [19:16] 的条件码字段,用来处理带条件的 WFI 或 WFE。绝大多数 Hypervisor 只关心那一位,因为带条件的情况极少出现。
HVC 陷入(EC=0x16 或 0x12)只看低 16 位,那是 HVC 指令携带的立即数。客户机调用 Hypervisor 时通常约定一组调用号,Hypervisor 拿立即数做 switch 分发即可。这也是最常见的客户机与 Hypervisor 通信方式,简单、快、不需要额外的内存队列。
SMC 陷入(EC=0x17、0x13、0x07)结构相同,也是立即数,但处理上要多一层考虑:客户机执行 SMC 通常是想要安全监控层的服务,Hypervisor 一般不该自己模拟,而是原样转发给 EL3,或者直接拒绝并注入一个异常。这个决策取决于你系统的安全边界划分。
4. 虚拟化场景下的四条主用法
4.1 陷入分派主流程的写法
所有陷入都进同一个向量入口,第一件事永远是读 ESR_EL2 并提取 EC。我习惯在入口处就把 EC、IL、ISS 三段拆成三个局部变量,后面所有逻辑都用这三个变量,不再重复做位运算。这样做除了可读性,还有个实际好处:位运算写错了很难发现,而集中拆一次,出错只可能错一处,方便复查。
分派函数的主体是一个 switch,每个 case 对应一节处理逻辑。默认分支绝对不能是空的。我的做法是:默认分支先打一条完整的现场日志(EC、IL、ISS、ELR、FAR、SPSR),然后给客户机注入一个未定义指令异常让客户机自己处理,最后返回“已处理”。这样做的好处是客户机最多丢失一个进程而不是整机挂死,同时日志里留下了完整线索,下次能直接定位。
注意:日志打印在陷入路径上是危险操作。陷入可能每秒发生几十万次,如果每次都打印,光是串口输出就能把系统拖垮。正确的做法是用一个限流计数器,同一种 EC 每秒钟只打一条,或者把日志写进环形缓冲区,由后台线程慢慢刷。
4.2 MMIO 模拟:ISV=1 的快路径与 ISV=0 的慢路径
MMIO 模拟是数据中止处理的典型应用,也是最能体现 HSR 价值的场景。整个流程分为五步。
第一步,从 HPFAR_EL2 和 FAR_EL2 拼出完整的中间物理地址。这里有个容易搞错的细节:数据中止发生时,FAR_EL2 里保存的是故障的虚拟地址,而 HPFAR_EL2 里保存的是故障的中间物理地址的页框部分。要把两者拼起来,标准写法是把 HPFAR 的低 4 位清掉,再或上 FAR 的低 12 位。之所以这么拼,是因为 HPFAR 只记录了 IPA 的第 12 位以上,第 0 到 11 位在硬件上没存,必须从虚拟地址的页内偏移取。如果忘了这一步,直接用 FAR_EL2 去查设备映射表,查到的永远对不上。
第二步,判断 ISV。如果为 1,直接读 SAS、SRT、SF、SSE,进入快路径。如果为 0,跳到慢路径,从 ELR 读指令字节自行解码。
第三步,按读写方向执行模拟。写操作要从保存的通用寄存器现场里取 Rt 的值,按 SAS 决定的宽度截断,再交给设备模型。读操作要反向走:先让设备模型返回值,按 SAS 和 SF 决定宽度,按 SSE 决定符号扩展,写回 Rt 对应的现场位置。这里有个必须处理的特例:Rt 等于 31 时,AArch64 里它代表零寄存器,写入应该被丢弃,不能真的去写第 31 个寄存器。我见过漏掉这个判断的实现,在客户机用str xzr, [x1]清寄存器时把 Hypervisor 自己的栈写坏了。
第四步,推进 ELR。因为故障指令没有成功执行,客户机需要重新执行它。但重新执行会再次触发同样的故障,所以标准做法是让 ELR 指向“下一条指令”,让客户机跳过这条访问,同时把模拟的结果通过 Rt 交回去。这就是所谓的“模拟完成”。
第五步,返回。硬件会从 ELR 恢复执行。
慢路径的实现要复杂一些,核心是读指令并识别编码。AArch64 的加载存储指令编码集中在几个固定位置,可以写一个简化解码器,只识别最常见的几种:单寄存器加载存储、成对加载存储、带前索引或后索引的变体。识别不出来的,注入异常让客户机自己承担后果。我在实际项目里会给慢路径加一个计数器,统计命中次数,如果某个慢路径频繁命中,说明客户机里有热代码在用它,值得专门优化或把那段地址改成直通映射,避免反复陷入。
4.3 vCPU 空闲管理:WFI 陷入与调度器配合
客户机操作系统空闲时会执行 WFI 等待中断。在虚拟化环境下,这条指令如果直接放行,物理 CPU 会真的进入低功耗状态,而客户机的中断是靠 Hypervisor 注入的,物理 CPU 睡下去可能就没人来叫醒它,或者醒来的时机完全不对。所以标准做法是把 WFI 陷入打开,拦下来交给 Hypervisor 处理。
处理逻辑本身很简单:读到 EC 是 0x01,看一眼最低位是 WFI 还是 WFE,然后把当前 vCPU 标记为空闲,让出物理 CPU 给调度器。调度器在下一个可运行 vCPU 之间切换。等有中断要投递给这个 vCPU 时,再把它唤醒。
坑在于放行的时机。如果 Hypervisor 拦下 WFI 之后立刻又返回客户机,客户机会重新执行 WFI,再次陷入,形成忙等循环,物理 CPU 占用率飙到 100%,功耗和性能双输。正确的做法是在让出之前必须确保“要么有别的 vCPU 可跑,要么真的进入了物理低功耗”,不能直接返回。
提示:WFI 和 WFE 的处理策略通常不同。WFI 一般配合调度器让出 CPU,WFE 则往往和事件寄存器配合,需要检查事件是否已置位,置位就清掉并直接返回,未置位才让出。混在一起处理会导致多核客户机的同步原语行为异常。
4.4 寄存器陷阱开关的成本控制
HCR_EL2 里的陷阱开关有十几个,每一个都对应一类陷入。新手上手时最容易犯的错是“全开”,理由通常是怕漏掉什么。全开的代价是巨大的:客户机内核启动时会大量读写系统寄存器,每一次都触发一次世界切换,启动时间可能从几百毫秒涨到几秒。
我的原则是:只在“确实需要干预”的寄存器上开陷阱。什么叫确实需要干预?三类。第一类是会影响隔离的,比如页表基址寄存器、地址翻译控制寄存器,这些必须拦,否则客户机可以绕过 Hypervisor 直接改映射。第二类是会影响物理资源的,比如缓存维护指令、TLB 维护指令,这些拦下来可以做批处理或者安全检查。第三类是 Hypervisor 需要提供虚拟值的,比如某些 ID 寄存器,客户机读到的值必须和 Hypervisor 暴露的 CPU 特性一致,不能让客户机看到真实硬件。
其余的,尤其是纯信息类的只读寄存器,能放就放。放行之后客户机直接读硬件,速度快,还省掉了模拟代码的维护成本。这条经验是我在做第二个项目时才想明白的,第一个项目里我把能开的陷阱全开了,性能测试数据惨不忍睹,后来一个个关掉,最后只留了必要的一小撮,性能直接回到可接受范围。
5. 实操:从零搭一个能跑的陷入处理骨架
5.1 现场信息采集顺序
进入异常向量后的第一步不是急着处理,而是把需要的现场信息一次性读全。我习惯的顺序是:先读 ESR_EL2(这一步最关键,越早读越好,避免被后续操作覆盖),再读 ELR_EL2 和 SPSR_EL2,然后读 FAR_EL2 和 HPFAR_EL2,最后把通用寄存器现场保存到 vCPU 结构里。
顺序上的讲究在于,ESR_EL2 是只读的异常记录,后续操作不会改它,但越早读出来越能保证后续逻辑基于同一份快照。而通用寄存器的保存必须在任何 C 代码执行之前完成,因为 C 函数调用会破坏寄存器。实际工程里这一段通常是一小段汇编,把 x0 到 x30 压栈,再把栈指针存进 vCPU 结构,然后才跳进 C 函数。
static inline uint32_t esr_ec(uint64_t esr) { return (uint32_t)((esr >> 26) & 0x3f); } static inline uint32_t esr_il(uint64_t esr) { return (uint32_t)((esr >> 25) & 0x1); } static inline uint32_t esr_iss(uint64_t esr) { return (uint32_t)(esr & 0x1ffffff); }这三个小函数看着没什么技术含量,但我强烈建议每个项目都写一遍,而且强制所有代码通过它们取字段,禁止在业务逻辑里直接写位运算。原因很实际:一旦哪天需要同时支持 AArch32 和 AArch64 两套编码,你只需要改这三处,而不是去全项目搜索>> 26。
5.2 分派与默认兜底
分派函数的结构我固定成这样:取 EC,switch,每个 case 做一件事并把结果返回,默认分支记日志并注入异常。
int vcpu_handle_exit(struct vcpu *v, uint64_t esr) { uint32_t ec = esr_ec(esr); uint32_t iss = esr_iss(esr); switch (ec) { case 0x01: return handle_wfx(v, iss & 0x1); case 0x16: case 0x12: return handle_hvc(v, iss & 0xffff); case 0x18: return handle_sysreg(v, esr); case 0x24: case 0x25: return handle_data_abort(v, esr); case 0x20: case 0x21: return handle_inst_abort(v, esr); case 0x32: return handle_serror(v); default: log_unknown_exit(v, esr, 1); return inject_undefined(v); } }这里有个设计选择值得说明:为什么把 0x24 和 0x25 合并处理?因为它们都是数据中止,字段布局一致,唯一的区别是异常来自低异常级别还是同级别。在 Hypervisor 里,0x24 是客户机触发的,0x25 是 Hypervisor 自己触发的,前者要模拟,后者通常意味着 Hypervisor 自己有 bug。合并到一个函数里处理,在函数内部再按 EC 分流,代码更紧凑,也避免了两份几乎相同的解析逻辑各自演化出差异。
5.3 关键代码:数据中止模拟完整流程
下面这段是我实际项目里数据中止处理的精简版,保留了所有关键判断。
int handle_data_abort(struct vcpu *v, uint64_t esr) { uint32_t iss = esr_iss(esr); uint64_t far = read_far_el2(); uint64_t hpfar = read_hpfar_el2(); /* 拼出完整的中间物理地址 */ uint64_t ipa = (hpfar & 0xFFFFFFFFFFF0ULL) | (far & 0xFFFULL); uint32_t dfsc = iss & 0x7f; if (dfsc >= 0x0d && dfsc <= 0x0f) { /* 权限故障,通常意味着映射属性配错 */ log_fault(v, "permission fault @ %llx", ipa); return inject_data_abort(v, esr, far); } if (!(iss & (1U << 24))) { /* ISV 为 0,走慢路径自行解码指令 */ return mmio_slow_path(v, esr, ipa); } unsigned sas = (iss >> 22) & 0x3; unsigned sse = (iss >> 21) & 0x1; unsigned srt = (iss >> 16) & 0x1f; unsigned sf = (iss >> 15) & 0x1; unsigned dir = (iss >> 10) & 0x1; /* 见下方说明,方向位需按实际架构确认 */ if (srt == 31) { /* 零寄存器,写入直接丢弃,读取返回 0 */ return advance_elr(v); } unsigned width = 1U << sas; if (dir == 0) { uint64_t val = vcpu_read_gpr(v, srt, sf); if (sse) { val = sign_extend(val, width * 8); } mmio_write(ipa, width, val); } else { uint64_t val = mmio_read(ipa, width); if (sse) { val = sign_extend(val, width * 8); } vcpu_write_gpr(v, srt, sf, val); } return advance_elr(v); }注意:上面代码里方向位的取法需要按你实际使用的架构版本来确认。AArch64 的数据中止 ISS 里并没有一个独立的方向位,读写方向要从指令编码里判断,或者从 FnV、CM 等标志间接推断。我在这里把它写成变量,是为了提醒自己每一处都要对照手册确认,而不是照抄网上的片段。实际项目中我用的是“先读客户机指令判断是加载还是存储”的方案,虽然多一次内存访问,但绝对可靠。
这段代码里最值得说的是srt == 31的判断。这个判断看起来是个边角情况,实际出现的频率比想象中高。客户机内核在清空一段内存、或者在驱动里写一个“忽略数据”的寄存器时,经常会用零寄存器作为源。如果不处理,你会去写第 31 个通用寄存器,而在多核环境下那个位置可能正被别的路径使用,症状是偶发的数据错乱,极难定位。
另一个值得说的是符号扩展。SSE 位存在是有原因的:客户机可能执行ldrsw这类带符号扩展的加载指令,读一个 32 位值扩展成 64 位。如果模拟时不处理符号扩展,客户机读回来的高位全是零,对于负数就变成了一个巨大的正数,接下来做什么都可能