前言

QEMU 是一个通用的、开源的模拟器,通过纯软件方式实现硬件的虚拟化,模拟外部硬件,为用户提供抽象、虚拟的硬件环境。QEMU 亦可藉由硬件虚拟化变身为虚拟机。

QEMU 支持以下两种方式进行模拟:

  • 用户模式(User Mode Emulation):在一种架构的 CPU 上运行为另一种架构的 CPU 编译的程序。在该模式下,QEMU 作为进程级虚拟机,只模拟系统调用前的用户态代码,系统调用进入内核后,由宿主机操作系统原生执行,QEMU 提供不同架构系统调用映射的转换。
  • 系统模拟(System Emulation):在该模式下,QEMU 作为系统级虚拟机提供运行客户机所需的完整环境,包括 CPU,内存和外围设备等。

概述

QEMU 模拟的基本逻辑

QEMU 提供两种 CPU 模拟实现方式,一种基于架构无关的中间码实现,另一种基于硬件虚拟化技术实现。

第一种方式,QEMU 使用 Tiny Code Generator(下文简称 TCG)将客户机指令动态翻译为宿主机指令执行。这种方式的主要思想是使用纯软件的方法将客户机 CPU 指令先解码为架构无关的中间码(即 Intermediate Representation),然后再把中间码翻译为宿主机 CPU 指令执行。其中,由客户机 CPU 指令解码为中间码的过程被称为前端,由中间码翻译为宿主机 CPU 指令的过程被称为后端。以 RISC-V 为例,指令的前端解码逻辑位于 target/riscv 中,后端翻译逻辑位于 tcg/ 中,外设及其他硬件模拟代码位于 hw/riscv 中。

第二种方式,基于硬件虚拟化技术实现,直接使用宿主机 CPU 执行客户机指令,可以达到接近真实机器的运行性能。

本文只关注系统模式下 TCG 方式的分析,不讨论基础硬件虚拟化技术的运行逻辑。

QEMU 翻译执行过程

TCG 定义了一系列中间码,将已经翻译的代码以代码块的形式储存在 Translation Block 中,通过跳转指令将宿主机 CPU 的指令集和客户机 CPU 的指令集链接。下图以 RISC-V 为例,给出了 QEMU v8.0.0 系统模拟的翻译执行流程。

translation_and_execution_loop.svg

我们可以发现 TCG 前端解码和后端翻译都按照指令块的粒度进行,将一个客户机指令块翻译成中间码,然后把中间码翻译成宿主机 CPU 指令,整个过程动态执行。为了提高翻译效率,QEMU 将翻译成的宿主机 CPU 指令块做了缓存,即上文提到的 Translation Block,CPU 执行的时候,先在缓存中查找对应的 TB,如果查找成功就直接执行,否则进入翻译流程。

从更加抽象的视角来看,TCG 模式下所谓客户机 CPU 的运行,实际上就是根据指令不断改变客户机 CPU 的状态,即改变描述客户机 CPU 的状态的数据结构中的有关变量。因为实际的代码执行过程是在宿主机 CPU 上完成的,因此客户机 CPU 的指令必须被翻译为宿主机 CPU 指令才能被执行,才能改变客户机 CPU 的数据状态。

QEMU 为了解耦把客户机 CPU 指令先解码为中间码,中间码其实就是一组描述如何改变客户机 CPU 数据状态且架构无关的语句,所以目标 CPU 状态参数会被传入中间码描述语句。中间码实际上是改变客户机 CPU 状态的抽象的描述,对于部分难以抽象成一般描述的指令就用 helper 函数进行补充。例如 RV32FD 指令集中的 fadd.d rd, rs1, rs2 指令,表示双精度浮点加,将 rs1rs2 寄存器中的双精度浮点数相加并将舍入后的结果送到 rd 寄存器。对于该指令的行为,TCG 中间码并没有合适的描述,因此 QEMU 使用 helper_fmadd_d 函数根据情况直接调用模拟 FPU 解决问题。最后,将中间码翻译为宿主机 CPU 代码时,TCG 后端使用 tcg_gen_xxx 函数描述具体某条客户机 CPU 指令对客户机 CPU 数据状态的改变。

翻译过程中 gen_intermediate_code 函数负责前端解码,把客户机的指令翻译成中间码。而 tcg_gen_code 负责后端翻译,将中间码翻译成宿主机 CPU 上的指令,其中 tcg_out_xxx 函数执行具体的翻译工作。

