コンパイルについて解説

C/C++

最近では、ソースコードのコンパイルはビルドツール等を使用するとコマンド一発で実行ファイル作成まで完了してしまうため、コンパイル処理の内部がどのように行われているかはあまり意識する必要がありません。

しかし、コンパイルはいくつかの独立した処理を順番に行われることで成り立っています。これらの処理を1つ1つ理解することで、コンパイルエラーが起きた時にどのフェーズで起きたかが判断でき、エラーの原因を特定しやすくなります。

本記事ではコンパイルの各フェーズでどのような処理が行われるか詳しく解説していきます。

コンパイル処理の流れ

コンパイル処理の流れは、以下の図のような手順となります。

※本記事では、「プリプロセス」から「リンク」までの処理を「コンパイル」と定義しています。(gccやg++では上記の図のような処理が行われます。)

「コンパイル」という言葉の定義や処理の内容は開発環境などに応じて異なる場合がありますが、広義の意味ではソースコードを入力してから実行ファイルを出力するまでの間に行われる処理の流れのことを指します。

コンパイルの各処理の内容について次章以降で説明します。

プリプロセス

プリプロセスは、C/C++においてコンパイルの最初に実行される処理です。プリプロセスを行うプログラムのことをプリプロセッサと呼びます。プリプロセスでは、以下のような処理が行われます。

  • ソースファイルの「#include」や「#define」などの#で始まるプリプロセッサ指令(または、プリプロセッサディレクティブ)に対して適切な処理を行う
  • ソースファイルのコメントの削除
  • ソースファイルの改行文字の削除

中でもプリプロセッサ指令の処理はプリプロセスのメインとなる処理です。以下にプリプロセッサ指令の主な種類を示します。

  • #include:外部ファイルを現在のファイルに挿入します。
  • #define:マクロを定義し、特定の値に置き換えます。
  • #undef:定義されたマクロを取り消します。
  • #if:指定された条件が真の場合にのみ、コードをコンパイルします。
  • #ifdef:指定されたマクロが定義されている場合に、コードをコンパイルします。
  • #ifndef:指定されたマクロが定義されていない場合に、コードをコンパイルします。
  • #else:#if, #ifdef, #ifndefの条件が偽の場合に、コードをコンパイルします。
  • #elif:前の#if, #elifが偽の場合に、別の条件を指定します。
  • #endif:#ifなどによる条件付きコンパイルの終了を示します。
  • #pragma:コンパイラに特定の命令を伝えるために使用されます。(コンパイラ依存)
  • #error:特定の条件下でコンパイルエラーを発生させます。
  • #line:ソースファイル内の現在の行番号とファイル名を変更します。

例として、以下のソース(ファイル名:sample1.c)に対して、プリプロセスのみ実行したいと思います。

#define PI 3.14159

int main() {
    double radius, area;

    /* 半径を設定 */
    radius = 3.0;

    /* 円の面積を計算 */
    area = PI * radius * radius;

    return 0;
}

gccに「-E」オプションを指定することで、プリプロセスのみを実行できます。実行結果が標準出力されるため、「sample_preprocessed.c」にリダイレクトします。

gcc -E sample1.c > sample_preprocessed.c

「sample_preprocessed.c」の中身は以下のようになります。

# 0 "sample1.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "sample1.c"


int main() {
    double radius, area;


    radius = 3.0;


    area = 3.14159 * radius * radius;

    return 0;
}

「sample1.c」で定義していたマクロ定数「PI」が設定していた数値に置き換えられ、コメントも削除されていることが分かります。

※「# 0 "sample1.c"」などの#で始まる行はプリプロセッサによる特殊なマーカーで、コンパイルエラーや警告が発生したときにエラーメッセージのコンテキスト情報(ファイル名や行番号)を提供するために使用されます。

字句解析

