Chiselを使ったRISC-Vの勉強(13.FPGAへの実装1)
メモリを同期読み込み書き込みにする
製作してきたChiselプロジェクトをFPGAに実装してみました。
Chiselを使ったRISC-Vの勉強(1)から目標にしていたようにAvalon-MM Masterとして動作させようとすると、
- 命令フェッチ時にデータ取得が1クロック遅れる
- ロード動作時に1クロック遅れる
(= どちらもメモリ読み込み動作時にのみ1クロック遅れる)
という現象が生じていました。これはChiselで命令メモリ/データメモリをMem(同期書き込み・非同期読み込みメモリ)として実装していたからです。
命令メモリ、データメモリ共にSyncReadMemに置き換えました。
github.com
git clone http://github.com/panda5mt/KyogenRV -b 0.1.1 --depth 1
超ミラクルスーパーウルトラ大規模改修工事の恐れがありましたが、パイプライン動作そのものを大きく変更するような変更がなかったため比較的小規模な改修ですみました。riscv-testsも変わらずパスしています。
この修正に伴い、追加で修正した分
- メモリR/W動作をハンドシェイクからレイテンシ動作に変更
Avalon-MMでは指定されたレイテンシでメモリリードライトを行います。もしスレーブが期待されたレイテンシで応答できない場合、スレーブ側はホストへwaitrequest信号を発行します。
ですので、Chisel側も準じた実装に変更しました。レイテンシに対応したステージでR/W動作を行い、waitrequest信号がきたらストールするだけです。(かえってハンドシェイク方式より単純になりました)
FPGAへの実装テスト
ここでQuartus Primeの使い方を紹介するつもりはないので少し駆け足で説明します。
ハーバードアーキテクチャCPUソフトコアをAvalon-MM Masterとする場合、命令メモリとデータメモリの2つのMasterを持つと思います。
この二つは独立ではなく、Back Pressureに支配されて連携動作をします。(今回はキャッシュ非搭載のため、命令/データは都度フェッチします)
- ChiselプロジェクトからVerilogコードを生成させる。
- PlatformDesignerに読み込ませるためのトッププロジェクトを作り(今回はSystemVerilog)、上記コードをインスタンスする。
- 新規プロジェクトを作り、PDで上記CPU + On-chip Memory + IOを使ったプロジェクトを作る
- hexエディタで命令を書く
- SigmalTapで実行中のCPUの動作を確認
項目2.の信号はavalon-MM Master信号と下記のように対応させました。
- clock
- reset
- byteenable
- waitrequest
- address
- read
- readdata
- write
- writedata
PlatformDesignerに登録が終わったら、項目3に進んでいきます。
それぞれの接続は以下のようになりました。onchip_memoryは0x0000番地から、I/Oはデータメモリ開始番地0x8000に配置します。
項目4.
以下は疑似コードです。これをアセンブラで記述します。今回書くコードの目的は命令/データメモリが正しく動作しているかの確認です。
uint32_t data; *(*PIO_BASE_ADDRESS) = 0xAA; // PIO = 0xAA data = *(*PIO_BASE_ADDRESS); data = data + 1; *(*PIO_BASE_ADDRESS) = data; // PIO = 0xAB
上記をアセンブラで記述します。
lui x1, 0x08 # x1=0x8000 li x2, 0xAA # x2 = 0xAA sw x2, 0(x1) # dmem[0x8000] = 0xAA lw x3, 0(x1) # x3 = dmem[0x8000] addi x3, x3, 1 # x3 = x3 + 1 sw x3, 0(x1) # dmem[0x8000] = x3 _loop: jal x0, _loop # loop
と書いてもいいのですが、これだとデータメモリに格納される前にデータフォワーディングでRWされるデータがよしなに取り扱われてしまうので、適宜パイプライン段数以上のnopを入れてやります。そうすることで本当にデータメモリの読み書きが機能しているかを確認することができます。
lui x1, 0x08 # x1=0x8000 li x2, 0xAA # x2 = 0xAA sw x2, 0(x1) # dmem[0x8000] = 0xAA nop nop nop nop nop lw x3, 0(x1) # x3 = dmem[0x8000] nop nop nop nop nop addi x3, x3, 1 # x3 = x3 + 1 nop nop nop nop nop sw x3, 0(x1) # dmem[0x8000] = x3 _loop: jal x0, _loop # loop
みたいな感じです。アセンブルして、得られた機械語をQuartusのHexエディタで記録保存、Onchip Memoryの初期値データとします。
SignalTapで動作状況を見てみました。
ロード命令も動作しています。waitrequestにより1クロック待たされていますが、動作しています。
Chiselを使ったRISC-Vの勉強(12. riscv-testsの全項目クリア)
riscv-testsクリア
riscv-testsのリグレッションテストを全てクリアしました。(fence.i命令は除外しました。RISC-Vの基本Iアーキテクチャでは必須ではなくなったからです。)
テストがfailしたもののほとんどはCSRに起因し、CSRレジスタのフォワーディングの実装の間違いに起因するものでした。
これはトレース結果を見ればすぐにわかるので修正→全テストのやり直しを繰り返せばクリアできるものが仕上がっていきます。
ここでは覚書としてクリアするのに1日以上かかった項目を挙げていきます。
CSRの実装 ≒ 例外の実装
intel FPGAに実装する際、カスタム命令を実行するコプロセッサを接続する予定なのでCSRを結局外してしまうのですが、ISAに定められている例外を正しく実装する事はコプロセッサを作る際の参考にもなるため、CSRをできる限り正しく実装することには意味があると言えます。
RISC-Vはフラグを持っていないCPUなので例外に頼る必要もあると思われます。
躓いていたテストは以下の実装でした。
- rv32mi-p-illegal
- rv32mi-p-ma_addr
- rv32mi-p-ma_fetch
- rv32mi-p-shamt
- rv32mi-p-illegal
illegalに関しては今回はRV32Iのみのアーキテクチャであり、RVC(圧縮命令)の実装をしていないので、16bit命令が来た際に正しく不正命令例外を発生させればよくクリアできました。
- rv32mi-p-ma_addr
load/storeのミスアラインアドレスのテストです。
ここで例外が発生したときにmepc(発生時のpc)に加えて、mtval(発生する要因となった命令そのもの)を格納する実装をしました。
- rv32mi-p-ma_fetch
CSRモジュールはEXステージの時のPC,ALUへの入力値(rs1,rs2,immなど)を与える実装にしています。ミスアラインが発生するのは分岐命令時です。分岐先に指定されているアドレスが不正なために例外が発生します。
- jal, jalrは条件なく分岐
- branch命令は条件成立で分岐
これが少し厄介な状況を生み出します。jal/jalr命令は分岐先を調べて即例外を発行できるのに対し、branchは少なくともmemステージ(=ALUから比較結果の回答待ち)まで待たないといけません。ですので以下のような対応にしました。
- jal, jalrは不正なアドレスを確認したら即例外発行
- branch命令はpcと命令を一旦仮に格納(mepc,mtval)→分岐後不正なpcであった場合仮格納していたpcと命令を復元し例外発生
としました。ここで、「jal/jalrもいっしょの挙動にすりゃいいじゃん」または「CSR動作中は本体CPUストールすれば?」となります。
後者は検討中です。CSR自体もパイプライン動作をしているので「CSRモジュールが動作中か否か」という条件を洗い出す作業とALUをCSRに引き渡すロジックの追加が必要になります。
前者は容易に思いつきますができません。理由は以下の通りです。
jal rd, <jump_address>
とした場合、rdにはこの命令の次のpcが格納されます。不正なアドレスにジャンプした後に例外判定をすると、rdが更新された後になってしまうため、rdの値をこの命令実行前の状態に復元する必要があります。
できなくはないけどする必要は全くないですし、いらない32bitのレジスタと配線を増やしてFPGAの動作を低下させる必要はないですね。
この命令もそうですが、パイプラインのなるべく早い段階で例外を発見し処理すると不要なパイプラインレジスタを実装せずにすみ、論理回路消費を抑え、高速化に寄与します。
- rv32mi-p-shamt
シフト命令で(※純粋なシフト命令のみのテストではない)テストパスできないなんてあるのか楽勝じゃん?とおもったら最後までPassできずに泣いていました。
躓いていたのはこの命令。
slli a0,a0,0x20 // a0 = a0 << 32
32bitシフトしたらゼロにすりゃいいのでは?と思っていたら、そういう理由ではありませんでした。
RV32Iでこの命令の即値は5bitです。つまり、0x20という表現はできません。不正命令として例外吐く動作が正しい挙動でした。基本的すぎるミスでウケる。
と、こんな感じでようやくクリアできました。
ここまで到達するのに3ヶ月。長かったですね。
今後
ランダムテストを通しながら以下の独立した試行をやっていきます。
コード
ここまでのコード全文は以下で。
github.com
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.1.0 --depth 1
Chiselを使ったRISC-Vの勉強(11. riscv-testsの自動化)
riscv-testsのリグレッションテストコードを自動生成
あまり記事にするほど内容は厚くないのですが備忘録として。
一つのテストコードをpassできても、今までpassできていたものがfailしてしまうロジックを埋め込んでしまう可能性は否めません。
全てのリグレッションテストを通しでpassさせる必要があります。今回の構成の場合、マシンモード/メモリウォーキング非搭載/32bit整数のみ/圧縮命令なしなのでrv32ui-p-/rv32mi-p-に対応すれば良く、テストコードは少なめの48個です。
まぁ比較的少ないのですが、手動で書いているとしんどいのです。
"rv32ui-p-add.hex test using Driver.execute" should "be pass test." in { iotesters.Driver.execute(Array(), () => new CpuBus())(testerGen = c => { CpuBusTester(c, "src/sw/rv32ui-p-add.hex") }) should be (true) }
こんな感じのコードをリグレッションテスト数だけ作り、今後64bit拡張、Sv39搭載なども考えているのでテストは増大することになります。今のうちにpythonで自動化することにしました。
参考にさせていただいたのはid:msyksphinz氏のブログ。
msyksphinz.hatenablog.com
方針を決めていきます。
- 手作業で頑張る部分
- riscv-testsをgit cloneし、kyogenrvのプロジェクト内にmakeするところまでは手動(これは1回実行すればいいはずなので)
- 自動化した部分
- フォルダ内のrv32ui-p-* rv32mi-p-*で始まるelfファイルを走査し、hexファイルを作る
- 作成したhexファイルのScalaTestコードを自動生成
- テストコード実行
使い方
readmeには記載したのですが、改めて。
- kyogenrvを導入
git clone http://github.com/panda5mt/KyogenRV
- riscv-testsを導入します
git clone https://github.com/riscv/riscv-tests cd riscv-tests git submodule update --init --recursive
- リンカスクリプトの修正
nano env/p/link.ld
プログラム (.text)セクションが0x00000000
から開始されるように修正
SECTIONS { . = 0x00000000; # -> ここを修正 .text.init : { *(.text.init) } . = ALIGN(0x1000); .tohost : { *(.tohost) } . = ALIGN(0x1000); .text : { *(.text) } . = ALIGN(0x1000); .data : { *(.data) } .bss : { *(.bss) } _end = .; }
- link.ldを上書きしriscv-testsをビルド
autoconf ./configure --prefix=<KyogenRVsプロジェクトのルートディレクトリのフルパス>/tests/ make make install cd ../
- KyogenRVのプロジェクトディレクトリに戻ります
cd KyogenRV/ make clean make riscv-tests
これでテストコードのhex生成からリグレッションテストまで一貫して行います。終了するまで3分ほどかかりました。長い。
ちょっと工夫しないとなぁ。
IntelliJのGUIで実行すると便利
IntelliJ IDEAユーザの方はmake riscv-tests
するのではなくterminalで
make tester-gen
すると、テストコード生成までで終えてくれます。あとはGUIで行う方がpass/failが分かりやすいと思います。
/src/test/scala/TestCoreAll.scala
が自動生成されたコードです。
下図のようにクリックし、[Run TestCoreAll]を選択します。
実行中。プログレスバーも出て進行度がわかります。
リグレッションテストがどこでfailしたかも一目瞭然。(下図の場合はfence.i命令テストでエラー)
Chiselを使ったRISC-Vの勉強(10. Load/Store全命令の実装)
実装中のRV32I RISC-V CPUですが、5段パイプラインのまま継続するか、6段パイプラインに増やすか見積もるため、Load/Storeを真面目に実装していませんでした。(今まで対応していたのは32bit長のlw/swのみ)
結論から申しますと、5段パイプラインのまま継続することにしました。詳細は後述する「パイプライン数の見積もり」をご覧ください。また、RV32Iとして一定の完成度になりつつあると思えてきたので、riscv-testsによるリグレッションテストも実施しました。
.dataセクションにも書き込めるようにする
Load/Store命令の実装がISA仕様を満たしているかをtestするにはriscv-testsのうち以下のものをクリアしなければいけません。
- rv32ui-p-sb
- rv32ui-p-sh
- rv32ui-p-sw
- rv32ui-p-lb
- rv32ui-p-lbu
- rv32ui-p-lh
- rv32ui-p-lhu
- rv32ui-p-lw
Dumpファイルを見れば分かりますが、上記のコードでは.dataセクションに初期値を置くため、データメモリが実装されていないとテストにパスしません。
今回製作したchiselプロジェクトではchisel-iotestersでコードを命令バス経由でメモリに書き込みます。
データメモリもこの命令バスからでもアクセスできるよう改造しました。
ですので、アンチグリッドロジックの実装は完全にAvalon-MM任せにできるので行いません。intel FPGA便利。
byte/halfword/wordの読み書きに対応する
データメモリ、命令メモリとも32bitアドレス/32bit幅のみのアクセスしかできていませんでしたが、RV32Iの場合load/store命令はbyte(8bit)/halfword(16bit)/word(32bit)のアクセスができないといけません。
これもAvalon-MMバス上ではbyteenable信号によりアラインメントの制約があるものの、8/16/32bitアクセスが可能です。
例えば、下位8bitのみ書き込みがしたい場合、
- writedata = (32bitデータ)
- byteenable = 0b’0001
とすれば32bitのうち、下位8bitのみ書き込まれます。
- writedata = (32bitデータ)
- byteenable = 0b’1100
とすれば32bitのうち、上位16bitのみ書き込まれます。
参考:
https://www.intel.com/content/dam/www/programmable/us/en/pdfs/literature/manual/mnl_avalon_spec.pdf
Chisel上でのメモリ側の実装は8bit x 4レーンに分け、byteenable信号によって書き込むレーンを選択するロジックを追加しAvalon-MM接続時と同等の環境を用意しました。
CPU側から、データメモリへの書き込みロジック(Store命令)は以下の通りです。
// send bus write size io.w_dmem_dat.byteenable := DontCare // mem_rs(1) when(mem_ctrl.mem_wr === M_XWR) { // Store命令か? when(mem_ctrl.mask_type === MT_B) { // byteライト switch(mem_alu_out(1, 0)){ // アドレスの下位2bitでアラインを判断 is("b00".U){ io.w_dmem_dat.byteenable := "b0001".U io.w_dmem_dat.data := mem_rs(1) } is("b01".U){ io.w_dmem_dat.byteenable := "b0010".U io.w_dmem_dat.data := mem_rs(1) << 8.U } is("b10".U){ io.w_dmem_dat.byteenable := "b0100".U io.w_dmem_dat.data := mem_rs(1) << 16.U } is("b11".U){ io.w_dmem_dat.byteenable := "b1000".U io.w_dmem_dat.data := mem_rs(1) << 24.U } } }.elsewhen(mem_ctrl.mask_type === MT_H) { // halfwordライト switch(mem_alu_out(1, 0)){ is("b00".U){ io.w_dmem_dat.byteenable := "b0011".U io.w_dmem_dat.data := mem_rs(1) } is("b10".U){ io.w_dmem_dat.byteenable := "b1100".U io.w_dmem_dat.data := (mem_rs(1) << 16.U) } } }.otherwise { // wordライト io.w_dmem_dat.byteenable := "b1111".U io.w_dmem_dat.data := mem_rs(1) } }
CPU側から、データメモリへの読み込みロジック(Load命令)は以下の通りです。
when(mem_ctrl.mem_wr === M_XRD) { // Load命令か? when(mem_ctrl.mask_type === MT_B) { // byteリード switch(mem_alu_out(1, 0)){ is("b00".U){ wb_dmem_read_data := Cat(Fill(24, io.r_dmem_dat.data(7)), io.r_dmem_dat.data( 7, 0)) } is("b01".U){ wb_dmem_read_data := Cat(Fill(24, io.r_dmem_dat.data(15)),io.r_dmem_dat.data(15, 8)) } is("b10".U){ wb_dmem_read_data := Cat(Fill(24, io.r_dmem_dat.data(23)),io.r_dmem_dat.data(23,16)) } is("b11".U){ wb_dmem_read_data := Cat(Fill(24, io.r_dmem_dat.data(31)),io.r_dmem_dat.data(31,24)) } } }.elsewhen(mem_ctrl.mask_type === MT_BU) { // byteリード符号なし switch(mem_alu_out(1, 0)){ is("b00".U){ wb_dmem_read_data := Cat(0.U(24.W), io.r_dmem_dat.data( 7, 0)) } is("b01".U){ wb_dmem_read_data := Cat(0.U(24.W), io.r_dmem_dat.data(15, 8)) } is("b10".U){ wb_dmem_read_data := Cat(0.U(24.W), io.r_dmem_dat.data(23,16)) } is("b11".U){ wb_dmem_read_data := Cat(0.U(24.W), io.r_dmem_dat.data(31,24)) } } }.elsewhen(mem_ctrl.mask_type === MT_H) { // halfwordリード switch(mem_alu_out(1, 0)){ is("b00".U){ wb_dmem_read_data := Cat(Fill(16, io.r_dmem_dat.data(15)), io.r_dmem_dat.data( 15, 0)) } is("b10".U){ wb_dmem_read_data := Cat(Fill(16, io.r_dmem_dat.data(31)), io.r_dmem_dat.data( 31, 16)) } } }.elsewhen(mem_ctrl.mask_type === MT_HU) { // halfwordリード符号なし switch(mem_alu_out(1, 0)){ is("b00".U){ wb_dmem_read_data := Cat(0.U(16.W), io.r_dmem_dat.data( 15, 0 )) } is("b10".U){ wb_dmem_read_data := Cat(0.U(16.W), io.r_dmem_dat.data( 31, 16)) } // others is("b01".U){ wb_dmem_read_data := 0.U } is("b11".U){ wb_dmem_read_data := 0.U } } }.otherwise { wb_dmem_read_data := io.r_dmem_dat.data } }.otherwise { wb_dmem_read_data := io.r_dmem_dat.data }
パイプライン数の見積もり
上記実装の試行錯誤をしながらパイプライン数の見積もりをしていました。
結論として、
- Load命令をStore命令にくらべて極端に多用する場合は6段パイプライン(たとえばGPIOの入力を絶えずポーリングするなど)
- それ以外の場合は5段パイプラインで十分そう
Load命令が連続すると、ストール時間が増えます。加えて
(中略) Load命令 分岐命令
という順序の場合、Load命令が分岐命令によってフラッシュされないようストール時間がさらに1クロック分増えます。この処理を多用するポーリング動作の場合は6段に拡張した方が処理速度は上がります。
が、低速な8bitマイコンではないのでこういう処理ばかりに専念させないと思われます。割り込み使うし。
加えていうのであれば、そこまでレイテンシ気にする場合はパイプラインマシンにとってコストが高い分岐命令を使うな、というところにまで発展するので。。。
こう言った結論から、5段パイプラインのままにしました。
riscv-testsを通してみた
RV32Iに要求されているテストのうち、以下がパスできたものです。
- rv32ui-p-add
- rv32ui-p-addi
- rv32ui-p-and
- rv32ui-p-andi
- rv32ui-p-auipc
- rv32ui-p-beq
- rv32ui-p-bge
- rv32ui-p-bgeu
- rv32ui-p-blt
- rv32ui-p-bltu
- rv32ui-p-bne
- rv32ui-p-jal
- rv32ui-p-jalr
- rv32ui-p-lb
- rv32ui-p-lbu
- rv32ui-p-lh
- rv32ui-p-lhu
- rv32ui-p-lui
- rv32ui-p-lw
- rv32ui-p-or
- rv32ui-p-ori
- rv32ui-p-sb
- rv32ui-p-sh
- rv32ui-p-simple
- rv32ui-p-sll
- rv32ui-p-slli
- rv32ui-p-slt
- rv32ui-p-slti
- rv32ui-p-sltiu
- rv32ui-p-sltu
- rv32ui-p-sra
- rv32ui-p-srai
- rv32ui-p-srl
- rv32ui-p-srli
- rv32ui-p-sub
- rv32ui-p-sw
- rv32ui-p-xor
- rv32ui-p-xori
- rv32mi-p-breakpoint
- rv32mi-p-csr
- rv32mi-p-mcsr
- rv32mi-p-sbreak
- rv32mi-p-scall
パスできなかったもの
- rv32ui-p-fence_i
- rv32mi-p-ma_addr
- rv32mi-p-ma_fetch
- rv32mi-p-illegal
- rv32mi-p-shamt
なおfence.i
命令はこの記事執筆時点(ISA v.2.1)ではRV32/64/128の基本I命令からは外されており、"Zifencei"extensionという拡張扱いになっています。
実装は必須ではありません。命令キャッシュとデータキャッシュのコヒーレント性を保つ唯一の命令ですが、実装コストの大きさ、およびunix系OSで命令フェッチした際のコヒーレンス維持命令としては実用に耐えないと見たようです。
詳しくは以下の、p31,32を参照。
https://github.com/riscv/riscv-isa-manual/releases/download/draft-20200611-d08e29e/riscv-spec.pdf
あと、rv32mi-p-ma_*のテストコード、dumpファイルを見ると16bit命令が埋め込まれているので圧縮命令なのかな?(ma=misalignedってこと?)今回Cには対応しないのでこれはパスしなくていいかなという気持ち。あるいは例外吐く処理を入れるべきかな。。調査中
ミスアライン例外を起こすのが正解でした。
対応します。
その他のものはCSRの実装が現状で不完全なため動作が確認できていませんが、OpenOCDなどデバッガを接続した際に必須となるものばかりなので、実装していきます。
コード全文
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.10.25 --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の勉強(9.CSR:外部割り込みの実装)
外部割り込みを実装しました。
RISC-Vに実装する割り込みはCPU外部からの割り込みだけ(あるいは各割り込み入力をマルチプレクスしてここへ入力、または割り込み信号の判別だけできるようにして入力を増やしていく感じで)でいいかな、と思ってきた次第です。
ターゲットFPGAはintel P.S.GのCyclone10LP/GX/MAX10シリーズを考えているのでペリフェラルの実装はPlatform Designerで行います。
各モジュールはCPUの外部かつAvalon-MM上に存在することになります。割り込みはこの外部からの信号のみ受け付けるようにします。
事の発端
タイマー割り込みの調査をしていました。
- mtime : 時間計測用。一定のクロックでカウントアップ。RV32/RV64でともに64bit長
- mtimecmp:時間比較用レジスタ。mtime >= mtimecmpでタイマー割り込み発動。RV32/RV64でともに64bit長
となっており、タイマー割り込み発火のためには以下のようにプログラムを記述します。
- mtvecに割り込みトラップ先のアドレスを格納
- mtimecmpに割り込み発動の時間を格納
- mie.mtie=1にしてタイマー割り込みを許可
なるほどねーと
アセンブラを書いてみました。
addi x1, x0, 0x3FF csrrw x0, mtimecmp, x1
コンパイルすると
Error: unknown CSR `mtimecmp'
???
Machine time register (memory-mapped control register). Machine time compare register (memory-mapped control register).
メモリーマップドなのかーい!
Privileged Architecture Version 1.7を見るとまだmtime
,mtimecmp
は32bit長でCSRレジスタになっており、v.1.9あたりからメモリマップドに変わっています。特にhart>=2であれば(いわゆるマルチコアであれば)割り込み関連のSFRはメモリマップドにする方が全てのhartのSFRに素早くアクセスができ、得策といえます。一見、当たり前のことのなのですが、ちょっとびっくり。
ここで方針を変更します。
CSRからメモリマップドIOにダイレクトにアクセスするのは得策とは言えません。
CPUとリソースの取り合いになるからです。割り込み発動も遅くなります。それにこんな事で調停回路つくるの超めんどくさい。
せっかくメモリーマップドなので、メモリーマップドなタイマーモジュールを作ります。
このモジュールにアクセスするのにCPUがmtime
,mtimecmp
またはそれ相当のSFRをRWするというわけです。
そしてこのモジュールがmtime >= mtimecmp
となったときにCSRレジスタMIP.MTIP=1となるようにします。
割り込み回路実装の検討
ここで外部割り込みでもタイマー割り込みでも仕組みが同じになってしまいました。
違いは割り込み要因が外部割り込みMIP.MEIP=1
かタイマー割り込みMIP.MTIP=1
かの違いです。
タイマー割り込み回路を作る前の素振りとして、外部割り込み回路を作ります。
外部割り込み回路実装
CPUへの実装をしていきます。
- 割り込み入力用の1ビット信号を用意
- 任意のタイミングで割り込みビット=1
- 割り込み発生
- mepcへ発生時のアドレスを格納
- mtvecのアドレスに分岐
簡単です。
このとき、割り込み発生時に分岐命令処理中ですと割り込み信号による例外が握り潰されてしまうことがあるので、pc更新の論理回路が例外発生時の処理を最優先にするよう修正しています。
mepcがゼロになるんだが
mepcは前回のブログでも取り上げた通り例外発生時にいたpcを格納するCSRレジスタです。
実はむずかしいのはmepcの実装でした。理由は以下の通りです。
マルチパイプラインCPUなので、分岐中は実際にデコードや演算処理しないけど便宜上フェッチする(あとでnopにするかフラッシュする)命令があったり、ストール中は一旦pcをゼロにして、分岐完了後に正しいカウンターをフェッチして再カウントしたりします。
という訳で、いつ発動するかわからない割り込みは、場合によっては有り得ないpcを戻り値としてmepcへ格納してしまうことになりかねません。
対策としては、パイプラインが処理中の内容を監視し、ストール中か分岐中かを判別する。もしどちらかの状態であれば、その時点で有効だった最後のpcの値を戻り値としてmepcに格納します。
判別用にpc_invalidという信号を用意しました。
// program counter check val pc_invalid: Bool = inst_kill_branch || (ex_pc === pc_ini)
割り込みが発動した場合かつ分岐中、ストール中(pc=0)はmepcにpcを格納しないようにしました。
この場合は最後に有効だったpcをmepcへ格納します。
それ以外の例外発生時、例えばecall命令などではどうなるかというと、自身が原因でストールが発生、次フェーズで例外要因の分岐が発動しているはずなので上記の処理したpcを戻り値として格納してはいけません。
例外発動するまえのpcの値が格納されるので正確ではなくなる。そのまま現在のpcをmepcへ格納するのが正しいといえます。
とまぁ、src/main/scala/core/csr.scalaを見てもらえればこの辺りは分かっていただけるかと思います。
実行結果
アセンブラを記述します。<0xXX>と書いてあるのはその命令のアドレスです。割り込みが発生すると_label_expc(=0x18番地に飛びます)
_label0: addi x1, x0, 0x18 # <0x00> csrrw x0, mtvec, x1 # <0x04> : mtvec = <0x18> lui x1, 0x01 srli x1, x1, 1 csrrw x0, mie, x1 # enable external interrupt(mie.mtie = 1) jal x0, _label1 # jump _label1 _label_expc: addi x2, x0, 0xAA # <0x18> csrr x3, mepc # csrr x4, mcause # jal x0, _label_expc # <0x24> : loop _label1: addi x5, x0, 0xBB # <0x28> _label2: addi x6, x0, 0xCC # <0x2C> jal x0, _label2 # <0x30>
さて今回は、アセンブラを書いただけだと割り込みが発生したかどうかわかりません。
chisel-iotestersでCPUに擬似的に任意の時間に割り込み信号を注入します。(今回はlp==96としました。割り込み許可フラグが1になった後であればどのタイミングでもOK)
// src/main/scala/core.scalaの578行目付近 (中略) if(lp == 96){ poke(signal = c.io.sw.w_interrupt_sig, value = true.B) } else{ poke(signal = c.io.sw.w_interrupt_sig, value = false.B) }
実行後。
x2 = 0xAA
となっているので無事割り込みトラッピングに成功しています。また
x3 = mepc = 0x2c
となっているので、割り込み発動時にaddi x6, x0, 0xCC
を実行中だったことがわかります。割り込み発動のタイミングによっては
x3 = mepc = 0x30
となる場合もあると思います。コード全文
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.10.21 --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の勉強(8.CSR:例外の実装と割り込みの準備)
riscv-testsをパスしたい
RV32Iと謳うにはriscv-testsのテストパタンセットのうちRV32I ISAに要求されているリグレッションテストをパスするのが望ましいと言えます。CSR周りはその限りではありません。(picorv32などの特権命令非搭載かつ例外処理が独自、という実装もある)
github.com
このパターンセットをパスすることにより、RISC-Vのアーキテクチャとして正しく実装されているという指標になります。
ここではriscv-testsのrv32ui-p-and.dump
からどのような挙動をするのかをみてみます。
実装が正しければ最終的にpass,正しくない実装があるとfailにジャンプします*1。
開始アドレスはリンカスクリプトlink.ldを書き換えて0x0000_0000からはじまるようにしています。(詳細後述)
(略) 0000062c <fail>: 62c: 0ff0000f fence 630: 00018063 beqz gp,630 <fail+0x4> 634: 00119193 slli gp,gp,0x1 638: 0011e193 ori gp,gp,1 63c: 05d00893 li a7,93 640: 00018513 mv a0,gp 644: 00000073 ecall 00000648 <pass>: 648: 0ff0000f fence 64c: 00100193 li gp,1 650: 05d00893 li a7,93 654: 00000513 li a0,0 658: 00000073 ecall 65c: c0001073 unimp 660: 0000 unimp 662: 0000 unimp
pass
でもfail
でもecall命令を使い例外を発生させています。なおpass
するとgp(x3) = 1となります。
ecallはmtvecに格納されているベクタアドレスに遷移します。
118: 00000297 auipc t0,0x0 11c: eec28293 addi t0,t0,-276 # 4 <trap_vector> 120: 30529073 csrw mtvec,t0
上記のように、mtvecにはtrap_vector
アドレスが格納されていることがわかります。
00000004 <trap_vector>: 4: 34202f73 csrr t5,mcause 8: 00800f93 li t6,8 c: 03ff0863 beq t5,t6,3c <write_tohost> 10: 00900f93 li t6,9 14: 03ff0463 beq t5,t6,3c <write_tohost> 18: 00b00f93 li t6,11 1c: 03ff0063 beq t5,t6,3c <write_tohost> 20: 00000f13 li t5,0 24: 000f0463 beqz t5,2c <trap_vector+0x28> 28: 000f0067 jr t5 2c: 34202f73 csrr t5,mcause 30: 000f5463 bgez t5,38 <handle_exception> 34: 0040006f j 38 <handle_exception> 00000038 <handle_exception>: 38: 5391e193 ori gp,gp,1337 0000003c <write_tohost>: 3c: 00001f17 auipc t5,0x1 40: fc3f2223 sw gp,-60(t5) # 1000 <tohost> 44: ff9ff06f j 3c <write_tohost>
trap_vector
に遷移したのち例外発生要因mcause
を調べています。
- mcause = 8:ユーザーモードでのecall
- mcause = 9:スーパーバイザーモードでのecall
- mcause = 11:マシンモードでのecall
となります。今回はマシンモードでの暫定実装ですのでecallでの遷移時は11以外は格納されません。
それ以外の場合、
gp = gp | 1337
が実行されます。ですのでtestに成功してもmcauseの要因がいい加減ですとgp=1が得られなく、テストは失敗に終わります。
なんで1337なんだろ。まあいいか。
なお、割り込み時もmcauseのMSBビット(RV32の場合はmcause[31]が1になる)以外、挙動は同じです。
と、ここまで考えてきたように、CSRを最低限riscv-testsをパスできるレベルで実装しなければいけなくなってきました。
riscv.org
もう一度まとめると、
riscv-testsは各命令のテストを終えた後、
- 汎用レジスタgp(x3)に1(pass)またはそれ以外(fail)を格納
- 例外を発生(ecall)
- mtvecに格納されているトラップベクタへ
- 例外の発生要因と発生時の特権モードの確認
- 特権モードが予期しないモードだった場合、gp = gp | 1337を計算し格納。
- 無限ループへ(終了)
という動作をします。
実装方針
CSRのうち、以下を実装しなきゃいけないことがわかります。めんどくさいなぁ。
・ecall命令
・mtvec
・mepc(例外発生したpcを格納)
・mcause
・mstatus(最低限。マシンモードのみ実装)
「めんどくさい」というのはここまで実装しておいて今更ですが、CSR周りはPrivileged ISAのバージョンアップ毎にガラリと仕様が変わる為、あまり頑張りたくない感じなのです。
これまでの先人たちの実装をgithubで探すと、ある機能を果たすレジスタ名が同じでアドレスだけ異なっていたり、読み書きの権限が異なっていたりとなかなかカオスです。
ただし、例外を実装してしまえば(誤解を恐れずにいうのであれば)汎用組込みマイコンとしてマストな機能である割り込みの実装ができたようなものなので…それをモチベーションに頑張ります。
参考にしたのはいつも通り、riscv-miniです。
github.com
しかし、今回目指している実装はPrivileged Architecture Version 1.11に準拠していますが、この実装はVersion 1.7に準拠している為、このままの実装はできません。(mtvecは読み込みしかできない実装だったし)
あと、いま制作しているプロジェクトは5ステージパイプラインですが、riscv-miniは3ステージです。データフォワーディングなどの仕組みが若干異なります。
riscv-testsの導入
基本的にREADMEにあるようにすれば導入できます。
git clone https://github.com/riscv/riscv-tests cd riscv-tests
ここで命令メモリ開始アドレスが異なっている場合は修正します。
nano env/p/link.ld
自分の場合は開始アドレスが0x0000_0000
だったので下記のように変更しました。
SECTIONS { . = 0x00000000; # ->ここを修正した .text.init : { *(.text.init) } . = ALIGN(0x1000); .tohost : { *(.tohost) } . = ALIGN(0x1000); .text : { *(.text) } . = ALIGN(0x1000); .data : { *(.data) } .bss : { *(.bss) } _end = .; }
保存後ビルドします。
git submodule update --init --recursive autoconf ./configure --prefix=$(インストールしたいフォルダ) make make install
prefixで指定したフォルダに何やらたくさん入りますが、欲しいバイナリは(インストールフォルダ)/share/riscv-tests/isa
に格納されています。
今回はand命令のコードをテストしようと思います。拡張子なしでrv32ui-p-and
というファイル名が存在していると思いますが、実態はelfファイルです。自分のプロジェクトで読み込めるようにオリジナルhexファイルに変換します。
riscv64-unknown-elf-objcopy --gap-fill 0 -O binary rv32ui-p-and rv32ui-p-and.bin od -An -v -tx4 -w4 rv32ui-p-and.bin > rv32ui-p-and.hex
これをsrc/main/scala/core/core.scala
で読み込むように指定します。
実行結果
まだ何十個もテスト残ってるけど。
コード全文
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.10.20 --depth 1 cd KyogenRV/
macOSな方はGNU odを使う関係上以下のようにしてcoreutilsをインストールしてください。
brew install coreutils
makefileでOSによってod/godをスイッチしています。
アセンブラは(プロジェクトフォルダ)/src/sw/test.sにあります。適宜書き換えてみてください。
nano src/sw/test.s
mepcはどのアドレスを格納すべきなのか
分岐命令とジャンプ命令では、
rd = (分岐が起こったアドレス) + 4.U
を脊髄反射で格納していました。mepcも同じように(例外発生アドレス)+4.Uを格納して良いのでしょうか。
twitterで教えていただきました。ありがとうございます。
MEPCは「例外が発生したPC」を格納するという定義ですので値を勝手に進めたりしません。しかも+4 進めてしまうとC命令で例外が発生すると再実行できなくなります。
— msyksphinz_dev (@dev_msyksphinz) 2020年6月14日
また、specificationにも以下の記述があるとslackでも教えていただいきました。
When a trap is taken into M-mode, mepc is written with the virtual address of the instruction that was interrupted or that encountered the exception. Otherwise, mepc is never written by the implementation, though it may be explicitly written by software.
トラップが発生しマシンモードに遷移すると、mepcには割り込み命令や、例外を発生させた命令の仮想アドレスが書き込まれます。それ以外の場合は、ソフトウェアによって明示的に代入されることはあるかもしれませんが、実装によってmepcがwriteされることはありません。
なるほどー。なので、もし復帰する際は自分で例外発生したアドレスに戻るか、その次の命令のアドレスにいくか記述してやる必要があるとのこと。
おしまい。
*1:Compress命令はまた異なる挙動になります。今回C命令を実装しない方向で進めています
Chiselを使ったRISC-Vの勉強(7.ハザードの解決)
MEMステージの実装中です。
制御ハザードはすでに分岐命令を実装した時点で実装しており、パイプライン化をしても特筆すべき点はありません。
ここではデータハザードを直していきます。
データハザードの解決が終わると、MEMステージをこのままにするか、もう1ステージ増やして6段パイプラインにするかが見積もりやすくなるはずです。
データハザードの解決
命令は上から下に実行されていきます。横軸は時間で、各命令のどのステージが進行中かがわかるようになっています。
例えばaddi命令。
- IF ステージ:命令フェッチ
- IDステージ:rs1,rs2決定
- EXステージ:ALUの入力と命令が確定
- MEMステージ:ALUからの演算結果(addiの場合は足し算の値)確定
- WBステージ:rdへALUからの演算結果代入
rd確定前に、次命令がrs1,rs2が確定しているべきステージ(IDステージ)を過ぎているからです。
例は少し変わりますが、以下のような命令が考えられます。
lui x1, 0x08 # x1 = 0x08 << 12 = 0x8000 addi x1, x1, 0x04 # x1 = x1 + 0x04 = 0x8004となるべきだが.....?
このままだと、最終的なx1の値は0x04となってしまいます。x1の値がまだWBステージまで到達する前に次の命令のEXステージで計算が開始されてしまうからです。
データフォワーディング
ここでハザードが起こることは分かりました。
解決していきます。この場合はデータフォワーディングすることによってこの問題に対処します。
rdに結果が代入されるのはWBステージですが、一つ前のMEMステージで値は確定しています。ですのでMEMステージで次命令のEXステージに結果をバイパスします。
他にデータフォワーディングが必要とされる状況が考えられるでしょうか。
データメモリにアクセスする命令でハザードが多発すると思われます
(例1)
addi x2 x0, 0xAA # x2 = 0xAA sw x2, 5(x31) # dmem[x31+5] = x2 lw x3, 5(x31) # x3 = dmem[x31+5]
ここでx3は0xAAとなるべきです。先ほど、データハザードの対策を適切にしたはずなのでこの場合でも正しい値が出るはずです。
でも以下の場合はどうでしょうか。
(例2)
addi x2 x0, 0xAA # x1 = 0xAA nop sw x2, 5(x31) # dmem[x31+5] = x2 lw x3, 5(x31) # x3 = dmem[x31+5]
「nopも入れてるんだし当然動くでしょ」と思ってたのですが、x3=0。しばらくデータメモリの実装を疑いましたが異常はありませんでした。
というか(例1)で動いているのでデータメモリの実装がおかしいはずはないのですが。。。
val ex_reg_rs1_bypass: UInt = MuxCase(ex_rs(0), Seq( (ex_reg_raddr(0) =/= 0.U && ex_reg_raddr(0) === mem_reg_waddr && mem_ctrl.rf_wen === REN_1) -> mem_alu_out, (ex_reg_raddr(0) =/= 0.U && ex_reg_raddr(0) === wb_reg_waddr && wb_ctrl.rf_wen === REN_1 /*&& wb_ctrl.mem_en === MEN_1*/) -> io.r_dmem_dat.data )) val ex_reg_rs2_bypass: UInt = MuxCase(ex_rs(1), Seq( (ex_reg_raddr(1) =/= 0.U && ex_reg_raddr(1) === mem_reg_waddr && mem_ctrl.rf_wen === REN_1) -> mem_alu_out, (ex_reg_raddr(1) =/= 0.U && ex_reg_raddr(1) === wb_reg_waddr && wb_ctrl.rf_wen === REN_1 && wb_ctrl.mem_en === MEN_1) -> io.r_dmem_dat.data, (ex_reg_raddr(1) =/= 0.U && ex_reg_raddr(1) === wb_reg_waddr && ex_ctrl.rf_wen === REN_0 && ex_ctrl.mem_en === MEN_1) -> wb_alu_out ))
データストールによる解決
他にどんな場合があるでしょうか。
WBステージでないと値が確定できない命令があります。Load命令全般です。
この場合はMEMステージから値だけもらってフォワーディングする、といった技が使えません。ストールを行うことで対処します。
(Load命令 && 次命令がLoad命令が使用したアドレスをrs1,rs2に使う) → ストール信号生成
ストール信号を発行するとpcの更新がとまり、ストールしたいステージのレジスタの更新が停止することになります。今回、IDステージを停止させるので、以下の図のようにIDステージが2回実行され、その時のEXステージはNOPとなります。
密結合メモリをFPGAの内部に作り込む前提である場合はここは問題とはなりませんが、FPGA外部のSDRAMなどにデータメモリを持つ場合はこの実装では動かないでしょう。
今回の実装の場合、メモリ側からackが帰ってくるまで待つのが正しい実装と言えます。ackが帰ってくるまで永久にIDステージを繰り返します。*1
var mem_stall: Bool = RegInit(false.B) when (ex_ctrl.mem_wr === M_XRD) { mem_stall := true.B } .elsewhen(io.r_dmem_dat.ack === true.B) { mem_stall := false.B } // メモリリード信号が発行されてから、メモリからACK信号(データ送信済み)がくるまでmem_stall信号をtrueにする。 load_stall := ((ex_reg_waddr === id_raddr(0) || ex_reg_waddr === id_raddr(1)) && (ex_ctrl.mem_en === MEN_1) && (ex_ctrl.mem_wr === M_XRD)) || mem_stall
テストコード
今回の実装を包括的に網羅するアセンブラを記述します。
_label1: lui x1, 0x08 # x1 = 0x08 << 12 addi x1, x1, 0x04 # x1 = x1 + 0x04 = 0x8004 addi x2, x1, 0x00 # x2 = x1 sw x2, 12(x31) # dmem[x31 + 12] = x2 = 0x8004 lw x3, 12(x31) # x3 = dmem[x31 + 12] => 0x8004になっているべき addi x4, x0, 0xAA # x4 = 0xAA sw x4, 12(x31) # dmem[x31 + 12] = x4 = 0xAA lw x5, 12(x31) # x5 = dmem[x31 + 12] = 0xAA beq x4, x5, _label2 # x4 = x5 なら_label2へジャンプ addi x10, x0, 0x44 addi x11, x0, 0x55 _label2: jal x0, _label2 # 無限ループ
所感
ようやく5ステージパイプラインを持つCPUっぽくなってきました。さらにストールやフォワーディングが必要になった場合は今回のコードに追記する形でできると思いますので、そんなに辛くなさそう。
あと、ストールのところで「メモリリード信号が発行されてから、メモリからACK信号(データ送信済み)がくるまでmem_stall信号をtrueにする。」としていますが、ハザードは解決されたもののちょっとタイムロスが発生しているので、もう少し工夫してみようかなと思います。
コード全文
コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。
git clone http://github.com/panda5mt/KyogenRV -b 0.0.10.15 --depth 1 cd KyogenRV/
macOSな方はGNU odを使う関係上以下のようにしてcoreutilsをインストールしてください。
brew install coreutils
makefileでOSによってod/godをスイッチしています。
アセンブラは(プロジェクトフォルダ)/src/sw/test.sにあります。適宜書き換えてみてください。
nano src/sw/test.s
次回は?
この一ヶ月ほど、駆け足でやってきました。
コロナの影響で止まっていた製造関係も少しづつ再開が始まってきました。
通常作業がいつもの80%くらいまで復活してきています。*2
ですのでRISCV関係は少しペースは落としていかなければならないと思いますが、引き続き続けていきますのでよろしくお願いいたします。