Qemu是一个流行的模拟器软件,主要特点:
这个系列主要是尝试分析qemu tcg方面的一些原理。
TCG是Tiny Code Generator
的缩写,最早是用在gcc种,作为一种通用后端来使用的,在这里我们简单将它称为qemu TCG模式,因为目前的开发环境是在loongarch平台上,下面就以 x86<->loongarch 的翻译为例分析。
TCG又分为两种模式:只运行app和仿真整个系统,前者只有指令翻译(普通指令),通常称为linux-user,后者包括整个系统的仿真:特权态指令,中断控制器,桥片,MMU等,通常称为softmmu。
在TCG模式下可以跨平台运行不同指令集的程序或操作系统。例如典型的android模拟器,android镜像大多数是基于arm构建的,但是我们可以在x86上运行qemu模拟器运行android系统,在上面适配各种应用。
在TCG模式下我们通过target和host来区分宿主机和模拟的指令架构,target是要模拟的指令架构或者计算机,host是Qemu运行的环境。使用android模拟器情况下 target 是 arm, host 是 x86 。
TCG以块为单位,将target的指令动态翻译成host指令,为了解耦不同arch上的关系,tcg设计成 target -> IR -> host , IR 也是一套虚拟的RISC 指令,target 只需要关注如何翻译到 IR ,host只需要关注如何翻译IR。前后端分离的架构对于新增某一种架构就相对比较简单,可以单独增加前端和后端的翻译。
target <-------------> IR <----------------> host
TCG 翻译过程中以Translation Block 为单位,即翻译块, 它对应一组target指令, 基本的操作就是扫描翻译target指令到 IR 直至以下几个条件:跳转指令, 系统调用,或者到达页的边缘,然后将tb的指令优化并翻译成host指令。一个tb中最多只有一个跳转指令。
为什么称TCG为JIT类型呢?
JIT是just in time的缩写,现代所有的高级语言运行环境中都有JIT实现,例如jvm,ebpf, python等,将类似于高级指令或者字节码的批量动态翻译成native的指令并且缓存起来,这样就能直接执行native指令来加速运行。TCG也是边翻译边执行,同时将翻译好的代码缓存起来,下次再次执行时能够加速,实现了JIT的概念。这里就要提一下android的AOT,将热点代码缓存到存储上以备下次使用,对于热点代码直接跳过翻译阶段,QEMU TCG产品化过程中是否也可以考虑一下这种方式呢。
关键的代码流程
cpu_exec
tb = tb_find(cpu, last_tb, tb_exit, cflags);
cpu_loop_exec_tb(cpu, tb, &last_tb, &tb_exit);
tb_find
tb = tb_lookup__cpu_state(cpu, &pc, &cs_base, &flags, cf_mask); //在cache中根据target PC 查找TB
if (tb == NULL) {
tb_gen_code(cpu, pc, cs_base, flags, cf_mask); //遍历target指令生成native code,中间先将target指令转换成IR,然后再将IR转换成native指令
}
if (last_tb) {
tb_add_jump(last_tb, tb_exit, tb);
}
tb_gen_code
tb = tb_alloc(pc);
gen_intermediate_code(cpu, tb);
gen_code_size = tcg_gen_code(tcg_ctx, tb);
tcg_tb_insert(tb);
我们先关注linux-user模式下也即是只翻译运行app的模式。
TCG执行时首先根据targe PC来找到对应TB,如果存在TB则说明它已经被翻译过并且缓存起来,直接执行就好。
如果没有在缓存中找到则开始翻译,翻译大体分为三步: target指令->IR, IR指令的优化, IR->host指令翻译。通常 target指令->IR 称为 frontend(前端翻译), IR->host指令 称为backend(后端翻译)。
在TCG翻译过程中,IR 存在几个概念:
temporary临时变量的翻译结果
loongarch原始指令:
addi.d $r30,$r24,8(0x8)
IR指令:
movi_i64 tmp2,$0x8
add_i64 s7,s1,tmp2
两条IR就是一个基本生命周期,用完就可以释放,在下面的basic block中可以重用。
这里还是先只讨论linux-user模式,它相对比较简单,softmmu需要初始化整个machine的状态而Linux-user没有machine的概念。
linux-user只支持模拟应用程序运行,入口在linux-user/main.c, 主要是支持ELF格式的可执行文件。在linux-user中不需要初始化前端的状态,它会解析需要翻译的可执行程序并找到入口点entry,从entry位置开始执行。
需要像内核一样加载程序并传给它一些变量,加载时包括以下几个方面:
1. 程序的加载
2. 程序的传参
3. 指令的翻译
main() {
loader_exec(execfd, filename, target_argv, target_environ) //加载应用程序,除此之外还有环境变量,参数
tcg_prologue_init(tcg_ctx);
tcg_region_init();
cpu_loop(env);
}
loader_exec() {
bprm->fd = fdexec; //bprm和内核中相同,一个对象程序的所有信息都有了
bprm->filename = (char *)filename;
bprm->argc = count(argv);
bprm->argv = argv;
bprm->envc = count(envp);
bprm->envp = envp;
retval = prepare_binprm(bprm);
load_elf_binary(bprm, infop);
}
load_elf_binary() {
load_elf_image(bprm->filename, bprm->fd, info, &elf_interpreter, bprm->buf); //加载程序镜像
setup_arg_pages(bprm, info); //准备参数
bprm->p = copy_elf_strings(1, &bprm->filename, scratch, bprm->p, info->stack_limit);
info->file_string = bprm->p; //文件名
bprm->p = copy_elf_strings(bprm->envc, bprm->envp, scratch, bprm->p, info->stack_limit);
info->env_strings = bprm->p; //环境变量
bprm->p = copy_elf_strings(bprm->argc, bprm->argv, scratch, bprm->p, info->stack_limit);
info->arg_strings = bprm->p; //参数
}
load_elf_image() {
load_addr = target_mmap(loaddr, hiaddr - loaddr, PROT_NONE,
MAP_PRIVATE | MAP_ANON | MAP_NORESERVE, -1, 0);
info->entry = ehdr->e_entry + load_bias; //程序的入口
}
后端和arch相关的主要集中在tcg_target_init。
tcg翻译target指令会遇到以下几种类型的指令:
TCG_TYPE_I32
TCG_TYPE_I64
TCG_TYPE_V64 //64位向量
TCG_TYPE_V128 //128位向量
TCG_TYPE_V256 //256位向量
翻译过程中需要保留一些特殊寄存器用来保存一些全局变量,是和arch ABI强相关的,例如SP,TP, ZERO等;
另外还需要定义在函数调用过程中哪些是callee-saved的寄存器,这些寄存器
定义了几个 GPR 的 bitmap :tcg_target_available_regs,tcg_target_call_clobber_regs,tcg_target_reg_alloc_order, tcg_target_call_iarg_regs, tcg_target_call_oarg_regs, s->reserved_regs
tcg_target_available_regs: 不同类型指令中可用的寄存器 bitmap
tcg_target_call_clobber_regs: 在函数调用过程中 caller 需要保存的寄存器
tcg_target_callee_save_regs: 在函数调用过程中 callee 需要保存的寄存器
tcg_target_call_iarg_regs: 在函数调用过程中寄存器传参的寄存器列表
tcg_target_call_oarg_regs:在函数调用过程中返回值寄存器列表
s->reserved_regs:在翻译 IR -> host 指令过程中不会使用的寄存器列表,类似于SP, TP, ZERO 和一些其他 reserved 的寄存器
在 qemu -> code cache 中它模拟的是函数调用过程,caller 对 caller clobbered寄存器进行保存,在prologue中 将callee saved 的寄存器都进行了入栈保存,其他不能使用的寄存器在 s->reserved_regs中进行标记,所以理论上在code cache 中可以使用除 s->reserved_regs 之外的所有寄存器。
定义了两个寄存器列表:tcg_target_reg_alloc_order 和 indirect_reg_alloc_order , 这两个的用途不同,前者主要是给 临时变量使用, 后者主要是给全局内存变量使用,通过定义不同的顺序可以减少变量使用寄存器的冲突概率。在寄存器使用冲突时需要保存原有寄存器内容到内存中,减少冲突就能减少指令。
tcg_target_reg_alloc_order: 翻译 IR -> host 申请使用 REG 的列表顺序,一般优先 callee saved寄存器,给临时变量使用。
indirect_reg_alloc_order:翻译 IR -> host, 一半是 callee saved寄存器逆序,给全局内存变量使用。
全局的TCGContext初始化
翻译代码时目前是单线程的,共享同一份的上下文,这样可以确保 同一份target 代码只会生成一份对应的 code cache, 在 tcg_context_init(&tcg_init_ctx); 初始化过程中,初始化 IR指令和helper指令和 TCGContext 的一些内部成员,为后续的指令翻译做准备。
prologue初始化
prologue 用来切换 qemu 代码和 code cache 的代码上下文,在 tcg_prologue_init(tcg_ctx) 调用 host 的 tcg_target_qemu_prologue 来组装这块代码。
在x86上组装出来的代码如下:
0x5644ca0ab000: push %rbp //函数调用过程中将callee saved的入栈保存,完成了callee本身需要做的事情,因此在执行tb代码时可以使用所有的寄存器,在tb执行过程中不遵循函数调用的接口规范了
0x5644ca0ab001: push %rbx
0x5644ca0ab002: push %r12
0x5644ca0ab004: push %r13
0x5644ca0ab006: push %r14
0x5644ca0ab008: push %r15
0x5644ca0ab00a: mov %rdi,%r14 //env到r14中,本身指向CPULOONGARCHState的对象,tb操作的对象都在
0x5644ca0ab00d: add $0xfffffffffffffb78,%rsp //
0x5644ca0ab014: jmpq *%rsi //跳转的tb的起始位置
0x5644ca0ab016: xor %eax,%eax //epilogue位置,返回值为0. 主要是为了goto_ptr使用
0x5644ca0ab018: add $0x488,%rsp //正常退出
0x5644ca0ab01f: emms
0x5644ca0ab022: pop %r15 //saved寄存器出栈
0x5644ca0ab024: pop %r14
0x5644ca0ab026: pop %r13
0x5644ca0ab028: pop %r12
0x5644ca0ab02a: pop %rbx
0x5644ca0ab02b: pop %rbp
0x5644ca0ab02c: retq //退出到qemu中
在target指令翻译过程TB以跳转指令结束,其他指令只需要根据语义翻译成IR指令就可以。其中比较特殊的是跳转指令,TB开始和TB结束时的一些特殊代码.
gen_tb_start
ld_i32 tmp0,env,$0xffffffffffffffe4 //env指向CPULOONGARCHState,偏移是CPUState.icount_decr.u32相对于env的偏移;
movi_i32 tmp1,$0x0
brcond_i32 tmp0,tmp1,lt,$L0 //如果icount_decr.u32小于0则跳转到label 0处
参考文档: https://qemu.readthedocs.io/en/latest/devel/tcg-icount.html
icount只有在SOFTMMU中才有用,并且不能用在multi-threaded TCG 中,主要用于执行过程中指令的计数,后面详细再讲。
gen_tb_end
set_label $L0 //生成一个label 0,给`br $label`或者`brcond_i32/i64 label`,`goto_tb`这些提供錨点
exit_tb $0x55f975f8bec3 //退出到tb_ret_addr
loongarch:
0x000000012001cae4: 40000d80 beqz $r12,12(0xc) # 0x2001caf0
IR:
---- 000000012001cae4 0000000000000000 0000000000000000
mov_i64 tmp2,t0 //目标寄存器
movi_i64 tmp3,$0x0 //zero
setcond_i64 bcond,tmp2,tmp3,eq //bcond是一个全局mem,if(tmp2 eq tmp3) bcond=true; else bcond = false;
movi_i64 tmp2,$0x0 //
brcond_i64 bcond,tmp2,ne,$L1 //if (tmp2 ne bcond) branch L1; L1是满足跳转条件的下一条指令
goto_tb $0x1 //第一次执行时是下一条指令,第二次执行时是next tb的第一条指令
movi_i64 PC,$0x12001cae8 //下一条指令
exit_tb $0x55f975f8bec1 //退出tb,返回值为tb和jump slot index 1的混合
set_label $L1
goto_tb $0x0
movi_i64 PC,$0x12001caf0 //跳转地址
exit_tb $0x55f975f8bec0 //退出tb,返回值为tb和jump slot index 0的混合
第一次执行时,goto_tb只是跳转到下一条指令,更新PC然后退出当前tb,此时是未优化的版本。
执行过程中qemu会根据pc寻找next tb,在执行next tb之前对上一个tb打补丁。
再次执行当前tb时,到了goto_tb跳转时,它已经被修改,会直接跳转到next tb的第一条指令而不需要返回到qemu中。
使用goto_tb + exit_tb,需要满足如下条件:
1. CPU state一定是常量,例如是否支持direct branch
2. direct branch不能进行跨页跳转。因为内存映射可能会随时被改变,目标地址可能无效或者更换了。
loongarch:
0x0000000120000968: 55c0f000 bl 114928(0x1c0f0) # 0x2001ca58
IR:
movi_i64 ra,$0x120000968 //将返回值放回到ra中. ra,PC都是一个全局地址变量
goto_tb $0x0 //如果当前的tb链接到index的TB,则goto_tb退出当前的tb然后跳转到index的TB。否则执行下一条指令。
movi_i64 PC,$0x12001c018 //将跳转地址放到PC中
exit_tb $0x55f1cb190900 //退出tb,返回值为当前执行的tb地址和跳转的index
loongarch:
0x000000012001c308: 53fdfbff b -520(0x3fffdf8) # 0x2001c100
IR:
goto_tb $0x0 //goto_tb只接受0或1的参数,一个tb中也最多只有两个label,一个是退出时的L0,还有一个是条件跳转时的L1。
movi_i64 PC,$0x12001c180 //跳转的PC
exit_tb $0x55f1cb191f40 //
loongarch:
0x0000000120000710: 4c000021 jirl $r1,$r1,0(0x0)
IR:
mov_i64 btarget,ra //将ra保存到btarget
movi_i64 ra,$0x120000714
mov_i64 PC,btarget
call lookup_tb_ptr,$0x60,$1,tmp6,env //这个是找下一个tb的,见accel/tcg/tcg-runtime.c:void *HELPER(lookup_tb_ptr)(CPUArchState *env), 如果下一个pc对应的tb存在则返回起始地址。否则返回tcg_ctx->code_gen_epilogue,它增加了返回值赋0的动作。
goto_ptr tmp6 //根据找到的地址跳转。goto_ptr一般前面跟着call lookup_tb_ptr查找的结果
backend的翻译也只看 IR 中比较特殊的指令,普通指令翻译就略过了。
call ptr
, 不过通过 -d op
dump出来的是如下形式: call lookup_tb_ptr,$0x60,$1,tmp2,env
调用的函数名称:lookup_tb_ptr
返回值:tmp2
参数:env
flags:
0x60 = TCG_CALL_NO_WRITE_GLOBALS|TCG_CALL_NO_SIDE_EFFECTS
call作用是直接使用qemu中helper函数
实现方式和手动组装汇编相似,都是遵循函数调用的ABI来进行函数调用,需要解决几个问题:输入参数,输出参数,caller clobbered寄存器的保存。
在 tcg_target_init 架构实现中,需要指定初始化 tcg_target_available_regs,实际上就是 函数调用过程中传参顺序, arg0, arg1…放哪个寄存器, 如果传参超过了最大传参则将多余的参数压栈。
输入参数:
首先计算超出寄存器最大传参的,然后通过入栈,写入栈指针上的空间,这部分是在tcg_target_qemu_prologue初始化过程中预留好的,给call 过程中栈传参专用的,目前通常是128个 LONG型的空间。
寄存器传参填入: 根据tcg_target_call_iarg_regs 的顺序选择寄存器,如果该寄存器正在被使用则将它首先保存回内存寄存器文件。之后将参数加载到寄存器中
保存caller clobbered寄存器:
当前整个TB就是一个函数,它的入口是tcg_qemu_tb_exec,在TB整个范围内认为所有通用用途寄存器都可用。现在要调用其他函数,需要保存caller-clobbered寄存器tcg_target_call_clobber_regs,在x86上就是
生成call指令:
不同平台上会根据跳转距离生成不同的指令
返回值:
根据ABI来处理返回值,tcg_target_call_oarg_regs 定义了返回值列表寄存器,然后将返回值寄存器放回到输出参数的位置
dicard
丢弃该指令,很简单直接丢弃。
set_label
它并不会生成实际的汇编代码,而只是记录当前label的位置,如果之前有使用该label的,则进行一个重定位操作patch_reloc,如果没有则记录位置,后面使用label时再进行重定位,使用label的IR指令有br $label
或者brcond_i32/i64 label
,goto_tb
。
在一个TB中最多有两个label,每个label表示一个basic block的结束,会将局部临时变量和全局变量进行保存。
basic block是比TB更小的单位,它从上一个basic block结束或者set_label的位置开始,遇到下面几条指令结束:branch指令,goto_tb, exit_tb, set_label, 在一个basic block结束的时候,标志着临时变量都被销毁,局部临时变量和全局变量需要保存到内存中。
所以在set_label标志着新的basic block的开始,也标志着当前block的结束,所以需要进行环境的清理。在实际实现中通过Livepass的分析,局部临时变量和全局变量应该已经和内存中保持一致了,在这个位置只是检查一致性。
其他通用指令的处理
其他指令分为参数的加载和IR指令->host指令的翻译转换。
参数分为输入参数和输出参数。
输入参数:
有不同的类型:CONST常数, MEM中数据,REG。其中CONST常数如果能直接放到指令中那就不需要额外操作,如果不能则需要申请一个新的寄存器加载到寄存器中;MEM类型需要申请一个新的寄存器然后加载到寄存器中;REG只需要检查寄存器是否符合约束条件即可。
输出参数:
选择合适的reg作为输出REG,但是并不会有加载数据到REG的操作,和输入参数不同的是,在生成指令后面会有对输出REG数据的操作
1. 对于TCGTemp要求 fixed_reg 但是REG != TCGTemp->reg的需要有 拷贝操作: mov TCGTemp->reg, REG
2. 检查是否需要SYNC,从REG中回写到内存
3. 检查是否DEAD,这部分并不会修改REG,而是标记该REG已经没有被使用,TCGTemp的val_type类型为DEAD
之后就是生成指令tcg_out_op,调用arch的接口生成host指令
这里简单介绍一下TCGTemp中的几个成员,TCGTemp代表IR中的所有变量,包括temp,local temp, global:
val_type:DEAD, CONST, MEM, REG; DEAD标记已经不使用了,CONST标记这个临时变量是一个阐述,MEM:标记是一个内存中的数据,REG表示当前已经加载到REG中了。在TCG IR的设计过程中,它使用RISC模型,并不会直接操作内存,而是有专门的load/store来同步内存数据到寄存器中,数据也是在寄存器中进行运算;在使用过程中CONST可能直接被放到指令中,也可能需要加载到REG,而MEM需要加载到REG中。
temp_global:是否是全局变量
temp_local: 是否是局部临时变量
temp_allocated:是否已经
reg: 数据加载到哪个寄存器中了,此时它的val_type = REG
mem_coherent:如果是内存变量,当它加载到REG中之后可能会被修改,这样就出现数据不一致的现象,在basic block结束的时候或者标记SYNC时会将reg中同步回内存中
mem_base, mem_offset:在内存中的位置, load/store时使用
mem_allocated: 额外申请内存时,一般时global memory变量和call过程中需要通过栈传参时
val: CONST类型使用
fix_reg: 使用固定的寄存器
寄存器使用中的几个接口,内部通过bitmap来管理:
tcg_regset_test_reg:查看reg是否已经被使用
tcg_regset_set_reg:标记reg已经被使用
tcg_reg_alloc:申请使用reg
tcg_reg_free:释放reg, 会找到对应的TCGTemp并且同步回内存中
reg和TCGTemp的映射关系:
reg->TCGTemp: TCGContext->reg_to_temp[TCG_TARGET_NB_REGS]
TCGTemp->TCGReg: TCGTemp->reg
goto_tb
goto_tb index退出当前的TB跳转到index TB位置,index只能是0和1。在i386下通过jmp类指令实现,在初次生成的指令中就是简单的跳转到下一条指令,在运行过程中会尝试进行direct jump的操作,这样就能直接跳转到其他TB的起始位置。
除了生成jmp指令外,还保存了当前jmp指令在TB中的偏移,这样在可以direct jump时修改该位置的跳转偏移到目标TB。
goto_ptr
这个就是寄存器跳转指令jmp,它现在的主要用途是和 call lookup_tb_ptr 搭配将相关的TB 链接起来。
call lookup_bt_ptr 会根据当前的 跳转目标的PC 寻找匹配的TB,如果存在目标TB则返回它的地址,否则返回JIT的 epilogue 地址,之后goto ptr 根据返回地址跳转到目标TB或者返回到qemu中。
exit_tb
exit_tb标记着从TB中返回到qemu,这里有个小优化,如果exit_tb 0则返回生成的epilogue位置,如果是exit_tb则返回tb_ret_addr。而epiogue是tb_ret_addr的上一条指令位置,将返回值强制赋为0,这样每个TB中就不必做这件事,相当于省了一条指令。
0x5592f355b016: xor %eax,%eax //epilogue,相当于强制返回0
0x5592f355b018: add $0x488,%rsp //tb_ret_addr,函数调用退出
指令翻译代码:
if (a0 == 0) {
tcg_out_jmp(s, s->code_gen_epilogue);
} else {
tcg_out_movi(s, TCG_TYPE_PTR, TCG_REG_EAX, a0);
tcg_out_jmp(s, tb_ret_addr);
}
qemu执行模拟代码的基本过程如下:
int cpu_exec(CPUState *cpu) {
if (sigsetjmp(cpu->jmp_env, 0) != 0) { <<保存现场,异常发生时通过 siglongjmp 跳转回到这里
while (!cpu_handle_interrupt(cpu, &last_tb)) {
tb = tb_find(cpu, last_tb, tb_exit, cflags);
cpu_loop_exec_tb(cpu, tb, &last_tb, &tb_exit);
}
}
}
在计算机体系结构中,中断,系统调用都属于异常,只是前者是异步异常,后者是同步异常。
static bool trans_syscall(DisasContext *ctx, arg_syscall *a)
{
generate_exception_end(ctx, EXCP_SYSCALL);
return true;
}
gen_helper_raise_exception_err(cpu_env, SYSCALL, terr)
-> call raise_exception_err
target/loongarch/op_helper.c: helper_raise_exception_err
-> do_raise_exception_err
-> cpu_loop_exit_restore(cs, pc);
-> cpu_loop_exit
void cpu_loop_exit(CPUState *cpu) {
cpu->can_do_io = 1;
siglongjmp(cpu->jmp_env, 1); //跳转回当前的上下文
}
void cpu_loop(CPULOONGARCHState *env)
{
trapnr = cpu_exec(cs); //返回EXCP_SYSCALL
switch (trapnr) {
case EXCP_SYSCALL:
env->active_tc.PC += 4; //PC执行结束
ret = do_syscall(env, env->active_tc.gpr[11], //使用通用的syscall来模拟系统调用
env->active_tc.gpr[4], env->active_tc.gpr[5],
env->active_tc.gpr[6], env->active_tc.gpr[7],
env->active_tc.gpr[8], env->active_tc.gpr[9],
-1, -1);
env->active_tc.gpr[4] = ret; //返回值
}
}
如果没有direct jump的话,qemu 模拟执行代码的流程就是这样:
QEMU -> code cache -> QEMU -> code cache -> ...
执行完一个TB后需要回到qemu,根据PC查找下一个TB再进入,这样来回的切换就比较低效,所以加了 direct jump的优化。
基本的做法时将 TB 串起来形成,只要 TB执行完并且他的跳转目标也在code cache中,就将两个TB通过 jump指令串接起来。串接的方式有两种:direct jump和indirect jump,目前基本上都是 direct jump方式,直接修改 TB 中的指令码。
TB1 第一次执行时并不知道需要跳转到 TB2,需要返回qemu,根据跳转目标 PC 找到 TB2,之后对 TB1 的代码进行 patch 跳转到 TB2。再次执行 TB1 时就可以直接跳转到 TB2 而无需 返回 qemu。这对于热点代码非常友好。
int cpu_exec(CPUState *cpu) {
tb = tb_find(cpu, last_tb, tb_exit, cflags); //尝试根据 PC 找到对应的TB,见下面的tb_find
cpu_loop_exec_tb(cpu, tb, &last_tb, &tb_exit); //执行当前的tb可能它已经是串起来的tb,最后一个tb没有继续跳转的目标了返回到qemu,所以last_tb不一定是执行的tb。
}
TranslationBlock *tb_find(TranslationBlock *last_tb) {
tb = tb_lookup__cpu_state(cpu, &pc, &cs_base, &flags, cf_mask);
if (tb == NULL) {
tb = tb_gen_code(cpu, pc, cs_base, flags, cf_mask);
}
/* See if we can patch the calling TB. */
if (last_tb) {
// last_tb 是 TB的指针,而tb_eixt是jump slot index, 链接last_tb -> tb
tb_add_jump(last_tb, tb_exit, tb);
}
}
static inline void tb_add_jump(TranslationBlock *tb, int n, TranslationBlock *tb_next) {
//保存tb->jmp_dest[n] 指向 tb_next
old = atomic_cmpxchg(&tb->jmp_dest[n], (uintptr_t)NULL, (uintptr_t)tb_next);
/* patch the native jump address */ 修改jmp指令的地址部分
tb_set_jmp_target(tb, n, (uintptr_t)tb_next->tc.ptr);
/* add in TB jmp list */ 这两个是关联关系是为了unlink时使用
tb->jmp_list_next[n] = tb_next->jmp_list_head;
tb_next->jmp_list_head = (uintptr_t)tb | n;
}
void tb_set_jmp_target(TranslationBlock *tb, int n, uintptr_t addr)
{
//找到需要修改的指令位置在TB中的偏移
uintptr_t offset = tb->jmp_target_arg[n];
//前一个TB的起始位置
uintptr_t tc_ptr = (uintptr_t)tb->tc.ptr;
//arch定义的修改指令
tb_target_set_jmp_target(tc_ptr, tc_ptr + offset, addr);
}
static inline void tb_target_set_jmp_target(uintptr_t tc_ptr,
uintptr_t jmp_addr, uintptr_t addr)
{
//直接修改相对跳转地址
atomic_set((int32_t *)jmp_addr, addr - (jmp_addr + 4));
}
里面有几个问题:
1. tb offset -> tb_next 中的 offset 怎么得出来的
2. jump slot index代表什么
3. tb中的jmp_list_head 和 jmp_list_next 什么用途
offset 存放在tb->jmp_target_arg[n],在 IR -> host 指令翻译过程中, 翻译 goto_tb 指令时记录的offset
tcg_out_op {
case INDEX_op_goto_tb:
/* direct jump method */
int gap;
/* jump displacement must be aligned for atomic patching;
* see if we need to add extra nops before jump
*/ jump指令的地址部分必须是4字节对齐的,这样可以使用原子指令修改
gap = tcg_pcrel_diff(s, QEMU_ALIGN_PTR_UP(s->code_ptr + 1, 4));
if (gap != 1) {
tcg_out_nopn(s, gap - 1); //如果不满足则通过nop指令填充
}
tcg_out8(s, OPC_JMP_long); /* jmp im */ jmp指令码
//将当前tb的地址记录到tb_jmp_insn_offset, 此时s->tb_jmp_insn_offset是指向tb->jmp_target_arg的
s->tb_jmp_insn_offset[a0] = tcg_current_code_size(s);
tcg_out32(s, 0); //此时相对跳转地址为0,即下一条指令
//将offset保存在tb_jmp_reset_offset, 此时s->tb_jmp_reset_offset 指向tb->jmp_reset_offset
set_jmp_reset_offset(s, a0);
break;
}
TB通常是以 跳转指令 结束的, 而跳转指令其实只有两种选择: 向坐或者向右, 两个 jump slot 代表两个跳转的目标, 也就是 goto_tb 0x0/0x1。
在上面连接 tb -> next tb时,并没有简单的形成 双向链表,而是又定义了两个成员,这些主要是为了unlink 场景下
static inline void tb_add_jump(TranslationBlock *tb, int n, TranslationBlock *tb_next) {
// tb_next->jmp_list_head 初值为自己,末兩位设为 10 (2)。
// 如果已有其它 TB,tb1,跳至 tb_next,則下面這條語句會使得
// tb->jmp_next 指向 tb1 (末兩位代表 tb1 跳至 tb_next 的方向)。
// tb_next->jmp_first 由原本指向 tb1 改指向 tb。
tb->jmp_list_next[n] = tb_next->jmp_list_head;
// tb_next 的 jmp_list_head 指回 tb,末兩位代表由 tb 哪一个条件 分支跳至 tb_next。
tb_next->jmp_list_head = (uintptr_t)tb | n;
}
有些特殊用法中会进行代码自修改,例如插入软件断点,内核中根据CPU特性选择不同的代码分支alternative功能,自组装代码等, 修改target代码区域,如果已经被翻译,那么TB翻译好的代码和target代码已经不一致了,需要重新翻译。
QEMU管理代码的基本单元不是 TB 而是 page, 此时就需要将整个page中的 TB unlink下来。
在 unlink 场景下,将 TB 从链上拆解下来,就和双向链表一样需要拆 前向 和 后向 的链接关系。
后向好拆,它只有两个分支跳转目标,根据tb->jmp_dest[n] 赋为空就可以了。
前向关系就不好管理了,因为不仅局限于两个TB会跳到该位置,所以需要一个链表的方式来管理前向关系,也就是 jmp_list_head 和 jmp_list_next。
假设有tb1 和 tb3 都跳转 到 tb2, 首先执行TB1 跳转到 TB2 的过程中 链接 tb1 -> tb2, 之后执行TB3 跳转到 TB2 的过程中 链接 tb3 -> tb2, 在direct jump 链接阶段,它会形成一个这样的链表关系:
其中 tb2->jmp_list_head 表示当前跳转到该 tb 的 链表头tb3位置,然后根据 tb3->jmp_list_next遍历 所有的 tb 直至 链表尾部NULL, 解除 tbx -> tb2 的链接关系;