TCG 细节

前端解码

QEMU 定义了 instruction pattern 来描述客户机 CPU 指令,一个 instruction pattern 是指一组相同或相近的指令,RISC-V 架构的指令描述位于 target/riscv 目录下的 insn16.decodeinsn32.decode 文件中。

QEMU 编译的时候会解析 .decode 文件,使用脚本 scripts/decodetree.py 生成对应指令描述函数的声明并存放于 <qemu_build_dir>/libqemu-riscv64-softmmu.fa.p 目录下的 decode-insn32.c.incdecode-insn16.c.inc 文件中。在这两个文件中还定义两个较为关键的解码函数 decode_insn32decode_insn16,QEMU 将客户机指令翻译成中间码的时候需要调用这两个解码函数。需要注意的是,脚本 scripts/decodetree.py 生成的只是 trans_xxx 函数的声明,其定义需要开发者实现,RISC-V 对应的实现位于 target/riscv/insn_trans/ 目录中。

Decode Tree

每种 instruction pattern 都有固定位和固定掩码,它们的组合构成了模式匹配的条件:

(insn & fixedmask) == fixedbits

对于每种 instruction patternscripts/decodetree.py 脚本定义了具体描述形式,下面进行简要分析:

  • Fields: CPU 在解码的时候需要把指令中的特性 field 中的数据取出作为传入参数(寄存器编号,立即数,操作码等),field 描述一个指令编码中特定的字段,根据描述可以生成取对应字段的函数。

    InputGenerated code
    %disp 0:s16sextract(i, 0, 16)
    %imm9 16:6 10:3extract(i, 16, 6) << 3 \extract(i, 10, 3)
    %disp12 0:s1 1:1 2:10sextract(i, 0, 1) << 11 \extract(i, 1, 1) << 10 \extract(i, 2, 10)
    %shimm8 5:s8 13:1 !function=expand_shimm8expand_shimm8(sextract(i, 5, 8) << 1 \extract(i, 13, 1))
    %sz_imm 10:2 sz:3 !function=expand_sz_immexpand_sz_imm(extract(i, 10, 2) << 3 \extract(a->sz, 0, 3))

    上表给出了一些例子,如第一行的 %disp 0:s16 表示指令编码第 0 位起的 16 位构成了一个带符号数,因此生成代码 sextract(i, 0, 16),意即从指令 i 的编码第 0 位开始取 16 位解释为带符号数返回。还有第三行的 %disp12 0:s1 1:1 2:10 表示该立即数由三个部分拼接而成,因此生成的代码中就包含了相应的移位、拼接运算。由 field 定义所生成的函数就负责完成这种与从指令编码中取数有关的计算。

  • Argument Sets: 定义数据结构。比如,target/riscv/insn32.decode 中定义的 &b imm rs2 rs1 在编译后的 decode-insn32.c.inc 中生成的数据结构如下,这个结构将作为 trans_xxx 函数的传入参数。

    typedef struct {
        int imm;
        int rs2;
        int rs1;
    } arg_b;
  • Formats: 定义指令的格式,例如下面的例子是对一个 32-bit 指令编码的描述,其中 . 表示一个 bit 位。

    @opr    ...... ra:5 rb:5 ... 0 ....... rc:5
    @opi    ...... ra:5 lit:8    1 ....... rc:5
  • Patterns: 用来定义具体指令。这里借助 RV32I 基础指令集中的 lui 指令进行详细分析:

    lui      ....................       ..... 0110111 @u

    另外列出相关的 format、argument、field 的定义,以便分析:

    # Argument sets:
    &u    imm rd
    # Formats 32:
    @u       ....................      ..... ....... &u      imm=%imm_u          %rd
    # Fields:
    %rd        7:5
    # immediates:
    %imm_u    12:s20                 !function=ex_shift_12

    可以看到 lui 指令的操作码是 0110111,指令的格式定义是 @u,使用的参数定义是 &u,而 &u 就是 trans_lui 函数的传入参数结构体里的变量定义,其中定义的变量名字是 immrd,这个 imm 实际的格式是 %imm_u,它是一个由指令编码 31-12 位定义的立即数,将指令编码 31-12 位的数值左移 12 位即可得到最终结果,rd 实际的格式是 %rd,是一个在指令编码 7-5 位定义的 rd 寄存器的标号。

    可以看到 target/riscv/insn_trans/trans_rvi.c.inc 中对应的 trans_lui 函数的实现如下:

    static bool trans_lui(DisasContext *ctx, arg_lui *a)
    {
        gen_set_gpri(ctx, a->rd, a->imm);
        return true;
    }