字句解析では、プリプロセス実行後のソースコードを読み取り、それを意味のある最小の単位である「トークン」に分割するプロセスです。字句解析を行うプログラムのことをレキサーと呼びます。レキサーは以下のような流れで処理を行います。

  1. 入力ソースコードの読み取り:レキサーはプリプロセスされたソースコードを入力として受け取ります。
  2. トークンの識別:レキサーはソースコードを一文字ずつ読み込み、これらの文字をトークンにグループ化します。
  3. 不要な要素の除去:空白、タブ、改行などの不要な文字やシーケンスは除去します。
※「トークン」とは?
「トークン」は、プログラミング言語において、意味を持つ最小のテキストの断片やシンボルです。プログラムを構成する基本的な要素であり、次の構文解析ステップでの解析の基盤となります。「トークン」には以下のような種類があります。
  • キーワード:予約語(if, while, returnなど)
  • 識別子:変数名、関数名など
  • リテラル:数値、文字列などの定数値
  • 演算子:算術、比較、論理演算子(+, -, ==, &&など)
  • 区切り文字:括弧、カンマ、セミコロンなど

例として、以下のソースコードをトークンに分割してみます。

int main() {
    int a = 5;
    return 0;
}

上記のソースコードは以下のようなトークンに分割されます。

  • ‘int’ (キーワード)
  • ‘main’ (識別子)
  • ‘(‘ (区切り文字)
  • ‘)’ (区切り文字)
  • ‘{‘ (区切り文字)
  • ‘int’ (キーワード)
  • ‘a’ (識別子)
  • ‘=’ (演算子)
  • ‘5’ (数値リテラル)
  • ‘;’ (区切り文字)
  • ‘return’ (キーワード)
  • ‘0’ (識別子)
  • ‘;’ (区切り文字)
  • ‘}’ (区切り文字)

構文解析

構文解析は、字句解析によって生成されたトークンのストリームを解析してプログラムの構文構造を構築するプロセスです。

構文解析を行うプログラムのことをパーサーと呼びます。パーサーは以下のような流れで処理を行います。

  1. トークンの受け取り:レキサーからトークンのストリームを受け取ります。
  2. 文法規則の適用:プログラミング言語の定義された文法規則に従って、トークンを解析します。
  3. 抽象構文木(AST)の生成:正しい構文に基づいて、抽象構文木(AST)と呼ばれるデータ構造を構築します。
  4. エラーの検出と回復:構文エラー(例えば、不適切なトークンの並びや期待されるトークンの欠如)が検出された場合、パーサーはエラーメッセージを生成し、可能であれば解析を続けるための回復策を取ります。

抽象構文木(AST)とは

抽象構文木(Abstract Syntax Tree、略してAST)は、ソースコードの構文的な構造を表すコンパイラのデータ構造の一つです。

ASTは、ソースコードの抽象的な表現であり、プログラムの構文解析の結果として生成されます。この木構造には、プログラムの構文的な要素がノードとして格納され、それらの関係が枝によって表現されます。

例として、以下のソースコードをASTで表現してみます。

int main() {
    int a = 5;
    return 0;
}

ASTは以下のようになります。(テキストベースで表現しています。)

FunctionDefinition
|-- Name: main
|-- ReturnType: int
|-- Parameters: []
|-- Body: BlockStatement
    |-- VariableDeclaration
    |   |-- DataType: int
    |   |-- Declarator
    |       |-- Name: a
    |       |-- Initializer: 5
    |-- ReturnStatement
        |-- Return Value: 0

エラー検出時に回復策がとられる理由

トークン解析時に構文エラーがあった場合に、その時点で解析を終了せずに回復策がとられ解析を進める理由は、一度のコンパイルでできる限り解析し可能な限り多くのエラーを報告するためです。

これにより、開発者は単一のコンパイル実行で複数のエラーを識別し、修正することができます。

ただし、回復策が完璧であるとは限らず、エラーの回復によって後続のエラーメッセージが誤解を招くこともあります。

意味解析

意味解析は、構文解析によって生成された抽象構文木(AST)を用いて、プログラムの意味を検証し理解するプロセスです。

