翻译器内部实现
QEMU 是动态翻译器,首次遇到一段代码时,将其转换为主机指令集。 动态翻译器通常很复杂且高度依赖 CPU。 QEMU 通过一些技巧,在获得良好性能的同时保持相对简单和易于移植。
动态翻译后端称为 TCG,即微型代码生成器,更多信息见 TCG 中间表示。 下面概述其主要特点和实现细节。
CPU 状态优化
目标 CPU 的许多内部状态会影响指令解释方式。 为提高速度,翻译阶段假定部分虚拟 CPU 状态在块内不变, 并将这些信息记录在翻译块(TB)中。 状态变化,例如特权级改变后,会生成新 TB; 旧 TB 只有在状态再次匹配其记录值时才会重新使用。 相同思路也适用于其他状态。例如 x86 的 SS、DS、ES 段基址均为零时, 翻译器甚至无需生成段基址加法。
直接块连接
每个翻译基本块执行后,QEMU 根据模拟 PC 和其他 CPU 状态, 例如 CS 段基址,查找下一个块。
最简单但优化较少的方式,是退出当前 TB,经结束代码返回主循环。 QEMU 在此查找下一 TB;若内存中尚不存在,则从客户机指令翻译。 然后从入口序言开始执行新 TB,再进入翻译后的指令。
这种退出方式会在继续执行指令前重新检查 cpu_exec_interrupt() 回调。 任何可能解除中断屏蔽的 CPU 状态变化之后,都必须这样退出。
若新 PC 对应的 TB 已存在,可将多个 TB 直接连接,避免返回主循环。 支持以下机制。
lookup_and_goto_ptr
tcg_gen_lookup_and_goto_ptr() 生成对 helper_lookup_tb_ptr 的调用, 查找匹配当前 CPU 状态的已有 TB。 找到时返回目标代码地址,否则返回 JIT 结束代码地址。 调用后总是紧接 goto_ptr 操作,跳转到返回地址, 从而进入下一 TB 或返回主循环。
goto_tb + exit_tb
翻译器通常按以下步骤实现分支:
调用 tcg_gen_goto_tb(),传入跳转槽索引 0 或 1。
生成更新 CPU 状态的 TCG 指令,将此前假定不变、 但主循环定位和执行下一 TB 所需的信息写回。 多数客户机只需目标 PC,部分还需其他数据。 更新的信息必须能由 cpu_get_tb_cpu_state() 和 cpu_restore_state() 推导。
调用 tcg_gen_exit_tb(),传入当前 TB 地址及相同槽索引。
goto_tb 最终翻译为跳到对应槽关联地址的指令。 最初该地址指向步骤 2 的状态更新代码。 exit_tb 则退出当前 TB,返回由最后执行 TB 地址和槽索引组成的带标记指针。
第一次执行时,步骤 1 直接进入步骤 2,更新状态后退出, 行为接近前面介绍的简单方式。
主循环随后根据当前状态查找下一 TB,必要时创建。 开始执行新 TB 前,会修补刚执行的 TB, 把 exit_tb 指定的跳转槽关联到新 TB 地址。
下一次执行旧 TB 到同一 goto_tb 时,只要目标仍在内存中, 就会直接进入目标首条指令,不再返回主循环。
使用此机制需要满足:
CPU 状态变化是常量,例如直接分支,而非间接分支。
直接分支不跨页,因为内存映射变化可能改变目标地址处的代码。
步骤 3 除槽索引外还返回刚执行 TB 的地址,即需要修补的 TB。 如果主循环直接进入的 TB 已与其他块连接, 最终返回的 TB 地址可能不同于最初进入的块。
自修改代码与翻译代码失效
x86 应用修改代码时不会显式通知指令缓存失效,因此自修改代码是特殊挑战。
用户模式每次为基本块生成翻译代码时,都会把对应主机页设为写保护, 除非它本来只读。写入该页会触发 Linux SEGV 信号, QEMU 随即使页内全部翻译代码失效,并重新允许写入。 系统模拟则通过软件 MMU 实现写保护。
为高效、正确地失效代码,维护各页所含 TB 的链表, 并维护其他链表以撤销直接块连接。
RISC 目标上,正确的软件会使用内存屏障和缓存刷新,部分保护原本并不必要。 但为正确处理异常,QEMU 仍要求生成代码始终匹配内存中的目标指令。
异常支持
遇到除零等异常时,使用 longjmp()。
主机 SIGSEGV 和 SIGBUS 处理器捕获无效内存访问。 QEMU 保存主机 PC 到目标 PC 的映射,根据异常位置的主机 PC 找到目标异常位置。
某些目标的部分虚拟 CPU 状态直到 TB 末尾才写回内存。 这适用于程序很少直接读取,或块内频繁变化的内部模拟状态, 例如 x86 条件码、SPARC 延迟槽和 Arm 条件执行。 每条目标指令都保存这些状态信息,发生异常时查询。
MMU 模拟
系统模拟使用软件 MMU,每次内存访问都执行虚拟到物理地址转换。
地址转换缓存(TLB)用于加速。 为避免 MMU 映射每次改变都清空翻译代码,QEMU 的缓存采用物理索引, 每个基本块按物理地址索引。
为避免映射变化使块链失效,只有跳转目标与当前块同页时才直接连接。
MMU 还区分 RAM、ROM 和 MMIO。 RAM、ROM 访问较快,因为转换缓存同时保存客户机地址与主机内存之间的偏移。 MMIO 则调用设备模拟的 C 代码。 MMU 还协助跟踪脏页及翻译块引用的页面。
分析 JIT 代码性能
Linux perf 将全部 JIT 代码视为单一块, 因为无法像普通代码那样利用调试信息,把各 PC 样本关联到更大的函数。 可用 -perfmap 或 -jitdump 生成映射文件突破这一限制。 -perfmap 较轻量,只生成客户机到主机的映射; -jitdump 还保存 JIT 代码和可用的客户机调试信息, 其输出需先与 perf.data 合并,才能查看最终报告。
perf record $QEMU -perfmap $REMAINING_ARGS
perf report
perf record -k 1 $QEMU -jitdump $REMAINING_ARGS
DEBUGINFOD_URLS= perf inject -j -i perf.data -o perf.data.jitted
perf report -i perf.data.jitted
qemu-system 只为 ELF 格式的 -kernel 文件生成映射。