12 分で読了 #compilers #runtime
このページの目次

リンクとロード

コンパイラが .o を出力した後、実行可能になるまでにはリンクとロードが残っている——リロケーションはシンボル参照をアドレスに変換し、PLT/GOT の遅延バインディングにより共有ライブラリは初回呼び出し時のみ解決され、LTO はモジュール間の最適化をリンク時に延期してプログラム全体で解析を行う。

概要

コンパイラが .o(オブジェクトファイル)を出力した後、「実行可能な実行ファイル」になるまでには2つのステップが残っている:⁠リンク⁠(複数の .o とライブラリのシンボル参照をアドレスに解決する)とロード⁠(実行ファイルをプロセスのアドレス空間にマップし、初期化後にエントリポイントへジャンプする)。リンカは単なる「連結」ではなく、このステップでリロケーションを行い、GC sections によって未使用のコードを削除し、LTO(コンパイルユニット間のグローバル最適化)を行う。本稿では「コンパイラ出力 → 静的リンク → 動的リンク → 動的ロード」という流れに沿って、リンクとロードの各層のメカニズムを詳しく解説する。

コンパイラの出力:オブジェクトファイル(ELF)

.o ファイル(ELF形式)は以下の要素を含む:

- .text:   機械語(コードセクション)
- .data:   初期化済みグローバル変数
- .bss:    未初期化(またはゼロ初期化)のグローバル変数 —— ファイルサイズを消費せず、OSはロード時にゼロページをマップする
- .rodata: 読み取り専用データ(文字列リテラル、浮動小数点定数)
- .symtab: シンボルテーブル(本ファイルがエクスポート/インポートするシンボル)
- .rela.text: テキストセクションのリロケーションテーブル(外部シンボルを参照する命令、リンカによる修正が必要)
- .rela.data: データセクションのリロケーションテーブル
- .debug_info, .debug_line, ...: DWARF デバッグ情報

コンパイラの最終ステップ(アセンブラ)が .o を出力する。リンカはすべての .o とライブラリのシンボルテーブルを読み取り、リロケーションを実行して実行ファイルを生成する。

リロケーション:「シンボル参照」から「アドレス」へ

.o ファイルの機械語において、外部シンボルを参照している箇所には 0 またはプレースホルダが埋められている。.rela.text はこれらの穴(リロケーション対象)を記録している:

典型的なリロケーションエントリ (R_X86_64_PC32):
  偏移: 0x42    ← .text 内で修正が必要な位置
  シンボル: printf  ← 参照するシンボル
  型: R_X86_64_PC32  ← 相対ジャンプ (PC + オフセット)
  加算値: -4      ← 補正 (命令内のPCは次命令を指すため、4バイト差)

リンカはすべてのセクションのロードアドレスを割り当てた後、すべてのリロケーションエントリを走査し、ターゲットシンボルの最終アドレスを計算して、型に応じた数式でターゲット位置のバイトを書き換える:

R_X86_64_PC32 修正数式:   *(target) = sym_addr - (target_addr + 4) + addend
R_X86_64_64   修正数式:   *(target) = sym_addr + addend        (絶対アドレス)

静的リンク:.a (archive)

静的ライブラリは .o のパッケージ(ar 形式)であり、リンク時に必要に応じて取り出される:

gcc main.o -L. -lmylib → リンカが行う処理:
  1. main.o 内の未解決シンボルを収集
  2. libmylib.a 内で各未解決シンボルを検索
  3. 見つかった場合 → .a から対応する .o を抽出し、リンク対象に追加
  4. 抽出された .o がさらに新しい未解決シンボルを導入する可能性あり → 再帰的(または複数パス)スキャン
  5. すべてのシンボルが解決 → リロケーション実行 → 実行ファイルを出力

リンカのシンボル解決は順序に依存する⁠——従来のUnixリンカは .o.a を左から右へ処理するため、libfoo.a がそれを参照する .o の前に配置されている場合、スキャンされない可能性がある(その時点では未解決シンボルがないため)。現代のリンカ(LLD)には --start-group / --end-group があり、これにより反復スキャンを実行して順序依存の問題を解消する。

リンカ GC(デッドセクションの削除)

コンパイラは関数単位で .text セクションの断片を生成する(gold および LLD には --gc-sections がある)。リンカはエントリポイント(_start)から始まり、call/jump 参照を追跡し、未使用の関数セクションをデッドとしてマークして破棄する。これにより最終的なバイナリサイズが削減される——DCE(デッドコード削除)に似ているが、.o ユニットを跨いで行われる。

動的リンク:.so (shared object)

共有ライブラリはリンク時に実行ファイルにコピーされるのではなく、リンク時に依存関係を記録し、実行時にロードされて動的に解決される。

GOT と PLT:遅延バインディングの2層

ターゲットコードとABI でPICにGOTが必要だと述べた。動的リンクの完全な図は GOT + PLT である:

