Hansen's ink

Back

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 寄存器一般如下定义:

/* 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 中分配的这些寄存器都是虚拟的寄存器,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);
c

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 指令的行为,看一个小例子对这种描述会有更直观的感受。

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
c

如上 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 指令来完成模拟。

add_i64 x2/sp,x2/sp,$0xffffffffffffffe0
add_i64 tmp4,x2/sp,$0x18
qemu_st_i64 x8/s0,tmp4,leq,0
c

这几条中间码只是表意,实际真正更新 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地址上的值
c

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中间码优化与后端机制
https://astro-pure.js.org/blog/simulator/qemu-tcg
Author Hansen W.
Published at September 2, 2024
Comment seems to stuck. Try to refresh?✨