ということで 前回の記事 で触れたように、macOS 上で 自作エミュレータで学ぶ x86 アーキテクチャ を読み進めていく際の開発環境を作ろうとした際の奮闘記を置いておく.

この本は x86 の CPU エミュレータを自作することで、普段使っている Intel CPU の基本的な振る舞いについてを学習できる本だ. とても面白い. しかし、この本は基本的に Windows 環境でサンプルを動作させることしか前提としておらず、macOS では動作が保証されていない. エミュレータ自体は C 言語で開発していくため、別に C 言語が使える環境ならばどの OS 上でも動作させることができるだろう. しかし、問題となるのは エミュレータに読み込ませるためのテストプログラムが Windows 以外の環境で用意できるか である. エミュレータを作る立場では、そもそもテストプログラムが意図した機械語列でなければ本に沿って作業を進めていくことができない. したがって、C 言語で書かれたテストプログラムを意図した通りの機械語列に変換させる必要がある. 通常、C 言語のプログラムがどのような機械語に変換されるかはコンパイラ任せであるため、意図したとおりの機械語列を得ようとするならば、全く同一の環境を用意して作業するのが当然望ましい だろう. (同じ OS、同じバージョンのコンパイラ)

はじめに弁明しておくと僕は前回の記事で述べた通り、結局 VM 上の Windows でテストプログラムをコンパイルし、生成されたバイナリを macOS 上に移動させることで本書を読み進めていった. したがって、以下の記事で説明している内容は たぶん参考にならない. というのも、やはり C コンパイラは macOS 環境下で意図した機械語列を吐いてくれなかったためである…


この本では、例えば最初に以下のようなプログラムをテストプログラムとして使用する.

  • casm-c-sample.c
void func(void) {
  int val = 0;
  val++;
}

本文 p.18 ではこのプログラムをコンパイルするために gcc -Wl,--entry=func,--oformat=binary -nostdlib -fno- asynchronous-unwind-tables -o casm-c-sample.bin casm-c-sample.c というコマンドを使っているが、本書の サポートサイト からダウンロードできるサンプルコード中には以下のような Makefile が含まれている.

TARGET = casm-c-sample.bin
Z_TOOLS = ../z_tools

CC = gcc
LD = ld
CFLAGS += -nostdlib -fno-asynchronous-unwind-tables \
	-I$(Z_TOOLS)/i386-elf-gcc/include -g -fno-stack-protector
LDFLAGS += --entry=func --oformat=binary

.PHONY: all
all :
	make $(TARGET)

%.o : %.c Makefile
	$(CC) $(CFLAGS) -c $<

%.bin : %.o Makefile
	$(LD) $(LDFLAGS) -o $@ $<

つまり、gcc -nostdlib -fno-asynchronous-unwind-tables -I../ztools/i386-elf-gcc/include -g -fno-stack-protector -c casm-c-sample.c を実行してオブジェクトコード casm-c.sample.o を生成した後に、リンカ ld を使って ld --entry=func --oformat=binary -o casm-c-sample.bin casm-c-sample.o を実行して機械語ファイル casm-c-sample.bin を作ろうとしているわけである.

まずは macOS 標準の gcc でコンパイルを実行してみた. macOS 上の gcc は実体は clang であるので、当然多少オプションが異なっていても不思議ではないと思ったが、gcc は問題なく casm-c.sample.o を生成できた. この gcc に対して渡しているオプションはちょっとよく分からないので、とりあえずそのまま指定しておくことにする.

後はリンクができれば良いわけだが、このプログラムは通常のプログラムとはちょっと作りが異なるためリンカに対してオプションを指定してやる必要がある. まず、このプログラムでは通常の C 言語プログラムのようにエントリポイントを main 関数にするのではなく、func 関数に設定してやる必要がある. (--entry=func) また、今回出力する機械語ファイルのフォーマットは Windows 用のバイナリファイル (PE 形式) でも、macOS 用のバイナリファイル (Mach-O 形式) でも Linux 用のバイナリファイル (ELF 形式) でもない. 通常の OS で動作する機械語ファイルは普通、ヘッダ と呼ばれる実行したいプログラムの機械語列以外のデータも含んでいる. このヘッダの形式が OS ごとに異なっているため、異なる OS 用のプログラムは他の OS 上では動作できないわけだ. (まぁ、ヘッダを統一して同一のファイル形式を使うようになったとしても、今度は OS のシステムコール呼び出しなんかは統一できるはずがないのでやっぱり動かないわけなのだが.)

