跳转至

QEMU TCG 介绍:二进制动态翻译原理和运行流程

主要贡献者

QEMU 版本

本文基于 QEMU v10.2.0(tag: v10.2.0,commit: 75eb8d57c6b9)。

QEMU 支持多种 accel(accelerator,加速器,决定 vCPU 的执行方式),但大体可以分为两种:指令模拟技术(TCG)、虚拟化技术(KVM、HVF)等。QEMU 有两种主要的运行模式:System mode 模拟整个机器(CPU、内存和虚拟设备)以运行客户机操作系统;User mode 则允许在一个 CPU 架构上运行为另一个 CPU 编译的用户态进程,此时 CPU 始终被模拟,主要支持 Linux 用户态程序。

概览

  • TCG 作为动态二进制翻译引擎的角色
  • IR 结构与 TB/BB 的基本概念
  • 翻译流程与执行路径
  • MTTCG 多线程模型
  • 跳转优化、代码缓存与性能分析

QEMU 的执行框架与 TCG

要理解 TCGTiny Code Generator),不能一开始就钻进翻译细节,而应先看清它在 QEMU 整体执行框架中的位置。QEMU 本质上不是单一的“虚拟机程序”,而是一个通用的执行框架:它既可以模拟完整机器,也可以只模拟用户态进程;既可以通过纯软件方式执行客户机代码,也可以在条件满足时借助硬件辅助虚拟化来加速执行。官方文档将其主要使用方式区分为 System EmulationUser Mode Emulation 两类;其中,当使用 TCG 时,QEMU 负责将客户机代码动态翻译为宿主机可执行代码。 ([QEMU][1])

QEMU 的两种主要运行模式

QEMU 的第一种主要运行模式是 System Emulation。在这种模式下,QEMU 要模拟的是“一整台机器”,而不是单个进程。它需要向客户机提供完整的硬件抽象,包括 CPU、内存、时钟、中断控制器以及各种虚拟设备。对客户机而言,它面对的是一台完整计算机,因此可以执行引导代码、启动操作系统内核,并继续运行整个操作系统栈。官方文档也明确将这一模式描述为“full system emulation”。

第二种主要运行模式是 User Mode Emulation。在这种模式下,QEMU 不再模拟整台机器,而是只模拟跨架构用户态程序执行所必需的那部分环境。更具体地说,它允许宿主机在保持自身操作系统不变的前提下,运行另一种 CPU 架构编译出来的用户态程序。此时,QEMU 仍然需要模拟客户机 CPU 的指令语义,但它通常不再负责完整设备模型与整机级硬件状态推进;官方文档也明确指出,在 User mode 中,CPU“始终是被模拟的”。

这两种模式的差别非常关键,因为它直接决定了 QEMU 需要承担的责任边界。在 System mode 中,QEMU 不仅要关心“这条指令做了什么”,还要关心访存、中断、设备交互、机器状态推进等整机级问题;而在 User mode 中,QEMU 的关注点更集中在用户态指令流与进程级 ABI 兼容上。后文很多机制——例如地址转换、异常处理、代码失效、访存路径——在两种模式下都会出现,但其复杂度和设计动机并不相同。

执行后端:QEMU 如何真正“执行”客户机代码

在 System mode 和 User mode 之上,QEMU 还支持多种不同的 执行后端,也就是“客户机代码最终如何在宿主机上被执行”。从总体上看,这些后端可以分成两类。第一类是 软件执行路径,即由 QEMU 自己理解并执行客户机指令;第二类是 硬件辅助执行路径,即在宿主机硬件允许的情况下,让客户机指令尽可能直接运行在物理 CPU 上。QEMU 官方文档和相关生态文档都将 TCGKVMHVF 等归入不同的 accelerator 或 execution backend 选择。

其中,最重要的软件执行后端就是 TCG。按照 QEMU 开发文档的定义,QEMU 的动态翻译后端称为 TCG;它会在首次遇到一段客户机代码时,将其转换为宿主机指令集上的可执行代码,并在之后重复利用翻译结果。也就是说,TCG 的职责不是“逐条解释执行每条指令”,而是充当一个 动态二进制翻译 引擎。

与之对应,硬件辅助执行路径的典型代表是 Linux 上的 KVM 和 macOS 上的 HVF。这类后端的共同特点是:当客户机架构与宿主机架构兼容,且宿主机提供相应硬件支持时,QEMU 不需要亲自翻译大部分普通客户机指令,而可以让这些指令尽可能直接运行在物理 CPU 上。此时,QEMU 更像是一个上层虚拟机管理框架和设备模型提供者,而不是纯粹的软件 CPU 模拟器。

因此,一个经常需要澄清的概念是:QEMU 不等于 TCG。QEMU 是整体框架;TCG 只是 QEMU 的一个执行后端,而且是负责“纯软件动态翻译”的那个后端。当读者后面看到 IR、TB、code buffer、direct block chaining 等机制时,应始终记住:这些首先是 TCG 子系统 的机制,而不是 QEMU 所有执行路径都必须经过的机制。

