☰
ARM虚拟化HSR/ESR_EL2异常陷入解析与MMIO模拟实践
2026/10/31 4:14:22 网站建设 项目流程

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未知原因记录日志,注入未定义异常
0x01WFI/WFE 被陷入让出 CPU 或直接返回
0x03AArch32 CP15 的 MCR/MRC 访问模拟协处理器寄存器
0x07AArch32 SMC 指令转发给安全监控层或模拟
0x0BAArch32 的 LDREX/STREX模拟独占访问
0x0DAArch64 BRK 指令当作断点处理或注入调试异常
0x11AArch32 SVC转发或注入
0x12AArch32 HVC客户机主动陷入
0x13AArch32 SMC同 0x07
0x15AArch64 SVC转发或注入
0x16AArch64 HVC客户机主动陷入
0x17AArch64 SMC转发或模拟
0x18AArch64 系统寄存器访问被陷入按 Op0/Op1/CRn/CRm/Op2 模拟
0x20来自低异常级别的指令中止处理取指故障
0x21同级别内的指令中止同上,注意 ELR 语义
0x22PC 对齐故障注入对齐异常
0x24来自低异常级别的数据中止MMIO 模拟的主战场
0x25同级别内的数据中止处理 Hypervisor 自身故障
0x26SP 对齐故障注入对齐异常
0x2CAArch64 浮点异常被陷入按需模拟或放行
0x32SError 中断通常注入客户机
0x34AArch32 断点调试相关
0x35AArch64 断点调试相关
0x3CAArch32 BKPT调试相关
0x3DAArch64 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]FnVFAR 无效标志
[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]Direction0 表示写,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 位。如果模拟时不处理符号扩展,客户机读回来的高位全是零,对于负数就变成了一个巨大的正数,接下来做什么都可能

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询