RISC-Vを目指す

なんとも久しぶりの更新なわけだが、今度のネタは2年半ほど前にやってたCPU作成の続きとなる。前回はオレオレアーキテクチャの4bitCPUだった。今回はそれの32bitへの拡張と、命令セットをRISC-VのRV32Iとして、まともなCPUを作ってみようという企画となる。
そうは言っても2年半の間、回路にはこれっぽっちも触れていなかったのでもう忘れてるし、何も進歩していないということで、果たしてどうなることやら。

RISC-Vとは

リスクファイブ。まあ、適当にぐぐってくださいな。オープンソースで開発されたライセンス料無しで使えるアーキテクチャで、MIPSなどと比べるとディレイスロットが無かったりしてシンプルになっている。賢い人が作っただけにオレオレアーキテクチャよりもずっと回路が作りやすいように考えられているだろう。
アーキテクチャについてはSpecifications - RISC-V Foundationを参照。

とりあえずサポート関数の追加

まあ、さすがに2年半も間があいているとコード見てもよくわからんくてな。何がわからんのかを考えてみると、マルチプレクサで条件をつけて分岐しているあたりが煩雑で、非常に把握しづらい。VerilogHDLなんかではif文やswitch〜caseでわかりやすく書けるので、そういうのがあればもっと見やすくなるのではないかと思った。ので手始めにそのへんのサポート関数を作ってみた。

def _if(cond, t, f)
  raise "size error" if t.size != f.size
  mux = Mux1_n.new(t.size)
  mux.s = cond
  mux.d = [t, f]
  mux.o
end

def _equal(a, b)
  raize "size error" if a.size != b.size
  ary = []
  a.zip(b) do |p1, p2|
    ary << (p1 ^ p2)
  end

  return ~ary.inject(ary.pop){|memo, item|memo | item}
end

def _to_pin(i, h, l)
  i.to_s.each_byte.map{|s|(s-0x30) == 1 ? h : l}
end

_ifは第1引数にOutputPin1個を渡し、それがHだったらt、Lだったfの回路を出力するようなマルチプレクサを構成する。マルチプレクサを作って繋げる作業が無くなってラクになる。_equalは2つの引数が一致したらH、違ったらLを出力する回路を構成する。XORしてORしてNOTするだけである。これらがあるとデコーダなどが非常に簡単になる。気がする。
_to_pinは0と1の文字列をHとLのピンの配列に変換する。32bit固定長の命令列を書くのが楽になる。

では作る

前の4bitのやつをベースに手を加える感じで行く。まずはデータバスを32bitにする。

# 8bitデータバス
bus = Bus.new(32)

コメント直し忘れた。次にROMから取得したデータをデコードするための便利線を作る。

## 命令デコーダ(RISC-V/RV32I)
insn = bus.o

insn_bit_funct7  = insn[0..6]
insn_bit_imm11_5 = insn[0..6]
insn_bit_imm11_0 = insn[0..11]
insn_bit_rs2     = insn[7..11]
insn_bit_rs1     = insn[12..16]
insn_bit_funct3  = insn[17..19]
insn_bit_rd      = insn[20..24]
insn_bit_opcode  = insn[25..31]

# デコード用ビットパターン
opcode_addi = _to_pin("0010011", vcc, gnd)
opcode_add  = _to_pin("0110011", vcc, gnd)
opcode_jalr = _to_pin("1100111", vcc, gnd)
funct3_addi = _to_pin("000"    , vcc, gnd)
funct3_add  = _to_pin("000"    , vcc, gnd)
funct3_jalr = _to_pin("000"    , vcc, gnd)
funct7_add  = _to_pin("0000000", vcc, gnd)

insnが命令になり、それを切り出した部分bit配列を用意する。また、それと比較する命令判定用のビットパターンも用意する。これらを_equalで判定して_ifに突っ込めば条件によるピンの選択ができるという寸法である。

デコーダ

デコーダはこのようになる。

# ADDI命令でH
insn_addi = _equal(insn_bit_opcode, opcode_addi) & _equal(insn_bit_funct3, funct3_addi)

# ADD命令でH
insn_add  = _equal(insn_bit_opcode, opcode_add) & _equal(insn_bit_funct3, funct3_add) & _equal(insn_bit_funct7, funct7_add)

# JALR命令でH
insn_jalr = _equal(insn_bit_opcode, opcode_jalr) & _equal(insn_bit_funct3, funct3_jalr)

# レジスタ書き込み条件
write_reg = insn_addi | insn_add | insn_jalr

今回とりあえず作ってみる命令はadd、addi、jalrの3つ。addはレジスタ同士の加算、addiはレジスタと固定値(12bit)の加算、jalrはレジスタ+12bit値のアドレスへのジャンプとなる。

回路ブロック

4bitCPUの時と同様にハーバードアーキテクチャとして、プログラムカウンタ、インクリメンタ、レジスタファイル、加算器を用意する。

## 各種回路ブロック生成
# 32bitプログラムカウンタ用レジスタ
pc = Register.new(32)
pc.clk = clk.o
pc.clrb = clrb.o
pc.w = vcc

# 30bitインクリメンタ
inc = Incrementer.new(30)

# レジスタファイル(32bitが32個)
regf = RegisterFile.new(5, 32)
regf.clk = clk.o
regf.clrb = clrb.o

