はじめに
こんにちは、永和システムマネジメントの自作CPUおじさん、はたけやまたかし( @htkymtks )です。
今回はRubyを使った小さなRISC-Vシミュレータの作り方をご紹介します(以前もシミュレータの記事を書いたのですが、シミュレータに大幅に手を入れたので、それに対応したHDリマスター版です)
リポジトリ
(今回ご紹介するシミュレータのリポジトリはこちら)
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)
@data = data.b
end
def read(addr)
word = @data.slice(addr, WORD_SIZE)
word.unpack1("l")
end
def write(addr, word)
@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)
printf "0x%08x\n", memory.read(4)
memory.write(0, 0x00ABCDEF)
printf "0x%08x\n", memory.read(0)
memory.write(4, -1)
printf "0x%08x\n", memory.read(4)
命令デコーダ
次は命令デコーダです。
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
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
printf "%05b\n", decoder.rd
printf "%03b\n", decoder.funct3
printf "%05b\n", decoder.rs1
printf "%05b\n", decoder.rs2
printf "%07b\n", decoder.funct7
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
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)
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
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),
_add(5, 4, 3),
].pack("l*")
cpu.init_memory(mem)
puts cpu.pc
puts cpu.x_registers[4]
puts cpu.x_registers[5]
cpu.run
cpu.run
puts cpu.pc
puts cpu.x_registers[4]
puts cpu.x_registers[5]
シミュレータ
最後はシミュレータです。渡されたプログラムファイルを読み込んで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ファイルを引数にシミュレータを実行します。
レジスタ 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 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
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
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
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
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チカ編)