FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

Binary Translation型エミュレータを作る(ホストマシンのレジスタ自動アサインを考える)

Rustで開発しているBinary Translation型のエミュレータRISC-Vのゲストコードを翻訳してx86_64のホストコードに変換している。RISC-Vのゲストコードはすでにレジスタ阿新済みなので何も考える必要はないが、ホストコードを生成する場合、x86_64のどのレジスタを使用して計算するかが問題になる。

特にx86の場合はよく考えなければならない。x86は汎用レジスタの種類で実行できる演算が異なるように見える。例えばRAX/EAXレジスタは割と何でもできるような命令に見えるが、それ以外のレジスタはできることに制限があるのでどのようにレジスタアサインをすればいいのか悩んでいる。

レジスタアサインについてもう一つ悩ましいのは、これがエミュレータであるということ。つまりエミュレータは高速動作が売りであり、全てにおいて速度が重要だということである。高速実行を阻害するような複雑なレジスタアサインはやりたくない。

すでに既存の実装であるQEMUはどのようにしているのか調査すると、x86をホストマシンとする場合はやはり最初にターゲットとなるレジスタをリストアップし、単純に使用していないレジスタを配置しながらアサインしているようだった。

static const int tcg_target_reg_alloc_order[] = {
#if TCG_TARGET_REG_BITS == 64
    TCG_REG_RBP,
    TCG_REG_RBX,
    TCG_REG_R12,
    TCG_REG_R13,
    TCG_REG_R14,
    TCG_REG_R15,
    TCG_REG_R10,
    TCG_REG_R11,
    TCG_REG_R9,
    TCG_REG_R8,
    TCG_REG_RCX,
    TCG_REG_RDX,
    TCG_REG_RSI,
    TCG_REG_RDI,
    TCG_REG_RAX,
#else
    TCG_REG_EBX,
    TCG_REG_ESI,
    TCG_REG_EDI,
    TCG_REG_EBP,
...

これらのレジスタを、レジスタアサインした順番に割り当てている、ように見えた。

  • qemu/target/riscv/insn_trans/trans_rvi.inc.c
static bool trans_add(DisasContext *ctx, arg_add *a)
{
    return gen_arith(ctx, a, &tcg_gen_add_tl);
}
  • qemu/target/riscv/translate.c
static bool gen_arith(DisasContext *ctx, arg_r *a,
                      void(*func)(TCGv, TCGv, TCGv))
{
    TCGv source1, source2;
    source1 = tcg_temp_new();
    source2 = tcg_temp_new();

    gen_get_gpr(source1, a->rs1);
    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;
}

上記のコードにおけるtcg_temp_new()で新たにレジスタを1つアサインし、tcg_temp_free()レジスタを一つ開放する、という流れなのか。x86レジスタを1つアサインしては計算を実行し開放する、という流れを繰り返している。

おそらくここでポイントなのは、可能な限りEAX/RAXをレジスタアサインしないということ?EAX/RAXは何でもできるので本当に特殊な動作を行う必要があるときだけ使うレジスタとして取っておいてあるように見える。

テストとして、以下のような小さなRISC-V機械語命令をQEMUで実行してみる。

    add  sp,sp,t0
0x7f6050000757:  4c 8b 65 10              movq     0x10(%rbp), %r12
0x7f605000075b:  49 03 dc                 addq     %r12, %rbx
0x7f605000075e:  48 89 5d 10              movq     %rbx, 0x10(%rbp)

自動的にr12というレジスタアサインされていた。この自動レジスタ配置を自作エミュレータで実現することを考える。

まず考えられるのは、レジスタの一覧を用意しておきそれをスタックのように1つずつ取り出していく方式。allocateレジスタ一覧からレジスタを1つ取得し、freeレジスタ一覧に戻す、という考え方だ。

        let source1 = self.tcg_temp_new();
        let source2 = self.tcg_temp_new();

        self.tcg_temp_free(source1);
        self.tcg_temp_free(source2);

現在はfreeは何もしていない。とりあえずレジスタは使った後必ず戻される(という風にコーディングする)という意味で。

    pub fn tcg_temp_new(&mut self) -> TCGv {
        let new_v = TCGv::new_temp(self.temp_list);
        self.temp_list = self.temp_list + 1;
        new_v
    }

    pub fn tcg_temp_free(&mut self, idx: TCGv) {
        self.temp_list = self.temp_list - 1;
    }

とりあえず以下の2つのレジスタを割り当て可能なレジスタとして指定した。

    fn convert_x86_reg(temp: u64) -> X86TargetRM {
        return match temp {
            0 => X86TargetRM::RDX,
            1 => X86TargetRM::RBX,
            _ => panic!("Not supported yet")
        }
    }

ADD命令を新しいTCGで置き換える

これまではADDのTCGレジスタから読み込み、計算から書き込みまでをまとめて1つのTCGとしていたが、もう少し粒度を挙げる。したがって、以下のように「レジスタからデータを読んでsource1に書き込む」「レジスタからデータを読んでsource2に書き込む」「source1source2を加算してsource1に書き込む」「source1レジスタにストアする」といった細かな要素に分解することを考えた。

これを実現したのが以下の新しいフォーマットのTCGだ。

    pub fn translate_add(&mut self, inst: &InstrInfo) -> Vec<TCGOp> {
        self.translate_rrr(TCGOpcode::ADD_64BIT, inst)
    }
    pub fn translate_rrr(&mut self, op: TCGOpcode, inst: &InstrInfo) -> Vec<TCGOp> {
        let rs1_addr= get_rs1_addr!(inst.inst);
        let rs2_addr= get_rs2_addr!(inst.inst);
        let rd_addr = get_rd_addr!(inst.inst); 

        if rd_addr == 0 {
            return vec![];
        }

        let source1 = self.tcg_temp_new();
        let source2 = self.tcg_temp_new();

        let rs1_op = TCGOp::new_get_gpr(source1, rs1_addr);  // Box::new(TCGv::new_reg(rs1_addr as u64));
        let rs2_op = TCGOp::new_get_gpr(source2, rs2_addr);  // Box::new(TCGv::new_reg(rs2_addr as u64));

        let tcg_inst = TCGOp::new_3op(op, source1, source1, source2);

        let rd_op = TCGOp::new_set_gpr(rd_addr, source1);  // Box::new(TCGv::new_reg(rs1_addr as u64));

        self.tcg_temp_free(source1);
        self.tcg_temp_free(source2);

        vec![rs1_op, rs2_op, tcg_inst, rd_op]
    }

TCGOp::new_get_gpr()new_3op()new_set_gpr()の3種類4つのTCGを組み合わせている。

それぞれのTCGは以下のように変換される。

  • new_get_gpr()TCGの変換ルーチン
    fn tcg_gen_get_gpr(emu: &EmuEnv, pc_address: u64, tcg: &tcg::TCGOp, mc: &mut Vec<u8>) -> usize {
        let dest_reg = tcg.arg0.unwrap();
        let src_reg = tcg.arg1.unwrap();

        assert_eq!(dest_reg.t, TCGvType::TCGTemp);
        assert_eq!(src_reg.t, TCGvType::Register);

        let target_x86reg = Self::convert_x86_reg(dest_reg.value);

        let mut gen_size = pc_address as usize;
        gen_size += Self::tcg_modrm_64bit_out(X86Opcode::MOV_GV_EV, X86ModRM::MOD_10_DISP_RBP, target_x86reg, mc);
        gen_size += Self::tcg_out(emu.calc_gpr_relat_address(src_reg.value) as u64, 4, mc);
        return gen_size;
    }
    fn tcg_gen_op_temp(pc_address: u64, op: X86Opcode, tcg: &tcg::TCGOp, mc: &mut Vec<u8>) -> usize {
        let dest_reg = tcg.arg0.unwrap();
        let source1_reg = tcg.arg1.unwrap();
        let source2_reg = tcg.arg2.unwrap();

        assert_eq!(dest_reg.t, TCGvType::TCGTemp);
        assert_eq!(source1_reg.t, TCGvType::TCGTemp);
        assert_eq!(source2_reg.t, TCGvType::TCGTemp);

        let mut gen_size: usize = pc_address as usize;

        let source1_x86reg = Self::convert_x86_reg(source1_reg.value);
        let source2_x86reg = Self::convert_x86_reg(source2_reg.value);

        gen_size += Self::tcg_modrm_64bit_raw_out(op, X86ModRM::MOD_11_DISP_RAX as u8 + source2_x86reg as u8, source1_x86reg as u8, mc);

        gen_size
    }
  • new_set_gpr()TCGの変換ルーチン
    fn tcg_gen_set_gpr(emu: &EmuEnv, pc_address: u64, tcg: &tcg::TCGOp, mc: &mut Vec<u8>) -> usize {
        let dest_reg = tcg.arg0.unwrap();
        let src_reg = tcg.arg1.unwrap();

        assert_eq!(dest_reg.t, TCGvType::Register);
        assert_eq!(src_reg.t, TCGvType::TCGTemp);

        let source_x86reg = Self::convert_x86_reg(src_reg.value);

        let mut gen_size = pc_address as usize;
        gen_size += Self::tcg_modrm_64bit_out(X86Opcode::MOV_EV_GV, X86ModRM::MOD_10_DISP_RBP, source_x86reg, mc);
        gen_size += Self::tcg_out(emu.calc_gpr_relat_address(dest_reg.value) as u64, 4, mc);
        return gen_size;
    }

これによりADD命令は以下のように変換されるようになった。

 0000000080000104:0000000080000104 Hostcode 00208f33 : add     t5, ra, sp
00007FBADC540000 488B9510000000       mov       0x10(%rbp),%rdx
00007FBADC540007 488B9D18000000       mov       0x18(%rbp),%rbx
00007FBADC54000E 4803D3               add       %rbx,%rdx
00007FBADC540011 488995F8000000       mov       %rdx,0xF8(%rbp)
00007FBADC540018 E9F2FF2400           jmp       0x0000_7FBA_DC79_000F

x86の命令効率は若干落ちているが、TCGのProgrammabilityが上がっているので良しとしよう。これで2つのレジスタ同士の演算命令をすべて書き換えた。