什么时候会走 TCG

最典型的一类场景,是 跨架构执行。例如,在 x86-64 宿主机上运行 RISC-VArm 的客户机代码时,宿主机 CPU 无法直接理解这些指令,因此必须由 QEMU 将客户机指令转换成宿主机可以执行的形式。QEMU 官方文档将这种能力明确归于 TCG:它在任意受支持宿主平台上模拟多种客户机 CPU 架构,并同时支持 system emulation 与 user-mode emulation。

另一类场景是:即使客户机与宿主机架构兼容,也没有使用硬件虚拟化后端。例如,宿主机不支持 KVM/HVF,或者运行时显式选择 -accel tcg,那么客户机代码仍然会通过 TCG 执行。QEMU 文档与相关手册都说明了 tcg 是可选 accelerator 之一,并且在不使用硬件虚拟化时负责软件执行路径。 ([QEMU][5])

相反,当客户机架构与宿主机架构兼容,且 KVM、HVF 等硬件辅助后端可用时,QEMU 通常会优先利用这些后端来运行客户机 CPU 代码。这意味着:在这些场景下,QEMU 仍然参与整个虚拟机的管理与设备模拟,但客户机的大部分普通指令并不经过 TCG。换言之,TCG 主要服务于“客户机代码不能直接在硬件上运行”或“运行时选择了软件翻译路径”这两类情形

动态二进制翻译基础:从解释执行到 JIT 翻译

当前面说 QEMU 可以用 TCG 来执行客户机代码时,一个最自然的问题是:为什么还需要“翻译”?为什么不能直接运行?

原因很简单。客户机程序里的指令,是为 Guest CPU 架构 编写的;而真正执行代码的是宿主机的 Host CPU。如果两者架构不同,宿主机就根本看不懂这些指令。比如一台 x86-64 机器,不能直接执行 RISC-V 程序中的机器码。要让程序跑起来,系统就必须先“理解”这些指令的含义,再想办法在宿主机上把同样的效果做出来。

解决这个问题,通常有三种思路:解释执行静态二进制翻译动态二进制翻译

解释执行

最容易理解的方法是 解释执行。先取出一条 Guest 指令,分析它的含义,再在宿主机上执行对应的操作。做完这一条,再处理下一条。

如果只看思路,它大概就是这样:

取一条 Guest 指令
→ 解析它的含义
→ 修改寄存器和内存状态
→ 继续下一条

这种方法的优点是直接、清楚,也容易实现。因为每一步都回到解释器主循环,所以调试和控制都比较方便。

但它有一个明显缺点:太慢

因为每执行一条 Guest 指令,宿主机都要重复做一遍“取指、解码、分派、执行”这套工作。也就是说,很多时间不是花在“完成这条指令的语义”上,而是花在“理解这条指令要做什么”上。这个额外开销会在每条指令上重复出现,所以性能通常不高。

因此,解释执行适合做原型、教学,或者用在性能要求不高的场景;但如果想让一个通用模拟器跑得更快,通常还需要别的办法。

静态二进制翻译:先全部翻好,再运行

第二种方法是 静态二进制翻译

它的思路是:既然解释执行反复分析同一段代码太浪费,那不如在程序运行之前,先把 Guest 代码整体翻译成 Host 代码,之后直接运行翻译后的结果。

也就是:

Guest 程序
→ 先翻译成 Host 程序
→ 再执行

这样做的好处很明显。翻译工作只做一次,运行时就不用反复解释了。

但它也有局限。因为很多程序的执行路径并不是在运行前就完全确定的。程序里可能有间接跳转、动态链接、运行时生成代码,甚至会修改自己的代码。这些情况都会让“先把整个程序一次性翻译完”变得困难。

所以,静态翻译适合一些比较封闭、行为比较稳定的场景,但对于像 QEMU 这样要处理通用系统软件、不同架构和复杂运行时行为的系统来说,它并不是最自然的方案。

动态二进制翻译:运行到哪里,就翻译到哪里

这就引出了第三种方法:动态二进制翻译

它的核心思想是:

程序运行到哪里,就翻译到哪里;翻译过的结果保存起来,下次再执行到同一段代码时,尽量直接复用。

这比前两种方法都更灵活。

它不像解释执行那样,每次都重新分析同一条指令; 也不像静态翻译那样,要求一开始就知道整个程序的完整形态。

它的基本流程可以概括为:

执行到某段 Guest 代码
→ 看这段代码以前有没有翻译过
→ 如果翻译过,就直接执行缓存结果
→ 如果没有,就现场翻译,再执行

这样做的关键好处在于:程序真正会反复运行的,通常只有少数“热点路径”。动态翻译不需要提前处理所有代码,只需要把真正执行到的那部分翻译出来,再把热点路径重复利用起来。这样,翻译成本就可以被后续多次执行摊薄。

