RISC-V の 4 段パイプライン特権命令付きプロセッサを書いた話

公開日:
RISC-V の 4 段パイプライン特権命令付きプロセッサを書いた話

弊学科の有名な授業に CPU 実験というものがありましたが、その時はプロセッサを書かずに FPU (浮動小数点演算器) に逃げて (?) しまいました 🤔
ちょっと書いてみたいなぁと思っていたところにちょうどいい機会があったので、特権命令付きでプロセッサを書いてみることにしました。
プロセッサを書くと言っても 、一度間近で書いている人を見ていながらも、何から始めればいいのかわからず、🤔をしていましたが、書いていくうちに少しずつ理解が進んで、最終的には何とか書き上げることができました 😅
実装は以下の GitHub にあげているので参考にしてください。

この記事では詳しく書き方を書くというよりとっつき方を書いて行こうと思っています。

まず、何が何やらわからんという人の内、実装を読めばわかるという人は上にあげた筆者の実装か、筆者が参考にした以下の実装を見るといいと思います。

逆に実装よりも自然言語で書いている方がわかりやすいという人は「FPGAマガジンNo.18 Googleも推す新オープンソースCPU RISC-Vづくり」という雑誌が参考になると思います。

1. 流れ

何から手をつけていいのかわからなかったので、とりあえず色々ググってみて全体のイメージをしてみました。

  1. ISA を決める
  2. C から RISC-V のバイナリに変換できるクロスコンパイラを導入する
  3. add 命令が動く簡単なプロセッサを書く
  4. 再帰で実装した fib が動くように命令を追加して、簡易的にメモリを作る
  5. 4段パイプライン (FDEW) をして、高速化する
  6. フォワーディング (E → DW → D) と分岐予測 (2 レベル適応予測器) を入れてストールを減らす
  7. CSR 転送命令を追加する
  8. 特権命令を実装して、例外/割り込みの処理を追加する

以上のような流れで実装していくことにしました。

今回は FetchDecodeExecuteWrite の 4 段からなるパイプライン構造を前提として話を進めていきます。
プロセッサの実装には System Verilogを使用しており、Vivadoのシミュレータも使用しています。
また、命令メモリもメモリについては実装せず、レジスタで代用しています。(オイ…)

2. ISA の決定

今回はRISC-V の公式ページ に公開されている仕様に準拠した以下の ISA (命令セットアーキテクチャ) を使っています。

  • 非特権命令
    • RV32I (ジャンプ, 分岐, ロードストア, 算術論理演算, ecall, ebreak)
    • RV32 Zicsr (CSR 転送命令)
    • RV32M (mul, div, rem)
  • 特権命令
    • Trap-Return Instructions (mret)

非特権命令については Volume 1, Unprivileged Spec v. 20191213、特権命令については Volume 2, Privileged Spec v. 20190608 を参考にしています。
また、浮動小数点命令 (RV32F) は使用していませんが、実装してみると動かせるコードが増えて楽しいと思います。

ちなみに、RISC-Vのレジスタの構成 (よく使うものだけ) は以下のようになっているので、参考にしてください。

レジスタ番号ABI 名詳細
r1raリターンアドレス
r2spスタックポインタ
r8s0 / fp退避レジスタ / フレームポインタ
r9s1退避レジスタ
r10 – 11a0 – 1関数引数 / 戻り値
r12 – 17a2 – 7関数引数
レジスタの構成

3. クロスコンパイラの導入

普段使っている Clang (Mac) や GCC (Windows, Linux) のようなコンパイラは自分のパソコンで実行するために人間が書いた言語をバイナリに変換してくれます。
しかし、今回しは RISC-V で動くバイナリを吐くコンパイラが欲しいので、自分のパソコンとは異なるパソコンで実行可能なバイナリを吐いてくれるコンパイラが必要になります。
それをクロスコンパイラと言います。
少ししかテストコードを書かないなら、自分でバイナリを書いたり、アセンブリを書いてそれを変換してもいいのですが、プロセッサかテストコードかどちらが間違っているのかわからなくて地獄ということになりかねないので、クロスコンパイラを導入しておきます。

導入の仕方や Makefile の書き方については以下の記事に書いたので、そちらをご覧ください。

4. プロセッサの構成

クロスコンパイラの導入を終えて、テストコードが書けるようになったので、次はプロセッサの構成を考えていきます。

4.1. 概要

