virt-report虚拟化社区动态

QEMU TCG 的翻译块与执行循环

本文由 OpenAI Codex (GPT-6-Astra) 辅助编写。

一、TCG 动态翻译与代码复用

TCG(Tiny Code Generator)把客户机指令转换为宿主机能够执行的代码,并缓存翻译结果。客户机再次运行到同一段程序时,QEMU 可以复用这些代码;循环、函数调用和分支因此不必每次重新译码。缓存的有效性取决于指令内容、执行模式和地址映射,代码改写或运行环境变化时需要重新检查。

本文沿一个三块循环展开:第一次执行 A 时查找缓存,未命中便翻译;执行到 B 后决定回到 A 还是进入 C;循环重复时复用并链接已有代码;程序改写 A 后撤销旧翻译。客户机访存、异常与多 vCPU 同步都围绕这条执行路径解释。

源码采用 QEMU 10.2.0,以 x86 系统模拟和 x86-64 宿主为例。TCG 在用户态实现客户机 CPU 的指令语义、特权状态和访存检查;KVM 的硬件执行路径可对照KVM vCPU 的执行循环与退出处理。两种加速器共享许多机器与设备模型,CPU 的执行机制各有不同。

译码器、IR 优化器、宿主代码生成器以及生成的代码都运行在 QEMU 宿主用户态中。客户机寄存器保存在每个 vCPU 的 CPUArchState,翻译好的机器码保存在 QEMU 的代码缓存。即使客户机与宿主都是 x86-64,TCG 执行的仍是按客户机语义生成的宿主代码。

源码版本:QEMU 10.2.0。

二、从当前 PC 到下一个翻译块

先固定一个简单循环:A 读取整数并加一,B 判断是否继续循环,C 处理循环结束后的工作。设第一次执行时,客户机 RBX 指向的 32 位整数是 41,A 的 mov eax, [rbx]; add eax, 1 执行后,客户机 EAX 应为 42。A、B 位于同一个 4 KiB 代码页,C 位于下一页。下列地址范围用于演示缓存与跨页关系,包含指令和填充字节;交互使用相同布局。

代码页 0x1000
  A:0x1000—0x101f   读取 [RBX],EAX 加 1,跳到 B
  B:0x1fe0—0x1fff   测试循环条件;成立跳 A,否则顺序进入 C

代码页 0x2000
  C:0x2000—0x201f   处理循环之外的工作,再跳到 B

一条执行轨迹:A → B → A → B → C → B
                              ↑
                    这里的分支结果发生变化

cpu_exec_loop() 以客户机 PC 与执行上下文查找 TranslationBlock(TB,翻译块)。第一次找不到 A,就由 tb_gen_code() 生成宿主代码;随后 cpu_tb_exec() 跳入这些代码。A 结束时选出下一个 PC,执行流继续查找或跳往 B。

执行阶段TCG 处理相关机制
第一次 A → B分别生成 A、B;满足条件时把 A 的出口连到 B指令译码、IR、宿主代码、TB 保存的条件
再次 B → A → B查找已有 A,建立 B → A;随后同页循环可直接跳转缓存复用与直接链接的成本差异
B → C,跨页按新 PC 查找 C;没有 C 才翻译跨页分支为何保留查找,缓存仍然有效
执行 A 中的内存读取经软件 TLB 访问 RAM,必要时进入 helper客户机地址转换、MMIO 与异常位置恢复
客户机改写 A 的指令使重叠 TB 失效、拆除相关链接,再按新字节翻译代码页追踪与旧宿主代码的撤销

这五步也给出了性能分析的分界:首次翻译、缓存查询、热代码执行、访存慢路径、代码失效。先确定时间花在哪一步,再决定增加缓存、改善访存还是减少重复翻译。

三、第一次执行 A:从指令译码到宿主机器码

1. 缓存未命中时的三阶段翻译

翻译分为三个阶段。客户机前端理解指令编码和体系结构语义,把操作表示为 TCG 中间表示(IR);公共代码优化 IR、分析临时变量的生命周期并分配宿主寄存器;宿主后端输出可执行机器码。target/i386/tcg/ 是 x86 客户机前端,tcg/i386/ 是 x86 宿主后端,目录名相似但职责不同。