因此,动态二进制翻译本质上是在做一个折中:

  • 它比解释执行复杂,
  • 但通常比解释执行快得多;
  • 它没有静态翻译那么“提前”,
  • 但对真实运行时的适应性更强。

JIT (Just In Time Compilation)

讲到动态翻译时,经常会看到一个词:JIT

这里的 JIT,直观上可以理解为:在程序运行过程中,临时把一段代码编译成当前宿主机可以直接执行的机器码。

如果系统只是“在运行时分析 Guest 指令”,但最后并没有把结果真正变成 Host 机器码,而只是换一种形式继续解释执行,那么性能提升通常有限。只有当翻译结果真的落成宿主机机器码,宿主机 CPU 才能直接执行这段结果,解释器主循环的负担才会明显减轻。

QEMU 需要支持很多不同的 Guest 架构,也要运行在很多不同的 Host 架构上。如果完全用解释执行,虽然实现直接,但性能通常太低;如果采用静态翻译,又很难适应通用系统模拟中的复杂运行时行为。因此,QEMU 选择了一条更实用的路线:动态二进制翻译

也就是说,QEMU 不会预先翻译整个客户机程序,而是在运行过程中按需翻译代码,并把翻译结果缓存起来复用。TCG 就是完成这件事的核心组件。

后面要讲的 TCG IRTranslation Block 和代码缓存,都是围绕这个目标服务的:尽可能减少重复翻译,尽可能减少重复解释,让 Guest 代码在 Host 上更高效地执行。


了解了这三种翻译技术的定位之后,我们来看 TCG 具体使用了什么样的中间表示来完成动态翻译。

TCG IR 介绍

可以把 TCG IR 理解成 QEMU 在内部使用的一种“中间语言”。它既不是 Guest 真正执行的指令,也不是 Host 最终运行的机器码,而是位于两者之间的一层表示。

Guest 指令
翻译前端:分析这条指令的语义
TCG IR
翻译后端:把 IR 生成为当前 Host 可执行的代码
Host 机器码

这层 IR 在于它把“理解指令语义”和“生成目标代码”这两件事分开了。前端只需要关心 Guest 指令本身的含义,后端只需要关心怎样把统一的 IR 变成当前 Host 能运行的机器码。这样一来,QEMU 就不必为每一种 Guest/Host 组合分别写一整套专门的翻译器,而是可以把问题拆成两部分:先把 Guest 指令翻译成 IR,再把 IR 翻译成 Host 代码。

以 RISC-V 的 addi a0, a0, 1 为例。它的语义很简单:读取寄存器 a0 的值,加上立即数 1,再把结果写回 a0。如果在 QEMU 源码中查找这条指令的翻译入口,可以在 target/riscv/insn_trans/trans_rvi.c.inc 中看到:

static bool trans_addi(DisasContext *ctx, arg_addi *a)
{
    return gen_arith_imm_fn(ctx, a, EXT_NONE, tcg_gen_addi_tl, gen_addi2_i128);
}

这段代码本身并没有直接生成某条 Host 汇编,而是把这条 Guest 指令交给一段更通用的逻辑处理。这里最关键的是传入了 tcg_gen_addi_tl。从名字上就可以看出,它不是“执行加法”,而是“生成一条加立即数的 TCG 操作”。也就是说,QEMU 在这里做的事情,是把 addi 的语义组织成 TCG IR,而不是立刻输出最终机器码。

如果继续看 target/riscv/translate.c 中的通用逻辑,可以把核心过程理解为下面这段精简代码:

static bool gen_arith_imm_fn(DisasContext *ctx, arg_i *a, DisasExtend ext,
                             void (*func)(TCGv, TCGv, target_long), ...)
{
    TCGv dest = dest_gpr(ctx, a->rd);
    TCGv src1 = get_gpr(ctx, a->rs1, ext);

    func(dest, src1, a->imm);

    gen_set_gpr(ctx, a->rd, dest);

    return true;
}

dest_gpr(ctx, a->rd) 取到目标寄存器 rd 对应的内部表示;在这个例子里,rd 就是 a0。接着,get_gpr(ctx, a->rs1, ext) 取到源寄存器 rs1 的内部表示;这里 rs1 也是 a0。这一步并不是直接去读宿主机真实寄存器,而是把 Guest 寄存器映射到翻译阶段可操作的对象上。

代码调用了 func(dest, src1, a->imm)。在 trans_addi 里传入的 func 正是 tcg_gen_addi_tl,所以这里的实际效果就是:向当前的 TCG IR 序列追加一条“加立即数”的操作,其含义相当于:

dest = src1 + imm

在这个例子中,也就是把 a0 当前对应的值加上立即数 1,把结果放到 dest 对应的值里。最后,gen_set_gpr(ctx, a->rd, dest) 再把这个结果关联回 Guest 的目标寄存器状态。这样,addi a0, a0, 1 的语义就被完整表达出来了。

