Qemu tcg中间码优化与后端机制

TCG基本逻辑

qemu tcg的基本翻译思路是把guest指令先翻译成中间码(IR),然后再把IR翻译成host指令。guest->IR->host这种三段式实现的好处是把前端翻译,优化和后端翻译拆开了,降低了开发的难度。

IR指令的理解是比较直白的,qemu定义了一套IR的指令,具体的定义在tcg/README里说明,在一个tb里,qemu前端翻译得到的IR被串联到一个链表里,中间码优化和后端翻译都靠这个链表得到IR,中间码优化时,需要改动IR时(比如,删掉不可达的IR),对这个链表做操作就好。

中间码不只是定义了对应的指令,也有寄存器的定义,它形成了一个独立的逻辑空间,在IR这一层,可以认为都在中间码相关的寄存器上做计算的。IR这一层定义了几个寄存器类型,它们分别是:global, local temp, normal temp, fixed, const, ebb

一般guest的gpr也被定义为IR这一层的global寄存器,中间码做计算的时候,会用到一些临时变量,这些临时变量就保存在local temp或者是normal temp这样的寄存器里,计算的时候要用到一些常量时,需要定义一个TCG寄存器,创建一个常量并把它赋给TCG寄存器。

riscv下global寄存器一般如下定义:

1
2
3
4
5
6
7
8
9
10
/* 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");
[...]

这里分配了对应的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中分配的这些寄存器都是虚拟的寄存器,IR翻译到host指令都要给虚拟寄存器分配对应的host物理寄存器,当一个TCG寄存器有TEMP_FIXED标记表示在后端翻译时把这个虚拟寄存器固定映射到一个host物理寄存器上,一般fixed寄存器都是翻译执行时经常要用到的参数。

中间码优化

前端翻译得到的IR可能会有优化的空间存在,所以qemu在进行后端翻译之前会先做中间码
优化,优化以一个TB为单位,优化的输入就是一个TB对应的IR和用到的TCG寄存器。

1
2
3
4
5
6
/* tcg/tcg.c */
tcg_gen_code
+-> tcg_optimize(s)
+-> done = fold_add(&ctx, op);

+-> reachable_code_pass(s);

tcg_optimize是做着一些常量的检查,进而做指令优化(折叠常量表达式), 我们取其中的一个case,比如fold_add具体看下,大概知道下这里是在干什么。可以看到这个case检测add_32/64这个IR的两个操作数是不是常量,如果是常量,那么在这里直接把常量相加后的结果放到一个常量类型TCG寄存器,然后把之前的add_32/64改成一条mov指令。

从名字就可以看出reachable_code_pass应该做的是一些死代码的删除,这里检测到运行不到的IR就直接从IR链表里把他们删掉。

中间码优化的输出还是IR链表和相关的TCG寄存器,可见我们也可以把这两个函数注释掉,从而把中间码优化关掉。可以看出,中间码优化和编译器IR优化的逻辑是类似的。

tcg后端功能

qemu用tcg模拟guest指令执行,qemu把guest指令先翻译成中间码,然后再把中间码翻译成host指令,host指令可以最终在host cpu上执行,这样就完成了翻译。

此部分关注的是后端翻译模型,也就是中间码翻译成host指令的过程。中间码是一套完整的指令集定义,使用中间码可以完整的表述guest指令的行为,看一个小例子对这种描述会有更直观的感受。

1
2
3
4
5
6
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,0

如上guest那条store指令,它被翻译成了两条中间码,第一条add_i64是用来计算sd要store的地址,计算出的地址保存在tmp4这个虚拟寄存器里,第二条中间码把s0的值store到tmp4描述的内存上,qemu用中间码和虚拟寄存器完整的表述guest的逻辑。这里qemu_st_i64这个中间码表示一个store操作,store的数据和地址都用虚拟寄存器描述,所以在qemu_st_i64之前要用add_i64先计算出store的地址,并保存在虚拟寄存器里。

qemu中,其它的guest指令也是这样先翻译成中间码和虚拟寄存器的表示,后端翻译基于中间码和虚拟寄存器进行。上面的中间码表述中,x2/sp和x8/s0还是guest上寄存器的名字,但是逻辑上guest上的寄存器都已经映射到qemu虚拟寄存器,所以中间码指令中的所有寄存器都是qemu的虚拟寄存器。

qemu模拟的guest cpu系统说到底就是host内存里表示的guest cpu的软件结构体的状态以及guest内存的状态,qemu中间码已经完整的描述了guest状态改变的激励,拿上面addi和sd guest指令的模拟为例,模拟addi的中间码是addi_64 x2/sp,x2/sp,$0xffffffffffffffe0 ,表示要把guest的sp加上-32,sd的中间码表示要把guest sp + 24指向的地址上的值改成s0的值。我们拿到如上中间码或者guest指令,甚至可以直接写c代码去完成模拟。qemu为了追求效率把中间码翻译成host指令来完成模拟。

