0%

qemu tcg翻译执行核心逻辑分析

翻译执行循环基本逻辑

整个模拟CPU执行指令的过程就是一个不断翻译执行的循环,当指令执行过程中有中断或者
异常,整个翻译执行的循环被打断,处理中断或者异常。异常是执行指令的时候触发的,qemu
在翻译执行的时候通过一个长跳转跳出循环,处理完异常,异常改变CPU状态和PC,qemu处理完
异常后,从新PC位置继续翻译执行(这个新PC一般就是异常处理向量的入口)。中断是外设
异步产生的,qemu在每次翻译执行的循环执行一次后,再次执行翻译执行之前检查下中断,
如果有中断,qemu就处理中断,和异常一样,qemu改变CPU状态和PC后,再次进入翻译执行
的循环。

1
2
3
4
5
6
7
setjmp;

while (检查并处理中断或异常) {
前端翻译;
后端翻译;
执行host执行改变guest CPU状态; // 有异常时longjmp到setjmp处
}

qemu在翻译的时候不是逐条guest指令翻译的,而是把一堆guest指令翻译到一个translation
block(tb)里,执行也是以tb为单位。qemu针对tb做了一些优化,它把已经翻译的tb放到哈希表
里,需要翻译的时候先查表,找到了就可以直接在host上运行tb里翻译好的指令,省去翻译
的过程,在这个基础上,如果tb和tb之间有跳转关系,qemu也可以在前一个tb里加指针,直接
指向下一个tb,一个tb执行完成,直接跳到下一个tb执行,这样连上面查表的过程也省去了,
这样的tb叫chained tb,宏观上看,qemu执行时,如果都是chained tb,完全有可能翻译过
一次后,再次执行的时候都在tb之间直接跳来跳去,没有翻译和查tb hash表的过程。

整个翻译的逻辑都在tb_gen_code里。

要理解具体的翻译执行的细节,需要了解整个机器是怎么起来的。qemu启动的时候时候,
会在如下的流程里初始化所谓accelerator的东西,qemu把tcg和kvm看成是qemu翻译执行的
两种加速器,如下就是相关初始化的配置,我们这里只关心tcg。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
main
+-> qemu_init
+-> configure_accelerators
+-> do_configure_accelerator
+-> accel_init_machine(accel, current_machine)
/*
* tcg的init_machine定义在accel/tcg/tcg-all.c, tcg_accel会被qemu
* 定义成一个类对象。tcg init_machine的回调函数是tcg_init_machine
*/
+-> acc->init_machine (tcg_init_machine)
+-> tcg_init
| +-> tcg_context_init
| /*
| * tcg/aarch64/tcg-target.c.inc,这个函数配置翻译时用到的host
| * 寄存器的信息,tcg_target_call_clobber_regs表示需要调用者
| * 保存的寄存器,这个函数把x19-x29(ARM64中需要被调用者保存)
| * 从这个集合中去除,reserved_regs表示有固定用途的寄存器,qemu
| * 后端翻译分配寄存器时,不能从其中分配,ARM64作为后端时,这样
| * 的寄存器有:sp/fp(x29)/tmp(x30)/x18/vec_tmp。
| *
| * tcg_target_call_iarg_regs/tcg_target_call_oarg_regs表示ARM64
| * host架构上函数入参和返回值可以用的寄存器,ARM64上直接静态
| * 定义到了tcg-target.c.inc中。
| */
| +-> tcg_target_init
| /*
| * tcg_ctx是TCGContext, 线程变量,是tb翻译执行的上下文. 每个
| * tb里都有一个段前导代码,这个代码用来在真正执行tb里的host
| * 指令的时候,做环境的准备。下面这个函数生成这段前导的指令。
| * 从下面可见tb的结尾时的代码也在这里生成了,前后都在准备和
| * 恢复执行tb的这个host函数的上下文,中间的br是跳掉tb的业务
| * 逻辑里执行业务代码。
| */
+-> tcg_prologue_init(tcg_ctx)
/* 我们这里假设host是arm64,tcg/aarch64/tcg-target.c.inc */
+-> tcg_target_qemu_prologue
/*
* 如上的这个函数里,用代码生成了一段arm64的汇编,大概是:
* (这个可以-d out_asm,通过输出host的反汇编得到)
* stp x29, x30, [sp, #-0x60]!
* mov x29, sp
* stp x19, x20, [sp, #0x10]
* stp x21, x22, [sp, #0x20]
* stp x23, x24, [sp, #0x30]
* stp x25, x26, [sp, #0x40]
* stp x27, x28, [sp, #0x50]
* sub sp, sp, #0x480
* mov x19, x0 <------ 第一个入参保存cpu结构体地址
* br x1 <------ 第二个入参保存的是生成指令地址
* movz w0, #0 <------ 这个地址保存到TCGContext的code_gen_epilogue
* add sp, sp, #0x480
* ldp x19, x20, [sp, #0x10]
* ldp x21, x22, [sp, #0x20]
* ldp x23, x24, [sp, #0x30]
* ldp x25, x26, [sp, #0x40]
* ldp x27, x28, [sp, #0x50]
* ldp x29, x30, [sp], #0x60
* ret
*
* 这些生成的指令被放到TCGContext的code_ptr, code_gen_prologue
* 也指向相同的一片buf。
*/

