CPUエミュレータを書く
概要
https://github.com/0n1shi/mipsemu
CPUエミュレータを書いたのでまとめる。 今回書いたのはMIPS1のCPUエミュレータ。命令は全て実装したわけではなくC言語のコードいくつか書きクロスコンパイルしたバイナリを実際に動作させ、ある程度のコードが問題なく動作することを確認した。
実際には以下のようなC言語を書き、
int c = 0; int main(void) { int a = 10; int b = 3; c = plus(a, b); return 0; } int plus(unsigned int a, unsigned int b) { return a + b; }
ビルドしたバイナリをエミュレータで実行した結果が以下になる。
実行した命令のトレースやレジスタの値、メモリダンプなどが出力される。
環境構築
クロスコンパイラ
開発はMacOS上で行った。MIPSのバイナリを生成するためのクロスコンパイラはビルドが面倒だったので以下のDockerイメージを使用した。
https://github.com/multiarch/crossbuild
クロスコンパイラを内包しており以下の環境をサポートする。
- x86_64-linux-gnu
- arm-linux-gnueabi
- arm-linux-gnueabihf
- aarch64-linux-gnu
- mipsel-linux-gnu
- powerpc64le-linux-gnu
- x86_64-apple-darwin
- x86_64h-apple-darwin
- i386-apple-darwin
- x86_64-w64-mingw32
- i686-w64-mingw32
Makefile
には以下のように記述した。
DOCKER = docker run --rm -v $(shell pwd):/workdir multiarch/crossbuild CROSS_ENV_DIR = /usr/mipsel-linux-gnu/bin CC = $(DOCKER) $(CROSS_ENV_DIR)/gcc
ビルド
$ gcc -ffreestanding -nostdlib -mips1 -O0 -c main.c -o main.o $ ld -T linker.ld -o main.elf main.o $ objcopy -O binary --only-section=.text --only-section=.data main.elf main
gccのオブションではOSが存在しない環境を考慮した組み込み関数や標準ライブラリ及びコンパイル時の最適化を無効化している。 リンカスクリプトでロードアドレスの設定を行い、objcopyで必要なセクションのみを抽出している。
リンカスクリプトは以下のように記述している。
SECTIONS { . = 0x0000; .text : { *(.text) } . = 0x8000; .data : { *(.data) } .bss : { *(.bss) } /DISCARD/ : { *(.reginfo) *(.MIPS.abiflags) *(.pdr) *(.comment) *(.gnu.attributes) } }
MIPS
MIPSはミップス・テクノロジーズが開発したRISCマイクロプロセッサの命令セット(ISA)で、かつてはワークステーションや組み込み機器、身近なモノだとPlayStationやNintendo64に採用されている。
ロード・ストアアーキテクチャ
MIPSはロード/ストアアーキテクチャを採用しておりコストが高いメモリへのアクセスはロード命令及びストア命令で行い、それ以外の演算は全てレジスタ間で行う。
アドレッシングモード
アドレッシングモードは1種類しか存在せずベースとなるレジスタに対してオフセットを指定するといった極めてシンプルなモノとなっている。
3オペランド方式
MIPSの命令セットは3オペランド方式でソースレジスタ、ターゲットレジスタ、ディスティネーション時レスタの3を用いる。その他多くの命令セットで採用される2オペランド方式とは異なり演算に使用したレジスタの値を破壊することがない。
レジスタ
一般的にMIPSではプログラムカウンタ、HI、LOに加え32本の汎用レジスタを持っている。
HI・LOは乗算と除算の結果を格納するために使用される特別なレジスタで特定の命令からのみアクセスが可能となっている。
32本の汎用レジスタのうち、0番目のレジスタも特殊なレジスタでダンプなどではzeroと表記され常に保持する値は0である。
その他の汎用レジスタは特定の用途はないがCコンパイラなどでは役割が決まっている。
名前 | 番号 | 用途 |
---|---|---|
$zero | 0 | 常にゼロ(書込み不可) |
$at | 1 | アセンブラ(擬似命令)が使用 |
$v0 ~ $v1 | 2-3 | 戻り値 |
$a0 ~ $a3 | 4-7 | 引数 |
$t0 ~ $t7 | 8-15 | 一時変数用 |
$s0 ~ $s7 | 16-23 | 退避が必要な変数用 |
$t8 ~ $t9 | 24-25 | 一時変数用 |
$k0 ~ $k1 | 26-27 | OS用 |
$gp | 28 | グローバルポインタ |
$sp | 29 | スタックポインタ |
$fp | 30 | フレームポインタ |
$ra | 31 | 戻りアドレス記憶用 |
命令セット
MIPSの命令セットは上位互換を有しつつバージョンアップされる過程で、MIPS1~MIPS5に分類された。現在ではMIPS32やMIPS64の2種類にまで整理されている。
今回は命令セットの基本となっているMIPS1を実装した。
命令形式
MIPSの命令は固定長で32bitで、R形式,I形式,J形式の3つ形式が存在する。
R形式
R形式は主にレジスタ間の演算を行う。
opcode(6bit) | rs(5bit) | rt(5bit) | rd(5bit) | shift(5bit) | function(6bit) |
---|---|---|---|---|---|
opcode
は常に0x00で代わりにfunction
の6bitで命令を指定する。rs
、rt
及びrd
はそれぞれソースレジスタ、ターゲットレジスタ、ディスティネーションレジスタを指す番号で汎用レジスタの何番目を利用するかを指定する。shift
はシフト命令で利用されるシフト幅である。
I形式
I形式は主に即値を用いた演算や分岐、データ転送を行う。
opcode(6bit) | rs(5bit) | rt(5bit) | immediate(16bit) |
---|---|---|---|
opcode
で命令の決定、rs
及びrt
はR形式と同様で、immediate
は即値として扱う。
J形式
J形式はジャンプ命令。
opcode(6bit) | address(26bit) |
---|---|
opcode
で命令の決定、address
を2回左シフトしたアドレスにジャンプする。
実装
エミュレータ実装はGO言語で行い、CLIはgithub.com/urfave/cli
を利用した。
主な実装の流れは以下。
まずエミュレータの初期化時に指定されたファイルパスにあるMIPSバイナリを読み込みbyte
配列で定義したメモリ上に展開する。
// 一部省略 func NewEmulator(text []byte) (*Emulator, error) { // store text on memory mem := &Memory{} for i, d := range text { mem[i] = d } return &Emulator{ CPU: NewCPU(mem), Memory: mem, }, nil }
展開したメモリ上にある命令を順次フェッチし、デコード、実行を繰り返す。
// 一部省略 func (emu *Emulator) Run() { for { data, _ := emu.CPU.Fetch() ins, _ := emu.CPU.Decode(data) _ = emu.CPU.Execute(ins) } }
Fetch()
では先程のbyte
配列から4byteを取得する。Decode()
でそのバイト列から命令を情報へと変換し以下のような構造体変数を返す。
type Instruction struct { Opcode int OpcodeType OpcodeType TypeR *InstructionTypeR TypeI *InstructionTypeI TypeJ *InstructionTypeJ } type InstructionTypeR struct { SourceRegister int TargetRegister int DestinationRegister int ShiftAmount int FuncCode int Function func(cpu *CPU, rs int, rt int, rd int, sa int) error } type InstructionTypeI struct { SourceRegister int TargetRegister int Immediate int Function func(cpu *CPU, rs int, rt int, imm int) error } type InstructionTypeJ struct { TargetAddress int Function func(cpu *CPU, addr int) error }
Function
は以下のようなmapで定義しており、Opcode
に対応するものを取得・設定する。
var FunctionTypeRMap = map[int]FunctionTypeR{ 0b100000: Add, 0b100001: Addu, 0b100100: And, 0b001101: Break, : }
最後にExecute()
内で上記のメソッドにデコードした引数を渡し実行する。
// 一部省略 func (cpu *CPU) Execute(ins *Instruction) error { switch ins.OpcodeType { case OpcodeTypeR: r := ins.TypeR _ = r.Function(cpu, r.SourceRegister, r.TargetRegister, r.DestinationRegister, r.ShiftAmount) case OpcodeTypeI: i := ins.TypeI _ = i.Function(cpu, i.SourceRegister, i.TargetRegister, i.Immediate) case OpcodeTypeJ: j := ins.TypeJ _ = j.Function(cpu, j.TargetAddress) } return nil }
Tips
コード実行の終了
実行終了のタイミングをスタックポインタがプログラム実行開始時の値(今回であれば0xFFFF)である際にreturn相当のジャンプ命令(jr ra
)が実行された場合にエミュレートを終了する。
ゼロ除算
GCCのデフォルトオプションではMIPSバイナリとしてコンパイル際にdiv
命令の後にゼロ除算をチェックするためのコードが生成される。オプションに-mno-check-zero-division
を付与することで無効化できた。