1
2
3
add_i64 x2/sp,x2/sp,$0xffffffffffffffe0
add_i64 tmp4,x2/sp,$0x18
qemu_st_i64 x8/s0,tmp4,leq,0

这几条中间码只是表意,实际真正更新guest cpu的数据结构和guest地址还需要host指令完成,所以实际翻译后的host指令可能是这样的:

1
2
3
4
5
6
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地址上的值

qemu的后端翻译就是完成如上功能,总结起来就是:

  1. 分配host物理寄存器;
  2. 生成host指令;
  3. host和guest之间的状态同步。

分配host物理寄存器

虚拟寄存器和host物理寄存器是两个独立的概念,虚拟寄存器可能会很多,而物理寄存器的个数是有限的,虚拟寄存器有自己的生命周期,虚拟寄存器生命周期结束后,它所使用的物理寄存器就可以给其它虚拟寄存器使用。因为host物理寄存器数目有限,就有可能出现host物理寄存器不够分的情况,这时候就需要把已经分配但是目前还没有用到的host物理寄存器的值保存到内存,这样就可以腾出host物理寄存器来使用。

qemu在处理host物理寄存器分配的时候,分了两步处理,第一步先确定虚拟寄存器的生命周期,一般叫做寄存器活性分析,第二步根据虚拟寄存器活性分析的结果具体分配物理寄存器。

针对一段中间码,qemu对其做逆序遍历,依此确定虚拟寄存器的生命周期。如果一个虚拟寄存器后续还中间码使用,那它还是live的,如果后面没有中间码用了,它就dead了。

所以,一个虚拟寄存器dead与否是和具体中间码一起看的,一个虚拟寄存器可能在前几个中间码中是live的(虽然这几个中间码并没有使用这个虚拟寄存器),最后一个使用它的中间码后这个虚拟寄存器就dead了。qemu里只要记录虚拟寄存器被引用时的状态就好。

生成host指令以及状态同步

我们把状态同步和host指令生成放到一起看,因为所谓状态同步也要生成host指令进行。

对于中间码的输入虚拟寄存器,需要先判断这个输入寄存器的值是保存在内存上,还是已经保存在host物理寄存器上了,如果还在内存上,qemu就要分配host物理寄存器,然后插入host上的load指令把内存上的值load到host物理寄存器上,如果虚拟寄存器的值已经在host物理寄存器上,那么它直接就可以参与计算。对于中间码的输出虚拟寄存器,qemu需要为它分配host物理寄存器。

中间码的输入和输出寄存器都有着落了,qemu就可以尝试把中间码翻译成host指令。这个翻译可能直接就可以翻译成一条host指令,也可能需要再插入几条host指令调整下。

guest指令对应的中间码执行完后,需要把guest指令的输出同步回guest CPU数据结构,所以qemu在这里还需要插入host store指令把数据刷回guest CPU。qemu在寄存器活性分析的时候会把需要做同步的虚拟寄存器打上sync的标记,生成host指令的时候遇见sync标记就可以直接插入host指令做同步。

并不需要每个guest指令执行完都要把信息刷回guest CPU数据结构,虽然guest CPU的信息是定义在guest CPU数据结构中的,但是我们是模拟guest CPU,只要不破坏模拟的逻辑,host物理寄存器上的值就可以先不刷回guest CPU数据结构。那什么时候需要刷回guest CPU,整个TB执行完时,虚拟寄存器需要被同步回guest CPU,当中间码可能导致guest CPU异常时,需要做同步,因为触发异常后,guest CPU跳转到异常处理地址,并且向软件报告异常处理的上下文,其中guest CPU的通用寄存器就都是从guest CPU数据结构获取。

加入BB的概念

上面讲的寄存器分配和状态同步其实还不完整,qemu的一个翻译块(TB)里是可以存在跳转中间码的,在有跳转中间码的情况下,上面逆序遍历确定虚拟寄存器活性的办法就会有问题。为此qemu中在TB的基础上又引入了Basic Block(BB)的概念,简单讲在一个BB内中间码都是顺序执行的,这样如上的逻辑在BB内还是成立的。所以,在BB的结尾就要dead全部虚拟寄存器,并且把guest CPU对应的虚拟寄存器向guest CPU数据结构做同步。


Qemu tcg中间码优化与后端机制
http://willimt.com/2024/08/02/模拟器/Qemu tcg中间码优化与后端机制/
作者
Willimt
发布于
2024年8月2日
许可协议