15日目: [x86] エミュレータ製作 (前半)
この記事はひとりでCPUとエミュレータとコンパイラを作る Advent Calendar 2017の15日目の記事です。
昨日まではx86の命令セットの解説をしていた。 重要な命令 は概ね紹介できたと思う1。 ざっくり振り返ると、CPUが実行できる命令は
これらの動作の組み合わせになっていた。
せっかく x86の命令を調べた し、それを再現するようなCPUを作りたくなる。 いきなりFPGAで作っても良いのだが、あれはしんどいので、まず手始めにCPUと似た動作をするやつをソフトウェア的に作ろうと思う。 要するに機械語を読み込んで命令を解釈し、算術演算に基づいてレジスタ/メモリの値を操作し、処理を終えたら次の機械語を読む、、、という動作を繰り返す、CPUと似た動きをするプログラムを作りたい。 そうした疑似CPUのことをエミュレータという2。 一口にエミュレータと言っても、どのレベルでCPUを再現するかに応じて種類がある。ストイックな順に並べると
1や2のようなエミュレータを作るのは、FPGAでCPUを作るのと似た作業になるだろう。
たとえば機械語のadd
命令を再現するために全加算器を作る必要が生じる。これは大変だ。
今回は3番目の機械語を実行した結果、レジスタがどうなるかを再現するようなエミュレータを作ろうと思う。
この場合、add
命令を再現するのに全加算器はいらない。a=a+b
のように普通に値を足せばよい。
つまりレジスタとメモリの状態を再現するのが目的であり、そのための手段は問わない。
こういうエミュレータは簡単に作れて、しかも役立つ。ご利益を列挙すると
- FPGAでCPUの回路を作る際に、回路シミュレータと自作エミュレータでメモリの値が一致するかチェックできる
- ある機械語を実機で動作させる前に、自作エミュレータで結果をチェックできる
- 自作コンパイラの吐いたコードが正しく動くかチェックできる
要するにCPUとコンパイラのデバッグを行う時に、ものすごく役立つのだ。 これらを自作したいのなら、あらかじめエミュレータを書いておくとスムーズに進むだろう。
タイトルでエミュレータなどと銘打ったが、実際に作るのはデバッガに近い。
C言語をgcc -g
でコンパイルしてgdb
でステップ実行し、i r
でレジスタの状態を見るような感じ。
予定
今後の予定だが、まず今日はmain
関数を解説し、エミュレータの大枠を説明する。
明日は機械語の解釈部分を説明し、完成する見込み。
前回FPGAでCPUを作ったときと全く同じ流れだ。
今日の内容を要約すると
- エミュレータ本体の構造体
emu
を初期化 - 外部ファイルから
emu.memory
に機械語を読み込む - 機械語を解釈する構造体
ope
を初期化 (詳細は明日) - 機械語を
emu.memory
を行ずつ実行 (詳細は明日) emu
とope
を解法
これらを実行するC言語のプログラムを書いた。 Cを選んだ深い理由はないけど、後にCで書いた自作コンパイラを解説する予定なので、あわせておいた。
エミュレータの本体部分
今日はmain
関数の中身を追うと言ったが、その
最初の処理
は
EMULATOR emu; initialize_EMULATOR(&emu);
エミュレータ本体を表す構造体を作り、初期化している。 この構造体は include/common.h で以下のように宣言されている。
typedef struct { uint32_t *eax; uint32_t *ecx; uint32_t *edx; uint32_t *ebx; uint32_t *esp; uint32_t *ebp; uint32_t *esi; uint32_t *edi; uint32_t *eip; uint32_t *eflags; uint8_t *memory; } EMULATOR;
意味は
11日目の記事
のレジスタの種類のセクションを読めばわかると思う。各種レジスタは32bit(4byte)で、メモリは8bit(1byte)の配列として実装した。
initialize_EMULATOR
ではレジスタやメモリ(ややこしいので以下ではemu.memory
と書く)をmalloc
してゼロで初期化している。
ただしemu.esp
だけはemu.memory
のアドレスの最大値で初期化している。
理由はスタックがメモリ空間の末尾に準備されるせい3なのだが、忘れた人のために次のセクションで詳しく説明する。
エミュレータのメモリに機械語を読み込む
emu
を初期化したので、次は
emu.memoryに機械語を読み込む
。
機械語のファイルを開いて、サイズ(全体で何バイトか?)をbin_size
に格納した後、fread
関数を使ってemu.memory
に中身を読み込んでいる。
FILE *bin; bin = fopen(argv[1], "rb"); // 機械語のサイズを取得 fseek(bin, 0, SEEK_END); const int bin_size = ftell(bin); rewind(bin); // 機械語をemu.memoryに読み込み // INIT_EIP_ADDRESSはデフォルトで0 fread(emu.memory + INIT_EIP_ADDRESS, 1, bin_size, bin); fclose(bin);
ここで重要なのは、emu.memory
のどこに機械語を読み込むかだ。
例えば全メモリが1MB(0x000000から0xFFFFFF)だとすると
emu.memory
の0番地から正の方向に機械語ファイルの中身をコピー- 開始時点の
eip
は0x000000
emu.memory
の0xFFFFFF番地から負の方向にスタック領域を確保- 開始時点の
esp
は0xFFFFFF
というメモリ配置になる。 「関数本体」と「グローバル変数」で分けられているが、これは気にしなくて良い4。 ひっくるめて「機械語ファイルの中身」だと思って欲しい。
ニーモニックの解釈
次は 機械語命令を解釈 する部分だ。これは配列と関数ポインタで実装した。
OPECODE ope[OPECODE_NUM]; initialize_OPECODE(ope);
たとえば1byteの機械語命令0x01
を読み込んだ時に、ope[0x01]
を使うことで処理が行えるようになっている。
構造体OPECODE
は
include/common.h
で宣言されており
typedef struct { char *mnemonic; void (*func)(); } OPECODE;
重要なのはvoid (*func)());
の部分。関数ポインタを使って、少々トリッキーなことをやっている。
この辺の詳細は明日話すことにしよう。
機械語を実行
最後に、 emu.memoryに読み込んだ機械語を実行 していく。
for (int i = 0; i < 100000; i++) { const int opecode = readmem_next_uint8(&emu); ope[opecode].func(&emu); if (opecode == 0xF4) break; }
forループの中では、まずemu.memory
のemu.eip
番地のアドレスを参照し、opecode
という変数に入れている。
たとえばopecode
の値が64の場合、16進数で0x40
なので、ope[opecode]
はinc eax
という処理に対応する。
ループの最後にbreak文がある。
これはもし読み込んだ機械語が0xF4
ならばニーモニックはhlt
なので、停止処理を実行している。
以上でエミュレータの動作の大枠は説明できたと思う。
明日はOPECODE
の構造体の中身を説明する予定。