Chiselを使ったRISC-Vの勉強(6.パイプライン化とALUを使用する命令の見直し)
突然ですがいままでシングルラインで組んでいたCPUプロジェクトをいきなり5ステージパイプラインにしました。
いきなり!ステージ
パイプライン化に向けての準備
本来でしたら、シングルステージでまだ実装が終わっていないMEMステージまで実装後複数段パイプラインにするのが筋だとおもいます。
しかしプロジェクトを進めていく上で問題が発生していました。
- 機能が増えていくにつれて各ステージの境目がわからなくなってきていた
- バス上にメモリマップドなメモリがあり、そこから命令の読み込み、データ読み書きをする前提である
- シングルクロック未満(=非同期)でメモリへの読み込み、書き込みは現実的に不可能。例えばアドレスを与える→データを受け取るを考えても最低2クロック必要。
- したがってメモリアクセスが終了するまで次の命令に移行しないようストール機能が必須
- 併せて各機能で分離して動作させるためステージングも必要
- RV32Iの場合、なお、データメモリは8bit/16bit/32bit単位でアクセスができないといけない
- データメモリは32bitバスを前提としているのでバス幅調整に1ステージ分追加するかも見積もる必要がある。
したがってこの時点でシングルパイプラインで実装し続けるメリットは皆無であり、一旦パイプライン化してからデータメモリ、その他機能を追加し全容を把握するのが最適と言えます。なおMEMステージで実装するデータメモリは命令メモリと基本的RWは変わらないので32bit幅のアクセスだけできるようかなり大雑把に実装しています。
予定していたマルチパイプライン化にこの段階で取り組むことにしました。
参考にしたコード
rocket/src/main/scala/dpath.scala · fe9adfe71b90046d7d6b84d0c9fe033b06c9229d · CoDEx / rocket-chip · GitLab
本家のRocket-Chipのブランチから探しても同じものが取得できると思います。
ステージごとにかなり簡潔に記述されているので、自分のプロジェクトに導入する際にも分かりやすかったです。
パイプライン化をしたからと言って、いままでの資産が無駄になることはなく、必要とされるデータのレジスタをステージ分用意するだけです。
例えばCPUのパイプラインが5ステージあって、任意のデータが最終ステージまで必要とされているのであれば、データ x5 のレジスタが必要になるだけです。
任意のデータが3段目のステージまで必要とされているのであれば、データ x3のレジスタが必要、といった具合です。
また各ステージで前段の処理が終わっていないと次のステージに移行できないようであれは適宜ストール信号を発行できるようにします。
この部分は未完成な部分があるので、次回以降に取り扱いたいと思います。
行数がいきなり増えるのでびっくりしますが、各ステージで分けて考えられるようになるので、シングルステージよりも考えやすいと思います。
ここでパイプライン化する際に必須ではないですが併せて修正した部分を取り上げます。
ALUの変更
いままで算術、論理命令だけの演算をしていました。しかし条件分岐もおなじALUを使う事ができるはずです。実際同じ演算を行いますし、ALUと別に条件分岐ごとに比較演算機を外部に作るのは論理回路の浪費に思われます。
条件分岐の処理中に算術演算命令を並行して行うようなハザードが発生するステージはないからです。
そういう並行して動作する構成も面白そうですけどそれはまたの機会に。
というわけで、少し処理を追加する必要がありました。
以下の4つの演算です
- ALU_SEQ (入力された2つの値(in.a, in.b)は等しいか?)
- ALU_SNE (入力された2つの値(in.a, in.b)は等しくないか?)
- ALU_SGE (in.a >= in.bか?)
- ALU_SGEU (UInt(in.a) >= UInt(in.b)か?)
※ALU_SGE (io.op1 >= io.op2か?)、SGEU(SGEの符号なし)はすでに実装済みです。
以下のような実装です。
object ALU { def ALU_ADD: UInt = 0.U(5.W) def ALU_SLL: UInt = 1.U(5.W) def ALU_SEQ: UInt = 2.U(5.W) def ALU_SNE: UInt = 3.U(5.W) def ALU_XOR: UInt = 4.U(5.W) def ALU_SRL: UInt = 5.U(5.W) def ALU_OR: UInt = 6.U(5.W) def ALU_AND: UInt = 7.U(5.W) def ALU_COPY1: UInt = 8.U(5.W) def ALU_COPY2: UInt = 9.U(5.W) def ALU_SUB: UInt = 10.U(5.W) def ALU_SRA: UInt = 11.U(5.W) def ALU_SLT: UInt = 12.U(5.W) def ALU_SGE: UInt = 13.U(5.W) def ALU_SLTU: UInt = 14.U(5.W) def ALU_SGEU: UInt = 15.U(5.W) def ALU_X: UInt = 0.U(5.W) // BitPat("b????") def isSub(op: UInt): Bool = op >= ALU_COPY2 // need sub? def isCmp(op: UInt): Bool = op >=ALU_SLT // Compare op? def isCmpU(op: UInt): Bool = op >= ALU_SLTU // Compare unsigned? def isCmpI(op: UInt): Bool = op(0) // need inverse for compare? //noinspection ScalaStyle def isCmpEq(op: UInt): Bool = !op(3) // EQ or NEQ compare operation? } import ALU._ //noinspection ScalaStyle class ALU extends Module { val io = IO { new Bundle { val op1: UInt = Input(UInt(32.W)) val op2: UInt = Input(UInt(32.W)) val alu_op: UInt = Input(UInt(5.W)) val out: UInt = Output(UInt(32.W)) val cmp_out = Output(Bool()) } } // Shift val op2_inv: UInt = Mux(isSub(io.alu_op), ~io.op2, io.op2).asUInt() val sum: UInt = io.op1 + op2_inv + isSub(io.alu_op) val shamt: UInt = io.op2(4,0).asUInt val shin: UInt = Mux(io.alu_op === ALU_SRA || io.alu_op === ALU_SRL,io.op1,Reverse(io.op1)) val shift_r: UInt = (Cat(isSub(io.alu_op) & shin(31), shin).asSInt >> shamt)(31, 0) val shift_l: UInt = Reverse(shift_r) val slt: Bool = Mux(io.op1(31) === io.op2(31), sum(31), Mux(isCmpU(io.alu_op), io.op2(31), io.op1(31))) val cmp = isCmpI(io.alu_op) ^ Mux(isCmpEq(io.alu_op), (io.op1 ^ io.op2) === 0.U, slt) val w_out = Mux(io.alu_op === ALU_ADD || io.alu_op === ALU_SUB, sum, Mux(io.alu_op === ALU_SLT || io.alu_op === ALU_SLTU, cmp, Mux(io.alu_op === ALU_SRA || io.alu_op === ALU_SRL, cmp, Mux(io.alu_op === ALU_SLL, shift_l, Mux(io.alu_op === ALU_AND, io.op1 & io.op2, Mux(io.alu_op === ALU_OR, io.op1 | io.op2, Mux(io.alu_op === ALU_XOR, io.op1 ^ io.op2, Mux(io.alu_op === ALU_COPY1, io.op1 , io.op2)))))))) io.out := w_out io.cmp_out := cmp } }
実装にあたってはいつものriscv-miniを参考にしました。
github.com
今回追加した機能はmini-riscvには含まれてはいませんので、自前で追加実装する必要があります。
ド・モルガンの法則がわかってればなんてことはないでしょ、、、ってここを実装するのすんごい時間かかった。
高専の数学1の最後の方を20年ぶりくらいに必死に勉強し直す始末に。
みんな社会人になっても宇宙人になっても仙人になってもおじいさんおばあさんになっても数学の勉強やろうね。趣味すらできなくなるから。
chisel-iotestersでプローブする信号の追加
今のところ必要としている信号は以下のものです。適宜追加していきます。
- IF/IDステージ: pc , 機械語
- EXステージ:rs1, rs2, imm
- MEMステージ:ALU演算結果
- WBステージ: ALU演算結果とrd
アセンブラを組む
前回のものに少し手を加えただけです。
今回の動作確認における実質的な内容は変わらないので各行の日本語のコメントは省略します。
test.s
に以下を記述します
_start0: addi x29, x0, 10 # x29 = x0 + 10 = 10 addi x31, x29, 0xAA # x31 = x29 + 0xAA = 180 (= 0xB4) _label1: addi x1, x0, 1 # x1 = x0 + 1 = 1 addi x2, x0, 2 # x2 = x0 + 2 = 2 addi x3, x0, 3 # x3 = x0 + 3 = 3 addi x4, x0, 4 # x4 = x0 + 4 = 4 addi x5, x0, 5 # x5 = x0 + 5 = 5 addi x6, x0, 6 # x6 = x0 + 6 = 6 addi x7, x6, 7 # x7 = x6 + 7 = 13 (= 0x0D) addi x8, x0, 8 # x8 = x0 + 8 = 8 jal x4, _label4 # x4 => address(_label2), jump _label4 _label2: addi x9, x0, 9 # x9 = 9 addi x10, x0, 10 # x10= 0x0A addi x11, x0, 11 # x11= 0x0B _label3: jalr x0, x5,0 # jump to x5 (= _label5) _label4: addi x12, x0, 12 # x12= 0x0C jalr x5, x4, 4 # x5 => _label5, jump x4+4 (= _label2+4) _label5: jal x0, _label5 # forever loop nop
動作確認
make clean make
で動作確認を行う事ができます。IntelliJのユーザはTerminalで一旦上記コマンドを実行してからrunしてみてください。(runだけだと機械語ファイルがない場合エラーになる)
ここではおもにアセンブラファイル2行目のaddi x31, x29, 0xAA
に注目したいと思います。
x29の値(0xAが格納されている)とimmの値(0xAA)の足し算の結果をx31に書き戻すという命令です。
コード全文
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.10.8 --depth 1 cd KyogenRV/
macOSな方はGNU odを使う関係上以下のようにしてcoreutilsをインストールしてください。
brew install coreutils
makefileでOSによってod/godをスイッチしています。
アセンブラは(プロジェクトフォルダ)/src/sw/test.sにあります。適宜書き換えてみてください。
nano src/sw/test.s
次回はハザードの解決に取り組みます。
Chiselを使ったRISC-Vの勉強(5. 分岐命令の実装)
前回の記事と分けてしまったら内容がほとんど皆無になってしまいました。
ないようがないよう
(この辺りでみんなブラウザ閉じる)
年取るってこういうことなんだなぁ。
さて、本題です。
前回riscv-asからアセンブルした機械語をChiselプロジェクトにワンストップで導入する仕組みを構築しました。
分岐命令の相対アドレスを計算するのが大変なのと、immが命令タイプに応じて異なるため、ハンドアセンブルが厳しくなったからです。人為的ミスの低減にもなるはずです。
immの種類多すぎでは
今更感が非常に強いですが、immを整理しました。
命令セットのImmに対応するビット位置がまちまちなので、それをここで統一しました。
CSR関係のzimmもここで整理しました。なお、参考にしたのはこちら。
ざっとこんな感じです。riscv-miniとほとんど変わらない実装
object ImmGen { def apply(sel: UInt, inst: UInt): SInt = { val sign = Mux(sel === IMM_Z, 0.S, inst(31).asSInt) val b30_20 = Mux(sel === IMM_U, inst(30,20).asSInt, sign) val b19_12 = Mux(sel =/= IMM_U && sel =/= IMM_J, sign, inst(19,12).asSInt) val b11 = Mux(sel === IMM_U || sel === IMM_Z, 0.S, Mux(sel === IMM_J, inst(20).asSInt, Mux(sel === IMM_B, inst(7).asSInt, sign))) val b10_5 = Mux(sel === IMM_U || sel === IMM_Z, 0.U, inst(30,25)) val b4_1 = Mux(sel === IMM_U, 0.U, Mux(sel === IMM_S || sel === IMM_B, inst(11,8), Mux(sel === IMM_Z, inst(19,16), inst(24,21)))) val b0 = Mux(sel === IMM_S, inst(7), Mux(sel === IMM_I, inst(20), Mux(sel === IMM_Z, inst(15), 0.U))) Cat(sign, b30_20, b19_12, b11, b10_5, b4_1, b0).asSInt }
分岐命令
実装した分岐命令は以下に挙げる通りです。WLEN
は命令1ワードあたりのアドレスバイト長で、今回は4(=32bit)です。imm_X
はXタイプの即値immという意味です。
疑似コード
rd = pc + WLEN, pc = pc + imm_J
- jalr
疑似コード
rd = pc + WLEN, pc = rs1 + imm_I
- beq
疑似コード
if (rs1 == rs2) then pc = pc + imm_B else pc = pc + WLEN
- bne
疑似コード
if(rs1 =/= rs2)then pc = pc + imm_B else pc = pc + WLEN
- blt
疑似コード
if(rs1 < rs2) then pc = pc + imm_B else pc = pc + WLEN
- bltu
疑似コード
if(rs1.asUInt < rs2.asUInt) then pc = pc + imm_B else pc = pc + WLEN
- bge
疑似コード
if(rs1 >= rs2)then pc = pc + imm_B else pc = pc + WLEN
- bgeu
疑似コード
if(rs1.asUInt >= rs2.asUInt)then pc = pc + imm else pc = pc + WLEN
前回のコミットと比較し、すべての分岐命令においてALUを使うように修正しました。回路規模肥大化の抑制を期待できます。
バブルロジックの導入
next_inst_is_valid
が真の時はそのまま、偽の時は次アドレスの命令をバブルします。
JAL、JALR命令は特に条件なく分岐をしてくれるので、この命令があった場合は次にフェッチしている命令語をバブルします。
Bから始まる分岐命令の場合、条件が真となれば次段でフェッチしている命令をバブルします。偽の場合は分岐しませんので命令はそのまま残します。
// bubble logic next_inst_is_valid := true.B switch (id_ctrl.br_type) { is( BR_NE ) { when(val_rs1 =/= val_rs2) { next_inst_is_valid.:=(false.B)} // NEQ = true: bubble next inst & branch .otherwise { next_inst_is_valid.:=(true.B) } } is( BR_EQ ) { when(val_rs1 === val_rs2) { next_inst_is_valid.:=(false.B)} // EQ = true: bubble next inst & branch .otherwise { next_inst_is_valid.:=(true.B) } } is( BR_GE ) { when(val_rs1 > val_rs2) { next_inst_is_valid.:=(false.B)} // GE = true: bubble next inst & branc .otherwise { next_inst_is_valid.:=(true.B) } } is( BR_GEU ) { when(val_rs1.asUInt > val_rs2.asUInt) { next_inst_is_valid.:=(false.B)} // GE = true: bubble next inst & branch .otherwise { next_inst_is_valid.:=(true.B) } } is( BR_LT ) { when(val_rs1 < val_rs2){ next_inst_is_valid.:=(false.B)} // LT = true: bubble next inst & branch .otherwise { next_inst_is_valid.:=(true.B) } } is( BR_LTU ) { when(val_rs1.asUInt() < val_rs2.asUInt) { next_inst_is_valid.:=(false.B)} // LT = true: bubble next inst & branch .otherwise { next_inst_is_valid.:=(true.B) } } is( BR_J ) { next_inst_is_valid.:=(false.B) } // JAL is( BR_JR ) { next_inst_is_valid.:=(false.B) } // JALR }
命令語を間違えて解釈していた
Uが末尾につくアセンブラ命令。bltuやbgeu。
immがunsignedだと本気で誤解していました。
逝ってこいレジスタ。負が扱えないので帰ってこれない。すごい。作ってみてもいいかもですね。(ダメだろ)
正確には、rs1、rs2を比較時にunsignedとして扱う命令でした。ですよね。。immだったら、なんのためにauipc
命令あるんだよという話になります。
JAL/JALR命令のテスト
そんなこんないろいろ間違いを治しつつ、テストコードを書きました。
(ここに掲載したのはjalとjalrだけです。bから始まる分岐命令も一応動作を確認済みです)
早くriscv-testを走らせられるレベルになりたい。
_start0: nop nop _label1: addi x1, x0, 1 # x1 = x0 + 1 = 1 addi x2, x0, 2 # x2 = x0 + 1 = 2 addi x3, x0, 3 # x3 = x0 + 2 = 3 jal x4, _label4 # x4に次アドレス(_lavel2)を格納し, _label4へジャンプ _label2: addi x7, x0, 7 # x7 = 7 addi x8, x0, 8 # x8 = 8 (RETURN HERE) addi x9, x0, 9 # x9 = 9 addi x10, x0, 10 # x10= 0x0A addi x11, x0, 11 # x11= 0x0B _label3: jalr x0, x5,0 # x5に格納されているアドレス(= _label5)へジャンプ _label4: addi x12,x0,12 # x12= 0x0C jalr x5, x4, 4 # x5に次アドレス(_lavel5)を格納し, x4(= _label2) + 4のアドレス( addi x8, x0, 8のところ)へジャンプ→(addi x7, x0, 7)は実行されない _label5: jal x0, _label5 # 無限ループ
結果を見てみます。intelliJであれば画面下部のterminalのタブで
make clean make test
で動作します。
実行結果。
コード全文
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.10.3 --depth 1 cd KyogenRV/
macOSな方はGNU odを使う関係上以下のようにしてcoreutilsをインストールしてください。
brew install coreutils
makefileでOSによってod/godをスイッチしています。
アセンブラは(プロジェクトフォルダ)/src/sw/test.sにあります。適宜書き換えてみてください。
nano src/sw/test.s
次回はデータRAMの実装に入ります。
Chiselを使ったRISC-Vの勉強(4. プロジェクトへの機械語の読み込みの自動化)
今更ながら環境を整える
VSCodeにChiselプラグインを入れつつ頑張っていたのですが、IDEがオブジェクトやクラスの階層を把握していないため、補完機能が乏しいことなどから限界を感じてました。
いつも参考にさせていただいている、diningyo氏(id:diningyo-kpuku-jougeki)のブログ
Scalaの勉強 - 統合開発環境IntelliJ IDEAのインストール - ハードウェアの気になるあれこれ
というわけでIntelliJ IDEAに乗り換えました。すごく快適。最初っからこうしていればよかった。
機械語の生成
いままでCPUロジックに与えるhexはハンドアセンブルしていましたが、そろそろ限界を感じています。
例えば、JAL命令。
こんなの毎回のように手動アセンブラやってたら脳が停止してしまいます。それに、間違った機械語を記述していたのに気づかずデコーダを作ったら収拾がつかなくなります。
これに関してはみなさん工夫されているようです。
RISC-V(RV32I)のアセンブリから機械語への翻訳(簡易) - Qiita
a163236氏によるscala記述のアセンブラです。機械語がバイナリ文字列で出力されるので、コピペでChiselプロジェクトに貼り付けられるのが魅力。
さて、自分はscalaでアセンブラとか記述できないので、実直にriscv64-unknown-elf-asとodコマンドを用いる方向にしました。
参考1:Chisel-Templateを使ってオリジナルデザインを作ってみるチュートリアル (3. CPUのコアのDebug-Trace作成) - FPGA開発日記
参考2:【 od 】コマンド――ファイルを8進数や16進数でダンプする:Linux基本コマンドTips(93) - @IT
Linuxやwsl環境で32bit(=4byte)長ごとに改行された可読できる機械語を出力するには以下のようにします。(アセンブラファイルはtest.s、欲しいファイルはtest.hexという名前とします。)
riscv64-unknown-elf-as test.s -o test.o riscv64-unknown-elf-objcopy --gap-fill 0 -O binary test.o test.bin od -An -tx4 -w4 test.bin >> test.hex
test.hexはこんな感じになります。
00000013 00100093 00100113 00200193 00300213 00400293 00500313 00600393 00700413 01b00e13 01c00e93 ….
1行あたり32bitで、16進数表記です。
これならコピペしたり、scalaからSource.fromFile
でオープンすることも可能です。
このブログの最後に明記しますが、Makefileを記述したので、make asm
コマンドでワンストップで生成できます。
Scalaからこのファイルをオープンするにはどうしたらいいでしょう。
以下のようにしました。 間違ってたらご指摘ください。
val s = Source.fromFile("test.hex") var bufs :Array[String] = _ try { bufs = s.getLines.toArray } finally { s.close() } // このあと、Arrayから読み出すコードを記述する
ここまでのコード
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
macOSな方はGNU odを使う関係上以下のようにしてcoreutilsをインストールしてください。
brew install coreutils
BSD odと区別するためなのはわかってるつもりですけど、GNU odをgodってするのどうかと思いますね………。
どうでもいいですけど。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.10 --depth 1 cd KyogenRV/
アセンブラは(プロジェクトフォルダ)/src/sw/test.sにあります。適宜書き換えてみてください。
nano src/sw/test.s
------- 補足:riscv-gnu-toolchainの導入(まだ導入していない人向け) -------
今回のコードからriscv-gnu-toolchainが必須となります。
こちらからソースからビルドした方がいいかも。
GitHub - riscv/riscv-gnu-toolchain: GNU toolchain for RISC-V, including GCC
パッケージ管理がディストリビューションごとに違うので、依存ファイルの導入はMacの場合を書きます。
brew install python3 gawk gnu-sed gmp mpfr libmpc isl zlib expat coreutils
riscv-gnu-toolchainを導入します。
git clone --recursive https://github.com/riscv/riscv-gnu-toolchain cd riscv-gnu-toolchain git submodule update --init --recursive ./configure --prefix=/opt/riscv --enable-multilib make
PATHを/opt/riscv/binに通します。
nano ~/.bash_profile # または nano ~/.bashrc
.bashrcまたは.bash_profileに以下を追加
export PATH=$PATH:/opt/riscv/bin
source ~/.bash_profile
またはsource ~/.bashrc
でPATHを通します。
------- 補足終わり -------
保存後、プロジェクトのルートフォルダで
make asm
するとアセンブルがはじまります。
また依存関係の解決をMakefileにさせているのでtest.sに変更があった場合は
make test
したときに同時にアセンブルも行います。(riscv-gnu-toolchainが導入されていて、PATHが通っていることが前提です。)
ビルドが完了すると(project_root)/src/sw/test.hex
が生成されます。
話題は変わりますが、上記プロジェクトには分岐命令の一部を実装しています。
この分岐命令の話などは、また次回の記事で。
Chiselを使ったRISC-Vの勉強(3. ALUの実装とIDステージの改良)
githubを巡回してみていろいろな実装を見て作戦を練っていました。
これまでRISCVの命令セットを見る限り、opcode(7bit) + funct3(3bit)で判別して適当に実装できるかなと考えていました。(10bitなら単純計算で1023命令は用意できるし)
- ALUへの演算命令、2入力への変数指定、結果の格納先(汎用レジスタ、pc、データメモリなど)指定
- データメモリ書き込みイネーブル、アドレス指定、読み込みイネーブル、アドレス指定
- CSR周りのゴニョゴニョ
- CPUストール制御信号
- 分岐制御信号
をIDステージで書けばいいかなと書き始めていました。
マイクロコードの記述方法の調査
IDステージの実装方法を調べているといろいろな実装が見つかります。opcodeから実直にimm,rs1,rs2,funct3を分けて実装しているのが、diningyo氏 (id:diningyo-kpuku-jougeki) のdirv
https://github.com/diningyo/dirv/blob/master/src/main/scala/dirv/pipeline/Idu.scala
とっても簡潔に記述されています。
氏のdirvはステージごとに綺麗に分かれていてパイプラインを組むときにとても作業がしやすそうです。実際、dirvトップモジュールはそれぞれのステージをConnectするだけの記述になっています。
しかし、いろんなrisc-v実装を見てると、マイクロコードはほとんどの実装で示し合わせたようにこんな実装(全く全てが同じではないですが、雰囲気としては同じ)をしてました。
class IDecode { val table: Array[(BitPat, List[BitPat])] = Array( LW -> List(Y, BR_N , OP1_RS1, OP2_IMI , ALU_ADD , WB_MEM, REN_1, MEN_1, M_XRD, MT_W) , LB -> List(Y, BR_N , OP1_RS1, OP2_IMI , ALU_ADD , WB_MEM, REN_1, MEN_1, M_XRD, MT_B ), LBU -> List(Y, BR_N , OP1_RS1, OP2_IMI , ALU_ADD , WB_MEM, REN_1, MEN_1, M_XRD, MT_BU ), LH -> List(Y, BR_N , OP1_RS1, OP2_IMI , ALU_ADD , WB_MEM, REN_1, MEN_1, M_XRD, MT_H ), LHU -> List(Y, BR_N , OP1_RS1, OP2_IMI , ALU_ADD , WB_MEM, REN_1, MEN_1, M_XRD, MT_HU ), …… )}
分岐制御、ALUの入力指定、IMMの種類、ALUでの演算指定、メモリ書き戻し、レジスタ書き戻し、などの指定がListで格納されています。
本家Rocket-chipなどがこの実装だから、なのはわかってたのですが、いろいろ調べてました。
lowRISCというプロジェクトがあり、メモリなどのカスタマイズができるようです。その際にこの体裁で拡張やカスタマイズを進めていくようなのでこの手順を真似しておいた方が今後新たにSoCの設計するときに有利だと思いました。
参考:Adding HW/SW support for the load and store tag instructions · lowRISC: Collaborative open silicon engineering
命令デコード(ID)ステージの実装
自分が参考するにあたって、上記のような手法でIDステージを記述しており、かつChisel3.2以降*1に対応したものはないか、さがしていました。
これが良さげ。
GitHub - ucb-bar/riscv-mini: Simple RISC-V 3-stage Pipeline in Chisel
Chisel3で記述されていて、かなりシンプルでした。これを参考に組み立てていきます。
方針としては、
- RV32Iに対応しているアセンブラのIDステージのみ移植し、いま自作しているプロジェクトにフィットするよう書き換える。
- ALUは32bit固定にして、わかりやすく記述。今後対応しきれないようであれば都度書き換え。
という感じで行くことににしました。
ALUの実装はこんな感じです。へんなところで頑張らない。足し算引き算、(排他的)論理和、論理積、シフトくらい。
ざっと見てVerilogコードがなんとなくでも想像できる形。(一部抜粋)
class ALU extends Module { val io = IO (new Bundle { val op1 = Input(UInt(32.W)) val op2 = Input(UInt(32.W)) val alu_op = Input(UInt(4.W)) val out = Output(UInt(32.W)) }) val shamt = io.op2(4,0).asUInt val w_out = Wire(UInt(32.W)) w_out := MuxLookup(io.alu_op, io.op2, Seq( ALU_ADD -> (io.op1 + io.op2), ALU_SUB -> (io.op1 - io.op2), ALU_SRA -> (io.op1.asSInt >> shamt).asUInt, ALU_SRL -> (io.op1 >> shamt), ALU_SLL -> (io.op1 << shamt), ALU_SLT -> (io.op1.asSInt < io.op2.asSInt), ALU_SLTU -> (io.op1 < io.op2), ALU_AND -> (io.op1 & io.op2), ALU_OR -> (io.op1 | io.op2), ALU_XOR -> (io.op1 ^ io.op2), ALU_COPY1 -> io.op1)) io.out := w_out }
30行足らず。わーい。
このALUをCPU側のモジュールにインスタンシエートし、入力出力をIDステージのフラグから判別してやればOK
ALUの入力セレクタは今のところこんな実装。I-type命令じゃない即値immなどは真面目に実装していないのでこれから頑張る。
// ALU OP1 selector val ex_op1 = MuxLookup(id_ctrl.alu_op1, 0.U(32.W), Seq( OP1_RS1 -> rv32i_reg(idm.io.inst.rs1), OP1_IMU -> 0.U(32.W), // DUMMY OP1_IMZ -> 0.U(32.W) // DUMMY ) ) // ALU OP2 selector val ex_op2 = MuxLookup(id_ctrl.alu_op2, 0.U(32.W), Seq( OP2_RS2 -> rv32i_reg(idm.io.inst.rs2), OP2_IMI -> idm.io.inst.imm, OP2_IMS -> 0.U(32.W) // DUMMY ) ) // ALUの入力にConnectし、ALUのオペレーションを4bit長で指示 import ALU._ val alu = Module(new ALU) alu.io.alu_op := id_ctrl.alu_func alu.io.op1 := ex_op1 alu.io.op2 := ex_op2
ALUの出力先はpcとかデータメモリとか色々あるはずですが、今回はまだ汎用レジスタだけ実装しています。
when (rf_wen === REN_1){ // 汎用レジスタ書き込みイネーブルであれば when (rd_addr =/= 0.U){ rv32i_reg(rd_addr) := alu.io.out // 汎用レジスタに書き込み }.otherwise { // rd_addr = 0 rv32i_reg(0.U) := 0.U(32.W) // ゼロレジスタが指定されていたら、強制的にゼロを書き込む(nop命令などの用途) } }
ALUを使い、かつ汎用レジスタのみで読み書きが完結する命令はI,およびR-typeの命令です。
- rd,rs1,imm(12bit)を使う命令
- rd,rs1,imm(5bit)を使う命令
- rd,rs1,rs2を使う命令
それぞれから1命令づつピックアップしてハンドアセンブルして、結果が期待された値となっているか確認。
- addi
- slli
- add
をピックアップ。
下記のように、バイナリを記述して、汎用レジスタの結果を見ます。
(中略) 0x00A00593L, // addi x11,x0,10 (x11 = 0 + 10 = 10) 0x00259093L, // slli x1,x11,2 (x1 = x11 << 2 = 40) 0x00B00613L, // addi x12,x0,11 (x12 = 11) 0x00C581B3L, // add x3,x11,x12 (x3 = x11 + x12 = 10 + 11 = 21 = 0x15)
期待される結果は、
x1 = 40(= 0x28)
x3 = 21(= 0x15)
x11 = 10(= 0x0A)
x12 = 11( = 0x0B)
です。
結果:
コード全文
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.9.1 --depth 1
*1:初期の頃に記述されたものはChisel2以前のバージョンで記述しているサンプルも多いため
Chiselを使ったRISC-Vの勉強(2)
汎用レジスタ(x0-x31)の実装
RISC-V(RV32I)の汎用レジスタは32bit長で32個あります。
https://content.riscv.org/wp-content/uploads/2017/05/riscv-spec-v2.2.pdf
まずRISC-Vの命令デコードには必須なのでこちらを用意します。
Chiselだとレジスタ宣言、ゼロ初期化まで1行でかけます。x0 = zero registerで固定ですが、めんどくさいのでx1-x31とまとめて確保しました。
val rv32i_reg = RegInit(VecInit(Seq.fill(32)(0.U(32.W)))) // x0 - x31:All zero initialized
デバッグトレース
今のところ、デバッグトレースで必要なものは何かを考え、テストオブジェクトに入出力できるようにしました。
- PC
- Program counterの読み書きができるようにしました。halt信号(後述)をtrueにしてから書き換え推奨。
- 命令メモリ
- 前回の記事で既に実装済み。
(ID)命令デコードの実装
力弱く、addiだけ実装しました。これを実装すると、上記で用意したx0-x31までのテストができるからです。
以下のようなアセンブラを記述します。
addi x1, x0, 0; // x1 = x0 + 0 (=0) addi x2, x0, 1; // x2 = x0 + 1 (=1) addi x3, x0, 2; // x3 = x0 + 2 (=2) addi x4, x0, 3; // x4 = x0 + 3 (=3) …. addi x31, x0, 1E; x31 = x0 + 0x1E (=30)
ここまで説明しておいてアレですけどアセンブラ流し込むスクリプトを記述してなかったので(おい)、、機械語で書きましょう。
AddiはRISC-Vの中でI-Typeの命令語で12bitの即値(imm)、1つの代入元(ソース)レジスタ(rs1)、1つの代入先(デスティネーション)レジスタ(rd)から構成されます。rs1はx0~x31、rdはx1~x31上にあるいずれかのレジスタ*1となります。
b????_????_????_????_?000_????_?001_0011
であれば良いことがわかります。16進数で表記すると、
0x00000093, // addi x1,x0,0 (x1 = x0 + 0 = 0) 0x00100113, // addi x2,x0,1 (x2 = x0 + 1 = 1) 0x00200193, // addi x3,x0,2 (x3 = x0 + 2 = 2) 0x00300213, // addi x4,x0,3 (x4 = x0 + 3 = 3) .... 0x01E00F93 // addi x31,x0,30
となります。うっひょ、マシン語とか超楽しいマジ無理。早くアセンブラからバイナリ流し込む機能実装しないと。
Chiselで機械語の判定はBitPatを使うと簡単にできます。
when (inst_code === BitPat("b????_????_????_????_?000_????_?001_0011")){ // ADDI = imm12=[31:20], src=[19:15], funct3=[14:12], rd=[11:7], opcode=[6:0] val dest = inst_code(11,7) // rd-pointer val imm = inst_code(31,20) // imm val src = inst_code(19,15) // src-pointer val dsrc = rv32i_reg(src) //rs when (0.U < dest && dest < 32.U){ //アドレスは1~31か? rv32i_reg(dest) := dsrc + imm } }
と言った感じです。
テストコードを書き直します。1部抜粋です。
poke(c.io.sw.halt, true.B) // CPUを停止 step(1) for (addr <- 0 to (memarray.length * 4 - 1) by 4){ poke(c.io.sw.wAddr, addr) poke(c.io.sw.wData, memarray(addr/4)) // 指定アドレスに機械語をロード println(f"write: addr = 0x${addr}%08X, data = 0x${memarray(addr/4)}%08X") step(1) } poke(c.io.sw.w_pc, 0) // プログラムカウンタをゼロに戻す=reset step(1) // fetch pc poke(c.io.sw.halt, false.B) // CPUを再開させる step(1) 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"read : addr = 0x$a%08X, data = 0x$d%08X") // メモリ上の機械語のダンプ step(1) } step(1) poke(c.io.sw.halt, true.B) step(1) for (lp <- 0 to 31 by 1){ poke(c.io.sw.gAddr, lp) step(1) val d = peek(c.io.sw.gData) println(f"read : x$lp%2d = 0x$d%08X") //x0 ~ x31レジスタの内容をダンプ step(1) }
動作確認
コード全文
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.4 --depth 1
きょうはここまで。
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
Cyclone10LP基板ができたのでLチカ
基板到着
新型コロナの影響なのか国内の基板製造にみなさん移行しているようで、いつもより時間がかかりました。
特に実装エラーもなかったようで安心。基板製造前の指示で認識マークを基板本体にも追加しています。
こんな感じでラズパイにスタックして使います。とてもコンパクト。
ラズパイ経由でFPGAのSRAMを書き換え、デバッグバウンダリスキャンができます。HDLコンパイルは自宅のWindowsで行い、遠隔地に仕掛けたラズパイからFPGAをコンフィグし、結果を取得するといった完全リモートでFPGAを扱うことができます。
クロスコンパイラとJTAGデバッガをラズパイにインストールしておき、
- 低消費電力要求時:動作周波数を下げたソフトコアCPU、最低限のペリフェラル
- 処理性能要求時:高速ソフトコアCPUと差動バス
などと状況に応じた機能を遠隔でコンフィグするなどの動作ができます。その後ラズパイ上のコンパイラ(もちろん遠隔からでも)からバイナリを流し込めます。
全日本Lチカ人権宣言
基板を作ってもLチカができないと人権を剥奪されるおそれがあるらしいようなのでLチカ人権宣言を行いました。
一番簡単なのはHDLでクロックを分周して下位のビットをLEDを接続した端子に出力する方法です。
めんどくさいので今回はやりません。ぼくFPGAとかHDLとかわかんないし。
Raspberry Pi ZeroからCyclone10LPへバウンダリスキャンレジスタからLED端子に1/0を書き込みます。
ラズパイにUrJTAGを使います。GPIOを使うビルドが必要になるので、以前のブログの方法でビルドするといいと思います。
Raspberry Pi 3でUrJTAGを使う - lynxeyedの電音鍵盤
インテル(アルテラ)のFPGAではJTAGモードではTDO/TDI/TCK/TMSの4端子を使います。それぞれがラズパイのGPIOに接続されている必要があります。
UrJTAGにラズパイのどの端子が、FPGAのTDO/TDI/TCK/TMSのどこに対応するか伝えます。
なおラズパイの40pinの端子番号ではなく、マイコン(BCM283x)のどのGPIOに対応しているか、で伝えます。
今回は
TDO=20, TDI=21, TCK=26, TMS=19
に対応して接続しています。ラズパイのピン番号とBCM283xの対応が知りたい時は、以下のようにします。
sudo apt update sudo apt install python3-gpiozero $pinout
UrJTAGに認識させます。
sudo jtag cable gpio tdo=20 tdi=21 tck=26 tms=19 detect
おそらく、以下のメッセージになると思います。
IR length: 10 Chain length: 1 Device Id: 00000010000011110011000011011101 (0x020F30DD) Manufacturer: Altera (0x0DD) Unknown Part! (001000001111001) (/usr/local/share/urjtag/altera/PARTS)
デバイスが不明です。BSDLファイルが必要になります。
インテルのページから取得できます。必要なのはCyclone10LP:10CL025U256のBSDLファイルです。
Intel Cyclone 10 LP Device BSDL Files
UrJTAGへBSDLファイルを導入する方法は多くのブログで取り上げられているので扱いません。
参考になるブログ
BSDLファイルを導入できると、先程のメッセージが変わると思います。
IR length: 10 Chain length: 1 Device Id: 00000010000011110011000011011101 (0x020F30DD) Manufacturer: Altera (0x0DD) Part(0): 10CL025U256 (0x20F3) Stepping: 0 Filename: /usr/local/share/urjtag/altera/10cl025u256/10cl025u256
さてLチカです。バウンダリスキャンレジスタにピンの状態を書き込んで点滅させます。
この基板ではLEDはCyclone10LPのIO84(PIN_L15)/IO85(PIN_L16)に接続されています。
先ほどのBSDLを見ると、
--I/O Pins ......(中略), "IOL16 : L16 , IOL15 : L15 , IOK16 : K16 , ..............
とありました。IOL15,IOL16の状態をバウンダリスキャンレジスタ経由で書き換えます。
以下のスクリプトをhoge.txtという名前で保存します。
cable gpio tdo=20 tdi=21 tck=26 tms=19 detect instruction EXTEST shift ir set signal IOL15 out 0 set signal IOL16 out 1 shift dr usleep 500000 shift ir set signal IOL15 out 1 set signal IOL16 out 0 shift dr usleep 500000 shift ir set signal IOL15 out 0 set signal IOL16 out 1 shift dr usleep 500000 shift ir set signal IOL15 out 1 set signal IOL16 out 0 shift dr usleep 500000 shift ir set signal IOL15 out 0 set signal IOL16 out 1 shift dr usleep 500000 shift ir set signal IOL15 out 1 set signal IOL16 out 0 shift dr usleep 500000 shift ir shift ir set signal IOL15 out 1 set signal IOL16 out 1 shift dr
以下のコマンドで実行します。
sudo jtag hoge.txt
動作風景
youtu.be
次回
RISC-V界隈で完成度がすごく高く、フリーで商用利用OKとホットなSyntacore社のRISC-VコアSCR1をCyclone10LPに移植したので、紹介いたいます。
お楽しみに。