FPGA開発日記

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

Binary Translation型エミュレータを作る(プロローグ・エピローグの分離)

前回は、QEMUの分岐命令の取り扱いについて確認していた。今回はこれを自作Binary Translationエミュレータに実装することを考える。 まずはシンプルに考えた方が良さそうだ。基本的な実装方針について確認する前に、少し追加の実装を行う必要があるためこれの検討を行う。

追加の実装というのは、Binary Translationで変換したコード内でジャンプや関数コールを実現するということだ。 これまではBinary Translationで変換されたコードは以下のようなプロローグコードに挟むような形で実行していた。

pushq    %rbp
pushq    %rsp
movq     %rdi, %rbp
addq     $-0x488, %rsp

// 実行したい機械語

addq     $0x488, %rsp
popq     %rbx
popq     %rbp
retq

ここでは実行したい機械語は新しいブロックにジャンプするわけではなく、プロローグとエピローグの間に埋め込んで実行する形にしていた。しかし、QEMUではこのような形にしていない(まあ別にどちらでもいいが...)まあこうすることによって、新しいブロックを作成する際に毎度毎度プロローグとエピローグを挿入する手間を避けているように感じる。

そこで関数ジャンプを実装しよう。試しに自作Binary Translationでプロローグを以下のように改造し、本体のブロックにジャンプするように変更を加える。

pushq    %rbp
pushq    %rsp
movq     %rdi, %rbp
addq     $-0x488, %rsp
jmpq    *%rsi

こうすることによって、プロローグとエピローグを毎回生成する手間を省くことができる。

さて、こうすることで分岐命令への第一歩を踏み出してみる。分岐命令は基本的に以下のような方針で組み立てることにする。

  1. 比較する値をレジスタにロードしてくる。
  2. cmp命令により比較を実行する。
  3. 分岐命令の種類に応じてジャンプ命令を実行する。例えば、BEQの場合はx86のje命令を実行し、成立の場合は所望のラベルまでジャンプする。
  4. 所望のラベルに飛ぶと、それは分岐が成立したということを意味する。実際にRISC-Vの命令上でジャンプすべきアドレスを生成し、これをPCが格納されているメモリ領域に格納する。その後エピローグまでジャンプする。
  5. 分岐が成立しない場合、現在のPCから次の命令を指し示すアドレスを生成し、それをPCが格納されているメモリ領域に格納する。その後エピローグまでジャンプする。

このように、プロローグ・エピローグとブロックの本体を明確に分離することにより、ブロックを終了したい場合はエピローグまでジャンプするだけで所望の手続きでブロックを終了することができる。

f:id:msyksphinz:20200827212226p:plain

これを使って、まずはシンプルなコードを実行して行こう。一番単純な、関数から戻ってくるだけのRET命令を作成する。

RISC-VのRET命令をデコードすると、以下のようなコードでTCGを生成しよう。

    fn tcg_gen_ret(
        pc_address: u64,
        tcg: &TCGOp,
        mc: &mut Vec<u8>,
        pe_map: &mmap::MemoryMap,
        tb_map: &mmap::MemoryMap,
    ) {
        let op = tcg.op.unwrap();
        let arg0 = tcg.arg0.unwrap();
        let arg1 = tcg.arg1.unwrap();

        assert_eq!(arg0.t, TCGvType::Register);
        assert_eq!(arg1.t, TCGvType::Register);
        assert_eq!(op, TCGOpcode::JMP);

        if arg0.t == tcg::TCGvType::Register
            && arg0.value == 0
            && arg1.t == tcg::TCGvType::Register
            && arg1.value == 1
        {
            Self::tcg_out(X86Opcode::JMP_JZ as u32, 1, mc);
            let tb_map_ptr = tb_map.data() as *const u64;
            let pe_map_ptr = pe_map.data() as *const u64;

            let mut addr_diff = unsafe { pe_map_ptr.offset_from(tb_map_ptr) };
            addr_diff *= 8;
            addr_diff += 32;
            addr_diff -= pc_address as isize;
            addr_diff -= 5;
            Self::tcg_out(addr_diff as u32, 4, mc);

           return;
        }

上記のコードは、 addr_diffはプロローグ・エピローグを格納しているメモリ領域と、テストコードを格納しているメモリ領域のアドレス差分を計算している。さらにプロローグの長さ(32バイト)を足し込み、エピローグを指すようにしている。JMP_JZ命令は地震の命令の大きさ+ジャンプ先にジャンプするので、5バイトの命令を生成する場合には-5をしてアドレスを調整する。

これにより、RET命令を実行するとエピローグにジャンプすることができるはずだ。 さっそく実行して行こう。

$ cargo run /home/msyksphinz/work/riscv/qemu/qemu_test/simple_start2.riscv
x00 = 00007fda72d60000  x01 = 0000000000000001  x02 = 0000000000000003  x03 = 0000000000000006
x04 = 000000000000000a  x05 = 000000000000000f  x06 = 0000000000000015  x07 = 000000000000001c
x08 = 0000000000000024  x09 = 000000000000002d  x10 = 0000000000000037  x11 = 0000000000000042
x12 = 000000000000004e  x13 = 000000000000005b  x14 = 0000000000000069  x15 = 0000000000000078
x16 = 0000000000000088  x17 = 0000000000000099  x18 = 00000000000000ab  x19 = 00000000000000be
x20 = 00000000000000d2  x21 = 0000000000000190  x22 = 0000000000000262  x23 = 00000000000003f2
x24 = 0000000000000654  x25 = 0000000000000a46  x26 = 000000000000109a  x27 = 0000000000001ae0
x28 = 0000000000002b7a  x29 = 000000000000465a  x30 = 00000000000071d4  x31 = 0000000000002b7a
PC = 0000000000000000

プログラムがとりあえずSegmentation Faultしないことは確認できた(本当はここまで来るのに死ぬほどSegmentation Faultを起こして大変だった)。 もう少し詳細な動作を確認していこう。それは次回。