这里的 TCGv 可以先把它理解成 TCG 在翻译阶段使用的一种“值对象”。它表示当前这条翻译链路里要操作的值。类似地,tcg_gen_*() 这一类函数也可以先理解成“往当前 IR 中追加一条操作”。因此,阅读这类代码时,重点不是把它看成“执行了一条加法”,而是要把它看成“描述了一条加法应该如何在后续代码生成阶段被实现”。

从这个例子可以看出,一条 Guest 指令进入 QEMU 后,通常不会直接对应成一条 Host 指令。更常见的情况是,它先被拆成若干更小、更通用的中间操作。这些中间操作共同组成 TCG IR,之后再由后端根据当前 Host 的架构,把它们翻译成真正可执行的机器码。


TCG 后端翻译流程

在 QEMU TCG 中,翻译与执行的基本单位是 Translation Block(TB)。QEMU 以当前 Guest PC 为起点,沿当前执行路径向前翻译一段 Guest 指令序列,并将其生成为一段可在 Host 上直接执行的机器码。这个翻译结果连同相关元数据一起构成一个 TB。后续如果再次执行到相同位置,且相关上下文仍然匹配,QEMU 就可以直接复用该 TB,而不必重新翻译。

TB 与传统编译器中的基本块有密切关系,但两者并不完全等同。可以把 TB 近似理解为:QEMU 在一次翻译过程中,从某个入口出发,沿线性执行路径得到的一段代码。翻译通常会在以下位置停止:遇到控制流转移、遇到异常或特权相关边界,或者跨越代码页边界。也就是说,QEMU 并不是“逐条指令翻译并执行”,而是“按块翻译、按块缓存、按块执行”。

多个 TB 在满足条件时还可以直接链接。这样,执行流就不必在每个 TB 结束后都回到主循环重新查找下一个 TB,而是可以从上一个 TB 直接跳入目标 TB。后文提到的 chained TB,正是这一机制的体现。

下面这张图给出了 TB 的基本执行方式:

                        +---------------------+
         1)             |                     |
       +----------------+   QEMU TCG engine   +---------------+
       |          +---->|                     |<---+          |
       |          |     +----------+---^------+    |          |
       |          |                |   |   4)      |          | 5)
       |          |            3)  |   +------+    |          |
       v          |2)              v          |    | 6)       v
+---------------+ |        +---------------+  |    |  +---------------+
|   prologue    | |        |   prologue    |  |    |  |   prologue    |
+---------------+ |        +---------------+  |    |  +---------------+
|               | |        |               |  |    |  |               |
|  Translation  | |        |  Translation  |  |    |  |  Translation  |
|     Block1    | |        |     Block2    |  |    |  |     Block3    |
|               | |        |               |  |    |  |               |
+---------------+ |        +---------------+- |    |  +---------------+
|   epilogue    | |        |   epilogue    |  |    |  |   epilogue    |
+------+--------+ |        +-------+-------+  |    |  +------+--------+
       +----------+                +----------+    +---------+

如果把整个过程进一步抽象,那么 TCG 的运行主线可以概括为:先根据当前 Guest PC 查找 TB 缓存;若命中,则直接执行已有 TB;若未命中,则触发翻译,生成新的 TB,写入缓存,再开始执行。其逻辑如下:

```text id="fvh417" +---------------+ +----------------------| Do something |-------------------+ | +---------------+ | v | +--------------+ +----------------+ Y +---------+ | | Guest PC +------>| Check TB Cache +-------->| Exec TB +-----+ +--------------+ +------+---------+ +---------+ | N ^ v | +-------------+ | | translation | | +-----+-------+ | v | +-----------------+ | |Save TB to Cache +-------------+ +-----------------+

在 QEMU 源码中,这一过程对应 `accel/tcg/cpu-exec.c` 中的执行主循环。下面这段代码展示了 TB 查找、缓存未命中时触发翻译、写回缓存,以及进入执行的基本流程:

```c
// 截取自:accel/tcg/cpu-exec.c -> cpu_exec_loop()

// 1. 查找 TB Cache (Fast Path)
tb = tb_lookup(cpu, s);

if (tb == NULL) {
    /* ---------------------------------------------------- */
    /* 2. 缓存未命中,触发 JIT 动态翻译                         */
    /* ---------------------------------------------------- */
    mmap_lock();

    // tb_gen_code 负责分配空间、前向翻译、后向二进制生成
    tb = tb_gen_code(cpu, s);

    mmap_unlock();

    // 3. 将新翻译的 TB 缓存回 CPU 局部的 tb_jmp_cache 哈希表
    h = tb_jmp_cache_hash_func(s.pc);
    jc = cpu->tb_jmp_cache;
    jc->array[h].pc = s.pc;
    qatomic_set(&jc->array[h].tb, tb);
}