各个CPU线程的初始化流程是:

1
2
3
4
5
6
7
8
/* target/riscv/cpu.c */
riscv_cpu_realize
/* softmmu/cpus.c */
+-> qemu_init_vcpu
/* 拉起guest cpu的线程, tcg的回调定义在accel/tcg/tcg-cpus.c */
+-> cpus_accel->create_vcpu_thread // tcg_start_vcpu_thread
+-> qemu_thread_create拉起线程: tcg_cpu_thread_fn
/* 如上线程的主体就是上面翻译执行的主循环 */

前端翻译

前端翻译在gen_intermediate_code里完成, 翻译成的中间码都挂到了tcg_ctx的ops链表里。
这里有几个相关的数据结构:TranslationBlock tb, TCGContext tcg_ctx, DisasContextBase dcbase。
tb是指一个具体翻译块,tcg_ctx是一个CPU的翻译上下文,对于每个具体的翻译块,进入和
出来翻译翻译块的host二进制都是相同的,就是上面prologue中的二进制。tb中翻译的业务
代码的host二进制在一个翻译上下文中产生,并添加到tb的各种缓存结构中。(todo:还没有
找见tcg_ctx的到一个tb时,新建tb中host二进制存储空间的地方)。dcbase用于前端翻译,
前端翻译可以看作是guest二进制反汇编成qemu中间指令的过程,想必disas context的命名
也来自这里。

1
2
3
4
5
tb_gen_code
+-> gen_intermediate_code
/* 这里翻译就是把一个个的guest指令得到的中间码连同操作数挂到ops链表里 */
+-> translator_loop
+-> tcg_gen_code

前端翻译中,我们会涉及TCGv以及helper函数的概念。TCGv从概念的角度可以看成是中间码
使用的寄存器,前端模拟实现一个指令的时候,要用到临时变量的时候,都要申请一个这样
的寄存器。比如,我们看下riscv的add指令的前端翻译的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static bool trans_add(DisasContext *ctx, arg_add *a)
{
return gen_arith(ctx, a, &tcg_gen_add_tl);
}

static bool gen_arith(DisasContext *ctx, arg_r *a,
void(*func)(TCGv, TCGv, TCGv))
{
TCGv source1, source2;
source1 = tcg_temp_new(); <------ A
source2 = tcg_temp_new();

gen_get_gpr(source1, a->rs1); <------ B
gen_get_gpr(source2, a->rs2);

(*func)(source1, source1, source2);

gen_set_gpr(a->rd, source1);
tcg_temp_free(source1);
tcg_temp_free(source2);
return true;
}

static inline void gen_get_gpr(TCGv t, int reg_num)
{
if (reg_num == 0) {
tcg_gen_movi_tl(t, 0);
} else {
tcg_gen_mov_tl(t, cpu_gpr[reg_num]); <------ C
}
}

在A行,我们申请了一个source1 TCGv, 在C行,我们把add指令的rs1寄存器上的值传递给
source1,后续继续使用source1参与计算。source1和guest寄存器实际上都是保存在host
的内存上的,实际运行的时候,host上的程序其实做的就是内存数据搬移的操作。后续add
指令的模拟,在中间码的层面看是把source1/source2相加,host实际计算的时候要把数据
移动到寄存器上计算,所以直接翻译可能是这样的:reg1 = load(rs1的内存), store(reg1, source1的内存),
reg1 = load(source1的内存), 同样的方式把rs2保存的值加载到reg2,reg3 = add(reg1, reg2),
store(reg3, rd的内存), 最后可能就优化成了reg1 = load(rs1的内存), reg2 = load(rs2的内存),
reg3 = add(reg1, reg2), store(reg3, rd的内存)。

