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

- Name
- Youngju Kim
- @fjvbn20031
0. 始める前に — "gcc hello.c -o hello" 一行の神秘
#include <stdio.h>
int main(void) {
printf("Hello\n");
return 0;
}
この 11 行のソースが gcc hello.c -o hello 一度で実行ファイルになる。あまりに自然で「コンパイラがうまくやってくれる仕事」として流してしまう。だが、いくつか奇妙な問いを投げてみよう。
printfの実際のコードはどこにある? 自分のプログラムの中? それとも実行時に取ってくる?- なぜ
hello.cをまずhello.oにしてからhelloにするのか? - C++ で overload された
void f(int)とvoid f(double)がなぜ 名前衝突しない のか? libc.so.6を削除すると Linux 全体が死ぬ理由は何か?stripで symbol を消すとバイナリは小さくなるのにデバッガが効かなくなるのはなぜか?
この記事では 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 を全く知らない。テキストを置換するだけだ。
#include <stdio.h>- stdio.h の内容をその位置に挿入。#define PI 3.14- すべてのPIを3.14に置換。#ifdef DEBUG- 条件付きでテキストを含める/除外する。
これが 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 レベルで印象的な点:
@printfは 宣言のみ で定義がない - link time で解決される symbol。@.strは定数文字列のための global variable。hello.oの.rodatasection に入る。
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) に格納する。興味深いのは:
call puts@PLTは今のところ「この位置に puts の実アドレスを埋めよ」という relocation entry として残るだけ。実アドレスは未知。leaq .LC0(%rip)の.LC0はこのファイル内の symbol なので、内部で offset を計算できる。
1.5 リンカ — 断片を実行可能な全体へ
リンカの中核的な責任:
- 複数の
.oファイルの統合: 同じ section (.text,.data) を連結する。 - symbol 解決:
printfのような外部 symbol を実際の定義と結び付ける。 - relocation: 最終アドレスが決まったら命令の address field を埋める。
- 実行ファイルヘッダ生成: 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 |
| (リンク用ビュー) | (リンク用ビュー) |
+------------------+------------------+
実行時ビュー リンク時ビュー
- Section: リンカが扱う。
.text,.data,.bss,.symtabなどに細かく分割。 - Segment (Program Header): kernel が扱う。section を権限 (r-x, rw-) 別にまとめてメモリにマップする。
2.2 主な section
| Section | 内容 | メモリ権限 |
|---|---|---|
.text | 実行コード | r-x |
.rodata | 読み取り専用定数 (文字列リテラル等) | r-- |
.data | 初期化済み global/static 変数 | rw- |
.bss | 0 初期化済み global/static 変数 | rw- |
.symtab | symbol table (関数/変数名 -> アドレス) | (ファイルのみ) |
.strtab | symbol 名の文字列プール | (ファイルのみ) |
.rela.text | .text に対する relocation entry | (ファイルのみ) |
.dynsym | dynamic symbol (リンカが使う) | r-- |
.plt, .got | dynamic linking 用のジャンプテーブル | r-x / rw- |
2.3 .bss の魔法 — 0 初期化変数がなぜファイルを食わないのか
int zeros[1000000]; // 4MB
int ones[1000000] = {1, ...}; // 4MB
onesは.dataに入る -> 実行ファイルが 4MB 大きくなる。zerosは.bssに入る -> 実行ファイルサイズは ほぼ変化なし。
理由: .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
main: このファイル内で 定義 (Bind=GLOBAL, section 1 =.text, Size=22)。printf: 未定義 (UND = undefined)。link time に外部で探す必要がある。
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
最もよくある原因:
- header に関数定義 (宣言ではなく) を書き、複数の
.cが include した場合。inlineかstaticを使うか、宣言だけ header に置き定義は.cに。 - header に global 変数定義 (
int counter = 0;) を書いた場合。extern int counter;で宣言のみにし、いずれか一つの.cに定義する。
C++ の inline 関数は特別扱いされる - リンカが重複を許可し一つだけ残す (COMDAT section)。
4. Relocation — アドレスが遅れて決まる世界
4.1 relocation が必要な理由
hello.o の main 関数で call printf@PLT を見た。この call 命令は 相対アドレス を使う (x86-64 の call rel32)。つまり「現在の PC からどれだけ遠くに jump するか」だが、問題は:
hello.oアセンブル時点でprintfの最終アドレスは分からない。main自体の最終アドレスすら分からない (別の.oが前に来うる)。
アセンブラは命令の 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_PC32 | PC 相対 32 bit (call, jmp) |
| R_X86_64_PLT32 | PLT 経由の call |
| R_X86_64_GOTPCREL | GOT entry 相対参照 |
| R_X86_64_TPOFF32 | TLS (thread-local storage) |
それぞれが「リンカがどう計算すべきか」を教える。これが ABI の中核部分。
4.3 -fPIC — Position Independent Code
shared library (.so) はプロセスごとに異なるアドレスにロードされうる。したがって絶対アドレスは使えない。
-fPIC (Position Independent Code) でコンパイルすると:
- global variable アクセス -> GOT (Global Offset Table) 経由。
- 関数呼び出し -> PLT (Procedure Linkage Table) 経由。
おかげで libc は一度メモリにロードされ、すべてのプロセスで共有できる。メモリ節約の立役者。
5. Static Linking vs Dynamic Linking
5.1 Static Linking — 全てを自分のバイナリに
gcc hello.c -o hello -static
- libc の
printfコードが実行ファイルにコピーされる。 - 実行ファイルサイズ: 10KB -> 800KB。
- 長所: libc がないシステムでも実行。バージョン互換問題なし。
- 短所: 大きい。複数プロセスがそれぞれ libc のコピーをメモリに持つ。
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
- 実行ファイルに「libc.so.6 をロードしてそこから printf を取得せよ」という 参照 だけが記録される。
- 実行時に dynamic linker (
/lib64/ld-linux-x86-64.so.2) が libc をメモリにマップし symbol を解決する。
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 -p や readelf -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 段階:
libc-2.35.so: 実ファイル、特定のバージョン。libc.so.6:SONAME、ABI バージョン。リンク時に実行ファイルに記録される。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 で実行するとよく見る。解決策:
- 古いシステムでビルド: Docker で古いディストリを再現。
- static linking: 互換問題なし (ただし PAM, NSS 等は今も dynamic load が必要)。
- Rust/Go: 自前の runtime を static link -> この問題を緩和。
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 の一部:
std::stringの構造が変わると、古いコンパイルのバイナリと新しいコンパイルのバイナリが混ぜられない。- GCC 5 で
std::stringとstd::listを C++11 互換 layout に変更した "dual ABI" 事件が有名。 _GLIBCXX_USE_CXX11_ABI=0/1で選べるが、混在すると微妙な crash。
結論: C++ ライブラリを配布する際は 抽象 C API を公開するのが安全。Qt、GTK はこの方式。
8. LTO (Link-Time Optimization) — 全プログラム最適化
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 オプション: コンパイル時に .o へ IR (LLVM bitcode または GCC GIMPLE) も一緒に保存する。link time でこれらの IR を全部集め、再度最適化する。
- Cross-module inlining:
addをmainにインライン化 -> 定数畳み込み ->return 5。 - Dead code elimination: 誰も呼ばない関数を除去。
- Devirtualization (C++): virtual 関数呼び出しを直接呼び出しに変換。
8.3 Thin LTO — スケーラビリティの改善
伝統的な LTO は全 IR を一度にロード -> 大規模プロジェクトで RAM を数十 GB 必要とする。
ThinLTO (LLVM): 各モジュールを独立にコンパイルしつつ 要約情報 (summary) を交換。cross-module 最適化の機会だけ選んで実施。Firefox、Chrome はこれでビルド。
8.4 LTO の代償
- ビルド時間の増加: link 段階が重くなる。
- デバッグの困難さ: 関数がインライン化され stack trace が難しい。
- incremental build の複雑さ: 1 ファイル修正で再 link が長くかかる。
それでも release build では通常 5-15% の性能向上 -> 価値はある。
9. PGO — プロファイル駆動最適化
9.1 「実際によく実行される経路を速く」
静的解析だけでは「if-else のどちらが頻繁に実行されるか」を知ることはできない。PGO は:
- 計測 build:
-fprofile-generateでコンパイル -> 各分岐/ループが何回実行されたか記録するコードを挿入。 - 代表ワークロード実行: 実プロダクションシナリオで実行。
*.gcdaファイルにカウンタを記録。 - 最適化 build:
-fprofile-useで再コンパイル -> カウンタに基づく最適化:- Hot code を
.text.hotに分離 -> i-cache 効率。 - Cold code は別の場所へ。
- 頻繁に分岐する方向を fallthrough パスに。
- 頻繁に呼ばれる関数のインライン化を優先。
- Hot code を
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 に類似。結果:
- runtime コストなし。
- バイナリサイズ爆発 (同じ関数が N 回複製)。
- コンパイル時間増加。
Rust は既定で static linking (libstd 含む)。.so を使うには crate-type = ["cdylib"] で明示する必要がある。
10.2 Go — runtime まで含めた単一バイナリ
Go は既定で static linking + runtime を統合 (garbage collector、scheduler を含む):
- 「最もシンプルな hello world」が 2MB 超。
- しかしデプロイが
scp binary user@server:/usr/local/bin/一行で終わる -> Docker 登場以前からの Go の killer app。 - cgo を使うと dynamic linking に変わる (
libcが必要) -> Docker distroless での問題の原因。
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 つ:
.oや.aを渡し忘れ:gcc main.o foo.o -o mainで foo.o を忘れる。- ライブラリ名が抜けている:
gcc main.c -o main -lmで-lm(math library) がないとsin,cosが未解決。 - ライブラリ順序:
gcc main.o -lfoo -lbarで bar が foo に依存する場合-lbar -lfooが正しい。GNU ld は左から右へ一度だけスキャン。 - C++ から C 関数の name mangling:
extern "C"なしで header を include。 - 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 で探すようにするには:
-Wl,-rpath=/opt/myapp/lib: ハードコード。$ORIGIN変数で「実行ファイルと同じディレクトリ」を指定可能。LD_LIBRARY_PATH: 環境変数。セキュリティ上 setuid では無視される。/etc/ld.so.conf.d/: システム全体。
$ORIGIN/../lib が特に便利: portable deploy 時に bin/ の隣の lib/ からライブラリをロード。
11.4 LD_PRELOAD — 偉大にして危険なツール
LD_PRELOAD=./mymalloc.so ./myapp
- すべての
malloc呼び出しを自分の実装で横取り。 - jemalloc、tcmalloc をこの方式で 既存バイナリに注入。
- 性能プロファイラ (memory leak detector) の原理。
- セキュリティリスク: 悪意ある LD_PRELOAD が setuid 実行ファイルを hijack しないように kernel が遮断。
12. 実務チェックリスト
デバッグ:
readelf -a binary- 全体構造スキャン。objdump -d binary- 逆アセンブル。nm binary- symbol 一覧。nm -Dは dynamic symbol。ldd binary- 依存ライブラリ (信頼できるバイナリにのみ)。strace -e openat binary- runtime にどのファイルを開くか。
性能 build:
-O2または-O3+-flto+-fno-plt+-Wl,-O1,--as-needed。- PGO を反映すべきホットスポットなら
-fprofile-generate/use。
セキュリティ build:
-fstack-protector-strong- stack canary。-D_FORTIFY_SOURCE=2- buffer overflow ガード。-fPIE -pie- position-independent executable。-Wl,-z,relro,-z,now- Full RELRO。-Wl,-z,noexecstack- NX bit。
サイズ削減:
-ffunction-sections -fdata-sections -Wl,--gc-sections- 未使用の関数/データを除去。strip --strip-debug。-Osまたは-Oz- サイズ優先最適化。
13. おわりに — gcc hello.c の裏に隠れた 40 年
hello.c が hello になる 8 秒の間に:
- 1970 年代 Unix v6 の ed.o + as.o パイプラインの末裔が動く。
- 1988 年に Sun が SVR4 と共に定義した ELF フォーマットが header を書く。
- 1995 年に Linux が採用した glibc 2.0 以降の 30 年分の symbol version が解決される。
- 2000 年代初頭に LLVM が提案した IR が (clang を使えば) 中間表現になる。
- 2010 年代の ThinLTO と PGO が release build を加速する。
- 2020 年代に Zig、Rust がクロスコンパイルの常識を塗り替えている。
一行の gcc hello.c はこれらすべての歴史の交差点で動く。今日の実行ファイルは昨日の知恵の上に乗っている。
次の記事では OS kernel がこのバイナリを実際にメモリにロードする過程 — execve system call、ページテーブル生成、mmap、プロセスアドレス空間の構造、vdso、system call の高速経路 — を掘り下げる予定だ。実行ファイルがメモリにロードされた後も旅は続く。
参考資料
- John R. Levine — "Linkers and Loaders" (Morgan Kaufmann, 1999) — 古典。
- Ian Lance Taylor — "Linkers" ブログシリーズ (2007) — gold linker の作者による解説。
- Ulrich Drepper — "How To Write Shared Libraries" (2011) — glibc メンテナによる決定版ドキュメント。
- System V ABI AMD64 Supplement — x86-64 ABI の公式ドキュメント。
- Itanium C++ ABI — mangling ルールの出典。
- LLVM ThinLTO 論文 (Apple, 2016)。
- Mike Pall — LuaJIT の PGO/LTO メモ。
- "Computer Systems: A Programmer's Perspective" — Bryant & O'Hallaron, 3rd ed — Chapter 7 (Linking)。