LabHub

ブログ

コンパイラとリンカの内部完全解説 — #include から実行ファイルになるまで ELF、シンボル、静的/動的リンク、再配置、LTO (2025)

한국어English日本語

0. 始める前に — "gcc hello.c -o hello" 一行の神秘

#include <stdio.h>
int main(void) {
    printf("Hello\n");
    return 0;
}

この 11 行のソースが gcc hello.c -o hello 一度で実行ファイルになる。あまりに自然で「コンパイラがうまくやってくれる仕事」として流してしまう。だが、いくつか奇妙な問いを投げてみよう。

この記事では C / C++ プログラムがソースから実行に至るまで の全過程を内部構造と一緒に掘り下げる。対象は Linux + ELF + GCC/Clang だが、原理は Windows (PE/COFF) や macOS (Mach-O) にもほぼ同じように当てはまる。

1. ビルドの 4 段階 — 前処理、コンパイル、アセンブル、リンク

1.1 一行の gcc コマンドの裏に隠れた段階

hello.c --[cpp: 前処理]----> hello.i  (展開された C ソース)
hello.i --[cc1: コンパイル]-> hello.s  (assembly)
hello.s --[as: アセンブル]--> hello.o  (object file)
hello.o --[ld: リンク]------> hello    (実行ファイル)

各段階は独立して実行できる。実際に中間生成物を見たければ:

gcc -E hello.c -o hello.i    # 前処理まで
gcc -S hello.i -o hello.s    # コンパイルまで
gcc -c hello.s -o hello.o    # アセンブルまで
gcc hello.o -o hello         # リンクまで

1.2 preprocessor — 単なるテキスト置換器

cpp (C preprocessor) は驚くことに C を全く知らない。テキストを置換するだけだ。

これが C マクロの危険さの根源だ。例:

#define SQUARE(x) x*x
SQUARE(1+2)  // -> 1+2*1+2 = 5 (意図は 9)

