はじめに
こんにちは、永和システムマネジメントの自作CPUおじさん、はたけやまたかし( @htkymtks )です。
今回はRubyを使った小さなRISC-Vシミュレータの作り方をご紹介します(以前もシミュレータの記事を書いたのですが、シミュレータに大幅に手を入れたので、それに対応したHDリマスター版です)
リポジトリ
(今回ご紹介するシミュレータのリポジトリはこちら)
- thata / rv32sim
RISC-Vとは
RISC-VはCPUの命令セットアーキテクチャ(ISA)のひとつで、使用料のかからないオープンソースライセンスで提供されていることや、命令セットの美しさから最近注目を集めています。
仕様
RISC-Vの仕様にはワード幅(32ビット、64ビット)や浮動小数点数サポートの有無など、いくつかのバリエーションがありますが、今回は32ビット整数演算のみをサポートする「RV32I」のサブセットを実装します。
- 今回実装するRV32Iの命令
- add命令 (ADD)
- sub命令 (SUB)
- or命令 (OR)
- and命令 (AND)
- addi命令 (ADD Immediate)
- beq命令 (Branch ==)
- slli命令 (Shift Left Logical Immediate)
- lw命令 (Load Word)
- sw命令 (Store Word)
- 命令の詳細はこのへんを参照
メモリ
では早速作っていきましょう。まずは命令やデータを格納するメモリを作ります。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」は即値に利用されます。
命令デコーダは命令のビット列からこれらパラメータの切り出し処理を行います。
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つの部品から構成されます。
- メモリ(さっき作ったMemoryクラス)
- プログラムやデータの保存領域
- プログラムカウンタ(PC)
- メモリ中の次に実行する命令のアドレスを指し示す
- 命令デコーダ(さっき作ったDecoderクラス)
- x0からx31まで32個のレジスタ
- RV32Iはx0からx31までの32ビットの整数レジスタを持ちます
- RISC-Vの「x0レジスタ」は常に0を返す特殊なレジスタになります
INST_TABLE
は optcode
と funct3
と funct7
をキーにした命令テーブルで、デコーダから返ってきた値を使って 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のリポジトリ内にサンプルプログラムが用意されているのでそちらを利用します。
- rv32sim/sample ディレクトリ
以下のサンプルプログラムはフィボナッチ数の第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ファイルを引数にシミュレータを実行します。
レジスタ x01
と x02
に「ヨロシク (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
【おまけ】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シミュレータ自作なんていかがですか?
参考
- RISC-V原典 オープンアーキテクチャのススメ
- RISC-V リファレンス
- rvemu
- Friends RISC-V改訂第3版
- xv6-riscv/kernel/uart.c
- 12ステップで作る組込みOS自作入門
- QEMUでRISC-Vベアメタルプログラミング(ヨロシク編)
- RISC-Vでベアメタルプログラミング(Lチカ編)