Moiz's journal

プログラミングやFPGAなどの技術系の趣味に関するブログです

RISCVエミュレータを作り始めた

はじめに

RISCVエミュレータを作り始めました。このエントリーはそのメモです。 個人的なメモなので、読者の方の事はまだあまり考えていません。

ある程度めどがたったらもうちょっと読みやすいエントリーを書きます。

目標

(完全な車輪の再発明ですが、個人の趣味ですので。)

参考図書

リポジトリ

https://github.com/moizumi99/m99_riscv_emulator

作業

  • 「動かしてわかるCPUの作り方10講」(以下CPU10講)のエミュレータを元に、雛形を作成。

    • ターゲット言語をC++に変更。
    • 拡張に備えてファイルを分割

      • RISCV_Emulator.cc (メイン)
      • RISCV_cpu.cc (エミュレータ本体)
      • load_assembler.cc (アセンブラ・コードの作成・ロード)
      • instruction_encdec.cc (機械語を32bitにパックするためのコード)
      • bit_tools.cc (ビットの切り出し等用のユーティリティ)
      • load_assembler_test.cc (load_assembler.cc用のテスト)
    • 拡張のための変更

      • 32bit命令のために配列をshortからuint32_tに変更
      • メモリ配列を大きくして、romとramを共通化
      • レジスタを32個に拡張
      • その他いろいろ(Makefileの作成など)
  • RISC-V 命令対応への変更

最初に使用する命令の選定

まずは「動かしてわかるCPUの作り方10講」で使われている1から10まで足し合わせるアセンブリコードを再現したい。

  • 「動かしてわかるCPUの作り方10講」のCPUの命令と、RISCVの命令(RV32I)の対応
CPU10講CPU CPU10講動作 対応するRISC-V命令
mov RegA <- RegB add rd, rs1, zeroで代用。(zeroはゼロレジスタ)
add RegA <- RegA + RegB add rd, rs1, rs2 (x[rd] <- x[rs1] + x[rs2])
ldl RegA(Low) <- Data (8bit) addi rd, zero, immediate (x[rd] <- zero + immeidate)で代用
ldh RegA(High) <- Data (8bit) 今回はスキップ。必要ならlui (Load Upper Immediateを実装)
cmp flag <- (RegA == RegB) 今回はスキップ(次のbeqで代用)
je pc <- Addr if flag==1 beq rs1, rs2, offset (pc += offset if rs1 == rs2)で代用
jmp pc <- Addr jal x0, offset (pc += offset)
ld RegA <- M[Addr] 今回はスキップ(レジスタを使うので)
st M[Addr] <- RegA 今回はスキップ(レジスタを使うので)
hlt 停止 ret (実体はjalr x0, x1, 0)で代用。x1は戻りアドレス

とりあえず、このエミュレータはret命令で停止するものとします。

それぞれの命令のラベルとタイプ、ファンクションのリストです。

命令 タイプ ラベル funct7 or 6 funct3
add R 0110011 0000000 000
addi I 0010011 NA 000
sub R 0110011 0100000 000
and R 0110011 0000000 111
or R 0110011 0000000 110
slli I 0010011 000000 001
srli I 0010011 000000 101
srai I 0010011 010000 101
beq B 1100011 NA 000
jal J 1101111 NA NA
jalr I 1100111 NA NA

funct6という名称は実際にはありませんが、immediateの上位6ビットが固定になっている部分を便宜上このように表記しました。

アセンブリコードの変更

まずは「動かしてわかるCPUの作り方10講」のサンプルコードを変換してみます。

# t0: カウンタ(初期値0)
addi t0, zero, 0
# t1; 和(初期値0)
addi t1, zero, 0
# t2: 上限(10)
addi t2, zero, 10
# ループ先頭
# t0 = t0 + 1
addi t0, t0, 1
# t1 = t1 + t0
add t1, t1, t0
# t0 = t2 なら+ 8ジャンプ
beq t0, t2, 8
# -16 (4命令バック)ジャンプ
jal x0, -16
# A0 (返り値を保存するレジスタに結果を移動)
add a0, t1, x0
# リターン(a0は戻り値)
jalr x0, ra, 0

