はじめに
RISCVエミュレータを作り始めました。このエントリーはそのメモです。 個人的なメモなので、読者の方の事はまだあまり考えていません。
ある程度めどがたったらもうちょっと読みやすいエントリーを書きます。
目標
(完全な車輪の再発明ですが、個人の趣味ですので。)
参考図書
リポジトリ
https://github.com/moizumi99/m99_riscv_emulator
作業
「動かしてわかるCPUの作り方10講」(以下CPU10講)のエミュレータを元に、雛形を作成。
- ターゲット言語をC++に変更。
拡張に備えてファイルを分割
拡張のための変更
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
それぞれフォーマットが異なり、タイプごとに引数の種類も異なります。
そこで必要な命令毎に
- タイプを判断しフォーマットを選択
- opcodeとfunct3、funct7を32bit内に埋め込む
- 必要な引数(rd, rs1, rs2, immediate )を判断
- 引数の情報を32bit内の指定された位置に埋め込む
というコードを書きます。
RISC-Vでは、何故か一部の即値のビット位置が入れ替わっていて、これを入れ替えるのが大変でした。C/C++でのビット演算に苦労していると、こんなアドバイスをいただきました。
ビットフィールドもダメですか?
— timeler (@haskellheads) September 1, 2019
喜んで使わせていただきます。これでいちいちマスクする苦労から開放されました。
ビットフィールドを使って、それぞれの要素(レジスタ番号、即値、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用に次の用なものにしました。
- 下7bitのオペコード(opcode)を読み込む
- 必要があれば、14:12ビットのfunct3を読み込む
- 必要があれば、31:25ビットのfunct7を読み込む(今回は未実装)
- opcode、funct3とfunct7から命令の種類を判定
- 必要に応じてrd(ディスティネーションレジスタ)、rs1(対象レジスタ1)、rs2(対象レジスタ2)をデコード
- 必要に応じて即値(12bit, 13bit, 21bit, 24bitの4種類がある)をデコード
さきほどシャッフルした即値のビットを戻す作業が面倒ですが、それ以外は単に対応する位置にあるビットを切り出すだけです。
命令の実行
今回選んだ命令に関しては、命令の実行はCPU10講と似た形式です。大きな違いは
程度です
その他
実は今回一番複雑だったのは機械語を作成する部分、次がデコーダーです。複雑なので最初からテストを(割と)ちゃんと書きました。 各アセンブラの命令毎に、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で書くことなのですが、これは年単位の目標です。