esm アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

Rubyでできる!RISC-Vシミュレータの作りかた 〜 From 4649 To HELLO WORLD 〜

HELLO WORLD〜

はじめに

こんにちは、永和システムマネジメントの自作CPUおじさん、はたけやまたかし( @htkymtks )です。

今回はRubyを使った小さなRISC-Vシミュレータの作り方をご紹介します(以前もシミュレータの記事を書いたのですが、シミュレータに大幅に手を入れたので、それに対応したHDリマスター版です)

リポジトリ

(今回ご紹介するシミュレータのリポジトリはこちら)

RISC-Vとは

RISC-VはCPUの命令セットアーキテクチャ(ISA)のひとつで、使用料のかからないオープンソースライセンスで提供されていることや、命令セットの美しさから最近注目を集めています。

仕様

RISC-Vの仕様にはワード幅(32ビット、64ビット)や浮動小数点数サポートの有無など、いくつかのバリエーションがありますが、今回は32ビット整数演算のみをサポートする「RV32I」のサブセットを実装します。

メモリ

では早速作っていきましょう。まずは命令やデータを格納するメモリを作ります。Memoryクラスは、バイナリデータが格納されたStringオブジェクトをメモリイメージとして受け取り、メモリイメージに対しての1ワードの読み込み(readメソッド)と1ワードの書き込み(writeメソッド)を提供します。

ちなみに「ワード」というのはCPUがデータの読み込み・書き込みを行う単位です。今回作成する32ビットRISC-Vは32ビット単位でデータの読み書きを行うため、1ワードは32ビット(4バイト)となります。

class Memory
  WORD_SIZE = 4

  attr_accessor :data

  def initialize(data = "\0" * 512)
    # バイナリデータとして扱いたいので ASCII-8BIT エンコーディングへ変換
    @data = data.b
  end

  # 指定したアドレスから1ワード(4バイト)のデータを読み込む
  def read(addr)
    word = @data.slice(addr, WORD_SIZE)

    # 「signed int32」でメモリの内容を読み込む
    # see: https://docs.ruby-lang.org/ja/latest/doc/pack_template.html
    word.unpack1("l")
  end

  # 指定したアドレスへ1ワード(4バイト)のデータを書き込む
  def write(addr, word)
    # 「signed int32」でメモリへ書き込む
    @data[addr, 4] = [word].pack("l")
  end
end

Memoryの使い方はこんな感じです。

RISC-VはリトルエンディアンなCPUなので、4バイトの 0x01010093 をメモリの 0x00000000 番地へ書き込むと「0x00000000 = 0x93, 0x00000001 = 0x0, 0x00000002 = 0x01, 0x00000003 = 0x01」のような順番に格納されます。

# メモリを初期化
memory = Memory.new("\x93\x00\x01\x01\x94\x00\x01\x01")

# アドレスを指定して読み込み
printf "0x%08x\n", memory.read(0)
#=> 0x01010093
printf "0x%08x\n", memory.read(4)
#=> 0x01010094

# アドレス0番地へ書き込み
memory.write(0, 0x00ABCDEF)
printf "0x%08x\n", memory.read(0)
#=> 0x00abcdef

# アドレス4番地へ書き込み
memory.write(4, -1)
printf "0x%08x\n", memory.read(4)
#=> 0xffffffff

命令デコーダ

次は命令デコーダです。

RISC-Vの命令は以下のようなフォーマットになっています。「opcode」「funct3」「funct7」は命令の判別に、「rd」「rs1」「rs2」は操作対象のレジスタ番号の指定に、「imm」は即値に利用されます。

命令デコーダは命令のビット列からこれらパラメータの切り出し処理を行います。

