Nullable

旧レガシーガジェット研究所

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;
}

ビルドしたバイナリをエミュレータで実行した結果が以下になる。

f:id:k--onishi:20210211153548p:plain

実行した命令のトレースやレジスタの値、メモリダンプなどが出力される。

環境構築

ロスコンパイラ

開発はMacOS上で行った。MIPSのバイナリを生成するためのクロスコンパイラはビルドが面倒だったので以下のDockerイメージを使用した。

https://github.com/multiarch/crossbuild

ロスコンパイラを内包しており以下の環境をサポートする。

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

ビルド

MIPSバイナリは以下のようにコンパイル

$ 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)で、かつてはワークステーションや組み込み機器、身近なモノだとPlayStationNintendo64に採用されている。

ロード・ストアアーキテクチャ

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で命令を指定する。rsrt及び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言語で行い、CLIgithub.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を付与することで無効化できた。

参考