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¶
要理解 TCG(Tiny Code Generator),不能一开始就钻进翻译细节,而应先看清它在 QEMU 整体执行框架中的位置。QEMU 本质上不是单一的“虚拟机程序”,而是一个通用的执行框架:它既可以模拟完整机器,也可以只模拟用户态进程;既可以通过纯软件方式执行客户机代码,也可以在条件满足时借助硬件辅助虚拟化来加速执行。官方文档将其主要使用方式区分为 System Emulation 与 User 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 官方文档和相关生态文档都将 TCG、KVM、HVF 等归入不同的 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-V 或 Arm 的客户机代码时,宿主机 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 代码整体翻译成 Host 代码,之后直接运行翻译后的结果。
也就是:
这样做的好处很明显。翻译工作只做一次,运行时就不用反复解释了。
但它也有局限。因为很多程序的执行路径并不是在运行前就完全确定的。程序里可能有间接跳转、动态链接、运行时生成代码,甚至会修改自己的代码。这些情况都会让“先把整个程序一次性翻译完”变得困难。
所以,静态翻译适合一些比较封闭、行为比较稳定的场景,但对于像 QEMU 这样要处理通用系统软件、不同架构和复杂运行时行为的系统来说,它并不是最自然的方案。
动态二进制翻译:运行到哪里,就翻译到哪里¶
这就引出了第三种方法:动态二进制翻译。
它的核心思想是:
程序运行到哪里,就翻译到哪里;翻译过的结果保存起来,下次再执行到同一段代码时,尽量直接复用。
这比前两种方法都更灵活。
它不像解释执行那样,每次都重新分析同一条指令; 也不像静态翻译那样,要求一开始就知道整个程序的完整形态。
它的基本流程可以概括为:
这样做的关键好处在于:程序真正会反复运行的,通常只有少数“热点路径”。动态翻译不需要提前处理所有代码,只需要把真正执行到的那部分翻译出来,再把热点路径重复利用起来。这样,翻译成本就可以被后续多次执行摊薄。
因此,动态二进制翻译本质上是在做一个折中:
- 它比解释执行复杂,
- 但通常比解释执行快得多;
- 它没有静态翻译那么“提前”,
- 但对真实运行时的适应性更强。
JIT (Just In Time Compilation)¶
讲到动态翻译时,经常会看到一个词:JIT。
这里的 JIT,直观上可以理解为:在程序运行过程中,临时把一段代码编译成当前宿主机可以直接执行的机器码。
如果系统只是“在运行时分析 Guest 指令”,但最后并没有把结果真正变成 Host 机器码,而只是换一种形式继续解释执行,那么性能提升通常有限。只有当翻译结果真的落成宿主机机器码,宿主机 CPU 才能直接执行这段结果,解释器主循环的负担才会明显减轻。
QEMU 需要支持很多不同的 Guest 架构,也要运行在很多不同的 Host 架构上。如果完全用解释执行,虽然实现直接,但性能通常太低;如果采用静态翻译,又很难适应通用系统模拟中的复杂运行时行为。因此,QEMU 选择了一条更实用的路线:动态二进制翻译。
也就是说,QEMU 不会预先翻译整个客户机程序,而是在运行过程中按需翻译代码,并把翻译结果缓存起来复用。TCG 就是完成这件事的核心组件。
后面要讲的 TCG IR、Translation Block 和代码缓存,都是围绕这个目标服务的:尽可能减少重复翻译,尽可能减少重复解释,让 Guest 代码在 Host 上更高效地执行。
了解了这三种翻译技术的定位之后,我们来看 TCG 具体使用了什么样的中间表示来完成动态翻译。
TCG IR 介绍¶
可以把 TCG IR 理解成 QEMU 在内部使用的一种“中间语言”。它既不是 Guest 真正执行的指令,也不是 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 序列追加一条“加立即数”的操作,其含义相当于:
在这个例子中,也就是把 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_lookup、tb_gen_code、cpu_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 执行路径”,从而让后续分析真正有抓手。