// 4. (可选) 优化项:直接跳转 (Direct Block Chaining)
// 允许的话将上个块的跳转地址直接改写指向当前 tb,跳过后续的 tb_lookup
if (last_tb) {
    tb_add_jump(last_tb, tb_exit, tb);
}

// 5. 陷入执行这一代码块:cpu_loop_exec_tb 内部调用 cpu_tb_exec
cpu_loop_exec_tb(cpu, tb, s.pc, &last_tb, &tb_exit);

这段代码体现了 TCG 主循环中最重要的两条路径。

第一条是快路径。tb_lookup(cpu, s) 负责查找当前是否已经存在可复用的 TB。如果查找成功,QEMU 就可以直接进入执行阶段,而不必再次翻译同一段代码。

第二条是慢路径。如果缓存未命中,就调用 tb_gen_code(cpu, s) 生成新的 TB。生成完成后,QEMU 会把该 TB 写回缓存,以便后续重复利用。这样,翻译成本主要集中在“首次进入某段代码”时支付,而不是在每次执行时重复支付。

在整个过程中,tb_gen_code() 是最关键的入口之一。它并不只是分配一个 TB 结构体,而是真正启动了一次完整的翻译过程。沿着 accel/tcg/translate-all.c 的调用链继续向下,可以看到 TCG 明确分成两个阶段:前端翻译负责把 Guest 机器码转换成 TCG IR,后端翻译负责把 TCG IR 生成为 Host 机器码。

// 截取自 tb_gen_code 的底层执行集 set_jmp_reset_and_gen_code

// 首先初始化/重置 TCG 的翻译上下文对象 tcg_ctx
tcg_func_start(tcg_ctx);

// 【A 阶段】前端翻译:从 Guest 机器码 => TCG IR (中间表示)
// 这里 cs->cc->tcg_ops->translate_code 实际上对应了如 RISC-V 目标后端的 gen_intermediate_code
cs->cc->tcg_ops->translate_code(cs, tb, max_insns, pc, host_pc);

// 【B 阶段】后端翻译:从 TCG IR => Host 宿主机机器码
// tcg_gen_code 函数内部包含常量折叠、死代码消除优化 (tcg_optimize),
// 并最终调用依赖 Host 架构的 tcg_out_op 生成原生指令,装填进 TB 执行区。
return tcg_gen_code(tcg_ctx, tb, pc);

这两步正好对应了前一节介绍的 TCG IR 机制。translate_code 负责读取 Guest 指令、分析其语义,并通过 tcg_gen_*() 之类的接口逐步构造 IR;tcg_gen_code 则负责对这些 IR 做必要的整理与优化,并最终生成真正的 Host 机器码。至此,一次完整的“Guest 指令序列 → TCG IR → Host 机器码”的翻译闭环才算完成。

翻译完成后,接下来的问题是:这段宿主机代码究竟如何被执行?这一过程由 cpu_tb_exec() 完成。它从 TB 中取出已经生成好的机器码入口地址,然后通过 tcg_qemu_tb_exec 把控制流切换到这段 JIT 代码上,让宿主机 CPU 直接执行它。

// 截取自 accel/tcg/cpu-exec.c -> cpu_tb_exec()

static inline TranslationBlock * QEMU_DISABLE_CFI
cpu_tb_exec(CPUState *cpu, TranslationBlock *itb, int *tb_exit)
{
    // 获取 JIT 编译好的那块内存的指针(也就是我们之前介绍 code_buffer 里的 host 机器码)
    const void *tb_ptr = itb->tc.ptr;

    // tcg_qemu_tb_exec 是一个函数指针强转,相当于直接按照宿主机 ABI 的调用约定,
    // call 到刚才生成的 tb_ptr 机器码上去!这就脱离了 C 语言的主循环,真正让 CPU 撒着欢儿跑指令去了。
    ret = tcg_qemu_tb_exec(cpu_env(cpu), tb_ptr);

    // 当遇到需要退出回主循环的跳出点(比如跨页、异常、IO),机器码会 jump 到 epilogue 恢复环境,
    // 最后再 return 回 ret,告诉 QEMU 我们执行到哪里了,为什么退出。
    // ...
}

这一步标志着执行阶段的边界发生了变化。在 cpu_tb_exec() 之前,QEMU 仍然主要运行在 C 代码层面,负责查找、翻译、缓存和调度;而进入 tcg_qemu_tb_exec 之后,真正执行的已经是刚刚生成出来的 Host 机器码。只有当 TB 执行到某个退出点时,控制权才会回到 QEMU 主循环。常见的退出原因包括:需要重新检查中断、处理异常、执行 I/O、跨页继续查找,或者当前 TB 无法再直接链接到下一个 TB。

因此,TCG 的完整工作路径可以概括为:

查找 TB 缓存 → 若未命中则执行前端翻译与后端发码 → 写入缓存 → 执行生成出的 Host 机器码 → 遇到退出条件后返回主循环。

这就是 QEMU TCG 最核心的执行闭环。