TCG 的翻译与运行路径
译码、IR 优化和代码生成发生在缓存未命中时;生成的宿主代码可多次执行,并按需要访问 CPU 状态、客户机内存和 helper。
译码、IR 优化和代码生成发生在缓存未命中时;生成的宿主代码可多次执行,并按需要访问 CPU 状态、客户机内存和 helper。 打开原图
Mermaid 源码
flowchart TB
    accTitle: TCG 的翻译与运行路径
    accDescr: 缓存未命中时,客户机前端将 x86 指令译为 TCG IR,公共优化与寄存器分配后由宿主后端输出代码。运行时 CPUArchState 提供寄存器状态,生成代码通过软件 TLB 或 helper 访问客户机内存与设备。
    subgraph compile[缓存未命中时翻译]
      direction LR
      G[客户机指令字节<br/>x86 前端译码] --> I[TCG IR<br/>TCGOp / temps]
      I --> O[优化与活跃性分析<br/>寄存器分配]
      O --> H[宿主后端生成代码<br/>TB.tc.ptr]
    end
    compile --> E[运行宿主代码<br/>可重复执行]
    V[CPUArchState<br/>寄存器与运行状态] <--> E
    E --> M{客户机访存}
    M -->|软件 TLB 命中普通 RAM| R[宿主 RAM 映射<br/>load / store]
    M -->|慢路径| C[访存 helper<br/>页表 / 权限 / MMIO]
    E --> F[指令语义 helper<br/>复杂操作或异常]
    classDef build fill:#e9f4ef,stroke:#3b7c65,color:#203d31
    classDef state fill:#eaf0f9,stroke:#6684ab,color:#263c56
    classDef slow fill:#f5efe2,stroke:#9d8152,color:#514328
    class G,I,O,H,E build
    class V,R state
    class C,F slow

x86_translate_code() 进入公共 translator_loop(),后者逐条调用 x86 译码回调。遇到改变控制流的指令、页边界、调试限制或翻译预算限制时结束本块。TB 通常覆盖一段顺序执行的客户机指令,但不要求恰好等于编译器定义的完整基本块;例如单步调试可以让一个 TB 只有一条指令。

2. 把 A 的三个操作逐层展开

下表展开 A 中的读取、加一和跳转。IR 和宿主序列为语义示意,省略寄存器分配、标志位和访存慢路径细节:

客户机 x86-64 指令TCG IR 表达的操作宿主 x86-64 代码承担的工作
mov eax, [rbx]qemu_ld 读取客户机地址,写入 RAX 的低 32 位并清零高位。检查软件 TLB,命中普通 RAM 后使用宿主 load;否则进入访存 helper。
add eax, 132 位加法、结果扩展,以及后续计算条件码所需的信息。执行算术并保存必要结果;宿主寄存器由分配器决定,不要求使用宿主 EAX。
jmp B同页且满足条件时生成 goto_tb 跳转槽及未链接时的退出路径。先跳到块尾的退出代码;建立链接后,跳转槽指向 B 对应的宿主代码。

同一条客户机指令可以展开为多条 IR 和多条宿主指令;复杂指令还可能调用 C helper。反过来,常量传播、无用操作删除等优化也会消除部分 IR。tcg_gen_code() 中依次执行优化、可达性与活跃性分析,再进行寄存器分配和代码生成,因此三层之间没有固定的一对一关系。

四、重复执行 A、B:缓存查找与直接链接

1. 第一次选中目标:查询或生成 TB

cpu_exec_loop() 处理待决事件后构造 TB 查询条件。缓存未命中时,tb_gen_code() 分配 TB 和代码空间,锁定涉及的代码页,调用前端与后端,再登记恢复索引、页关联和全局查询表。cpu_tb_exec() 最终通过 tcg_qemu_tb_exec() 进入宿主代码。返回值中包含最后退出的 TB 和出口编号;如果中途经过了链接块,这个 TB 可能已经不是最初进入的那个。

