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

リンクとロード

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

概要

コンパイラが .o(オブジェクトファイル)を出力した後、「実行可能な実行ファイル」になるまでには、2つのステップが残っている:⁠リンク⁠(複数の .o とライブラリのシンボル参照をアドレスに解決する)とロード⁠(実行ファイルをプロセスのアドレス空間にマップし、初期化後にエントリポイントへジャンプする)。リンカは単なる「連結」ではなく、このステップでリロケーション(再配置)、未使用コードの GC セクションの削除、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 中の未解決(unresolved)シンボルを収集
  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 遅延バインディング: 初回呼び出しは遅いが、以降は直接ジャンプ 初回呼び出し 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オーバーヘッドが発生しない。

シンボル挿入 (symbol interposition)

動的リンクはシンボル挿入をサポートしている:複数の .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を実行可能; 代償としてリンク時間が長くなる(大規模プロジェクトでは倍増の可能性)が、5〜15% のパフォーマンス向上をもたらす。
  • ThinLTO: プログラム全体最適化のスケーラブルなアプローチ——すべてのIRを単一モジュールにマージする(メモリ使用量が大きすぎる)のではなく、「呼び出し関係」に基づいてモジュール間のサマリーを作成し、各モジュール内で個別に最適化を実行し、ホットパスの跨モジュール呼び出しのみをインライン展開する。ThinLTO は gold/LLD のデフォルトLTOモードである。

LTO はコンパイル時間に大きな影響を与える——プログラム全体の最適化がリンク時に再実行されるため、大規模プロジェクトではリンク時間が倍増する可能性がある。その一方で利点も非常に大きい:モジュール間のインライン展開により呼び出し境界が解消され、通常 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