コンパイルの4段階
gcc は内部で4つの工程を順に実行しています。まず前処理(プリプロセス)で #include や #define を展開し、次にコンパイルでC言語をアセンブリへ翻訳、続いてアセンブルで機械語のオブジェクトファイル(.o)へ変換し、最後にリンクで必要なライブラリと結合して実行ファイルを完成させます。-E で前処理だけ、-S でアセンブリまで、-c でアセンブルまで(.o生成)と、途中段階で止めることもできます。各段階を意識すると、エラーがどの工程で起きたか切り分けやすくなります。
gcc hello.c -o hello と打つと一瞬で実行ファイルができる。だが、その一瞬の裏側では、実は4つの工程が順番に走っている。
gcc は1枚岩のプログラムではない。複数の専用ツールをまとめて呼び出す「指揮者」のような存在だ。
ソースを実行ファイルにするまでを、前処理・コンパイル・アセンブル・リンクという4段階に分けて進める。
普段はこの4つが裏で連続して実行されるため意識しない。だが、各段階が何をしているかを知っておくと、エラーがどの工程で起きたのかを切り分けられるようになり、トラブル対応が格段に楽になる。
ここでは、ソースが実行ファイルへ姿を変えていく過程を、一段ずつたどっていこう。
📝 第1段階 前処理という下ごしらえ
最初の工程は前処理で、プリプロセッサが担当する。ここでは、コンパイルの本番に入る前の「下ごしらえ」が行われる。
具体的には、#include で指定したヘッダファイルの中身をその場に展開し、#define で定義したマクロを実際の値や式へ置き換え、#ifdef などの条件で不要な部分を取り除く。
ここで一つ、意外な事実を押さえたい。この段階ではまだCの文法を解釈しておらず、あくまでテキストとしての置き換え・展開を行っているだけなのだ。
たとえば #include <stdio.h> という1行は、stdio.h の中身そっくりそのままに差し替えられる。
この段階の結果だけを見たいときは gcc -E hello.c と打つ。-E は前処理だけで止めるオプションで、出力は通常そのまま画面に流れる。
覗いてみると驚く。ヘッダを展開した結果なので、たった数行のソースが数百行に膨らんでいるのが分かる。マクロが期待通りに展開されているか確かめたいときにも、この -E は役立つ。
🧮 第2段階 狭い意味のコンパイル
下ごしらえの済んだコードを、次にCの文法に従ってアセンブリ言語へ翻訳する。これが狭い意味でのコンパイルだ。
ここで言葉の整理をしておきたい。コンパイルという言葉は、広義にはソースから実行ファイルを作る一連の流れ全体を指すが、狭義にはまさにこのC言語をアセンブリへ翻訳する工程を指す。
アセンブリ言語は、機械語に非常に近い、CPUの命令を人間がかろうじて読める形で書いたものだ。
この工程では、変数や式や制御構造(if や for など)が、レジスタ操作やジャンプ命令といった低レベルの手続きへ落とし込まれる。
最適化(-O2 などの指定)が効くのも主にこの段階で、コンパイラが処理を組み替えて速く・小さくしようとする。文法エラー(セミコロンの欠落や型の不一致など)の多くも、この段階で検出される。
アセンブリまでで止めたいときは gcc -S hello.c とする。-S を付けると hello.s というアセンブリのテキストファイルが生成され、中を見るとCPU向けの命令が並んでいるのが確認できる。
🧩 第3段階 アセンブルとオブジェクトファイル
アセンブリ言語のテキストを、CPUが直接解釈できる機械語へ変換するのがアセンブルだ。担当するのはアセンブラというツールである。
この工程で生成されるのがオブジェクトファイルで、拡張子は .o だ。
ここで腑に落ちない点が出てくる。オブジェクトファイルの中身はすでに機械語になっているのに、まだ単体では実行できないのだ。
理由は、printf のように他の場所(ライブラリ)にある関数の実体がまだ結び付いておらず、参照だけが空欄のまま残っているからである。
この段階で止めるには gcc -c hello.c と打つ。-c はアセンブルまで(=オブジェクトファイル生成まで)で止めるオプションで、実行すると hello.o ができる。
大きなプログラムをソースごとに .o へ分けてビルドする手法は、この工程を個別に行うことで成り立っている。その空欄を誰がいつ埋めるのかは、次の段階で見る。
🔗 第4段階 リンクで空欄が埋まる
最後の工程がリンクだ。第3段階で残った空欄が、ここで埋まる。
コンパイルで生成された複数のオブジェクトファイルどうしや、printf などライブラリ側にある関数の実体を結びつけて、ようやく1つの実行ファイルを完成させる。この作業を行うプログラムをリンカと呼ぶ。
第3段階で空欄のまま残っていた関数の参照が、ここで実際のアドレスへと埋められ、すべてのピースがつながって動かせる状態になる。
たとえば標準ライブラリにある printf の実体は、このリンクの段階で実行ファイルへ結び付けられる。
printf のような標準Cライブラリの関数は gcc が自動でリンクしてくれるが、数学関数のように別のライブラリに分かれているものは、リンクするライブラリを自分で指定する必要がある(この指定方法は後の段階で扱う)。
こうして見ると、普段の gcc hello.c -o hello は、前処理からこのリンクまでの4段階を一気通貫で実行している。途中の中間ファイルは内部で使い捨て、最終成果物である実行ファイルだけを手元に残してくれているわけだ。
🧭 各段階を知ると、エラーが切り分けられる
4段階を理解しておく最大の利点は、エラーメッセージの切り分けにある。
たとえば「ヘッダが見つからない」という類のエラーは前処理の段階、「型が合わない」「文法が変だ」というエラーはコンパイルの段階、そして undefined reference という未定義参照のエラーはリンクの段階で起きている。
とくにこの undefined reference は初学者を悩ませる定番で、「関数を呼んでいるのに、その実体をリンクできていない」というリンク段階の失敗を示す。原因はライブラリの指定漏れや、必要な .o を渡し忘れていることが大半だ。
エラーがどの工程のものかが分かれば、見るべき場所が絞れる。
各段階の中間ファイルを観察したいときは、オブジェクトファイルの中身を nm でシンボル(関数や変数の名前)一覧として、あるいは objdump で逆アセンブルして確認できる。
コンパイルがブラックボックスではなく4つの明確な工程の連なりだと分かると、ビルドのトラブルにずっと冷静に向き合えるようになる。