TB 的查找、生成、链接与失效
已缓存但尚未链接的代码仍需查找;直接链接省去目标查找。代码写入触发失效时,同时清理查询结构和跳转关系。
已缓存但尚未链接的代码仍需查找;直接链接省去目标查找。代码写入触发失效时,同时清理查询结构和跳转关系。 打开原图
Mermaid 源码
flowchart TD
    accTitle: TB 的查找、生成、链接与失效
    accDescr: 主循环查询每 vCPU 跳转缓存与全局 QHT。未命中时翻译并发布 TB,命中则执行缓存代码。同页静态分支可修补为直接链接,其他合适分支通过 helper 查询。代码改写使相交 TB 失效,同时清理缓存并撤销入链与出链。
    L[主循环<br/>处理事件与构造查询条件] --> Q{跳转缓存 / QHT<br/>存在匹配的 TB?}
    Q -->|未命中| T[译码与代码生成<br/>发布恢复索引、页关联和 TB]
    Q -->|命中| E[执行宿主代码]
    T --> E
    E --> B{块尾路径}
    B -->|已有同页静态链接| E
    B -->|同页出口首次返回| P[主循环找到目标 TB<br/>修补跳转槽并登记入链]
    P --> E
    B -->|跨页 / 间接分支快路径| H{helper 查询目标}
    H -->|命中 / goto_ptr| E
    H -->|未命中| L
    B -->|退出请求 / 事件| L
    W[写入覆盖客户机代码] --> X[标记相交 TB 失效<br/>移出查询结构与页关联]
    X --> U[撤销入链与出链<br/>来源跳转恢复原始出口]
    U -.-> Q
    classDef active fill:#e9f4ef,stroke:#3b7c65,color:#203d31
    classDef lookup fill:#eaf0f9,stroke:#6684ab,color:#263c56
    classDef stale fill:#f5efe2,stroke:#9d8152,color:#514328
    class T,E,P active
    class L,Q,B,H lookup
    class W,X,U stale

2. 同页循环:把 A、B 的出口直接连起来

同页静态分支预留 goto_tb 槽。第一次经过该出口时,槽仍指向本块的原始退出路径,执行流回到主循环。主循环找到目标 TB 后,tb_add_jump() 将槽修补到目标的宿主代码入口,并把来源挂到目标的入链表。下一次经过相同出口便可直接进入目标块,省去主循环和目标查询。

translator_use_goto_tb() 要求分支目标与当前 TB 的起始 PC 位于同一客户机虚拟页,且没有禁止链接;系统模拟主循环还拒绝将该直链指向跨两个代码页的 TB。跨页映射可以独立变化,未经重新查询就保留跳转容易运行到旧代码。断点、单步和需要重新检查中断的状态变化也会限制链接。

3. B 转向 C:跨页查找仍可复用代码

跨页或间接跳转仍有另一条快路径:lookup_and_goto_ptr 调用 helper_lookup_tb_ptr(),按新的 CPU 状态查询目标。命中就返回目标代码入口,由生成的 goto_ptr 继续执行;未命中才回到主循环生成代码。因此应区分三种成本:翻译新块、查询已有块、沿已修补链接直接跳转。缓存命中不等于已经建立直链,跨页也不等于每次重新翻译。

下面固定执行模式和地址映射,用 A、B、C 三个块观察这些路径。A 与 B 在同一页,C 位于另一页;“改写 A/B 指令”修改与这两个块相交的代码范围,C 保持有效。演示一次推进一个 TB,真实执行会连续运行,直到退出请求、异常或其他条件需要返回调度循环。

跟随 PC,观察 TB 查找与链接

A、B 位于同一代码页,C 位于下一页。每次推进执行一个 TB;先沿 A → B → A 建立链接,再比较跨页分支和代码改写。

首次执行 A、B 时,查找未命中并生成对应 TB,同时建立 A → B 的直接链接。再次进入 A 可命中已生成的 TB,并建立 B → A;后续同页跳转可沿链接执行。B 的指令位于 0x1fe0—0x1fff,条件不成立时顺序进入 0x2000 的 C。改写 0x1000—0x1fff 的代码页会使 A、B 失效并解除链接;C 保留。启用 JavaScript 可逐步观察。

示例采用 x86 系统模拟、4 KiB 代码页,两页分别映射到不同的 RAM 后备页,映射与翻译上下文固定;每个 TB 的指令范围为 32 字节,F0/C0 表示固定的 flags/cflags。每一步只展示入口选择和下一 PC,不执行真实指令,也不以步数衡量性能。代码改写发生在两次执行之间;失效项保留在图中便于比较,实际查找不会返回它们。