GOT + PLT 遅延バインディング: 初回呼び出しは遅いが、以降は直接ジャンプ(lazy binding) 初回呼び出し printf(遅いパス: 動的リンカによる解決) call printf@plt (PLT スタブ) → GOT[printf] を参照 (未解決) jmp _dl_runtime_resolve 動的リンカがシンボルテーブルを検索→アドレスを取得 GOT[printf] を更新 = 実際のアドレス → jmp printf N 回目の呼び出し (ヒットパス: GOT に実際のアドレスあり) call printf@plt 同じ PLT スタブ GOT[printf] に実際のアドレスが既に存在 → jmp printf, 追加オーバーヘッドなし GOT はキャッシュの役割を果たす: 初回呼び出しでは動的リンカによる完全な解決と GOT への書き戻しが必須; 以降の呼び出しでは GOT を直接読み込んで実際のアドレスにジャンプする——これが遅延バインディングの時間節約の仕組みである。

これが lazy binding である:共有ライブラリのシンボルは、初めて呼び出されたときにのみ解決され、GOT がキャッシュとして機能する。LD_BIND_NOW=1 環境変数は、ロード時にすべてのシンボルを即時解決する(遅延なし)——起動は遅くなるが、実行時に PLT オーバーヘッドが発生しない。

シンボルインターポジション(シンボル挿入)

動的リンクはシンボルインターポジションをサポートする:複数の .so が同じ名前の malloc を定義している場合、最初にロードされた定義が有効になる。実行ファイルで定義されたシンボルは .so のシンボルを上書きする(デフォルト動作)、LD_PRELOAD で明示的に挿入できる。これは jemalloc/tcmalloc がシステム malloc を置き換える仕組みである——ただしパフォーマンスの罠にもなる:挿入によりコンパイラは malloc をインライン化できず、呼び出しのたびに PLT を経由する必要がある。

dlopen: ランタイムロード

プログラム実行中に任意のライブラリを動的にロードできる:

void *handle = dlopen("libplugin.so", RTLD_NOW);
void (*fn)(int) = dlsym(handle, "plugin_init");
fn(42);
dlclose(handle);

JIT コンパイラは dlopen を使用してコンパイル済みの .so をロードすることが多い——コンパイルされたコードは .o.so へのリンク → dlopendlsym で具体的な関数を呼び出す。V8 や Julia もこのアプローチを採用している——JIT によって生成された機械コードを .so としてラップし、動的にロードする。これは直接メモリに書き込むよりも安全である(OS が .text セクションの NX/書き込み保護を保証するため、手動で mprotect を実行する必要がない)。

LTO(Link-Time Optimization): リンク時全体最適化

従来の各 .c(コンパイルユニット)は個別に .o にコンパイルされ、最適化はユニット内のみで行われる——ユニット間の関数インライン化、デッドコード削除、定数伝播などは他のユニットが見えない。LTO はコンパイルユニット間の最適化をリンク時に延期する:

LTO: リンク時に IR をまとめてプログラム全体で最適化し、コンパイル時に各ユニットが独立して最適化するものではない 通常コンパイル: 各コンパイルユニットが独立して最適化、リンカは連結のみを行う .c opt ユニット内最適化 .o 機械語 リンカ 連結 LTO: .c は機械語ではなく IR を生成し、リンク時にマージしてプログラム全体で最適化 .c IR (機械語ではない) .o に保存 (ビットコード) リンカが収集 すべての .o の IR 1つのモジュールにマージ opt→codegen→リンク LTO は最適化をリンク時に延期する: リンカはすべての IR を見ることができ、通常コンパイルでは見えないモジュール間のインライン化/DCE を実行; 代償としてリンク時間が長くなる(大規模プロジェクトでは2倍になる可能性もある)が、5–15% のパフォーマンス向上をもたらす。
  • ThinLTO: プログラム全体最適化のスケーラブルなアプローチ——すべての IR を単一モジュールにマージする(メモリが大きすぎる)のではなく、「呼び出し関係」に基づいてモジュール間のサマリーを作成し、各モジュール内で個別に最適化し、ホットパスのモジュール間呼び出しのみをインライン化する。ThinLTO は gold/LLD のデフォルトの LTO モードである。

LTO はコンパイル時間に大きな影響を与える——プログラム全体の最適化がリンク時に再実行されるため、大規模プロジェクトではリンク時間が2倍になる可能性がある。その一方で利点も非常に大きい:モジュール間のインライン化により呼び出し境界が解消され、通常 5–15% のパフォーマンス向上が期待できる。

参考

  • Levine: "Linkers and Loaders" — リンクとロードの古典的教科書
  • Ian Lance Taylor: "Linkers" シリーズ (https://lwn.net/Articles/276782/) — gold リンカの著者がリンカの原理を解説
  • System V AMD64 ABI: 動的リンク部分 (GOT/PLT の正式仕様)

Keywords: static linking, dynamic linking, archive, .a, shared object, .so, ELF, .text, .data, .bss, .symtab, relocation, R_X86_64_PC32, GOT, PLT, lazy binding, _dl_runtime_resolve, symbol interposition, LD_PRELOAD, dlopen, dlsym, linker script, gc-sections, LTO, ThinLTO, link-time optimization, gold, LLD