意味解析を行うプログラムのことをセマンティックアナライザーと呼びます。セマンティックアナライザーは以下の内容について検証し、不正の場合はエラーを報告します。

  • :変数の型、式の型、関数の引数と戻り値の型などが適切であるかチェックします。
  • 変数の宣言とスコープ:使用される変数が適切に宣言されており、適切なスコープ内で使用されているかをチェックします。
  • 関数呼び出し:関数が正しい引数で呼び出されているか、存在する関数名での呼び出しかをチェックします。
  • 演算子の使用:演算子が適切な型のオペランドとともに使用されているかを検証します。例えば、除算演算子がゼロによる除算を行っていないかなどをチェックします。
  • 型の強制と変換:型の暗黙的な強制(例えば、整数型から浮動小数点型への自動変換)や明示的な型変換が適切に行われているかを検証します。
  • アクセス権:プログラム要素(変数、関数、クラスなど)へのアクセスが適切なアクセス権に基づいて行われているかをチェックします。例えば、プライベート変数へ外部からのアクセスが行われていないかなどです。

中間コード生成

中間コード生成では、構文解析フェーズで生成されたASTをもとに、中間表現(Intermediate Representation、略してIR)を生成します。ここで生成される中間コードは、以下のような特徴があります。

  • 機械独立性:特定のハードウェアアーキテクチャ1に依存しないので、同じ中間コードから異なるハードウェアアーキテクチャ向けの機械語を生成することが可能です。
  • 最適化しやすい形式:様々な最適化技術が適用可能な形式で表現され、次の「コード最適化」フェーズで効率的な最適化を行うことが可能です。

GIMPLE

gccではGIMPLEと呼ばれる中間コードが生成されます。例として、以下のソースコード(sample2.c)からGIMPLEを生成・出力してみます。

int main() {
    int x = 0, y = 2, z = 3;

	x = y + z;

    return 0;
}

以下のコマンドを実行することで、出力できます。

~$ gcc -fdump-tree-gimple -o /dev/null sample2.c
~$ ls
a-sample2.c.006t.gimple  sample2.c

GIMPLEが出力されたファイル「a-sample2.c.006t.gimple」が生成されました。中身は以下のようになりました。

int main ()
{
  int D.1948;

  {
    int x;
    int y;
    int z;

    x = 0;
    y = 2;
    z = 3;
    x = y + z;
    D.1948 = 0;
    return D.1948;
  }
  D.1948 = 0;
  return D.1948;
}

コード最適化

コード最適化では、中間コードをもとにプログラムの実行効率を向上させるためにさまざまな変換と改善が行われます。以下のような処理が行われます。

  • 不要なコードの除去:実行されることのないコードや、結果が使用されない計算を削除します。
  • ループ最適化:ループ内の計算を減らし、反復回数を削減することで、ループの効率を向上させます。
  • 共通式の除去:同じ計算が複数回行われる場合、一度の計算にまとめて再利用します。
  • インライン展開:関数呼び出しを関数本体のコードで置き換えることにより、関数呼び出しのオーバーヘッドを削減します。
  • 変数のレジスタへの割り当て:変数をレジスタに割り当てることで、メモリアクセスを減らし、アクセス速度を向上させます。
  • ループ展開:ループ内の反復を部分的に展開し、ループのオーバーヘッドを減らします。
  • 条件分岐の最適化:条件分岐の数を減らすために、プログラムの制御フローを再構成します。
  • 定数畳み込み(定数伝播):定数値をコード中で直接置き換えることにより、不要な計算を減らします。
  • 演算子強度低減:計算コストの高い操作をより効率的な操作に置き換えます(例:乗算を加算に置き換える)。

アセンブル

アセンブルでは、中間コードからアセンブリ言語に変換し、さらにアセンブリ言語をターゲットマシンの機械語(バイナリコード)に変換します。

アセンブリ言語は、機械語を扱いやすくするために作られたプログラミング言語で、機械語と1対1で対応しています。また、ハードウェアアーキテクチャによって書き方が異なります。