実際に使われているのは、addi, add, beq, jal, jalだけなのでこれらの命令を優先して実装します。

命令のエンコーダー

まずはCPUに渡すバイナリを作る必要があります。

RISC-V原典を眺めながら、各命令のopcode、funct3、funct7、レジスタ番号、即値を32bitに埋め込んでいくコードを書きます。

RISC-V(RV32I)の命令は次の6つのタイプにわかれます。

  • R-type
  • I-type
  • S-type
  • B-type
  • U-type
  • J-type

それぞれフォーマットが異なり、タイプごとに引数の種類も異なります。

そこで必要な命令毎に

  1. タイプを判断しフォーマットを選択
  2. opcodeとfunct3、funct7を32bit内に埋め込む
  3. 必要な引数(rd, rs1, rs2, immediate )を判断
  4. 引数の情報を32bit内の指定された位置に埋め込む

というコードを書きます。

RISC-Vでは、何故か一部の即値のビット位置が入れ替わっていて、これを入れ替えるのが大変でした。C/C++でのビット演算に苦労していると、こんなアドバイスをいただきました。

喜んで使わせていただきます。これでいちいちマスクする苦労から開放されました。

ビットフィールドを使って、それぞれの要素(レジスタ番号、即値、opcode等)をクラスメンバーとして定義します。

例えばこんな感じです。

class bitfield {
  public:
    virtual uint32_t value() { return 0; };
    virtual void set_value(uint32_t value) {};

    uint8_t opcode: 7;
    uint8_t rd: 5;
    uint8_t rs2: 5;
    uint8_t rs1: 5;
    uint8_t funct3: 3;
};

class r_type : public bitfield {
  public:
    uint8_t funct7: 7;
    uint32_t value();
    void set_value(uint32_t value);
};

最初はunionとstructを使って、こんな感じで書いていたのですが、

union r_type {
    uint32_t value;
    struct {
        uint8_t funct7 : 7;
        uint8_t rs2 : 5;
        uint8_t rs1 : 5;
        uint8_t funct3 : 3;
        uint8_t rd : 5;
        uint8_t opcode : 7;
    };
};

パッキングやパディングが不安なのでとりあえずクラスにしています。 アトリビュートを使ってうまくいくようならこの方法にもどそうと思います。

機械語を生成する部分は、CPU10講にならって決め打ちのバイナリを生成するコードを書きました。

void load_assembler(uint32_t *mem) {
  mem[0] = asm_addi(T0, ZERO, 0);
  mem[1] = asm_addi(T1, ZERO, 0);
  mem[2] = asm_addi(T2, ZERO, 10);
  mem[3] = asm_addi(T0, T0, 1);
  mem[4] = asm_add(T1, T1, T0);
  mem[5] = asm_beq(T0, T2, 8);
  mem[6] = asm_jal(ZERO, -16);
  mem[7] = asm_add(A0, T1, ZERO);
  mem[8] = asm_jalr(ZERO, RA, 0);
}

これで実行対象機械語コードが用意できました。次はいよいよCPUエミュレータ側のコードです。

命令のデコード

命令のデコードはCPU10講の内容を参考に、RISC-V用に次の用なものにしました。

  1. 下7bitのオペコード(opcode)を読み込む
  2. 必要があれば、14:12ビットのfunct3を読み込む
  3. 必要があれば、31:25ビットのfunct7を読み込む(今回は未実装)
  4. opcode、funct3とfunct7から命令の種類を判定
  5. 必要に応じてrd(ディスティネーションレジスタ)、rs1(対象レジスタ1)、rs2(対象レジスタ2)をデコード
  6. 必要に応じて即値(12bit, 13bit, 21bit, 24bitの4種類がある)をデコード