直链循环仍需响应退出请求。普通 TB 的入口代码检查退出标记,主循环再处理相应中断或工作;开启 icount 时还有指令预算。TB 长度、链接策略与这些检查共同影响调度开销,不能单凭“链接越长”判断运行速度。

五、缓存依据:TB 保存代码,CPU 状态保存运行值

A 的宿主代码生成后,需要同时保留“执行哪个入口”和“在什么条件下可以复用”两类信息。EAX 这次计算出的数值属于当前 vCPU;A 的指令字节、执行模式与生成代码则决定缓存是否有效。

结构关键内容职责
CPUArchState / CPUX86Stateregs[] / eip / hflags / cc_op / cc_dst某个 vCPU 的客户机寄存器、执行模式和延迟条件码状态。
TCGContextops / temps / gen_tb / code_gen_ptr当前翻译过程的 IR、临时变量、目标 TB 与代码输出位置。
TCGOpopc / args[] / life / link一条 IR 的操作码、参数、生命周期信息及操作链表连接。
TranslationBlockpc / cs_base / flags / cflags / page_addr[]记录生成代码所依据的执行环境及 RAM 代码页;绝对 pc 字段用于非 CF_PCREL 路径。
TranslationBlock.tcptr / size已生成宿主代码的入口与长度;TB 的 size / icount 则记录客户机代码长度和指令数。
TB 跳转与恢复信息jmp_dest[2] / jmp_list_head / jmp_reset_offset[2]最多两个可修补出口、所有入链及原始目标;宿主代码后另存指令位置恢复数据。

TB 缓存代码,CPUArchState 保存每个 vCPU 的运行值。例如前端把客户机通用寄存器定义为相对于 tcg_env 的全局变量;生成代码通过当前 env 访问它们。第一次读到 41,A 得到 42;另一次读到 99,同一个有效 TB 就应得到 100,无需为数据值变化重新翻译。只要代码与执行上下文匹配,不同 vCPU 也可以执行同一个 TB,各自修改自己的寄存器状态。

TB 查找的地址与执行状态条件

相同 PC 不一定能复用同一个 TB。x86 的 x86_get_tb_cpu_state() 提取线性 PC、CS 基址,以及由 hflags 和部分 EFLAGS 组成的 flags。32/64 位模式、特权级、单步等状态会影响译码与生成逻辑;cflags 还包含并行执行、指令数限制和链接控制等翻译条件。普通通用寄存器的运行值则不会整体塞进缓存键。

每个 vCPU 先查自己的 tb_jmp_cache,未命中再查全局 QHT。全局查询还要确认实际代码位于哪一段 RAM,防止相同虚拟 PC 映射到不同代码后误用旧块。本文版本的 x86 系统模拟默认采用 CF_PCREL 相对 PC 代码,因此满足其他条件时,同一段 RAM 代码可以在不同虚拟映射下复用;未采用该模式时还要比较绝对 PC。这里的 RAM 地址是 QEMU 内部地址,不是 HPA。

无论怎样复用,客户机的当前 PC 都必须正确。直链节省的是目标查找与主循环往返;x86 生成代码仍按需要更新 eip。具体缓存键与更新位置可对照文末的 cpu-exec.c 和 gen_jmp_rel() 源码。

六、A 读取内存:软件 TLB、RAM 与设备慢路径

生成代码中存在两类不同的内存操作。访问 env 中的寄存器状态是 QEMU 自身的宿主内存访问;客户机的 [rbx] 访存使用 qemu_ld / qemu_st,携带客户机地址、访问宽度、字节序和 MMU 索引等信息,需要遵守客户机权限及地址转换规则。

系统模拟的软件 TLB 缓存客户机虚拟页到 RAM 宿主映射及访问属性。普通 RAM 命中时,生成代码可快速计算宿主地址并完成 load/store;缺页、权限不符、跨页、监视点、代码写保护或 MMIO 等情况进入相应慢路径。软件 TLB 未命中会调用客户机架构的地址翻译逻辑;确实触发客户机页故障时,才交付客户机异常。