( https://github.com/jameslzhu/riscv-card/blob/master/riscv-card.pdf より)

class Decoder
  attr_reader :opcode, :rd, :funct3, :rs1, :rs2, :funct7, :i_imm, :s_imm, :b_imm

  def initialize
    @opcode = nil
    @rd = nil
    @funct3 = nil
    @rs1 = nil
    @rs2 = nil
    @funct7 = nil
    @i_imm = nil
    @s_imm = nil
    @b_imm = nil
  end

  def decode(inst)
    @opcode = (inst & 0x0000007f)
    @rd = (inst & 0x00000f80) >> 7
    @funct3 = (inst & 0x00007000) >> 12
    @rs1 = (inst & 0x000f8000) >> 15
    @rs2 = (inst & 0x01f00000) >> 20
    @funct7 = (inst & 0xfe000000) >> 25
    @i_imm = (inst & 0xfff00000) >> 20
    @s_imm = ((inst & 0xfe000000) >> 20) | ((inst & 0x00000f80) >> 7)
    @b_imm = ((inst & 0x80000000) >> 19) |
             ((inst & 0x00000080) << 4) |
             ((inst & 0x7e000000) >> 20) |
             ((inst & 0x00000f00) >> 7)
  end

  # NOP命令(no operation 何も行わない命令)かどうかを判定(あとで使う)
  def nop?
    @opcode == 0b0010011 &&
      @funct3 == 0 &&
      @rd == 0 &&
      @rs1 == 0 &&
      @i_imm == 0
  end
end

命令デコーダの使い方はこんな感じです。

# 適当なバイト列を読み込ませて正しくデコードできるか確認
decoder = Decoder.new
decoder.decode(0b1000001_10111_10011_101_10001_0110011)
printf "%07b\n", decoder.opcode
#=> 0110011
printf "%05b\n", decoder.rd
#=> 10001
printf "%03b\n", decoder.funct3
#=> 101
printf "%05b\n", decoder.rs1
#=> 10011
printf "%05b\n", decoder.rs2
#=> 10111
printf "%07b\n", decoder.funct7
#=> 1000001

CPU

次はCPUです。今回作成するCPUは以下の4つの部品から構成されます。

  1. メモリ(さっき作ったMemoryクラス)
    • プログラムやデータの保存領域
  2. プログラムカウンタ(PC)
    • メモリ中の次に実行する命令のアドレスを指し示す
  3. 命令デコーダ(さっき作ったDecoderクラス)
  4. x0からx31まで32個のレジスタ
    • RV32Iはx0からx31までの32ビットの整数レジスタを持ちます
    • RISC-Vの「x0レジスタ」は常に0を返す特殊なレジスタになります

INST_TABLEoptcodefunct3funct7 をキーにした命令テーブルで、デコーダから返ってきた値を使って INST_TABLE を引くことでどの命令を呼び出すかを決定します。

また、 _add_sub は実行される命令の本体になります。

class Cpu
  INST_TABLE = {
    [0b0110011, 0x0, 0x00] => :_add,
    [0b0110011, 0x0, 0x20] => :_sub,
    [0b0110011, 0x6, 0x00] => :_or,
    [0b0110011, 0x7, 0x00] => :_and,
    [0b0010011, 0x0]       => :_addi,
    [0b0010011, 0x1]       => :_slli,
    [0b1100011, 0x0]       => :_beq,
    [0b0000011, 0x2]       => :_lw,
    [0b0100011, 0x2]       => :_sw
  }

  attr_accessor :pc
  attr_reader :x_registers, :memory

  def initialize
    @pc = 0                   # プログラムカウンタ
    @x_registers = [0] * 32   # レジスタ
    class << @x_registers
      # x0は常に0を返す
      def [](nth)
        nth == 0 ? 0 : super
      end
    end

    @decoder = Decoder.new
    @memory = Memory.new(     # メモリ
      ("\x00" * 512).b
    )
    @nop_count = 0
  end

  def init_memory(data)
    @memory.data[0, data.size] = data
  end

  def run
    inst = fetch

    decode(inst)

    # NOPが5回来たら処理を終える
    return false if @nop_count >= 5

    execute
    true
  end

  def fetch
    @memory.read(@pc)
  end

  def decode(inst)
    @decoder.decode(inst)
    if @decoder.nop?
      @nop_count += 1
    else
      @nop_count = 0
    end
  end

  def execute
    op_f3_f7 = [@decoder.opcode, @decoder.funct3, @decoder.funct7]
    op_f3 = [@decoder.opcode, @decoder.funct3]
    inst_symbol = INST_TABLE[op_f3_f7] || INST_TABLE[op_f3]
    send inst_symbol
  end

  ### Instructions

  def _add
    rd = @decoder.rd
    rs1 = @decoder.rs1
    rs2 = @decoder.rs2
    @x_registers[rd] = @x_registers[rs1] + @x_registers[rs2]
    @pc = @pc + 4
  end

  def _sub
    rd = @decoder.rd
    rs1 = @decoder.rs1
    rs2 = @decoder.rs2
    @x_registers[rd] = @x_registers[rs1] - @x_registers[rs2]
    @pc = @pc + 4
  end

  def _or
    rd = @decoder.rd
    rs1 = @decoder.rs1
    rs2 = @decoder.rs2
    @x_registers[rd] = @x_registers[rs1] | @x_registers[rs2]
    @pc = @pc + 4
  end

  def _and
    rd = @decoder.rd
    rs1 = @decoder.rs1
    rs2 = @decoder.rs2
    @x_registers[rd] = @x_registers[rs1] & @x_registers[rs2]
    @pc = @pc + 4
  end

  def _slli
    rd = @decoder.rd
    rs1 = @decoder.rs1
    i_imm = @decoder.i_imm
    @x_registers[rd] = @x_registers[rs1] << (i_imm & 0b11111)
    @pc = @pc + 4
  end

  def _addi
    rd = @decoder.rd
    rs1 = @decoder.rs1
    i_imm = @decoder.i_imm

    minus_flg = (i_imm & 0b100000000000) >> 11
    imm = if minus_flg == 1
            # TODO もっといい感じに書きたい
            imm = (0b1000000000000 - i_imm) * -1
          else
            imm = i_imm
          end
    @x_registers[rd] = @x_registers[rs1] + imm
    @pc = @pc + 4
  end

  def _beq
    rd = @decoder.rd
    rs1 = @decoder.rs1
    rs2 = @decoder.rs2
    b_imm = @decoder.b_imm

    minus_flg = (b_imm & 0b1000000000000) >> 12
    imm = if minus_flg == 1
            # TODO もっといい感じに書きたい
            imm = (0b10000000000000 - b_imm) * -1
          else
            imm = b_imm
          end
    @pc = if @x_registers[rs1] == @x_registers[rs2]
            @pc + imm
          else
            @pc + 4
          end
  end

  def _lw
    rd = @decoder.rd
    rs1 = @decoder.rs1
    imm = @decoder.i_imm
    @x_registers[rd] = @memory.read(@x_registers[rs1] + imm)
    @pc = @pc + 4
  end

  def _sw
    rs1 = @decoder.rs1
    rs2 = @decoder.rs2
    imm = @decoder.s_imm
    @memory.write(@x_registers[rs1] + imm, @x_registers[rs2])
    @pc = @pc + 4
  end
end

CPUの使い方はこんな感じです。インスタンスを生成してメモリへプログラムをセット、runメソッドが呼ばれると「メモリから命令をフェッチ」→「命令のデコード」→「命令の実行」が行われ、実行結果がレジスタに反映されます。

# 命令組み立て用のユーティリティメソッド
def _add(rd, rs1, rs2)
  0b0110011 |
    (rd << 7) |
    (0x0 << 12) |
    (rs1 << 15) |
    (rs2 << 20) |
    (0x00 << 25)
end

cpu = Cpu.new
cpu.x_registers[1] = 10
cpu.x_registers[2] = 20
cpu.x_registers[3] = 30

# 命令メモリをセット
mem = [
  _add(4, 1, 2), # x4 = x1 + x2
  _add(5, 4, 3), # x5 = x4 + x3
].pack("l*")

cpu.init_memory(mem)

# 実行前
puts cpu.pc
#=> 0
puts cpu.x_registers[4]
#=> 0
puts cpu.x_registers[5]
#=> 0

cpu.run # 1つめの命令を実行
cpu.run # 2つめの命令を実行

# 実行後
puts cpu.pc
#=> 8
puts cpu.x_registers[4]
#=> 30
puts cpu.x_registers[5]
#=> 60

シミュレータ

最後はシミュレータです。渡されたプログラムファイルを読み込んでCPUを初期化した後、CPUのrunメソッドを回し続けます。

本物のコンピュータではCPUは止まることなく回り続けますが、シミュレータのCPUはいつかは止める必要があります。CPUの止め方はシミュレータによってマチマチで「サポート外の命令を呼び出す」とか「nop命令(no operation なにもしない命令)を5回連続で呼び出す」など、いろいろな方法があります。今回はnop命令を5回連続で呼び出したら停止することにします。

class Simulator
  def initialize
    @cpu = Cpu.new
  end

  def init_memory(data)
    @cpu.init_memory(data)
  end

  def start
    loop do
      @cpu.run || break
    end
  end

  def dump_registers
    puts "-" * 80

    for i in 0..7
      for j in 0..3
        print "\t" unless j == 0
        n = (i * 4) + j
        print sprintf "x%02d = 0x%x (%d)", n, @cpu.x_registers[n], @cpu.x_registers[n]
      end
      print "\n"
    end

    puts "-" * 80
    puts sprintf "pc = 0x%x (%d)", @cpu.pc, @cpu.pc
  end
end

if $0 == __FILE__
  sim = Simulator.new
  mem = File.binread(ARGV.shift)
  sim.init_memory(mem)
  sim.start
  sim.dump_registers
end

シミュレータを実行

それではシミュレータを実行してみましょう。rv32simのリポジトリ内にサンプルプログラムが用意されているのでそちらを利用します。

以下のサンプルプログラムはフィボナッチ数の第10項の値を求めるプログラムです。 x1 レジスタに第10項のフィボナッチ数である 55 が格納されています。

プログラムファイルの作り方

シミュレータが完成したので、次はオリジナルのプログラムを作成してみましょう。

今回はgccを利用してプログラムをビルドするため、RISC-V向けのGNUツールチェインをインストールします。

macOSではHomebrewを使って以下の手順でインストールできます(macOS Monterey ではバイナリがインストールされますが、そうでない場合はソースからビルドされるためインストール完了するまで数時間かかります…)

$ brew tap riscv-software-src/riscv
$ brew install riscv-tools

macOS以外の環境では以下を参考にすると良いかも。

RISC-V 用のクロスコンパイラを使ってみる | Hassy's Tech Blog

(追記)Dockerもあるよ

GNUツールチェイン構築済みのDockerイメージを使う方法もあります(後述の「【おまけ】Dockerを使ったビルド手順」をご覧ください)

gccを使ってアセンブル

GNUツールチェインをインストールしたらgccを使ってアセンブルしてみましょう。アセンブルするソースファイルを用意して、

# 4649
# https://github.com/thata/rv32sim/blob/master/sample/4649.S

  .text
  .globl _start
  .type _start, @function
_start:
  addi x1, x0, 0x46 # x1 = ヨロ(46)
  addi x2, x0, 0x49 # x2 = シク(49)
  # シミュレータを停止させるための nop
  nop
  nop
  nop
  nop
  nop

アセンブルを行い実行ファイル(ELFファイル)を生成、ELFファイルを機械語のみのバイナリ形式(ROMファイル)に変換、作成したROMファイルを引数にシミュレータを実行します。

レジスタ x01x02 に「ヨロシク (0x46, 0x49)」がセットされていれば成功です。

$ cd sample
$ riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -Wl,-Ttext=0x00 -nostdlib -o 4649.elf 4649.S
$ riscv64-unknown-elf-objcopy -O binary 4649.elf 4649.rom
$ cd ..
$ ruby rv32sim.rb sample/4649.rom

4649〜

【おまけ】Dockerを使ったビルド手順

GNUツールチェイン構築済みのDockerイメージ(15GBくらいあるよ...)を使ってビルドする手順は以下の通り。「riscv64-unknown-elf-gcc」が「/opt/riscv/bin/riscv32-unknown-elf-gcc」になってるのでご注意ください。

# Dockerイメージをpullしてくる
$ docker pull kamiyaowl/riscv-gnu-toolchain-docker

# ビルド
$ cd sample
$ docker run --rm \
  -v $PWD:/usr/src/myapp \
  -w /usr/src/myapp kamiyaowl/riscv-gnu-toolchain-docker \
  /opt/riscv/bin/riscv32-unknown-elf-gcc \
  -march=rv32i -mabi=ilp32 -Wl,-Ttext=0x00 -nostdlib -o 4649.elf 4649.S
$ docker run --rm \
  -v $PWD:/usr/src/myapp \
  -w /usr/src/myapp kamiyaowl/riscv-gnu-toolchain-docker \
  /opt/riscv/bin/riscv32-unknown-elf-objcopy -O binary 4649.elf 4649.rom

# シミュレータで実行
$ cd ..
$ ruby rv32sim.rb sample/4649.rom

シミュレータ外部とのデータのやりとり

これでRISC-Vシミュレータが完成したわけですが、これだけだとレジスタに値を書き込むことしかできず、ちょっと物足りないですよね。

ということで、シミュレータへ仮想シリアルデバイスを導入し、仮想シリアルデバイスを通じて標準入出力へアクセスできるようにシミュレータを拡張してみましょう。

メモリマップドI/O

メモリのアドレス空間の一部を外部デバイスに割り当てて、メモリのアクセスと同じ方法で外部デバイスへアクセスする方法を「メモリマップドI/O」と呼びます。

今回は仮想シリアルデバイスの入出力インターフェースをメモリの0x10000000番地へ割り当てて、0x10000000番地へ書き込みが行われたら標準出力へ書き込み、0x10000000番地から読み込みを行ったら標準入力から読み込みを行えるようにします。

仮想シリアルデバイスの組み込み

まずはSerialクラスを作成します。このクラスを介して標準入出力とのデータのやり取りを行います。

class Serial
  def initialize(input = $stdin, output = $stdout)
    @input = input
    @output = output
  end

  def write(word)
    @output.putc(word & 0xFF)    
  end

  def read
    c = @input.getc until c
    # getc は String が返ってくるので、ASCIIコードに変換
    c.ord
  end
end

次にCpuクラスを以下のように修正します。lw(Load Word)命令で 0x10000000 番地からのデータの読み込みが指示された場合はSerialからデータを読み込み、sw(Store Word)命令で 0x10000000 番地へデータの書き込みが指示された場合はSerialへデータを書き込むようにします。

class Cpu
  def initialize
    ...
    #--- 追加ここから ---
    @serial = Serial.new
    #--- 追加ここまで ---
  end

  #--- 追加ここから ---
  SERIAL_ADDRESS = 0x1000_0000
  def serial_address?(address)
    address == SERIAL_ADDRESS
  end
  #--- 追加ここまで ---

  #--- _lwの修正(ここから) ---
  def _lw
    rd = @decoder.rd
    rs1 = @decoder.rs1
    imm = @decoder.i_imm
    address = @x_registers[rs1] + imm
    @x_registers[rd] =
      if serial_address?(address)
        # 標準入力から読み込む
        @serial.read
      else
        @memory.read(address)
      end
    @pc = @pc + 4
  end
  #--- _lwの修正(ここまで) ---
  
  #--- _swの修正(ここから) ---
  def _sw
    rs1 = @decoder.rs1
    rs2 = @decoder.rs2
    imm = @decoder.s_imm
    address = @x_registers[rs1] + imm
    if serial_address?(address)
      # 標準出力へ書き込む
      @serial.write(@x_registers[rs2])
    else
      @memory.write(address, @x_registers[rs2])
    end
    @pc = @pc + 4
  end
  #--- _swの修正(ここまで) ---
end

標準出力へ出力

まずは標準出力へ文字を出力してみましょう。

出力したい文字をt0レジスタへセットして0x10000000番地へ書き込むことで標準出力への出力を行います。

# hello
# https://github.com/thata/rv32sim/blob/master/sample/hello.S

  .text
  .globl _start
  .type _start, @function
_start:
  // シリアル通信の送受信レジスタのアドレス ( 0x10000000 ) を gp レジスタにセット
  // 1024を18ビットシフトさせて 0x10000000 を作成する
  addi gp, zero, 1024
  slli gp, gp, 18

  addi t0, zero, 'H'
  sw t0, 0(gp)
  addi t0, zero, 'E'
  sw t0, 0(gp)
  addi t0, zero, 'L'
  sw t0, 0(gp)
  addi t0, zero, 'L'
  sw t0, 0(gp)
  addi t0, zero, 'O'
  sw t0, 0(gp)
  addi t0, zero, ' '
  sw t0, 0(gp)
  addi t0, zero, 'W'
  sw t0, 0(gp)
  addi t0, zero, 'O'
  sw t0, 0(gp)
  addi t0, zero, 'R'
  sw t0, 0(gp)
  addi t0, zero, 'L'
  sw t0, 0(gp)
  addi t0, zero, 'D'
  sw t0, 0(gp)
  addi t0, zero, '!'
  sw t0, 0(gp)
  addi t0, zero, '!'
  sw t0, 0(gp)
  addi t0, zero, '\n'
  sw t0, 0(gp)
  nop
  nop
  nop
  nop
  nop

上記のソースをアセンブルして、

$ riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -Wl,-Ttext=0x00 -nost
dlib -o sample/hello.elf sample/hello.S
$ riscv64-unknown-elf-objcopy -O binary sample/hello.elf sample/hello.rom

実行してみます。「HELLO WORLD!!」が出力されればOKです。

標準入力から入力

次は標準入力から入力を受け取り、それをそのまま標準出力へ出力してみます。

# loopback
# https://github.com/thata/rv32sim/blob/master/sample/loopback.S

  .text
  .globl _start
  .type _start, @function
_start:
  // シリアル通信の送受信レジスタのアドレス ( 0x10000000 ) を gp レジスタにセット
  // 1024を18ビットシフトさせて 0x10000000 を作成する
  addi gp, zero, 1024
  slli gp, gp, 18
loop:
  // 標準入力から入力を受け取り
  lw a0, 0(gp)
  // 標準出力へ出力する
  sw a0, 0(gp)
    // 無限ループなので、終了させる場合は Ctrl-c で
  beq zero, zero, loop

上記のソースをアセンブルして、

$ riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -Wl,-Ttext=0x00 -nostdlib -o sample/loopback.elf sample/loopback.S
$ riscv64-unknown-elf-objcopy -O binary sample/loopback.elf sample/loopback.rom

実行してみます。入力した文字がそのまま画面へ表示されれば成功です。

C言語で書きたいんだけど...

今回はソースコードの短さを優先したためいくつか命令が足りず、C言語でプログラムを書くことができませんでした。次回は足りない命令を追加してC言語を使ったベアメタルプログラミングに挑戦する予定です。

終わりに

以上、Rubyを使ったシンプルなRISC-Vシミュレータの作り方のご紹介しました。

皆さんも梅雨の時期のおうち時間にお好きなプログラム言語でCPUシミュレータ自作なんていかがですか?

参考