まず、最終的にパイプライン化をすることを意識しているのであれば、パイプライン化を意識してコードを書いていくことをお勧めします。
ですが、いきなりパイプライン化した状態のコードを書こうとすると大変だし、バグらせると思うので、例えば 4 段なら、FetchDecodeExecuteWrite のそれぞれの段に分けて実装して、一段ずつ状態を遷移していくようにするのが 1 つ目のステップとしてはいいと思います。
つまり、まずは下の図の上のように実装した後、下のようにパイプライン化をするという流れがいいと思います。

パイプライン化前後の実行の様子
パイプライン化前後の実行の様子

これを実現するために各モジュールを作っていくのですが、今回は以下の図のように Core がメインで全体の統制をとりながら、各モジュールにデータを渡して処理をしてもらうという構成を取りました。
(これが絶対に正しいというわけではないです。)

各モジュールの関係と全体像
各モジュールの関係と全体像

今回は全ての段が 1 クロックで終わるようにしているため、completeを表す線は付けていませんが、パイプラインのフラッシュを行うために rstn 、ストールを実現するために enabledの線を各段につけることにしました。

4.2. フォワーディング

また、フォワーディングではフロー依存である RAW (Read After Write) の解決が求められている。
つまり、本来レジスタに書き込みがされるまで待って実行しないといけないものを、レジスタに書き込む前に実行してしまうというのがフォワーディングの役割である。

今回は以下の図のようにE → DW → Dの二種類のフォワーディング機構を用意している。
それぞれ、一つ前の命令、二つ前の命令との RAWを解決するためのものである。

フォワーディング機構
フォワーディング機構

具体的には前者は E が終わった後の値が書き込まれるレジスタと D で値を読み出したレジスタを比較して一致していれば、E が終わった後の値を採用するという形で実装しており、後者は Wでレジスタへの書き込みを行う際に、D でレジスタからの読み込みを行うアドレスと比較して、同じなら書き込もうとしている値を読み出すという形で実装している。

4.3. 分岐予測

分岐予測では今回は 2 レベル適応予測器を採用しました。
2 レベル適応予測器は以下の図のように 2 ビット予測器と大域分岐履歴レジスタを組み合わせたものである。
過去2回の履歴の結果を元にどの分岐履歴テーブルを選ぶかを決め、プログラムカウンタ (PC) の下位 8 ビットを元にインデックスを指定し、そのエントリにある 2 ビット予測器の結果を元に分岐予測を行っています。
さらに、PC の上位 24 ビットと分岐先アドレスキャッシュのタグを比較して、一致した場合は分岐先の PC に遷移します。
分岐の結果が分かった段階で、使用した分岐履歴テーブルの値と大域分岐履歴レジスタを適切に更新します。

2レベル適応予測器
2 レベル適応予測器

分岐予測については以下の記事も参考にしました。

4.4. 詳細な構成図

さらに詳しく書いた図を天下り的に示すと以下のようになりました。

プロセッサの全体図
プロセッサの全体図

後はこれを一つずつ実装していけば、プロセッサを動かすことができます。
(初めは何が何かわからない状態だったのですが、一度書けてしまうとわかった気になってしまってこれ以上詳しく説明できません…)

ここまでくれば後はプロセッサの理解というよりは Verilogの書き方の問題になるので、少しずつ練習して慣れていってください。
人間の頭は時系列順に考えるのが得意なのですが、Verilog で回路を書くときには並列的な処理を考えないといけないので、人間の頭には向いていないですね…

Verilogの大まかな書き方については以下の記事にまとめたので、ご覧ください。

また、シミュレーションやデバッグの方法については以下の記事をご覧ください。

5. CSR 転送命令と特権命令について

今回は以下の例外 / 割り込みに対するハンドラを実装した。

  • 例外
    • 2: 不正命令 (無効オペコード)
    • 3: ブレークポイント
    • 8: ユーザモードからのシステムコール
    • 11: マシンモードからのシステムコール
  • 割り込み
    • 7: マシン・タイマ割り込み
    • 11: マシン・外部割り込み

割り込みについてはテストベンチから core に適宜割り込み信号を送って、その信号を元に割り込みを起こしています。

具体的な実装方法や機構については以下の記事にまとめたのでご覧ください。

6. 実験

次に、クロスコンパイラでコンパイルしたコードを元にテストをしてみます。

6.1. memory

