C/C++の実行プログラムはバイナリ・ファイルですから,テキスト・エディタで編集できるソースコードとの間には大きなギャップがあります。特に予期せぬエラーが発生するとそのギャップは問題解決の大きな障害になります。両者のギャップを埋めることで,C/C++のコンパイル~リンク~ロードの様子を探検してみましょう*1

 プログラミングで誰もがお世話になるソフトウエアと言えば,コンパイラかインタプリタであろう。

 正確な表現ではないが,コンパイラはソースコードから実行ファイルを生成し,インタプリタはソースコードをそのまま実行してくれる。その機能さえ知っていれば,両者をブラックボックスとして扱ってもプログラムは作成できる。実際,難しいことを知らなくてもプログラミングできるようにブラックボックス化が進んできたのが,現在の開発ツールであり開発環境だ。

 20年前なら,ただコンパイルを実行するだけでも,複雑なコマンドライン引数を理解して書き込む必要があった。そこには冷たい荒野が広がっていて,何をするにもすべて自分でコツコツと積み上げる必要があった。しかし,現在は手厚い開発ツールの支援が受けられる。コンパイルはクリック一つでできてしまう。かつての冷たい荒野は,どんなサービスもお金を払えば即座に受けられる大都会に変貌したのだ。そのおかげで,C++のような面倒な手続きを多く要求されるプログラム言語であっても,比較的容易に挑戦することが可能になっている。

 しかし,ひとたび手厚い開発ツールの支援を離れてしまうと,昔ながらの冷たい荒野が広がっていることを,プログラマは忘れてはいけない。

 例えば,ここに筆者がC++言語で作成したプログラムがある。アップル,オレンジ,バナナの平均価格を計算し,その平均価格の平均を計算するという単純なものだ*2。筆者は自分の開発マシン上でプログラムを作成し,ユーザーのWindows XPマシンにインストールした。するとある処理を実行したとたん,思わぬエラーが発生した(図1)。こんなとき,あなたならどうやってエラーの原因を見つけるだろう?

図1●筆者のサンプル・プログラムで発生したエラー画面
図1●筆者のサンプル・プログラムで発生したエラー画面 [画像のクリックで拡大表示]

Win32プログラムでも16進数からコードを特定できる

 ユーザーのマシンに開発ツールがインストールされていれば,さらにソースコードが手元にあれば問題はない。容易にバグの発生したコードを特定できるだろう。しかし普通,実際に完成したプログラムを動かすユーザーのマシンには,開発ツールなどはない。画面には無味乾燥なエラーが表示されるだけである。仮に開発ツールがあったとしても,開発段階では発生しないバグというのもある。再現条件がわからず,開発ツール上で同じ現象を発生させられないこともある。すっかりお手上げになるかもしれない。

 これが,.NET Frameworkの世界なら,もう少しマシな情報が手に入る。筆者が作成したのはWin32ベースのプログラムだが,その .NET版を実行すると,Debugビルドではなくても,リスト1のような例外情報を得ることができる。つまり,main関数から呼ばれたcalc関数の内部で,0除算例外(System.DivideByZeroException)が発生したことを,このメッセージから読み取ることができるのだ*3

リスト1●同じサンプル.NET Framework対応版で得られたエラー情報
リスト1●同じサンプル.NET Framework対応版で得られたエラー情報 [画像のクリックで拡大表示]

 しかし,だからといって筆者は,なにもWin32を捨てて,すぐにでも .NETへ移行すべきと言っているわけではない。

 実は簡単な知識さえあれば,Win32のプログラムでも無味乾燥な16進数*4からソースコードの位置を特定できるのである。機械語,アセンブリ言語などの低レベルの知識は必要ない。16進数という数字さえ扱えれば,後は何とかなる。16進数の引き算が必要とされるケースもあるが,それはWindowsに付属する「電卓」で十分だ。何ら難しいことはない。

 しかも,その手順を学ぶことでもっと大きなものが得られる。実行ファイルからソースコードへの道を探ることで,ソースコードがどうコンパイルされて実行ファイルとして生成されていくかを,まるでテープを逆戻しするように理解できるのだ。開発環境の裏側でどのような仕事が行われているかを知ることができれば,より複雑な問題に直面したとき,必ず役に立つ。

 ではさっそく,未知の世界に足を踏み入れてみよう!

アドレスとコードの関係を つきとめるのが最終目標

 物事には,必ず始めと終わりがある。まず最初に,スタートラインとゴールを確認しておこう。スタートラインとは,「プログラムが例外で停止した位置を示す16進数の数値」である。前述のサンプル・プログラムのケースならば,図1のエラー画面の上から3行目,Address:の後に書かれた「0x00000000004010ad」という部分にあたる。OSの種類や環境によって他のウィンドウやイベント・ビューアに記録されることがあるが,いずれの場合でも同じ情報が含まれるはずである。

 一方ゴールとは,その数値が「ソースコード上のどの位置かを明らかにすること」である。どのソース・ファイルのだいたい何行目かがわかれば目的達成とする。「だいたい何行目」とあいまいなのは,Releaseビルドを行った場合,コンパイラが実行内容を最適化した結果,順番が入れ替わったり,複数の行を塊として扱う場合があるためである。

 ところで,0x00000000004010adという数値はいったい何を意味しているのだろうか。それを知ることから,ゴールを目指す旅を始めよう。

 このAddressという数値は,文字通り「アドレス」と呼ばれるメモリー上の場所を特定するための番号である。厳密に言えば,ここでいうメモリーは仮想メモリーである。メモリーの内容がハードディスクに書き込まれているケースもあるが,それも含めてここでは「メモリー」と呼んで扱っていくことにしよう。

 メモリーには,アドレスという番号が割り当てられている。その番号は,ポインタを整数型にキャストして得られる数値と同じである。もちろん,OS,プログラム,変数…いずれもメモリー上に存在している。アドレスは,1バイト(8ビット)単位で割り当てられ,番地と表記することもある(図2)。例えば,アドレス0番地には1バイトの情報が,アドレス1番地には別の1バイトの情報が…という要領でメモリー上にバイト単位の情報がある。

図2●メモリーとアドレスのイメージ
図2●メモリーとアドレスのイメージ

 単純化して言えば,プログラムとは,CPUに対する機械語命令を示すバイト値が並んだものである。それを実行するために,CPU(32ビットのx86プロセサ)の内部にはEIP(Extended Instruction Pointer)というポインタ(レジスタとも言われる)がある。このポインタは,機械語命令をCPU内で実行するごとに1ずつ値を増やしていく機能を持っている(図3)。

図3●メモリーに格納されたプログラムの実行イメージ。EIPというポインタが,実行する命令(バイト値)を次々指し示していく
図3●メモリーに格納されたプログラムの実行イメージ。EIPというポインタが,実行する命令(バイト値)を次々指し示していく

 大ざっぱに言えば,前述の0x00000000004010adという数値は,プログラムが例外を発生させた瞬間の,このEIPの値を示している。逆に考えれば,EIPが指し示している機械語の命令を調べれば,例外が発生した原因を調査することができるということになる。サンプルの例でいえば,0x00000000004010adというアドレスが指し示す機械語命令を調べれば,最終的にソースコード上のどの行に対応するかを突き止められるということだ。

 しかし「機械語まで勉強するのはイヤだ」という人もいるだろう。実際,そのためだけに機械語命令を勉強するのはあまりにハードルが高すぎる。安心してほしい。冒頭で述べたようにそこまでの知識は必要ない。もっと別の方法を使って原因を調査するヒントを求めていこう。