C++ で constexpr と template がマクロを置き換えた理由はここにある。しかし preprocessor は header guard (#ifndef HEADER_H)、プラットフォーム分岐 (#ifdef _WIN32)、条件付きログなどの用途では今も必須だ。

gcc -E の出力を見ると、元の 11 行の hello.c が 数万行 に展開される。stdio.h がさらに数十の system header を取り込むからだ。

1.3 コンパイラ — 本物の「理解」が始まる場所

コンパイラ frontend (Clang, GCC cc1) の仕事:

hello.i
  -> Lexer (トークン分解)
  -> Parser (AST 生成)
  -> Semantic Analyzer (型チェック、名前解決)
  -> IR 生成 (GIMPLE / LLVM IR)
  -> 最適化 pass 群 (数十)
  -> Backend (ターゲット CPU の assembly 生成)
hello.s

LLVM IR の例 (hello.c をコンパイル):

@.str = private constant [7 x i8] c"Hello\0A\00"

define i32 @main() {
  %1 = call i32 @printf(i8* getelementptr ([7 x i8], [7 x i8]* @.str, i32 0, i32 0))
  ret i32 0
}

declare i32 @printf(i8*, ...)

IR レベルで印象的な点:

1.4 アセンブラ — テキストをバイナリに

hello.s (x86-64 の例):

    .section .rodata
.LC0:
    .string "Hello"
    .text
    .globl main
main:
    pushq %rbp
    movq  %rsp, %rbp
    leaq  .LC0(%rip), %rdi
    call  puts@PLT
    movl  $0, %eax
    popq  %rbp
    ret

as (GNU assembler) はこれを 機械語バイト に変換し、ELF object file (hello.o) に格納する。興味深いのは:

1.5 リンカ — 断片を実行可能な全体へ

リンカの中核的な責任:

  1. 複数の .o ファイルの統合: 同じ section (.text, .data) を連結する。
  2. symbol 解決: printf のような外部 symbol を実際の定義と結び付ける。
  3. relocation: 最終アドレスが決まったら命令の address field を埋める。
  4. 実行ファイルヘッダ生成: kernel が読める ELF header を作る。

この 4 つがなぜ重要かは次章以降で詳しく。

2. ELF — Linux バイナリの共通語

2.1 ELF の 2 つの視点

ELF (Executable and Linkable Format) は 1999 年に Linux が標準として採用して以来、Unix 系の事実上の標準になった。同じファイルを 2 つの方法 で見られるように設計されている。

+------------------+------------------+
| ELF Header       | ELF Header       |
+------------------+------------------+
| Program Header   |                  |
| (実行用ビュー)   |                  |
+------------------+ Sections:        |
|                  |  .text           |
| Segments:        |  .rodata         |
|  LOAD (r-x)      |  .data           |
|  LOAD (rw-)      |  .bss            |
|  INTERP          |  .symtab         |
|  DYNAMIC         |  .strtab         |
|                  |  .rela.text      |
+------------------+------------------+
| Section Header   | Section Header   |
| (リンク用ビュー) | (リンク用ビュー) |
+------------------+------------------+
    実行時ビュー       リンク時ビュー

2.2 主な section

Section内容メモリ権限
.text実行コードr-x
.rodata読み取り専用定数 (文字列リテラル等)r--
.data初期化済み global/static 変数rw-
.bss0 初期化済み global/static 変数rw-
.symtabsymbol table (関数/変数名 -> アドレス)(ファイルのみ)
.strtabsymbol 名の文字列プール(ファイルのみ)
.rela.text.text に対する relocation entry(ファイルのみ)
.dynsymdynamic symbol (リンカが使う)r--
.plt, .gotdynamic linking 用のジャンプテーブルr-x / rw-

2.3 .bss の魔法 — 0 初期化変数がなぜファイルを食わないのか

int zeros[1000000];  // 4MB
int ones[1000000] = {1, ...};  // 4MB

理由: .bss は「サイズ情報だけ」をファイルに記録する。kernel がプログラムをロードするときに zero page (global zero page) をマップする。4MB の 0 をディスクに保存するのは無駄だからだ。

最初の書き込みで copy-on-write により実ページが割り当てられる。だから「0 で初期化」は「初期化なし」より性能的に有利 (CPU が zero page を良くキャッシュしている)。

2.4 実際に見てみる — readelf の世界

$ readelf -h hello
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 ...
  Class:                             ELF64
  Data:                              2's complement, little endian
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Entry point address:               0x401050

$ readelf -S hello     # section header
$ readelf -l hello     # program header (segment)
$ readelf -s hello     # symbol table
$ objdump -d hello     # 逆アセンブル

これらを hello のような小さなプログラムに実行すると、ELF の構造が見えてくる。システムプログラミング学習の最良の方法。

3. Symbol — 名前とアドレスを繋ぐ環

3.1 symbol とは何か

symbol は「名前 -> アドレス」のマッピングだ。コンパイル時には関数や global variable の実アドレスは決まっていないが、名前は存在する。リンカがすべての .o ファイルを統合しながらアドレスを割り当てる。

hello.o の symbol table:

$ readelf -s hello.o

   Num:    Value          Size Type    Bind   Vis       Name
     5: 0000000000000000    22 FUNC    GLOBAL DEFAULT    1 main
     6: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf

3.2 Binding — Local, Global, Weak

Binding意味
LOCALファイル内部用 (static 関数/変数)。他ファイルから見えない。
GLOBAL外部に公開。複数のファイルがこの symbol にリンク可能。
WEAK既定値だが他に STRONG 定義があれば上書きされる。

WEAK のトリック: ライブラリが既定実装を WEAK で提供すれば、ユーザは同名の STRONG 定義で簡単に override できる。pthread の pthread_mutex_lock のような symbol がそう。

// 既定実装 (libc 内部)
__attribute__((weak)) void my_log(const char* msg) {
    fprintf(stderr, "LOG: %s\n", msg);
}

// ユーザによる override
void my_log(const char* msg) {
    write_to_elasticsearch(msg);
}

3.3 重複 symbol — "multiple definition of foo" エラー

同名の GLOBAL symbol が複数の .o にあるとリンカがエラーを出す:

/usr/bin/ld: b.o: multiple definition of `foo'; a.o: first defined here

最もよくある原因:

C++ の inline 関数は特別扱いされる - リンカが重複を許可し一つだけ残す (COMDAT section)。

4. Relocation — アドレスが遅れて決まる世界

4.1 relocation が必要な理由

hello.omain 関数で call printf@PLT を見た。この call 命令は 相対アドレス を使う (x86-64 の call rel32)。つまり「現在の PC からどれだけ遠くに jump するか」だが、問題は:

アセンブラは命令の address field を 0 で埋め、「ここに printf のアドレス - 現在アドレスを書き込め」という relocation entry.rela.text に記録する:

$ readelf -r hello.o

Relocation section '.rela.text':
  Offset    Info    Type        Sym.Value    Sym. Name + Addend
  0000000c  ...     R_X86_64_PLT32    0    .rodata - 4
  00000015  ...     R_X86_64_PLT32    0    puts - 4

4.2 relocation type の多様性

x86-64 だけでも数十種類の relocation type がある:

Type意味
R_X86_64_64絶対 64 bit アドレス
R_X86_64_PC32PC 相対 32 bit (call, jmp)
R_X86_64_PLT32PLT 経由の call
R_X86_64_GOTPCRELGOT entry 相対参照
R_X86_64_TPOFF32TLS (thread-local storage)

それぞれが「リンカがどう計算すべきか」を教える。これが ABI の中核部分。

4.3 -fPIC — Position Independent Code

shared library (.so) はプロセスごとに異なるアドレスにロードされうる。したがって絶対アドレスは使えない。

-fPIC (Position Independent Code) でコンパイルすると:

おかげで libc は一度メモリにロードされ、すべてのプロセスで共有できる。メモリ節約の立役者。

5. Static Linking vs Dynamic Linking

5.1 Static Linking — 全てを自分のバイナリに

gcc hello.c -o hello -static

static library の構造

libfoo.a は実はただの アーカイブ だ:

$ ar t /usr/lib/x86_64-linux-gnu/libc.a | head
init-first.o
libc-start.o
sysdep.o
...

ar (archiver) で .o ファイルを束ねたもの。リンカは自分のプログラムが 必要とする symbol を定義した .o だけ選んで含める (lazy linking)。

「必要」は global 変数の ファイル単位 で判定される -> 使っていない関数が同じ .o にあれば引きずり込まれる -> これが -ffunction-sections -fdata-sections -Wl,--gc-sections が必要な理由。

5.2 Dynamic Linking — 必要なときに取り込む

gcc hello.c -o hello  # 既定: dynamic linking

INTERP section — dynamic linker の名前

$ readelf -l hello | grep -A1 INTERP
  INTERP ...
  [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

実行ファイルは自身を実行する「インタプリタ」を指定する。kernel は実際には ld.so を先に実行し、ld.so が自分のプログラムをロードする。

5.3 ldd の秘密

$ ldd hello
    linux-vdso.so.1 (0x00007ffd...)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
    /lib64/ld-linux-x86-64.so.2 (0x00007f...)

ldd は「このプログラムがどの shared library を要求するか」を見せる。しかし内部的には プログラムを実際に実行するが main 呼び出しの前に中断 させて dynamic linker が解決したマップを読む。だから悪意あるバイナリに ldd を使うと危険 - objdump -preadelf -d を使う方が安全。

5.4 GOT/PLT の魔法

dynamic call の流れ:

main が printf を呼び出す
  ->
call printf@plt  (PLT entry にジャンプ)
  ->
PLT[printf]:
  jmp *GOT[printf]     ; GOT からアドレスを読んでジャンプ
  ; 最初は GOT が「resolver 呼び出し」に設定
  -> (初回呼び出し時)
resolver が ld.so を呼び -> printf の実アドレスを見つける -> GOT[printf] を更新
  ->
以降の呼び出しは直接 GOT[printf] に飛ぶ (lazy binding)

Lazy binding: 初回呼び出し時だけ resolve。使われない関数は resolve されない。起動速度が向上。

LD_BIND_NOW=1: 起動時にすべての symbol を resolve。遅延はないが起動が遅い。セキュリティが向上 (GOT が書き込み不可領域になる -> RELRO)。

5.5 RELRO — セキュリティの小さな革命

Return-Oriented Programming 攻撃の標的の一つが GOT だった。GOT を書き込み可能にしておくと、攻撃者がそこに悪性アドレスを書き込み次の呼び出しを hijack できる。

Partial RELRO: .got.plt は書き込み可能 (lazy binding に必要)、.got だけ読み取り専用。

Full RELRO (-Wl,-z,relro,-z,now): 起動時にすべての symbol を resolve 後、.got.plt も読み取り専用に。LD_BIND_NOW=1 と同じ効果。

最新のディストリビューションは Full RELRO が既定。

6. shared library のバージョン管理 — .so の後ろの数字

libc.so.6 -> libc-2.35.so
libc.so -> libc.so.6

3 段階:

  1. libc-2.35.so: 実ファイル、特定のバージョン。
  2. libc.so.6: SONAME、ABI バージョン。リンク時に実行ファイルに記録される。
  3. libc.so: linker name、開発者の便宜用の symbolic link。gcc -lc はこれを探す。

6.1 Symbol Versioning — 同じ名前、異なるバージョン

glibc は同じ関数が複数のバージョンで存在しうる:

$ objdump -T /lib/x86_64-linux-gnu/libc.so.6 | grep memcpy
0000000000000000  DF *UND*    memcpy@GLIBC_2.14
0000000000000000  DF *UND*    memcpy@@GLIBC_2.17

2011 年に memcpy がバグ修正を含む新バージョンに変わった (Adobe Flash が依存していた誤った挙動が壊れた)。古いバイナリは @GLIBC_2.2.5 を使い、新規コンパイルは @@GLIBC_2.14 を使う - 同じ libc.so.6 の中で共存

これが Linux で 10 年前のバイナリも今なお動く理由。

6.2 SO バージョン非互換 — "GLIBC_2.35 not found"

./myapp: /lib/x86_64-linux-gnu/libc.so.6: version GLIBC_2.35 not found

Ubuntu 22.04 でビルドしたバイナリを Ubuntu 20.04 で実行するとよく見る。解決策:

7. C++ name mangling — overload の秘密

7.1 なぜ mangling が必要か

C では関数名 = symbol 名:

void foo(int) { }   // symbol: foo

C++ では同名の複数 overload:

void foo(int);
void foo(double);
void foo(int, int);

これらが同じ symbol ならリンカが区別できない。解決策: name mangling

void foo(int)       -> _Z3fooi
void foo(double)    -> _Z3food
void foo(int, int)  -> _Z3fooii

7.2 Itanium ABI の mangling ルール

GCC/Clang が使う Itanium C++ ABI の mangling:

_Z              : prefix (mangling)
N ... E         : namespace
3foo            : 名前 "foo" (長さ 3)
i               : int
d               : double
Pi              : pointer to int
PKc             : pointer to const char

複雑な例:

namespace ns {
    class C {
        void method(const std::string& s, int n);
    };
}

// mangle 後の symbol:
_ZN2ns1C6methodERKSsi

7.3 c++filt — 人間が読める形に

$ c++filt _ZN2ns1C6methodERKSsi
ns::C::method(std::string const&, int)

デバッガ、stack trace、リンカエラーで mangle された名前が出てきたら c++filt で復号する。

7.4 extern "C" — mangling 無効化

C library を C++ から使うには mangling を切らねばならない:

extern "C" {
    #include <stdio.h>
}

extern "C" void my_c_api(int x);  // symbol: my_c_api (mangling なし)

plugin architecture、dynamic loading (dlsym) で必須。

7.5 ABI 互換性の悪夢

C++ では名前だけでなく class layout も ABI の一部:

結論: C++ ライブラリを配布する際は 抽象 C API を公開するのが安全。Qt、GTK はこの方式。

8.1 コンパイル単位の限界

伝統的なモデルではコンパイラは 1 ファイル (translation unit) しか見ない:

// foo.c
int add(int a, int b) { return a + b; }

// bar.c
extern int add(int, int);
int main() { return add(2, 3); }

コンパイラは add が「常に定数 5 を返す」ことを知ることができない -> add 呼び出しを定数 5 に置き換える最適化は不可能。

8.2 LTO の解法

-flto オプション: コンパイル時に .oIR (LLVM bitcode または GCC GIMPLE) も一緒に保存する。link time でこれらの IR を全部集め、再度最適化する。

8.3 Thin LTO — スケーラビリティの改善

伝統的な LTO は全 IR を一度にロード -> 大規模プロジェクトで RAM を数十 GB 必要とする。

ThinLTO (LLVM): 各モジュールを独立にコンパイルしつつ 要約情報 (summary) を交換。cross-module 最適化の機会だけ選んで実施。Firefox、Chrome はこれでビルド。

8.4 LTO の代償

それでも release build では通常 5-15% の性能向上 -> 価値はある。

9. PGO — プロファイル駆動最適化

9.1 「実際によく実行される経路を速く」

静的解析だけでは「if-else のどちらが頻繁に実行されるか」を知ることはできない。PGO は:

  1. 計測 build: -fprofile-generate でコンパイル -> 各分岐/ループが何回実行されたか記録するコードを挿入。
  2. 代表ワークロード実行: 実プロダクションシナリオで実行。*.gcda ファイルにカウンタを記録。
  3. 最適化 build: -fprofile-use で再コンパイル -> カウンタに基づく最適化:
    • Hot code を .text.hot に分離 -> i-cache 効率。
    • Cold code は別の場所へ。
    • 頻繁に分岐する方向を fallthrough パスに。
    • 頻繁に呼ばれる関数のインライン化を優先。

9.2 実務での効果

Chrome、Firefox、Clang 自身が PGO でビルドされる。一般に 10-30% の性能向上。特に JIT や VM のようなインタプリタループで効果大。

10. Rust、Go — 古典的リンキングとは違う道

10.1 Rust — monomorphization と LTO

Rust は generic を monomorphize する (instantiate-on-use):

fn foo<T>(x: T) { ... }
foo::<i32>(1);
foo::<String>("a".into());

// コンパイラは foo を i32 用と String 用で 2 回生成 -> 別々の symbol

C++ template に類似。結果:

Rust は既定で static linking (libstd 含む)。.so を使うには crate-type = ["cdylib"] で明示する必要がある。

10.2 Go — runtime まで含めた単一バイナリ

Go は既定で static linking + runtime を統合 (garbage collector、scheduler を含む):

10.3 Zig — クロスコンパイルの福音

Zig は 自身のツールチェーンに libc のソースを同梱:

zig cc -target x86_64-linux-gnu hello.c -o hello  # Linux 用
zig cc -target aarch64-macos hello.c -o hello    # ARM Mac 用

Go のクロスコンパイルは楽だが cgo が絡むと複雑だった問題を、Zig は C のクロスコンパイルまで 解決する。Bun、Uber などが採用。

11. 実戦の罠集

11.1 "undefined reference to func" — 最もよくあるリンカエラー

原因 5 つ:

  1. .o.a を渡し忘れ: gcc main.o foo.o -o main で foo.o を忘れる。
  2. ライブラリ名が抜けている: gcc main.c -o main -lm-lm (math library) がないと sin, cos が未解決。
  3. ライブラリ順序: gcc main.o -lfoo -lbar で bar が foo に依存する場合 -lbar -lfoo が正しい。GNU ld は左から右へ一度だけスキャン。
  4. C++ から C 関数の name mangling: extern "C" なしで header を include。
  5. symbol が export されていない: Windows DLL で __declspec(dllexport) 抜け、または -fvisibility=hidden で全部隠している。

11.2 strip の罠

strip hello                 # 全 symbol を除去 -> crash の stack trace が不可能
strip --strip-debug hello   # debug 情報だけ除去 -> production に適切
objcopy --only-keep-debug hello hello.debug
objcopy --strip-debug hello
objcopy --add-gnu-debuglink=hello.debug hello   # 分離保管

通常 production では strip しつつ debug symbol は別途保存し、crash 時に gdb で組み合わせる。

11.3 rpath vs runpath

実行ファイルが libfoo.so を /opt/myapp/lib で探すようにするには:

$ORIGIN/../lib が特に便利: portable deploy 時に bin/ の隣の lib/ からライブラリをロード。

11.4 LD_PRELOAD — 偉大にして危険なツール

LD_PRELOAD=./mymalloc.so ./myapp

12. 実務チェックリスト

デバッグ:

性能 build:

セキュリティ build:

サイズ削減:

13. おわりに — gcc hello.c の裏に隠れた 40 年

hello.c が hello になる 8 秒の間に:

一行の gcc hello.c はこれらすべての歴史の交差点で動く。今日の実行ファイルは昨日の知恵の上に乗っている。

次の記事では OS kernel がこのバイナリを実際にメモリにロードする過程execve system call、ページテーブル生成、mmap、プロセスアドレス空間の構造、vdso、system call の高速経路 — を掘り下げる予定だ。実行ファイルがメモリにロードされた後も旅は続く。

参考資料

コメント

まだコメントはありません。

ログインするとコメントできます