まず、r15 = a[0](=1) + a[1](=2) となるはずの memory.c をコンパイルした結果を実行してみます。
ちなみに、アセンブリは memory.S になるので、実際にどのような命令が実行されたかはこちらで確認できます。

clocks       :    16
pc           :    12
instructions : total    13, normal     13, exception     0, others     0
prediction   : total     2, succeed     1, fail          1
register     :
		r00:    0,    r01:    1,    r02: 2016,    r03:    0,
		r04:    0,    r05:    0,    r06:    0,    r07:    0,
		r08: 2048,    r09:    0,    r10:    0,    r11:    0,
		r12:    0,    r13:    0,    r14:    1,    r15:    3

r15 = 3 となっていてメモリの演算はうまく行っていそうだとわかります。

ちなみに、クロック数については、実行した命令の回数と、ストールの回数、パイプラインの段数から計算できるので、検証を行ってみましょう。
まず、命令は 13 回実行されていて、ストールは分岐予測のミス 1 回あたり、F ステージ及び D ステージにかかる 2 クロックで、分岐予測のミスの回数は最後のジャンプ命令のミスを無視すると 0 回、パイプラインの段数によって余分にかかるクロック数は段数より 1 小さい値になるので、4 - 1 = 3 クロックとなります。
これらを合計すると、13 + 2 x 0 + 3 = 16 となり、出力結果と一致していることがわかります。

6.2. fib

次に、純粋に再帰で実装した fib.c をコンパイルした結果を実行してみます。
ちなみに、アセンブリは fib.S になるので、実際にどのような命令が実行されたかはこちらで確認できます。
結果は r15 = fib(10) (=89) となるはずです。

clocks       :  4222
pc           :    35
instructions : total  3809, normal   3809, exception     0, others     0
prediction   : total   622, succeed   416, fail        206
register     :
		r00:    0,    r01:   35,    r02: 2032,    r03:    0,
		r04:    0,    r05:    0,    r06:    0,    r07:    0,
		r08: 2048,    r09:    0,    r10:   89,    r11:    0,
		r12:    0,    r13:    0,    r14:    0,    r15:   89

確かに r15 = 89 となっていて fib の計算はうまく行っていそうだとわかります。

先程と同様にクロック数を計算すると、3809 + 2 x 205 + 3 = 4222 となり、出力結果と一致していることがわかります。

6.3. fib-csr

最後に、ちょっと工夫して、アセンブリをいじって ecallebreakの例外を発生させる命令、 csrr csrw の CSR 転送命令、mret の特権命令を追加してみました。
さらに、バイナリには 0 のみからなる不正な命令も追加しました。
また、テストベンチから core に割り込み信号を適宜送るようにしました。

例外や割り込み後の処理については基本的に OS 側が指定してくれるはずなのですが、今回は例外発生時に遷移するアドレスも固定し、簡易的に自作したものを使用しています。

結果は先程と同じく r15 = fib(10) (=89) となるはずです。
アセンブリコードは fib-csr.S です。

clocks       : 18613
pc           :    43
instructions : total 14587, normal   3809, exception   709, others 10069
prediction   : total  1344, succeed  1130, fail        214
register     :
    r00:    0,    r01:   43,    r02: 2032,    r03:    0,
    r04:    0,    r05:    0,    r06:    0,    r07:    0,
    r08: 2048,    r09:    0,    r10:   89,    r11:    0,
    r12:    0,    r13:    0,    r14:    0,    r15:   89

結果は確かに一致していて、レジスタの内容も r01 (最後のプログラムカウンタの値) 以外は先程と同じ値になっているので、正しく処理できていそうだとわかる。

クロック数については割り込みを考えるのが面倒なので、今回は計算しません。(もし計算した人がいれば教えてください… 🙏)

7. まとめ

かなり長くなってしまいましたがようやくまとめです 👏
プロセッサを HDL で自分で一から書くというのは何から手をつけていいのかわからず難しい部分も多かったですが、実際に一旦パイプラインが動いたあたりからかなり理解が進んで、色々な機能をどんどん実装していけるようになりました。
これまで実装してきたであろう言語とは考え方が違うところも多いですが、並列処理をイメージできる頭になれば HDL での開発もどんどんできるようになっていくと信じているので、少しずつがんばりましょう…!

コメントを残す

メールアドレスが公開されることはありません。

CAPTCHA