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
に書き込む」「source1
とsource2
を加算して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つのレジスタ同士の演算命令をすべて書き換えた。