RISC-Vを目指す(4)

新年明けましておめでとうございます。
さて、去年に引き続きRISC-Vの命令セットを実装してみる試練。前回でっちあげたRAMは書き込みしかできなかったので、lb命令を実装してRAMからの読み込みを作ってみよう。書き込みができたのだから読み込みも簡単かと思いそうだが、実際のところちょっとだけ読み込みのほうがめんどい。

外部RAMの都合

RAMは組み合わせ回路に含んでいないのでクロックの立ち上がりを認識してアドレスとデータをRAMに取り込むようにしていた。書き込みの場合はクロックの立ち上がりの時点でアドレスとデータを出力していればそれで処理は完了していたのだが、読み込みの場合、同時点ではRAMがアドレスを受け取るだけである。つまりRAMからデータが出てくるのは最速で次のクロックとなるので、RAMからのデータをレジスタに書き込むためにlb命令は最速で2クロックかかる。

対策案

最も簡単な対策はlb命令を2クロック命令にすることである。1bitレジスタをステータスとして追加して、通常はL、lb命令実行(1クロック目)時にHにして、次のクロックでLに戻す。このステータスがHの時はCPUの動作を一時停止する。具体的にはレジスタファイルとpcへの書き込みを遮断する。
1命令1クロックじゃないとヤダヤダ、という向きには2段パイプライン化という手段がある。今回の場合ならレジスタ書き込み回路だけを2段目に分離すれば見た目1クロック1命令実行ができるようになる。この場合、2段目の書き込みレジスタと1段目の読み込みレジスタが衝突することが懸念されるので、フォワーディングというテクニックで2段目のデータを1段目に引っ張ってくることでパイプラインストールを防ぐことができる。
無論、現在はハーバードアーキテクチャだからこれで解決するだけで、RAMからプログラムを読み込むノイマンアーキテクチャ化した場合は命令フェッチとデータの読み書きが衝突してしまうので、どうあがいても1クロック1命令にはならない。これをどうにかするにはキャッシュメモリを導入してRAMアクセスを減らすことで1クロック1命令に近づくが、近づくだけで、最終的にはスーパースカラとかで複数命令同時実行などする必要がある。まあ、実のところ1クロック1命令にこだわる理由が無い。

2クロック命令

とりあえずlb命令を追加しよう。

# デコード用ビットパターン
opcode_lb   = _to_pin("0000011", vcc, gnd)
funct3_lb   = _to_pin("000"    , vcc, gnd)

# lb命令でH
insn_lb  = _equal(insn_bit_opcode, opcode_lb)

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

lb命令はIタイプのフォーマットなので即値デコーダは既にある。次に回路の停止用にレジスタを用意する。

# 回路停止用1bitレジスタ(Hの時に停止)
stall = Register.new(1)
stall.clk = clk.o
stall.clrb = clrb.o
stall.w = vcc

st = ~stall.o[0] & insn_lb # 実行状態かつlb命令のときにHになる(次のクロックですぐLに戻る)
stall.d = [st]

あとはpcとレジスタファイルの書き込み条件を変更する。

# pcの書き込み条件
pc.w = ~st

regf.w = write_reg & ~st # レジスタ書き込み条件

これで停止中にフリップフロップが書き変わらなくなったのでlb命令は2クロック命令となった。CPUは停止するがRAMへのアドレスや制御信号は出しているので2クロック目にはRAMからのデータが届く。これを2クロック目のレジスタ書き込み動作で保存するわけだ。

RAM

RAMには出力用のピンなども用意してはあったのだが、Read用信号線とRead動作を追加する。

# 外付けRAM
class RAM
  attr_reader :memory

  def initialize(a, d)
    @memory = Array.new(80*30){0}
    @o = Array.new(d){OutputPin.new}
    @o.each{|t|t.set(false)}
  end

  def clk=(v)
    v.output_objects << self
    @clk = v
  end

  def a=(v)
    @addr = v
  end

  def d=(v)
    @data = v
  end

  def w=(v)
    @w = v # HがWrite有効
  end

  def r=(v)
    @r = v # HがRead有効
  end

  def o
    @o
  end

  def update
    # クロックがHになったら処理する
    if @clk.get
      address = ("0b" + @addr.map{|o|o.get ? "1" : "0"}.join).to_i(2)

      if @w.get # RAMへの書き込み
        data = ("0b" + @data.map{|o|o.get ? "1" : "0"}.join).to_i(2)
        @memory[address] = data
      end

      if @r.get # RAMからの読み込み
        memory = @memory[address].to_s(2).rjust(@o.size, "0")
        @o.each.with_index do |t, i|
          t.set(memory[i] == "1")
        end
      end
    end
  end
end

RAMの読み込み信号を繋ぐ。

ram.r = st

レジスタファイルの書き込みにlb命令時の条件を追加してRAMからのデータを書き込めるようにする。

# レジスタファイルの入力ポート接続
regf.d = _if(insn_jalr | insn_jal, inc.o + [gnd, gnd],  # jalr or jalの場合はrdに戻りアドレスを格納する
         _if(insn_lb,              [gnd]*24 + ram.o,    # lb命令の場合はramの出力
                                   adder.o              # それ以外の命令は加算器の結果を格納する
         ))

全加算器の入力は都合よく今のままでいけた。こういうところはうまいことできてるよね。RISC-V。

実行するプログラム

# 32アドレスぶんの命令
rom.d = RISCVASM.asm do
  addi r2, r0, 72
  sb r2, 0, r0
  addi r2, r0, 101
  sb r2, 1, r0
  addi r2, r0, 108
  sb r2, 2, r0
  addi r2, r0, 108
  sb r2, 3, r0
  addi r2, r0, 111
  sb r2, 4, r0
  addi r2, r0, 32
  sb r2, 5, r0
  addi r2, r0, 119
  sb r2, 6, r0
  addi r2, r0, 111
  sb r2, 7, r0
  addi r2, r0, 114
  sb r2, 8, r0
  addi r2, r0, 108
  sb r2, 9, r0
  addi r2, r0, 100
  sb r2, 10, r0
  addi r2, r0, 33
  sb r2, 11, r0
  lb r2, 0,  r0
  sb r2, 80, r0
  lb r2, 1,  r0
  sb r2, 81, r0
  lb r2, 2,  r0
  sb r2, 82, r0
  lb r2, 3,  r0
  sb r2, 83, r0
end.map{|t|_to_pin(t, vcc, gnd)}.reverse

Hello world!の後ろにちょこっと追加して、文字を読み込んで真下に表示するようにしてみた。32命令しか書けないので少しだけ。

おしまい

これを実行するとこのようになる。

コンニチハしたと思ったらいきなり地獄に落ちた。
コードはこちら。忘れてたけどアセンブラこちら
RAMの読み書きができたということはスタックが作れるのではないかね。あとメモリマップドI/Oでキーボード読み込みとか。夢が広がるけどとりあえずスタック作るにはswとlwで32bitアクセスできないとダメだな。32bitアクセス自体は問題なさげだけど32bit/16bit/8bitの読み書きを自由にできるようにするのってRAMをどう作ればいいんだろう。