しかくいさんかく

解答略のメモ

15日目: [x86] エミュレータ製作 (前半)

この記事はひとりでCPUとエミュレータとコンパイラを作る Advent Calendar 2017の15日目の記事です。

昨日まではx86の命令セットの解説をしていた。 重要な命令 は概ね紹介できたと思う1。 ざっくり振り返ると、CPUが実行できる命令は

これらの動作の組み合わせになっていた。

せっかく x86の命令を調べた し、それを再現するようなCPUを作りたくなる。 いきなりFPGAで作っても良いのだが、あれはしんどいので、まず手始めにCPUと似た動作をするやつをソフトウェア的に作ろうと思う。 要するに機械語を読み込んで命令を解釈し、算術演算に基づいてレジスタ/メモリの値を操作し、処理を終えたら次の機械語を読む、、、という動作を繰り返す、CPUと似た動きをするプログラムを作りたい。 そうした疑似CPUのことをエミュレータという2。 一口にエミュレータと言っても、どのレベルでCPUを再現するかに応じて種類がある。ストイックな順に並べると

  1. トランジスタの電圧変動をリアルタイムで忠実に再現(超ストイック)
  2. クロック毎のNANDゲートのOn/Offを再現(かなりストイック)
  3. 機械語を実行した結果、レジスタがどうなるかを再現

1や2のようなエミュレータを作るのは、FPGAでCPUを作るのと似た作業になるだろう。 たとえば機械語add命令を再現するために全加算器を作る必要が生じる。これは大変だ。

今回は3番目の機械語を実行した結果、レジスタがどうなるかを再現するようなエミュレータを作ろうと思う。 この場合、add命令を再現するのに全加算器はいらない。a=a+bのように普通に値を足せばよい。 つまりレジスタとメモリの状態を再現するのが目的であり、そのための手段は問わない。

こういうエミュレータは簡単に作れて、しかも役立つ。ご利益を列挙すると

  • FPGAでCPUの回路を作る際に、回路シミュレータと自作エミュレータでメモリの値が一致するかチェックできる
  • ある機械語を実機で動作させる前に、自作エミュレータで結果をチェックできる
  • 自作コンパイラの吐いたコードが正しく動くかチェックできる

要するにCPUとコンパイラデバッグを行う時に、ものすごく役立つのだ。 これらを自作したいのなら、あらかじめエミュレータを書いておくとスムーズに進むだろう。

タイトルでエミュレータなどと銘打ったが、実際に作るのはデバッガに近い。 C言語gcc -gコンパイルしてgdbでステップ実行し、i rレジスタの状態を見るような感じ。

予定

今後の予定だが、まず今日はmain関数を解説し、エミュレータの大枠を説明する。 明日は機械語の解釈部分を説明し、完成する見込み。 前回FPGAでCPUを作ったときと全く同じ流れだ。

今日の内容を要約すると

  1. エミュレータ本体の構造体 emu を初期化
  2. 外部ファイルからemu.memory機械語を読み込む
  3. 機械語を解釈する構造体 ope を初期化 (詳細は明日)
  4. 機械語emu.memoryを行ずつ実行 (詳細は明日)
  5. emuopeを解法

これらを実行する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)だとすると

f:id:kaitou_ryaku:20171212033457p:plain

  1. emu.memoryの0番地から正の方向に機械語ファイルの中身をコピー
  2. 開始時点のeip0x000000
  3. emu.memoryの0xFFFFFF番地から負の方向にスタック領域を確保
  4. 開始時点のesp0xFFFFFF

というメモリ配置になる。 「関数本体」と「グローバル変数」で分けられているが、これは気にしなくて良い4。 ひっくるめて「機械語ファイルの中身」だと思って欲しい。

ニーモニックの解釈

次は 機械語命令を解釈 する部分だ。これは配列と関数ポインタで実装した。

OPECODE ope[OPECODE_NUM];
initialize_OPECODE(ope);

たとえば1byteの機械語命令0x01を読み込んだ時に、ope[0x01]を使うことで処理が行えるようになっている。

構造体OPECODEinclude/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.memoryemu.eip番地のアドレスを参照し、opecodeという変数に入れている。 たとえばopecodeの値が64の場合、16進数で0x40なので、ope[opecode]inc eaxという処理に対応する。

ループの最後にbreak文がある。 これはもし読み込んだ機械語0xF4ならばニーモニックhltなので、停止処理を実行している。

以上でエミュレータの動作の大枠は説明できたと思う。

明日はOPECODEの構造体の中身を説明する予定。


  1. 割り込みやブート関係の命令は完全に無視している。

  2. x86エミュレータというと、ブート処理やIO、割り込み周りもちゃんと作ってる雰囲気がただようので怒られそう。「これからFPGAで作るx86サブセットCPUの、レジスタとメモリをシミュレーションするやつ」と言えばたぶん怒られない。

  3. FPGAでCPUを作った時もそうしていた。

  4. コンパイラ製作のコード生成パートで、このへんの問題と向き合うことになる。