lynxeyedの電音鍵盤

MBDとFPGAと車載で使うデバイスの備忘録

Chiselを使ったRISC-Vの勉強(9.CSR:外部割り込みの実装)

外部割り込みを実装しました。
RISC-Vに実装する割り込みはCPU外部からの割り込みだけ(あるいは各割り込み入力をマルチプレクスしてここへ入力、または割り込み信号の判別だけできるようにして入力を増やしていく感じで)でいいかな、と思ってきた次第です。

ターゲットFPGAintel 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'

???


f:id:Lynx-EyED:20200625193918p:plain

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)
 }

実行後。


f:id:Lynx-EyED:20200625202401p:plain
x2 = 0xAAとなっているので無事割り込みトラッピングに成功しています。
またx3 = mepc = 0x2cとなっているので、割り込み発動時にaddi x6, x0, 0xCCを実行中だったことがわかります。
割り込み発動のタイミングによってはx3 = mepc = 0x30となる場合もあると思います。

コード全文

github.com

コードは今後も漸進的に更新されるので、以下のように-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に要求されているものをパスしないといけません。

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は各命令のテストを終えた後、

  1. 汎用レジスタgp(x3)に1(pass)またはそれ以外(fail)を格納
  2. 例外を発生(ecall)
  3. mtvecに格納されているトラップベクタへ
  4. 例外の発生要因と発生時の特権モードの確認
  5. 特権モードが予期しないモードだった場合、gp = gp | 1337を計算し格納。
  6. 無限ループへ(終了)

という動作をします。

実装方針

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で読み込むように指定します。

実行結果


f:id:Lynx-EyED:20200618204708p:plain
gp(x3) = 1となっているので成功です。and命令はちゃんと動くようだ。よかった。
まだ何十個もテスト残ってるけど。

コード全文

github.com

コードは今後も漸進的に更新されるので、以下のように-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で教えていただきました。ありがとうございます。


また、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段パイプラインにするかが見積もりやすくなるはずです。

データハザードの解決

命令は上から下に実行されていきます。横軸は時間で、各命令のどのステージが進行中かがわかるようになっています。


f:id:Lynx-EyED:20200531143535p:plain
ここで、正確な値の入力を必要とするのは、EXステージです。同じ汎用レジスタに連続でアクセスする場合、これが問題となることがあります。
例えばaddi命令。

  • IF ステージ:命令フェッチ
  • IDステージ:rs1,rs2決定
  • EXステージ:ALUの入力と命令が確定
  • MEMステージ:ALUからの演算結果(addiの場合は足し算の値)確定
  • WBステージ:rdへALUからの演算結果代入


f:id:Lynx-EyED:20200531144814p:plain
addiの結果代入先のrdが、次命令のrs1,rs2のいずれかになると大変困ります。
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ステージに結果をバイパスします。


f:id:Lynx-EyED:20200531145458p:plain



他にデータフォワーディングが必要とされる状況が考えられるでしょうか。
データメモリにアクセスする命令でハザードが多発すると思われます
(例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)で動いているのでデータメモリの実装がおかしいはずはないのですが。。。


f:id:Lynx-EyED:20200531151901p:plain
1行目の命令がWBステージに到達する前に3行目の命令はIDステージに突入しています。ですのでここではWBステージからMEMステージにデータフォワーディングしてあげる必要があります。


f:id:Lynx-EyED:20200531212006p:plain
この部分をコードで記述すると以下のようになりました。信号名は参考にしたrocket-chipのものと同等です。

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となります。


f:id:Lynx-EyED:20200531213000p:plain
しかしこの実装はあまり筋が良いとは言えません。データが次のステージで必ず読み込める前提になっているからです。
密結合メモリをFPGAの内部に作り込む前提である場合はここは問題とはなりませんが、FPGA外部のSDRAMなどにデータメモリを持つ場合はこの実装では動かないでしょう。
今回の実装の場合、メモリ側からackが帰ってくるまで待つのが正しい実装と言えます。ackが帰ってくるまで永久にIDステージを繰り返します。*1

f:id:Lynx-EyED:20200531214100p:plain
このストール信号の部分のコードの記述は以下のようになります。

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             # 無限ループ

実行結果


f:id:Lynx-EyED:20200531215028p:plain
実行できています。
x10,x11に値が代入されてしまっている場合は、beq命令が正しく評価されていない=>前命令のload命令でx4が格納される前に実行されてしまった可能性があります。