アセンブリ言語を機械語に変換するプログラムのことをアセンブラと呼びます。アセンブルは、以下のような流れで処理が行われます。

  1. アセンブリ言語の生成:中間コードからアセンブリ言語が生成されます。
  2. アセンブリの処理:アセンブラがアセンブリ言語のコードを受け取り、対応する機械語の命令に変換されます。
  3. オブジェクトファイルの生成:機械語に変換されたコードはオブジェクトファイルとして出力されます。オブジェクトファイルとは、実行可能ファイルやライブラリファイルに変換されるための中間生成物です。リンクが行われていないため、外部関数やライブラリへの未解決の参照を含んでいます。
※gccでは、中間コード(GIMPLE)をそのままアセンブリ言語への変換を行うのではなく、一旦RTLと呼ばれるさらに低レベルの中間コードに変換され、その後アセンブリ言語への変換が行われます。

アセンブリ言語の生成

gccでアセンブリ言語の生成を行ってみます。以下のソース(ファイル名:sample3.c)からアセンブリ言語を生成します。

int main() {
    int x = 0, y = 2, z = 3;

	x = y + z;

    return 0;
}
~$ gcc -S sample3.c
~$ ls
sample3.c  sample3.s

「sample3.s」というアセンブリ言語のファイルが生成されました。「sample3.s」の中身は以下のようになります。

	.file	"sample3.c"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	endbr64
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$0, -12(%rbp)
	movl	$2, -8(%rbp)
	movl	$3, -4(%rbp)
	movl	-8(%rbp), %edx
	movl	-4(%rbp), %eax
	addl	%edx, %eax
	movl	%eax, -12(%rbp)
	movl	$0, %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
	.section	.note.GNU-stack,"",@progbits
	.section	.note.gnu.property,"a"
	.align 8
	.long	1f - 0f
	.long	4f - 1f
	.long	5
0:
	.string	"GNU"
1:
	.align 8
	.long	0xc0000002
	.long	3f - 2f
2:
	.long	0x3
3:
	.align 8
4:

C言語のソースと比較すると非常に量の多いソースファイルとなっています。アセンブリ言語になると、C言語では抽象化されていた部分が具体化されるため、その分コード量が多くなります。

※アセンブリ言語については以下の記事で解説しています。

リンク

リンクでは、個々のコンパイルされたオブジェクトファイルやライブラリを結合し、実行可能な単一のプログラム(実行ファイル)を生成します。リンクを行うプログラムのことをリンカと呼びます。

リンクは、以下のような流れで処理が行われます。

  1. 入力の収集:リンカは、複数のオブジェクトファイルと静的ライブラリを入力として受け取ります。
  2. シンボルの解決:リンカは各オブジェクトファイルと静的ライブラリ内のシンボル(関数、変数など)を調べ、未解決の外部参照を検索します。未解決の参照は、他のオブジェクトファイルやライブラリに定義されている対応するシンボルと結び付けられます。
  3. 再配置:各オブジェクトファイルは、それ自体が独立したアドレス空間でコンパイルされているため、リンカはこれらを単一のアドレス空間にマッピングするために再配置を行います。
  4. ライブラリの統合:静的ライブラリから必要なコードが抽出され、最終的な実行可能ファイルに組み込まれます。
  5. 実行ファイルの生成:すべてのシンボルが解決され、コードが再配置された後、リンカはこれらを組み合わせて単一の実行可能ファイルを生成します。(※共有ライブラリを使用する場合、共有ライブラリの関数や変数のシンボルは解決されません。)

まとめ

コンパイルの処理の流れについて見てきましたが、コマンド一つで終わる処理が内部では様々な独立した処理が実行されていることが分かりました。内部処理を全て理解するのは大変ですが、大枠のイメージだけでも掴んでおくと、より効率的に開発作業が行えるようになると思います。

脚注

  1. ここでのハードウェアアーキテクチャは、命令セットアーキテクチャ(ISA)のことを指します。命令セットアーキテクチャ(ISA)の詳細に関しては「命令セットアーキテクチャ(Instruction Set Architecture:ISA)について分かりやすく解説」の記事を参考にしてください。 ↩︎
タイトルとURLをコピーしました