为了进一步减少频繁返回主循环的成本,QEMU 还引入了 direct block chaining。如果上一个 TB 的某个出口在当前条件下可以确定将会跳转到下一个 TB,那么 QEMU 可以把这个出口直接改写为跳向目标 TB 的机器码入口。这样,后续执行时就不必再次经过 tb_lookup。不过,这种直接链接并不是无条件成立的,它受到地址翻译、页边界和 CPU 状态等约束,因此只会在满足条件时启用。

除了翻译与执行本身,TB 的正确复用还依赖一套失效机制。因为一旦 Guest 代码、映射关系或相关页属性发生变化,原先生成的 TB 就可能已经不再有效。如果仍继续执行旧 TB,就会破坏正确性。因此,QEMU 必须能够在代码发生变化时及时使相关 TB 失效,并清理已有的直接链接关系。

在用户态仿真中,这一问题通常通过代码页写保护来处理。QEMU 在生成 TB 后,会将对应代码页设置为写保护;如果之后对该页发生写入,就会触发信号,QEMU 进而使该页上的 TB 失效,并撤销相关缓存和跳转关系。

在系统模式下,情况更加复杂。此时 QEMU 通过 SoftMMU 模拟客户机的访存路径,并利用 TLB 缓存地址转换结果。TB 的索引与链接策略需要考虑虚拟地址到物理地址的映射关系,以避免映射变化后继续错误复用旧 TB。为了减少这种不一致,链式跳转通常被限制在同一客户机页内;RAM 和 ROM 访问可以通过宿主机内存偏移快速定位,而 MMIO 则仍需回到 QEMU 的设备模型代码进行处理。

在翻译前夕,vCPU 对指令的获取通常出现在以下几种场景中:

  • 在翻译客户机代码时,需要根据当前地址读取 Guest 指令并送入前端翻译流程。
  • 在搜索 TB 哈希表时,需要依据地址与相关状态查找已有 TB。
  • 在链式 TB 跳转时,如果目标仍位于可直接处理的范围内,可以继续沿已知路径执行;若跨页或条件不再满足,则需要退回常规查找或重新翻译流程。

这种设计在性能与正确性之间做了折中:一方面尽量通过缓存和直接链接减少重复查找,另一方面又通过页边界、失效机制和主循环返回点保证执行语义不被破坏。

随着虚拟 CPU 数量增加,单线程执行会逐渐成为瓶颈。为此,QEMU 又引入了 MTTCG(Multi-threaded TCG),允许不同 vCPU 在线程级并行执行各自的 TCG 路径。不过,MTTCG 改变的是并发执行方式,而不是这里介绍的基本翻译模型;TB 仍然是翻译、缓存和执行的基本单位。

我们后续会通过单独的章节,详细讲解 softmmu 的实现。


拓展阅读

多线程 TCG

前面介绍的 TCG 执行流程,默认可以理解为“一个 vCPU 沿着自己的翻译与执行路径前进”。但在 system mode 下,客户机往往不止一个 vCPU。如果仍然让所有 vCPU 串行轮转执行,那么随着 vCPU 数量增加,性能很快就会受到限制。为了解决这个问题,QEMU 引入了多线程 TCG,也就是 MTTCG

MTTCG 的基本思想并不复杂:每个 vCPU 对应一个宿主线程,这样多个 vCPU 就可以并行执行各自的 TCG 路径。需要注意的是,MTTCG 改变的是“谁来执行”,而不是“如何翻译”。TB 仍然是翻译、缓存和执行的基本单位;tb_lookuptb_gen_codecpu_tb_exec 这一整套流程,在多线程环境下依然成立。

多线程执行带来的第一个问题,是查找路径如何避免过多加锁。QEMU 的做法是尽量让热路径保持轻量:每个 vCPU 有自己的 tb_jmp_cache,同时系统还维护全局 TB 哈希表。查找已有 TB 的过程主要依赖原子读写,只有在真正需要修改共享状态时,例如生成新 TB、建立或撤销 TB 之间的直接跳转时,才进入加锁路径。

第二个问题,是代码生成如何同步。因为生成 TB 不只是构造一个数据结构,还会向代码缓存中写入新的宿主机机器码。如果多个线程同时修改同一片缓存区域,就必须进行协调。在 user mode 下,翻译缓冲区是共享的,因此生成和回填过程需要串行化;在 system mode 下,QEMU 会尽量减少翻译阶段对全局锁的依赖,但只要涉及共享代码缓存或共享元数据,仍然需要同步保护。

第三个问题,是失效与一致性。假设某个 vCPU 正在执行某个 TB,而另一个 vCPU 修改了相关地址空间状态,或者触发了 TLB flush,那么原来的 TB 可能已经不再安全。这时,QEMU 必须撤销旧的直接跳转关系,清理缓存项,并让相关 vCPU 在一个安全的时点看到更新后的状态。因此,在 MTTCG 中,“并行执行”并不意味着“完全无同步”,而是尽量让同步发生在必要的位置,而不是落在最常见的查找和执行路径上。