MMIO 最终到达 QEMU 的 MemoryRegion 和设备回调,仍需满足设备的锁与执行上下文约束。TCG 执行大量普通 RAM 访问时不需要每次进入设备模型;访问设备寄存器也不能直接当作普通宿主内存读取。地址空间、RAM 与 MMIO 的对应关系可继续阅读QEMU 内存初始化与地址空间管理。

翻译缓存和软件 TLB 是两个层次:前者复用指令的宿主代码,后者缓存每次访存所需的地址转换。客户机更改页表并完成必要的 TLB 维护后,代码查询必须基于新的映射;映射回原来的 RAM 代码且上下文匹配时,尚未失效的 TB 仍可能被复用。

七、A 的读取发生异常:恢复准确的客户机位置

假设示例块中的第一条 load 触发客户机页故障:这一轮尚未取得 41,也没有执行加一,因此客户机应停在 load 对应的指令位置,EAX 保留本次读取前的值。生成代码为减少内存访问,可能把部分状态留在宿主寄存器中,或延迟物化 x86 条件码;异常恢复需要确定块内实际执行到的位置。

前端在每条指令开始时生成 insn_start 记录。x86 保存当前客户机 PC 信息及 cc_op;代码生成阶段再关联实现该指令的宿主代码边界。encode_search() 把这些信息压缩存放在宿主代码之后。异常 helper 通过宿主返回地址找到 TB,再沿恢复表确定故障对应的客户机指令。

cpu_restore_state_from_tb() 解码恢复数据,调用 x86 的 restore_state_to_opc() 设置 eip,必要时恢复条件码操作类型;随后异常退出路径回到预先建立的 setjmp 边界,由执行循环处理异常。这个表保存的是重建准确位置所需的信息,不是每条指令执行前的完整寄存器快照。通用寄存器及内存更新的正确时序,仍由翻译器、寄存器分配和 helper 的异常约定共同保证。

八、A 的指令被改写:失效旧 TB 并撤销链接

客户机内核打补丁、JIT 生成代码或普通程序修改可执行区域后,缓存的宿主代码可能已经过期。系统模拟通过软件 TLB 的慢写路径跟踪代码页写入;notdirty_write() 检查代码脏标志,必要时调用 tb_invalidate_phys_range_fast()。

PageDesc 连接着涉及该 RAM 页的 TB。失效代码按写入范围检查 TB 覆盖的指令字节,处理相交的块。小范围写入不必无条件使同页所有 TB 失效,某些较宽的失效入口则可以覆盖更大的范围。上述交互同时改写 A、B,是因为写入范围与两者都相交。

  1. 在跳转锁保护下设置 CF_INVALID,阻止新的入链连接到旧 TB。
  2. 从全局 QHT、页关联及各 vCPU 的跳转缓存中移除对应记录。
  3. 解除旧 TB 的出链,并遍历它的入链表,将来源跳转槽恢复到原始退出路径。
  4. 下次执行相关客户机地址时重新查找,必要时根据新字节翻译。

如果一个 x86 TB 正在改写自己覆盖的代码,QEMU 还要避免继续执行块内已经翻译好的旧指令。精确自修改代码路径利用宿主返回地址恢复当前位置,退出当前 TB,并请求下一次以一条指令的 TB 重新执行。这与只删除哈希表记录不同:正在运行的控制流也需要妥善退出。

单个 TB 失效主要撤销复用和链接资格,不立即把所有宿主代码空间重新分配出去。代码缓冲区耗尽时,QEMU 使用安全工作机制协调全局 flush,清空翻译缓存并重置代码区域。增大 tb-size 可能减少容量导致的重新翻译,但会增加内存占用,也无法避免真正的代码改写失效。

九、扩展到多 vCPU:共享代码与同步边界

MTTCG 为每个 vCPU 创建宿主线程,线程拥有自己的 CPU 状态、跳转缓存和翻译上下文。运行普通翻译代码时释放 BQL;进入需要全局串行化的设备或控制路径时,再按相应规则同步。QEMU 10.2 的 x86 前端声明支持 MTTCG,默认自动模式会在未启用 icount 时选择它;显式 thread=single 则保留轮转执行方式。