我们实际看个模拟执行的例子,使用-d in_asm,op,out_asm得到guest汇编、中间码和host汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
IN: test_add
0x0000000000010430: 1101 addi sp,sp,-32
0x0000000000010432: ec22 sd s0,24(sp)
0x0000000000010434: 1000 addi s0,sp,32
[...]

OP:
ld_i32 tmp0,env,$0xfffffffffffffff0
movi_i32 tmp1,$0x0
brcond_i32 tmp0,tmp1,lt,$L0

---- 0000000000010430
mov_i64 tmp2,x2/sp
movi_i64 tmp3,$0xffffffffffffffe0
add_i64 tmp2,tmp2,tmp3
mov_i64 x2/sp,tmp2
[...]

OUT: [size=192]
0xffff7c025c00: b85f0274 ldur w20, [x19, #0xfffffffffffffff0] [tb header & initial instruction]
0xffff7c025c04: 7100029f cmp w20, #0
0xffff7c025c08: 5400052b b.lt #0xffff7c025cac
0xffff7c025c0c: f9400a74 ldr x20, [x19, #0x10] <----- 10430
0xffff7c025c10: d1008294 sub x20, x20, #0x20
0xffff7c025c14: f9000a74 str x20, [x19, #0x10]
[...]

就只看第一条addi指令的模拟过程,这是test_add这个函数一进来开栈的指令,把sp向低地址
移动32。可以看到中间码是和前端翻译过程相对应的,先把寄存器值和-32保存到TCGv变量上,
然后对TCGv做add运算,然后把运算结果保存回sp。最终翻译得到的host代码,把sp的值load
到x20,计算后再存回cpu结构体的对应位置,x19保存就是cpu结构体的地址。

具体代码实现上,TCGv这个变量的数值其实就是对应变量相对于存储空间基地址的偏移。
这个存储空间不只是有描述cpu的结构体(riscv上是CPURISCVState),还有TCGContext,CPU
的寄存器都是存在cpu结构体里的,上面这个例子的sp就是这样。TCGContext保存变量的逻辑
还没有搞清。前端翻译只是把这些信息都挂到中间码的链表里,得到host指令在后端翻译里。

qemu可以用生成的host指令模拟guest,也可以直接调用host上的函数改变guest CPU的状态,
后者在qemu里叫helper函数。理论上,所有的模拟都可以用helper函数,但是,显然helper
函数会降低模拟的速度。

以riscv为例,增加一个helper函数的一般套路是: 1. 在target/riscv/op_helper.c里增加
函数的定义;2. 在target/riscv/helper.h增加对应的宏,宏的参数分别是:helper函数名字、
函数的返回值、函数的入参;3. 在中间码里用gen_helper_xxx直接调用helper函数,返回值
保存在gen_helper_xxx的第一个参数里,常数入参需要用tcg_const_i32/i64生成下常数TCGv,
实际上是为这个常数分配TCG寄存器存储空间。

helper函数的实现逻辑是生成函数调用的上下文,然后跳转到函数的地址执行指令,也就是
先把函数的入参放到寄存器上,然后调用跳转指令跳到函数地址执行。

后端翻译

后端翻译在tcg_gen_code里,核心是在一个循环里处理前端翻译的中间码,把中间码翻译成
host上的汇编,具体分析可以参考这里

执行

执行翻译好的host指令是在大循环的cpu_loop_exec_tb里。翻译好的host汇编的整体逻辑
如上面prologue的样子,tb里的业务代码对应的host汇编通过中间的br指令调用。tb对应的
业务代码对应的host汇编,也就是前端翻译、后端翻译一起得到的host二进制放在tb->tc.ptr
指向的地址,prologue的二进制放在tcg_ctx->code_gen_prologue指向的地址。tb->tc.ptr
在函数调用的时候被放到了x1寄存器,这个和br x1也是相对应的。

1
2
3
4
cpu_loop_exec_tb
+-> cpu_tb_exec
+-> tcg_qemu_tb_exec(env, tb->tc.ptr)
+-> tcg_ctx->code_gen_prologue(env, tb->tc.ptr)