所感

ようやく5ステージパイプラインを持つCPUっぽくなってきました。さらにストールやフォワーディングが必要になった場合は今回のコードに追記する形でできると思いますので、そんなに辛くなさそう。
あと、ストールのところで「メモリリード信号が発行されてから、メモリからACK信号(データ送信済み)がくるまでmem_stall信号をtrueにする。」としていますが、ハザードは解決されたもののちょっとタイムロスが発生しているので、もう少し工夫してみようかなと思います。

コード全文

github.com

コードは今後も漸進的に更新されるので、以下のように-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関係は少しペースは落としていかなければならないと思いますが、引き続き続けていきますのでよろしくお願いいたします。

*1:RAMが破壊されている場合も考慮した方がいい場合もあります。例外吐く処理を実装した方が最善かもしれません

*2:疫病の危機が去ったわけではありません。自分や他の人の健康や命に対し無頓着であってはなりません。しかし、このブログはそう言った話題に乗じてなにか政治的社会的プロパガンダを発したりはこれまでもしてきませんでしたしこれからも同様です。露ほども為にはならないかもしれませんが、面白いと思う人に面白いと思われるのならそれが本望です。うんこ。

Chiselを使ったRISC-Vの勉強(6.パイプライン化とALUを使用する命令の見直し)

突然ですがいままでシングルラインで組んでいたCPUプロジェクトをいきなり5ステージパイプラインにしました。

いきなり!ステージ


f:id:Lynx-EyED:20200524120200j:plain
すまんこれがやりたくてステージングしたわけじゃないんだ信じて欲しい。このクソコラ作るのがこの記事書く時に一番時間かかったけど信じて欲しい。

パイプライン化に向けての準備

本来でしたら、シングルステージでまだ実装が終わっていない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年ぶりくらいに必死に勉強し直す始末に。
みんな社会人になっても宇宙人になっても仙人になってもおじいさんおばあさんになっても数学の勉強やろうね。趣味すらできなくなるから。

新編 高専の数学1 第2版・新装版

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に書き戻すという命令です。


f:id:Lynx-EyED:20200524145150p:plain
図のように、各ステージごとに命令が処理されWBステージでx31に足し算の結果が書き戻されているのを確認する事ができました。

コード全文

github.com

コードは今後も漸進的に更新されるので、以下のように-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もここで整理しました。なお、参考にしたのはこちら。

github.com

ざっとこんな感じです。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

で動作します。
実行結果。


f:id:Lynx-EyED:20200508144407p:plain
この通り、x7に値は代入されていません。正しく動作していると言えるでしょう。

コード全文

github.com

コードは今後も漸進的に更新されるので、以下のように-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に乗り換えました。すごく快適。最初っからこうしていればよかった。


f:id:Lynx-EyED:20200504114858p:plain

機械語の生成

いままでCPUロジックに与えるhexはハンドアセンブルしていましたが、そろそろ限界を感じています。
例えば、JAL命令。


f:id:Lynx-EyED:20200504020102p:plain
相対アドレスに使われるimmはこんなにビットシャッフルしているし、20bit(省略されているがLSBの1bit含めると21bit)長で、2の補数を取ります。
こんなの毎回のように手動アセンブラやってたら脳が停止してしまいます。それに、間違った機械語を記述していたのに気づかずデコーダを作ったら収拾がつかなくなります。

これに関してはみなさん工夫されているようです。

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 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命令は用意できるし)

  1. ALUへの演算命令、2入力への変数指定、結果の格納先(汎用レジスタ、pc、データメモリなど)指定
  2. データメモリ書き込みイネーブル、アドレス指定、読み込みイネーブル、アドレス指定
  3. CSR周りのゴニョゴニョ
  4. CPUストール制御信号
  5. 分岐制御信号

を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の命令です。


f:id:Lynx-EyED:20200428205047p:plain
その中から3タイプに分けました。

  • 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)
です。
結果:


f:id:Lynx-EyED:20200428205023p:plain

コード全文

コードは今後も漸進的に更新されるので、以下のように-bでタグを指定してcloneするのがいいと思います。

git clone http://github.com/panda5mt/KyogenRV -b 0.0.9.1 --depth 1 

*1:初期の頃に記述されたものはChisel2以前のバージョンで記述しているサンプルも多いため