で、今回は自作エミュレータに読み込ませるためのテストプログラムを作りたいのだが、OS 特有のヘッダファイルなんかが含まれていると逆に邪魔になるのでこのようなヘッダ情報は出力させないようにする. (--oformat=binary) したがって、出力されるファイル中には 純粋に実行する用途としてのバイト列 のみが含まれ、ファイルの構造に関するヘッダ情報などは一切含まれないことになる.

アセンブラによって出力された機械語ファイルの中身が、元々アセンブリ言語で書いておいた処理に一対一で対応していて全く無駄なバイト列が付与されていない、といった状態である.

さて、このリンク処理を実行してみようとすると以下のようなエラーが発生する.

ld: unknown option: --entry=func

これは何かと言えば、リンカである ld--entry=func というオプションを理解できていないということである. つまり、プログラムを func 関数から実行するように設定できないということだ.

これについて調べてみると Stack Overflow で この問題について言及している質問 を見つけた. これによると、macOS にデフォルトで搭載されている ld ではプログラムのエントリポイントが main であることがハードコーディングされているため、変更できないとのこと. GNU が開発している GNU ld であればこのようなエントリポイント指定を受け付けるらしい.

実は問題はまだある. 先程 gcc (clang) でのコンパイルはうまく行ったが、実は clang が生成するのは “64 ビット CPU 向けの機械語列 (オブジェクトコード)” であるのに対して、本書で作成するエミュレータでは “32 ビット CPU 向けの機械語列” が入力されることが前提となっている. よって、実は gcc によってコンパイルされたオブジェクトコードも適切な機械語列ではなかったのである.

さてどうしようか.

この問題をクリアするためには 32 ビットマシン向けの gcc と GNU ld を macOS 上で動かす必要がある. つまり、クロスコンパイラ を用意する必要があるわけだ. 今回は 32 ビット Linux 向けのクロスコンパイラを用意することにした. クロスコンパイラの準備についてはちょっと長くなるので この記事 を参照してほしい.

さて、このクロスコンパイラを使うと gcc/ld によるコンパイルとリンクは以下のように問題なく実行することができる. (クロスコンパイラは ~/cross/i386-elf-gcc-7.3.0 に配置されている.)

$ ~/cross/i386-elf-gcc-7.3.0/bin/i386-elf-gcc -nostdlib -fno-asynchronous-unwind-tables -I../ztools/i386-elf-gcc/include -g -fno-stack-protector -c casm-c-sample.c
$ ~/cross/i386-elf-gcc-7.3.0/bin/i386-elf-ld --entry=func --oformat=binary -o casm-c-sample.bin casm-c-sample.o

さてコンパイルとリンクが上手くいっても、肝心な機械語列が意図したものでなければ意味がない. ndisasm を使って得られた機械語列を確認してみる.

$ ndisasm -b 32 casm-c-sample.bin
00000000  55                push ebp
00000001  89E5              mov ebp,esp
00000003  83EC10            sub esp,byte +0x10
00000006  C745FC00000000    mov dword [ebp-0x4],0x0
0000000D  FF45FC            inc dword [ebp-0x4]
00000010  90                nop
00000011  C9                leave
00000012  C3                ret

一方、本書 p.19 に示されている逆アセンブル結果は以下のようになっている.

> ndisasm -b 32 casm-c-sample.bin
00000000  55                push ebp
00000001  89E5              mov ebp,esp
00000003  83EC10            sub esp,byte +0x10
00000006  C745FC00000000    mov dword [ebp-0x4],0x0
0000000D  FF45FC            inc dword [ebp-0x4]
00000010  C9                leave
00000011  C3                ret

うーん、90 (nop) が増えてる…!

したがって、一応エラーは出さずに本書で指定されているような手順でコンパイルを行うことができるようにはなったが、やはり完全に意図したとおりの機械語列を得ることは難しいようだ.

このような試行錯誤の末、本に沿って作業を進める上では実際に Windows 上でコンパイルしたほうが確実だなぁ… と判断したわけである.