因此,可以把 MTTCG 理解为:在不改变 TCG 基本翻译模型的前提下,让多个 vCPU 能够更自然地利用宿主机的多核能力。 它提高的是并发执行能力,而不是改变 TB 的含义或 TCG 的基本工作方式。


直接跳转优化

如果每执行完一个 TB,都回到 QEMU 主循环,再由主循环查找下一个 TB,那么这条执行路径中会不断出现额外开销。QEMU 为了减少这类开销,引入了 TB 之间的直接跳转。它的核心思想是:如果当前 TB 的某个出口已经可以确定下一个 TB 是谁,那么就不必回到主循环重新查找,而是直接跳过去。

            1)          +---------------------+
       +----------------+   QEMU TCG engine   +---------------------------+
       |                +---------------------+                           |
       v                                                                  |
+---------------+          +---------------+          +---------------+   |
|   prologue    |          |   prologue    |   3)     |   prologue    |   |
+---------------+ +------> +---------------+  +-----> +---------------+   |
|               | |        |               |  |       |               |   | 5)
|  Translation  | |        |  Translation  |  |       |  Translation  |   |
|     Block1    | |        |     Block2    |  |       |     Block3    |   |
|               | |2)      |               |  |       |               |   |
+---------------+-+        +---------------+--+       +---------------+---+
|   epilogue    |          |   epilogue    |          |   epilogue    |
+------+--------+          +-------+-------+          +------+--------+

这个优化的意义很直接。QEMU 主循环本来负责很多事情,例如查找 TB、处理中断、检查异常条件等。如果一条执行路径上的每个 TB 都要经过这一层,那么即使 Guest 代码本身很简单,也会不断支付“退出 TB → 回到主循环 → 再进入下一个 TB”的固定成本。直接跳转就是为了尽量省掉这一步。

不过,TB 之间能否直接链接,并不是只看 Guest PC 是否连续。QEMU 在生成 TB 时,会假设某些 CPU 状态在这个 TB 内保持不变,例如特权级、地址空间相关状态、部分架构相关上下文等。因此,一个 TB 不只是“某段 Guest 地址对应的代码”,而是“在某些状态前提下,这段 Guest 地址对应的代码”。如果这些前提变化了,那么即使 Guest PC 没变,旧 TB 也可能不再适用。

在执行路径上,常见的快速直跳方式有两类。

第一类是 lookup_and_goto_ptr。它的做法是:先快速查一下目标 TB 是否已经存在;如果存在,就直接跳过去;如果不存在,就退回 epilogue,再进入主循环,让主循环决定下一步该怎么做。这种方式的特点是:它尽量快,但仍然保留了“查不到就回主循环”的退路。

第二类是 goto_tb + exit_tb。它会在当前 TB 中预留一个跳转槽位。第一次执行到这里时,TB 先通过 exit_tb 返回主循环,主循环确认目标 TB 之后,再把这个槽位回填为直接跳转到目标 TB 的机器码。这样,下次执行到同样的位置时,就可以直接跳过去,不必再查找一次。

这类优化有一个非常重要的限制:通常只在同一个 Guest 页内成立。 原因并不是实现“恰好这样写”,而是因为一旦跨页,地址翻译、页属性变化和 TB 失效问题都会变得更复杂。把直接链接限制在页内,可以让这些问题更容易控制。因此,当目标跳转跨页时,QEMU 往往会退回常规路径,重新查找或重新翻译。

所以,TB 直接跳转的本质可以概括为一句话:在不破坏正确性的前提下,尽量减少返回主循环的次数。


代码缓存管理

TCG 之所以比解释执行更高效,一个关键原因是:翻译结果不是一次性使用,而是会被缓存起来。 这块保存翻译结果的区域,就是代码缓存。

可以把代码缓存理解成一块专门分配出来的可执行内存。QEMU 在这里存放两类内容:一类是和当前 Host 相关的公共代码,例如 prologue 和 epilogue;另一类是各个 TB 自身对应的宿主机机器码。当前 TB 的入口地址会记录在相应字段中,之后执行时,QEMU 就可以直接跳到这段机器码上运行,而不必重新翻译。

code_buffer = mmap()
|                                             TCGContext.code_ptr
v                                              v
+-----------+----------+-------------+---------+------------------+
|           |          |             |         |                  |
|  prologue | epilogue |  TB.struct  | TB.code |     ...          |
|           |          |             |         |                  |
+-----------+----------+-------------+---------+------------------+
^           ^                        ^
|           |                        |
|           |                        tb.tc.ptr
|           tcg_code_gen_epilogue
tcg_qemu_tb_exec

从执行角度看,代码缓存的作用非常明确。第一次遇到某段 Guest 代码时,QEMU 需要翻译;翻译完成后,结果写入代码缓存。下一次再执行到同样的位置,只要相关状态仍然匹配,就可以直接复用缓存中的 TB。也就是说,代码缓存把“首次翻译的代价”摊薄到了后续多次执行上。

