Chiselを使ったRISC-Vの勉強(1)
前回紹介したようにプロトタイプ用のFPGAができて, その後USB Type-C化などもいたしました。
これで電力問題が解決したため、FPGA基板からRaspiに給電かつ給電時の衝突防止までできるようFETを追加する改造もしました。
さて、とても使いやすいRISCVのSCR1を紹介する予定でしたが、こちらはまた後での紹介にしようかなと思います。
なおgithubにSCR1のCyclone10LP向けの修正は置いてありますので参照ください。
GitHub - panda5mt/fpga-sdk-prj: FPGA-based SDK projects for SCRx cores
Chiselやるか
あまり気乗りはしてなかったのですが、昨今のコロナの影響で、思うように部品調達ができず仕事が従来の半分くらいの稼働率なのでChiselを勉強しています。動機としては
- UC BerkeleyでもRISC-Vの実装に使われている
- HDLのようにとりあえずシミュレーションのために合成するという手間が省けそう
- 高位合成言語ではない(抽象度は同じ)
- 自前のRISC-Vが必要になってきた
と言った感じでしょうか。
合成時間中にいろいろ閃いたことを忘れたり、ロジックが複雑になると合成時間だけでかなりもったいない。
あと、自分の開発の仕方に問題があるとは思うのですが、複雑なロジックの場合、単純なロジックから組み立ててシミュレーションするのですが、それでも結構作り込んでからシミュレーションしてしまう(FPGA実機でうごいてるし、わざわざめんどくさいとか。そんなの)。複雑化していくと指数関数的にシミュレーションも遅くなっていきます。
あと、この期に及んでフルスクラッチする理由としては入手できるRISC-V実装がバスがオリジナル(おそらく学習用)、またはAXIベース(その方が汎用性は高い)であり、SCR1でもAvalon-MMへはバスブリッジを利用していてQsysで細かい設定をしたい自分としては不便ということがあります。
またSCR1はパイプラインが最大4段となっていて、CPUストール頻度が5段に比べてかなり高い。
などなどがあります。intel PSG FPGAに実装する前提で、はじめからCPUバスを意識して実装してフルスクラッチで実装していきたい。
参考となったブログ、サイト
- Chisel handbook
https://github.com/schoeberl/chisel-book/wiki/chisel-book.pdf
- Test(PeekPokeTester)の書き方
Chiselで作ったモジュールをPeekPokeTesterでテストするまでの流れのまとめ - ハードウェアの気になるあれこれ
- 初めはここを見ながら書きました。とても丁寧なまとめ
ChiselのBundleの使い方をまとめてみる - ハードウェアの気になるあれこれ
- IFステージの実装はここを参考に
Chisel-Templateを使ってオリジナルデザインを作ってみるチュートリアル (1. デザインの作成) - FPGA開発日記
方針
基本的にCPUは以下のステージ構成を取るはずです。命令が複数存在しているCPUであることが前提ですが。。
(IF)命令フェッチ
(ID)命令デコード
(EX) 演算
(MA)メモリアクセス
(WB)レジスタ書き込み
- それぞれのステージをChiselで記述、テスト
- 上記ステージを接続
- シングルパイプラインCPUとしてテスト
- 5ステージパイプライン化
- 64bit(RV64I)化
とりあえず、命令をフェッチする部分を記述します。
今の状態ですとはっきり言ってVerilog/VHDLで記述した方が10倍速で組めそうですが、我慢して組んでいきます。上記参考ブログでも書かれていますが、本当にBundleは慣れると武器になるはず。
まず、CPUのPCとかメモリの前にインターフェースを記述しておきます。後でAvalon-MMに移行しやすいように。
一部抜粋になります。
// address channel bundle class AddressChannel extends Bundle { val req = Output(Bool()) // request signal val addr = Output(UInt(32.W)) // address (32bit) } // data channel bundle class DataChannel extends Bundle { val ack = Output(Bool()) // data is available ack val data = Output(UInt(32.W)) // data (32bit) } // HOST :read only(IMem) // HOST :read/Write(Dmem) class HostIf extends Bundle { // IO definition val r_ach = new AddressChannel val r_dch = Flipped(new DataChannel) // flipped I/O // write operation val w_ach = new AddressChannel val w_dch = new wDataChannel //(中略) } // Memory-Mapped Slave IF // Slave :read/Write(IMem) // Slave :read/Write(Dmem) class SlaveIf extends Bundle { // IO definition // read operation val r_ach = Flipped(new AddressChannel) // flipped I/O val r_dch = new DataChannel // write operation val w_ach = Flipped(new AddressChannel) // flipped I/O val w_dch = Flipped(new wDataChannel) }
基本的インターフェース(read/write)を記述し、ホスト側スレーブ側に割り当てます。
Flippedを使うとIn/Out logicが反転するので記述が楽になります。
CPU側。PCをカウントアップし、アドレスをインターフェースに与えるだけ。
こちらも一部抜粋
class Cpu extends Module { val io = IO(new HostIf) // initialization val r_addr = RegInit(0.U(32.W)) val r_data = RegInit(0.U(32.W)) val r_req = RegInit(true.B) // fetch signal val r_rw = RegInit(false.B) val r_ack = RegInit(false.B) val w_req = RegInit(true.B) val w_ack = RegInit(false.B) val w_addr = RegInit(0.U(32.W)) val w_data = RegInit(0.U(32.W)) when (io.sw.halt === false.B){ when(r_ack === true.B){ r_addr := r_addr + 4.U(32.W) // increase program counter } } // (中略) io.r_ach.addr := r_addr io.r_ach.req := r_req // write process io.w_ach.addr := w_addr io.w_dch.data := w_data io.w_ach.req := w_req //io.w_dch.ack := w_ack // read process r_ack := io.r_dch.ack r_data := io.r_dch.data }
メモリ側は割愛。必要であればこの記事最後にあるGithubからCloneしてください。
トップモジュールでCPUとmemoryをコネクトします。
class CpuBus extends Module { val cpu = Module(new Cpu) val memory = Module(new IMem) //(中略) // Read memory memory.io.r_ach.req <> cpu.io.r_ach.req memory.io.r_ach.addr <> cpu.io.r_ach.addr cpu.io.r_dch.data <> memory.io.r_dch.data cpu.io.r_dch.ack <> memory.io.r_dch.ack // write memory memory.io.w_ach.req <> cpu.io.w_ach.req memory.io.w_ach.addr <> cpu.io.w_ach.addr memory.io.w_dch.data <> cpu.io.w_dch.data cpu.io.w_dch.ack <> memory.io.w_dch.ack }
この後、テストオブジェクトからメモリへダミーデータを与え、CPUがフェッチできているか確認します。
// CPUを停止させ、メモリに書き込む poke(c.io.sw.halt, true.B) // メモリへ書き込み for (addr <- 0 to (memarray.length * 4 - 1) by 4){ poke(c.io.sw.wAddr, addr) poke(c.io.sw.wData, memarray(addr/4)) step(1) } step(1) poke(c.io.sw.halt, false.B) // CPUを再開させる for (lp <- 0 to (memarray.length - 1) by 1){ val a = peek(c.io.sw.addr) val d = peek(c.io.sw.data) println(f"addr = 0x$a%08X, data = 0x$d%08X") // メモリアドレス、データを16進表記 step(1) }
動作確認。make test
で動作するようMakefileに記述してあります。
ここまでのコード全文は以下で。コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.3 --depth 1