# 32bit加算器
adder = Adder.new(32)

RISC-Vの命令アドレスは4バイト単位で下位2bitが0となるが、いまあるインクリメンタは1ずつしか足せないので、30bitカウンタとしてLを下位2bitはL2個を固定で接続することにする。RISC-VのRV32I命令セットではレジスタが32bit32本となるのでレジスタファイルをそのように構成する。このへんはコンストラクタの引数で自由にできるように作っておいたので簡単である。

回路接続

これらの回路を接続する。

## 回路接続
# ROMの出力はThreeStateBufferなのでBusオブジェクトにaddする
bus.add rom.o
# ROMの出力の値はBusオブジェクトから取り出す

# pcとインクリメンタ接続
pc.d = _if(insn_jalr, adder.o, inc.o + [gnd, gnd])
inc.d = pc.o[0, 30]

# ROMにPCを接続
rom.a = pc.o[27, 3]
rom.csb = gnd

# レジスタファイルの入力ポート接続
regf.d = _if(insn_jalr, inc.o + [gnd, gnd], adder.o)
regf.sin = insn_bit_rd
regf.w = write_reg # レジスタ書き込み条件

# レジスタファイルの出力ポート選択信号の接続
regf.sout0 = insn_bit_rs1
regf.sout1 = insn_bit_rs2

# 32bit全加算器
adder.d = [regf.o0, _if(insn_add, regf.o1, [insn_bit_imm11_0[0]]*20 + insn_bit_imm11_0)]
adder.x = gnd

ROMは命令8個分しか作らないのでアドレス線3本である。pcとregfの入力にjalr命令の判定が登場するが、RISC-Vのjalr命令は指定レジスタに戻りアドレスを格納する機能があり、また、ジャンプ時に即値とレジスタの値を足してジャンプするため、add時とjalr時でレジスタとPCの入力値が逆転する感じになる。
adderの入力はaddのときはレジスタrs2、それ以外(addiとjalr)は即値を入れて、rs1と足すようになっている。もっと命令が増えるとこのへんはややこしくなるため、もうちょっと便利線を増やしてわかりやすくする必要があると思われる。

プログラム

適当なプログラムを作ってみた。

# ROM
rom = ROM.new(3, 32) # アドレス線3本、データ線8本
rom.d = [ # 8アドレスぶんのデータ
  _to_pin("00000000000000000000000010110011", vcc, gnd), # add  r1, r0, r0
  _to_pin("00000000001100001000000010010011", vcc, gnd), # addi r1, r1, 3
  _to_pin("11111111111100001000000010010011", vcc, gnd), # addi r1, r1, -1
  _to_pin("00000000001100001000000010010011", vcc, gnd), # addi r1, r1, 3
  _to_pin("11111111111100001000000010010011", vcc, gnd), # addi r1, r1, -1
  _to_pin("00000000000100001000000010110011", vcc, gnd), # add  r1, r1, r1
  _to_pin("00000000000000000000000001100111", vcc, gnd), # jalr r0, r0, 0
  _to_pin("00000000001100001000000010010011", vcc, gnd), # addi r1, r1, 3
].reverse

_to_pinのおかげで簡単になったはずだがさすがに32bit固定長命令をハンドアセンブルして書くのは辛い。早急にアセンブラが必要だろう。しかしこの3つの命令があればフィボナッチ数列ぐらい計算できたかもしれない。ともあれ実行結果は以下。

ところでRISC-Vのレジスタ0番はゼロレジスタで、読めば常に0、書いても無視というシロモノである。これは非常に便利で、レジスタを1個食うが、3オペランド命令セットではたとえばadd rd, rs, r0とすればmovの代わりになるし(RISC-Vにはmovは無い)、同様にゼロクリアするにも使えるし、ジャンプ時に戻りアドレスが必要ない場合(jmp命令的な動作)はr0に戻りアドレスを入れれば捨てられる。便利だ。

おしまい

シミュレータのライブラリはRISC-Vを目指すシミュレータ · GitHub、回路のコードはRISC-Vを目指す回路 · GitHubとなる。DXRubyでロジアナを実装しているのでそのまま動かすにはDXRubyが必要である。のでRubyは2.1〜2.3のあたりで。2.4用も用意したいのだがどうにもRubyInstaller2用にうまく作れなくて困っている。
とりあえずここまでは割と簡単にできたのだが、残りの命令は計算命令がたくさんと、条件分岐などがある。計算ロジックはALUとしてまとめたほうがよいだろうし、条件分岐もALUに値を突っ込んで結果を出力してそれを元にPCをいじる感じでできそうだ。CSR関連や割り込み、例外、特権などの機能はできそうな気がしない。そこまでできるなら本物のCPU作れそうだしな。
このシミュレータはうちで動かすと30Hzぐらいしか出ないので何かをリアルタイムに動かすのは難しい。フリップフロップやマルチプレクサあたりを入出力互換の専用コード(回路をシミュレーションしない)にすれば速くはなるのだろうが、そのへんはもうちょっと後で速度がどうしても欲しくなったときにやることにする。
まあ、最近また仕事で忙しいプロジェクトに突っ込まれてしまったので、年内に進捗があるかどうかって感じ。