QEMU tcg中间码优化与后端机制
TCG 基本逻辑
QEMU TCG 的基本翻译思路是先把 guest 指令翻译成中间码(IR),再把 IR 翻译成 host 指令。guest → IR → host 这种三段式实现把前端翻译、优化和后端翻译拆开,降低了开发难度。
IR 指令本身不难理解。QEMU 在 tcg/README 中定义了一套 IR 指令,在一个 TB 内,前端翻译得到的 IR 被串联成一个链表,中间码优化和后端翻译都基于这个链表取用 IR;优化过程中需要改动 IR 时(比如删掉不可达的 IR),直接操作这个链表即可。
中间码不只定义了指令,也定义了寄存器,二者形成一个独立的逻辑空间。在 IR 这一层,计算都发生在中间码对应的寄存器上。IR 层定义了以下几类寄存器:global、local temp、normal temp、fixed、const、ebb。
通常,guest 的 gpr 也会被定义为 IR 层的 global 寄存器。中间码计算时会用到一些临时变量,它们保存在 local temp 或 normal temp 这类寄存器里;需要常量参与计算时,则定义一个 TCG 寄存器,创建常量并赋给它。
在 RISC-V 中,global 寄存器一般这样定义:
/* target/riscv/translate.c */
riscv_translate_init
[...]
+-> cpu_gpr[i] = tcg_global_mem_new(cpu_env,
offsetof(CPURISCVState, gpr[i]), riscv_int_regnames[i]);
/* 在TCGContext里分配对应的空间,并且设定这个寄存器是TEMP_GLOBAL */
+-> tcg_global_mem_new_internal(..., reg, offset, name);
[...]
+-> cpu_pc = tcg_global_mem_new(cpu_env, offsetof(CPURISCVState, pc), "pc");
[...]c这里分配了对应的 TCG 寄存器,返回值是这些寄存器存储地址相对 tcg_ctx 的偏移。注意,这里得到的是 global 寄存器的描述结构,类型为 TCGTemp;global 寄存器本身存储在 CPURISCVState 的相应字段中,TCGTemp 通过 mem_base 和 mem_offset 指向具体存储地址。
实际上,所有 TCG 寄存器都在 TCGContext 中分配了对应的存储空间,并配置了相关参数。这些参数与 IR 一起交给后端,供 IR 优化和后端翻译使用;后端凭借 TCGContext 的地址和各寄存器的偏移,就能定位到具体的 TCG 寄存器。
normal temp 只在一个 BB 内有效,local temp 在一个 TB 内有效。fixed 要结合 host 寄存器分配来理解:IR 中分配的寄存器都是虚拟寄存器,翻译到 host 指令时需要为每个虚拟寄存器分配对应的 host 物理寄存器。若某个 TCG 寄存器带有 TEMP_FIXED 标记,表示后端翻译时把它固定映射到一个 host 物理寄存器上,一般 fixed 寄存器都是翻译执行时经常用到的参数。
中间码优化
前端翻译得到的 IR 存在优化空间,所以 QEMU 在后端翻译之前会先做中间码优化。优化以一个 TB 为单位,输入就是该 TB 对应的 IR 及其用到的 TCG 寄存器。
/* tcg/tcg.c */
tcg_gen_code
+-> tcg_optimize(s)
+-> done = fold_add(&ctx, op);
+-> reachable_code_pass(s);ctcg_optimize 主要对常量做检查,进而做指令优化(折叠常量表达式)。以其中的 fold_add 为例:它检测 add_32/64 这条 IR 的两个操作数是否为常量,若是,就把常量相加后的结果放进一个常量类型的 TCG 寄存器,并把原来的 add_32/64 改写为一条 mov 指令。
从名字可以看出,reachable_code_pass 做的是死代码删除:检测到不可达的 IR 时,直接把它们从 IR 链表中删掉。
中间码优化的输出仍然是 IR 链表和相关的 TCG 寄存器。因此把这两个函数注释掉,就可以关闭中间码优化。整体来看,这套逻辑与编译器的 IR 优化是类似的。
TCG 后端功能
QEMU 用 TCG 模拟 guest 指令执行:先把 guest 指令翻译成中间码,再把中间码翻译成 host 指令,host 指令最终在 host CPU 上执行,翻译过程至此完成。
这一部分关注后端翻译模型,也就是中间码翻译成 host 指令的过程。中间码是一套完整的指令集定义,能够完整表述 guest 指令的行为,看一个小例子会有更直观的感受。
addi sp,sp,-32 <-- guest汇编
sd s0,24(sp)
add_i64 x2/sp,x2/sp,$0xffffffffffffffe0 <-- 中间码
add_i64 tmp4,x2/sp,$0x18
qemu_st_i64 x8/s0,tmp4,leq,0c如上所示,guest 的这条 store 指令被翻译成两条中间码:第一条 add_i64 计算 sd 要写入的地址,结果保存在 tmp4 这个虚拟寄存器里;第二条中间码把 s0 的值写入 tmp4 指向的内存。QEMU 用中间码和虚拟寄存器完整地表述了 guest 的逻辑。这里的 qemu_st_i64 表示一次 store 操作,数据与地址都用虚拟寄存器描述,所以在它之前需要用 add_i64 先计算出 store 的地址,并保存到虚拟寄存器。
QEMU 中其它 guest 指令也遵循同样的过程:先翻译成中间码和虚拟寄存器的表示,后端翻译再基于它们进行。上面的中间码里,x2/sp、x8/s0 仍是 guest 寄存器的名字,但逻辑上这些寄存器都已映射到 QEMU 虚拟寄存器,所以中间码指令中的所有寄存器都是 QEMU 的虚拟寄存器。
说到底,QEMU 模拟的 guest CPU 系统,就是 host 内存中表示 guest CPU 的软件结构体的状态,以及 guest 内存的状态。中间码已经完整描述了 guest 状态改变的激励:以上面 addi 和 sd 两条 guest 指令为例,模拟 addi 的中间码是 add_i64 x2/sp,x2/sp,$0xffffffffffffffe0,表示把 guest 的 sp 加上 -32;sd 的中间码表示把 guest sp + 24 指向的地址上的值改成 s0 的值。拿到这样的中间码(甚至直接拿 guest 指令),完全可以写 C 代码完成模拟。QEMU 为了追求效率,把中间码翻译成 host 指令来完成模拟。
add_i64 x2/sp,x2/sp,$0xffffffffffffffe0
add_i64 tmp4,x2/sp,$0x18
qemu_st_i64 x8/s0,tmp4,leq,0c这几条中间码只是描述意图,真正更新 guest CPU 数据结构和 guest 地址还要靠 host 指令,所以实际翻译后的 host 指令可能是这样的:
ldr x20, [x19, #0x10] 把guest cpu中的sp load到host的x20寄存器
sub x20, x20, #0x20 使用host sub指令完成guest sp的计算
str x20, [x19, #0x10] 更新guest cpu中sp的值
add x21, x20, #0x18 使用host add指令计算store的地址,并保存到host的x21寄存器
ldr x22, [x19, #0x40] 把guest cpu中的s0 load到host的x22寄存器
str x22, [x21, xzr] 使用host str指令更新guest地址上的值cQEMU 的后端翻译就是完成上述工作,总结起来有三点:
- 分配 host 物理寄存器;
- 生成 host 指令;
- host 与 guest 之间的状态同步。
分配 host 物理寄存器
虚拟寄存器与 host 物理寄存器是两个独立的概念:虚拟寄存器可以有很多,物理寄存器的个数有限;虚拟寄存器有自己的生命周期,生命周期结束后,它占用的物理寄存器就可以给其它虚拟寄存器复用。由于 host 物理寄存器数目有限,可能出现不够分配的情况,这时需要把已分配但暂时未用到的物理寄存器的值保存到内存,腾出寄存器供后续使用。
QEMU 处理 host 物理寄存器分配时分成两步:第一步确定虚拟寄存器的生命周期,一般称为寄存器活性分析;第二步根据活性分析的结果,为虚拟寄存器具体分配物理寄存器。
QEMU 对一段中间码做逆序遍历,据此确定虚拟寄存器的生命周期:如果后续还有中间码使用某个虚拟寄存器,它就是 live 的;如果后面不再有中间码使用,它就变为 dead。
所以,一个虚拟寄存器是 dead 还是 live,要与具体的中间码一起看:它可能在前几条中间码中仍是 live 的(虽然这几条中间码并没有使用它),在最后一条使用它的中间码之后才变为 dead。QEMU 只需要记录虚拟寄存器被引用时的状态即可。
生成 host 指令以及状态同步
这里把状态同步和 host 指令生成放在一起讲,因为状态同步本身也要靠生成 host 指令来完成。
对于中间码的输入虚拟寄存器,先判断它的值是保存在内存中,还是已经加载到 host 物理寄存器上。如果还在内存中,QEMU 就要分配 host 物理寄存器,并插入 load 指令把内存中的值加载到该物理寄存器;如果值已经在物理寄存器上,就可以直接参与计算。对于中间码的输出虚拟寄存器,QEMU 需要为它分配 host 物理寄存器。
输入和输出寄存器都确定下来后,QEMU 就可以尝试把中间码翻译成 host 指令。这个翻译可能直接对应一条 host 指令,也可能需要额外插入几条 host 指令做调整。
guest 指令对应的中间码执行完后,需要把输出同步回 guest CPU 数据结构,所以 QEMU 在这里还要插入 host store 指令,把数据刷回 guest CPU。QEMU 在寄存器活性分析阶段会把需要同步的虚拟寄存器打上 sync 标记,生成 host 指令时遇到该标记,就直接插入相应的同步指令。
并不是每条 guest 指令执行完都要把信息刷回 guest CPU 数据结构。guest CPU 的信息虽然定义在 guest CPU 数据结构中,但我们是在模拟 guest CPU,只要不破坏模拟的逻辑,host 物理寄存器上的值就可以先不刷回。那什么时候必须刷回?一是整个 TB 执行完时,需要把虚拟寄存器同步回 guest CPU;二是当中间码可能导致 guest CPU 异常时,也需要做同步,因为异常触发后,guest CPU 要跳转到异常处理地址,并向软件报告异常处理上下文,而上下文中的通用寄存器都是从 guest CPU 数据结构获取的。
引入 BB 的概念
上面讲的寄存器分配和状态同步其实还不完整:QEMU 的一个翻译块(TB)内可能存在跳转中间码,有跳转中间码时,逆序遍历确定虚拟寄存器活性的办法就有问题。为此,QEMU 在 TB 的基础上引入了 Basic Block(BB)的概念。简单来说,BB 内的中间码都是顺序执行的,上面的逻辑在 BB 内仍然成立。所以,在 BB 的结尾要把所有虚拟寄存器置为 dead,并把 guest CPU 对应的虚拟寄存器同步回 guest CPU 数据结构。