全局 TB 查询表和已生成代码仍可共享。并行翻译使用各线程的上下文与代码区域,同时通过代码页锁、QHT 插入和跳转锁保证发布与失效安全。如果两条线程生成了等价 TB,发布阶段可以保留已有结果、丢弃重复翻译。增加 vCPU 线程后,代码改写、跨 vCPU TLB 操作和全局 flush 的协调成本也随之增加。

客户机 CPU 的并行还要求保持它的内存模型。x86 前端声明允许普通 Store→Load 重排,而其他规定的顺序要维持;tcg_gen_req_mo() 比较客户机要求与宿主默认保证,生成缺少的屏障。带 LOCK 的原子读改写不能拆成让其他 vCPU 任意插入的普通 load/store。遇到后端无法直接支持的原子操作,QEMU 可通过 EXCP_ATOMIC 退出,在排他执行区内用单指令 TB 完成,再恢复并行执行。

每个 TB 有多个客户机指令,不代表整个 TB 是一笔原子事务。并行 vCPU 可以观察到客户机内存模型允许的中间状态;QEMU 内部的页锁和跳转锁保护翻译元数据,也不代替客户机程序自己的锁与屏障。

十、TCG 的演进与性能取舍

1. 从 dyngen 到 TCG,再到多线程执行

2008 年引入 TCG时,Fabrice Bellard 明确提出要减少不同 GCC 版本带来的问题,并改善代码生成。早期 dyngen 依赖预先编译的微操作片段;TCG 逐步改为由公共 IR 和宿主后端直接组织代码,使优化与寄存器分配拥有更明确的接口。代价是每种宿主后端都要实现、验证指令编码及相关约束。

TB 缓存和直接链接减少了热代码的重复工作,但引入了代码页追踪、跳转撤销和精确异常恢复。较大的块可以摊薄入口开销,也会增大生成成本和失效时的重做范围;复杂优化若明显拖慢首次翻译,对短生命周期程序未必划算。这些取舍解释了 TCG 为什么围绕有界 TB 持续优化,而不简单追求把整段程序提前编译完。

2017 年的 MTTCG 设计进一步把系统模拟从单线程轮转推向每 vCPU 一线程。旧模型天然串行化了许多共享访问;并行后,原子指令、内存屏障、TB 发布与清理都要获得明确的同步规则。当前实现支持并行翻译和细粒度锁,但设备共享状态、代码改写频率及客户机锁竞争仍会限制扩展性。

2. 沿主路径判断优化是否有效

主要耗时改进方向代价与验证
冷启动大量翻译减少重复生成,控制翻译与优化复杂度比较启动时间和进入稳定执行后的耗时;更复杂优化未必能在短任务中回本
热循环 A ↔ B复用 TB、满足条件时直接链接检查查找 / helper 占比与真实吞吐;链接仍需退出检查和正确失效
A 的 load 经常走慢路径区分软件 TLB 未命中、权限、跨页与设备访问扩大 TB 缓存不能直接解决客户机访存或设备延迟
改写代码后反复翻译确认是否真实代码更新或代码缓冲区容量不足增加 tb-size 可缓解容量 flush,却不能跳过必要的代码失效
增加 vCPU 后收益有限检查客户机锁、共享设备、原子慢路径和全局协调同时比较线程 CPU、吞吐与延迟,不能以线程数量代替扩展性

在宿主启动测试 QEMU 时,选择下面的 TCG 加速器和日志参数;已有的 KVM 加速器配置应先移除,避免同时指定两种执行方式。日志输出位于宿主的 /tmp/qemu-tcg.log:

-accel tcg,thread=single \
-d in_asm,op,op_opt,out_asm \
-D /tmp/qemu-tcg.log

in_asm 展示客户机指令,op 与 op_opt 展示优化前后 IR,out_asm 展示生成的宿主代码。对 A 的 load/add/jump,可以沿这几段核对翻译过程。它们主要记录代码生成,热循环复用既有 TB 时未必再次输出,因此日志条数不能直接换算为执行次数;详细日志也会改变运行速度。

对较大工作负载,-perfmap 或 -jitdump 能帮助宿主 perf 识别 JIT 代码。分析时还应查看 helper 和设备路径的采样,避免把所有耗时都归因于翻译器。TCG 的优势是跨架构执行、软件可观测性和 CPU 语义模拟;需要同架构生产负载吞吐时,应结合硬件虚拟化路径比较。

十一、参考资料