さきほどシャッフルした即値のビットを戻す作業が面倒ですが、それ以外は単に対応する位置にあるビットを切り出すだけです。

命令の実行

今回選んだ命令に関しては、命令の実行はCPU10講と似た形式です。大きな違いは

  • 命令種類が減った
  • ゼロ番目のレジスタはゼロレジスタ
  • ジャンプが相対ジャンプになった
  • ret命令で動作終了

程度です

その他

実は今回一番複雑だったのは機械語を作成する部分、次がデコーダーです。複雑なので最初からテストを(割と)ちゃんと書きました。 各アセンブラの命令毎に、2〜3の命令例が正しいバイナリになっているか、またバイナリが正しくデコードされているのかを確認しました。コードはload_assembler_test.ccにあります。実は今回一番時間がかかっている部分かもしれません。

ファイルが増えたのでMakefileを作成しておきました。

実行結果

では実行してみます。

$ ./RISCV_Emulator 
Assembler set.
Execution start
   PC    Binary     T0     T1     T2     T3     A0
    0  00000293      0      0      0      0      0
    4  00000313      0      0      0      0      0
    8  00a00393      0      0      0      0      0
   12  00128293      0      0     10      0      0
   16  00530333      1      0     10      0      0
   20  00728463      1      1     10      0      0
   24  ff1ff06f      1      1     10      0      0
    8  00a00393      1      1     10      0      0
   12  00128293      1      1     10      0      0
   16  00530333      2      1     10      0      0
   20  00728463      2      3     10      0      0
   24  ff1ff06f      2      3     10      0      0
    8  00a00393      2      3     10      0      0
   12  00128293      2      3     10      0      0
   16  00530333      3      3     10      0      0
   20  00728463      3      6     10      0      0
   24  ff1ff06f      3      6     10      0      0
    8  00a00393      3      6     10      0      0
   12  00128293      3      6     10      0      0
   16  00530333      4      6     10      0      0
   20  00728463      4     10     10      0      0
   24  ff1ff06f      4     10     10      0      0
    8  00a00393      4     10     10      0      0
   12  00128293      4     10     10      0      0
   16  00530333      5     10     10      0      0
   20  00728463      5     15     10      0      0
   24  ff1ff06f      5     15     10      0      0
    8  00a00393      5     15     10      0      0
   12  00128293      5     15     10      0      0
   16  00530333      6     15     10      0      0
   20  00728463      6     21     10      0      0
   24  ff1ff06f      6     21     10      0      0
    8  00a00393      6     21     10      0      0
   12  00128293      6     21     10      0      0
   16  00530333      7     21     10      0      0
   20  00728463      7     28     10      0      0
   24  ff1ff06f      7     28     10      0      0
    8  00a00393      7     28     10      0      0
   12  00128293      7     28     10      0      0
   16  00530333      8     28     10      0      0
   20  00728463      8     36     10      0      0
   24  ff1ff06f      8     36     10      0      0
    8  00a00393      8     36     10      0      0
   12  00128293      8     36     10      0      0
   16  00530333      9     36     10      0      0
   20  00728463      9     45     10      0      0
   24  ff1ff06f      9     45     10      0      0
    8  00a00393      9     45     10      0      0
   12  00128293      9     45     10      0      0
   16  00530333     10     45     10      0      0
   20  00728463     10     55     10      0      0
   28  00030533     10     55     10      0      0
   32  00008067     10     55     10      0     55
Return value: 55

実行できました!

今後の課題

次のステップですが、

  • テスト用コードのリファクタリング(今は手作業なので命令が増えると大変)
  • ロードストア等、対応する命令を増やす
  • メモリ領域を増やす
  • 外部プログラムファイルの読み込み(できればelf形式で)

というあたりを考えています。

最終目標はFPGAで動作するCPUをRTLで書くことなのですが、これは年単位の目標です。