trans_xxx 函数

trans_xxx 函数负责将具体的客户机指令转换为中间码指令,若转换成功则返回 true,否则返回 false。下面以 RISC-V 架构的 add 指令为例进行分析。

如下是 target/riscv/insn_trans/trans_rvi.c.inc 文件中对 add 指令的模拟。

static bool trans_add(DisasContext *ctx, arg_add *a)
{
    return gen_arith(ctx, a, EXT_NONE, tcg_gen_add_tl, tcg_gen_add2_tl);
}

函数 gen_arith 被定义在文件 target/riscv/translate.c 中:

static bool gen_arith(DisasContext *ctx, arg_r *a, DisasExtend ext,
                      void (*func)(TCGv, TCGv, TCGv),
                      void (*f128)(TCGv, TCGv, TCGv, TCGv, TCGv, TCGv))
{
    TCGv dest = dest_gpr(ctx, a->rd);
    TCGv src1 = get_gpr(ctx, a->rs1, ext);
    TCGv src2 = get_gpr(ctx, a->rs2, ext);

    if (get_ol(ctx) < MXL_RV128) {
        func(dest, src1, src2);
        gen_set_gpr(ctx, a->rd, dest);
    } else {
        if (f128 == NULL) {
            return false;
        }

        TCGv src1h = get_gprh(ctx, a->rs1);
        TCGv src2h = get_gprh(ctx, a->rs2);
        TCGv desth = dest_gprh(ctx, a->rd);

        f128(dest, desth, src1, src1h, src2, src2h);
        gen_set_gpr128(ctx, a->rd, dest, desth);
    }
    return true;
}

注意到函数中 func 指向的函数是由 trans_add 传入的 tcg_gen_add_tl 函数,而此函数又在 inluce/tcg/tcg-op.h 中以宏定义的形式被定义为 tcg_gen_add_i64tcg_gen_add_i32 函数,下面给出 tcg_gen_add_i64 函数的定义:

void tcg_gen_addi_i64(TCGv_i64 ret, TCGv_i64 arg1, int64_t arg2)
{
    if (arg2 == 0) {
        tcg_gen_mov_i64(ret, arg1);
    } else if (TCG_TARGET_REG_BITS == 64) {
        tcg_gen_add_i64(ret, arg1, tcg_constant_i64(arg2));
    } else {
        tcg_gen_add2_i32(TCGV_LOW(ret), TCGV_HIGH(ret),
                         TCGV_LOW(arg1), TCGV_HIGH(arg1),
                         tcg_constant_i32(arg2), tcg_constant_i32(arg2 >> 32));
    }
}

RISC-V 的 add 指令内容是从 CPU 的 rs1rs2 寄存器中取操作数,相加后送入 rd 寄存器中。宏观上看,gen_arith 函数首先调用 dest_gprget_gpr 这两个寄存器操作封装函数获取 rs1rs2 寄存器的值,并准备 rd 寄存器。然后通过 func(dest, src1, src2) 最终调用 tcg_gen_addi_i64 函数完成两数相加,最后使用 gen_set_gpr 将结果传送至 rd 寄存器,完成 add 指令解码。

接着,我们针对 gen_set_gpr 进行深入分析,以 RV32 指令为例,追踪该函数的调用链:

gen_set_gpr.svg

分析上述调用链的参数可以发现最后生成了一条 mov_i32 t0, t1 指令,意思是将 t1 寄存器中的数移动到 t0 寄存器中。该指令先被挂到了一个链表里,此后的后端翻译会把这些指令翻译成宿主机指令。到这里,前端解码的逻辑就基本上打通了。还有最后一个问题需要解决:cpu_gpr[reg_num] 这个全局变量是如何索引到客户机 CPU 寄存器的?

解决该问题的基本思路是,只要 TCG 前端和后端约定描述客户机 CPU 状态数据结构相同,确保 cpu_gpr[reg_num] 指向的就是相关寄存器在这个数据结构中的位置即可,这一点在 cpu_gpr[] 数组的初始化过程中具体体现:

void riscv_translate_init(void)
{
    int i;
    // ...
    for (i = 1; i < 32; i++) {
        cpu_gpr[i] = tcg_global_mem_new(cpu_env,
            offsetof(CPURISCVState, gpr[i]), riscv_int_regnames[i]);
        // ...
    }
    // ...
}

cpu_gpr[] 数组在初始化时调用 tcg_global_mem_new 函数在 TCG 上下文 tcg_ctx 中分配空间并返回其相对地址,而后段翻译时访问 cpu_gpr[] 数组就是在访问 TCG 上下文中描述寄存器的变量,这样 cpu_gpr[reg_name] 就在前端和后端之间建立了连接。

后端翻译

后端的代码主要负责将中间码翻译成宿主机指令,本质上就是根据中间码的描述使用宿主机指令来改变内存中表示的客户机 CPU 的数据结构以及客户机内存的状态。考虑以下两条 RISC-V 汇编指令:

addi            sp,sp,-32
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

注意到 sd 指令被翻译成了两条中间码,第一条 add_i64 是用来计算 sd 指令的目标地址,计算结果保存在 tmp4 这个虚拟寄存器里,第二条中间码把 s0 的值储存到虚拟寄存器 tmp4 描述的内存上。在中间码中,x2/spx8/s0 仍然是客户机 CPU 上寄存器的名字,但是逻辑上已经全部映射为 QEMU 虚拟寄存器。TCG 前端将 RISC-V 汇编指令解码为中间码和虚拟寄存器的表示,后端翻译则基于中间码和虚拟寄存器进行。再次审视上述两条指令,addi 的中间码表示要把客户机的 sp 寄存器加上 -32sd 的中间码表示要将客户机的 s0 寄存器中的值送到 sp 寄存器加 24 后得到的地址处。对于这些中间码,在 ARM 架构的宿主机上可能被翻译为以下指令:

ldr      x20, [x19, #0x10]
sub      x20, x20, #0x20
str      x20, [x19, #0x10]
add      x21, x20, #0x18
ldr      x22, [x19, #0x40]
str      x22, [x21, xzr]

这段指令主要进行了以下操作:把客户机 CPU 的 sp 寄存器装载到宿主机 CPU 的 x20 寄存器,使用 sub 指令完成客户机 CPU sp 寄存器值的计算并进行更新;使用 add 指令计算客户机 CPU 的 sd 指令的目标地址并保存到宿主机 CPU 的 x21 寄存器,接着把客户机 CPU 的 s0 寄存器装载到宿主机 CPU 的 x22 寄存器,最后使用 str 指令更新目标地址处的值。

通过以上案例可以发现,TCG 后端主要完成三件事情:分配宿主机 CPU 寄存器、生成宿主机 CPU 指令以及宿主机 CPU 和客户机 CPU 之间的状态同步。其中,状态同步实际上通过两次映射完成:第一次是 TCG 前端解码时将客户机 CPU 寄存器映射为 QEMU 虚拟寄存器,第二次是 TCG 后端分配宿主机 CPU 寄存器时将 QEMU 虚拟寄存器映射为宿主机 CPU 的物理寄存器。

下面仍然以 add 指令为例,给出后端代码调用过程的详细分析:

tcg_gen_code.svg

tcg_gen_code 是整个后端翻译的入口,负责寄存器和内存区域之间的同步逻辑并根据不同指令类型调用相关函数将中间码翻译为宿主机 CPU 指令。默认情况下,tcg_gen_code 会调用 tcg_reg_alloc_op 函数,该函数会生成用宿主机 CPU 指令描述的同步逻辑,存放在 TB 中,最后调用不同架构的开发者提供的 tcg_out_op 函数完成具体指令的翻译工作。针对 add 指令,最终会调用 tcg_out32() 函数,该函数负责将一个 32 位无符号整数 v 写入到指针 s->code_ptr 对应的内存位置,并根据目标平台的指令单元大小更新该指针的值。

总结

本文主要分析了 QEMU 系统模式下指令解码模块。QEMU 将客户机 CPU 指令解码为中间码,中间码是对指令如何改变客户机 CPU 数据状态的抽象描述,TCG 后端将中间码翻译为宿主机 CPU 指令,也就是将中间码所描述的对客户机 CPU 数据状态的更改用宿主机 CPU 指令的形式进行描述,执行完成后就达到了模拟客户机 CPU 运行的效果。

至此,从前端到后端,从解码到翻译的逻辑链条就完整了。

参考资料