代码缓存当然不是无限的。当缓存空间不足时,QEMU 需要进行 TB flush。所谓 flush,就是把当前缓存中的 TB 清掉,再从空状态继续生成新的翻译结果。这个策略看起来比较直接,但实现简单,而且和 TB 的失效机制比较容易结合。

除了“空间不够”之外,TB 还可能因为别的原因失效。最常见的情况是:Guest 代码发生了修改,或者地址映射发生了变化。此时,原来缓存中的 TB 已经不能再可靠地代表那段 Guest 代码,QEMU 就必须把它废弃。也就是说,代码缓存管理不仅仅是“内存够不够”的问题,更重要的是维护“缓存中的 TB 现在是否仍然有效”。

因此,代码缓存并不是一个普通的“存放 JIT 代码的数组”,而是 TCG 整个运行机制的中心。TB 的生成、复用、直接跳转、失效和刷新,都是围绕这块缓存组织起来的。


TCG 插件

QEMU 还提供了一套 TCG 插件机制,用于在翻译和执行过程中插入额外的观测逻辑。它的主要用途不是改变客户机语义,而是做分析、统计、跟踪和实验。

这套机制的一个重要特点是:它建立在 TCG 的统一翻译框架之上,因此可以实现较强的架构无关性。如果直接从 Guest ISA 级别做工具,不同架构往往需要分别实现;而插件机制依托 QEMU 的翻译流程,可以在更统一的层面观察 TB、指令执行以及内存访问行为。

插件通常在 qemu_plugin_install 中注册回调。之后,QEMU 会在合适的时机触发这些回调,例如:

  • 在 TB 翻译时,插件可以枚举这个 TB 中包含的指令;
  • 在指令执行前,插件可以执行对应的执行回调;
  • 在成功的 load/store 之后,插件可以得到内存访问相关信息。

这里有两个边界需要特别清楚。

第一,插件更适合“观测”,不适合“控制”。也就是说,它主要用来收集信息,而不是把自己变成系统语义的一部分。

第二,很多插件句柄只在回调期间有效,不能长期持有。比如某个 TB 句柄、某条指令句柄或某次内存访问句柄,通常都只是临时对象。如果插件需要长期保存信息,就必须在回调中把需要的数据复制出来。

在性能上,插件机制本身也可能带来明显开销。因为一旦为每条指令、每次访存都注册复杂回调,系统很容易被观测逻辑拖慢。为此,QEMU 还提供了一些相对轻量的统计手段,例如 inline operation 和 scoreboard,用来做低成本计数和简单状态记录。实际使用时,通常需要在“信息丰富程度”和“插桩开销”之间做平衡。

因此,可以把 TCG 插件理解为:在不改动 QEMU 主体执行语义的前提下,为研究、调试和分析提供一套统一的观测入口。


插件适合在运行时进行自定义插桩。如果你只是想快速定位热点代码或分析 TCG 翻译开销,QEMU 还集成了更轻量的性能分析支持。

TCG 性能分析

当需要分析 TCG 的性能时,一个常见困难是:perf 看到的往往只是 QEMU 进程中的一段匿名 JIT 代码,而不是“这是哪个 TB、对应哪段 Guest 代码”。为了解决这个问题,QEMU 提供了 -perfmap-jitdump 两种辅助方式,把 JIT 代码与 Guest 代码之间的对应关系暴露给 Linux perf

-perfmap 较轻量。它会生成一份映射文件,使 perf 能够把热点样本定位到相应的 JIT 代码块上。对于快速查看“哪些 TB 最热”,这种方式比较直接。

-jitdump 提供的信息更完整。它不仅记录映射关系,还可以导出更多与 JIT 代码相关的调试信息。不过,使用它时通常还需要经过 perf inject,把这些信息合并进 perf.data,之后才能正常做性能报告。

常见的命令如下:

# 轻量级性能分析,仅生成 guest↔host 映射,直接 perf report
perf record $QEMU -perfmap $REMAINING_ARGS
perf report

# 保存 JIT 代码与调试信息,需先 perf inject 合并到 perf.data 再报告
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

在使用这些工具时,需要注意一点:它们回答的是“热点在哪里”,而不是自动告诉你“为什么这里会慢”。例如,某个 TB 很热,可能是因为它本来就位于热点循环中;也可能是因为没有成功形成直接跳转,导致频繁回到主循环;还可能是因为 TB 经常失效,不得不反复重新翻译。单靠一份 perf report,通常只能看到表面现象。要进一步判断原因,还需要结合 TB 结构、缓存命中情况、失效频率,以及具体 Guest 工作负载来分析。

因此,-perfmap-jitdump 更像是性能分析的入口,而不是最终结论本身。它们帮助我们把“匿名 JIT 代码”重新映射回“具体的 TB 和 Guest 执行路径”,从而让后续分析真正有抓手。