リンクとロード

このページの目次

リンクは、オブジェクトファイル内のシンボル、セクション、リロケーションを結合して実行可能イメージまたは共有オブジェクトを構成します。ロードは、そのイメージをプロセスのアドレス空間に配置し、実行に必要な動的な準備を完了させます。これらは互いに連携しますが、異なるフェーズです。

本文の具体的な形式と数式は、Linux ELF および x86-64 System V ABI を例に挙げています。他のプラットフォームのオブジェクト形式、呼び出し規約、ダイナミックリンク戦略については、それぞれの仕様を参照してください。

オブジェクトファイルには機械語以外に何があるか

内容役割
.text、.data、.rodataコードおよび異なる属性を持つデータ
.bss などの NOBITS セクションゼロ初期化が必要な領域を示し、対応するゼロバイトのペイロードは保存しない
シンボルテーブル定義、未定義参照、バインディング、可視性など
リロケーションエントリ後続のツールがアドレス依存フィールドをどのように修正すべきかを指示する
デバッグ情報機械語の位置をソースコードや型に関連付ける

セクションはリンク時の視点での組織単位です。プログラムヘッダはロード時の視点でのセグメントを記述します。実行時には、PT_LOAD などのプログラムヘッダに基づいてマッピングされ、セクション名ごとに個別に mmap されるわけではありません。.bss にはメタデータが存在しますが、それが表す各ゼロバイトをファイルに保存する必要はありません。

シンボル解決とレイアウト:誰を参照するかを先に決める

1つのオブジェクトファイルが helper を定義し、別のオブジェクトファイルが helper を参照しているとします。リンカは、バインディング、可視性、バージョン、リンクルールに基づいて、その参照がどの定義に対応するかを決定し、関連する内容をレイアウトしてリロケーションを適用します。

同名でも必ずしも衝突するわけではありません。ローカルシンボルには独自のスコープがあり、弱シンボルと強シンボルには異なるルールがあり、ダイナミックシンボルは可視性と検索範囲の影響も受けます。逆に、ダイナミックシンボルの解決を「最初にロードされたライブラリが常に優先される」と単純化することもできません。

リロケーション:各タイプには正確な数式がある

S をターゲットシンボルのアドレス、A を addend、P をリロケーションフィールドの位置とする。x86-64 における R_X86_64_PC32 は S+A−P を使用し、R_X86_64_64 は S+A を使用します。異なるタイプでは、フィールドの幅とオーバーフローチェックも規定されています。AMD64 ABI リロケーションテーブル

PC相対リロケーション:P は修正対象フィールドであり、次の命令ではない call オペコード アドレス 0x1000 4バイトのオフセットフィールド P = 0x1001 次の命令 アドレス 0x1005 S = 0x2000、A = −4:S + A − P = 0xFFB CPU は次のアドレスにオフセットを加算する:0x1005 + 0xFFB = 0x2000。さらに4を減算してはならない。

典型的な call rel32 のエンコーディングでは、CPU が相対的に参照するのは次の命令ですが、ABI の数式ではフィールド位置 P が使用され、一般的な addend −4 が両者の差を既に処理しています。もし数式が S−(P+4)+A と書かれ、かつ A が −4 である場合、4 が重複して減算されてしまいます。

外部関数呼び出しでは R_X86_64_PLT32 が使用される可能性があり、そのターゲットは PLT エントリに関連し、リンカによって直接呼び出しに緩和される場合があります。すべてのコンパイラが外部呼び出しで PC32 を明示的に表示することを要求することはできません。数式を理解した後は、実際のリロケーションタイプを確認すべきです。

4バイト書き込む前に、符号付き範囲を確認する

rel32 オフセットは符号付き32ビット数です。ターゲットが呼び出し点より前にある場合、オフセットは負になり、リトルエンディアンフィールドへの書き込みには2の補数が使用されます。ターゲットが遠すぎる場合、下位32ビットに単純に切り捨てることはできず、そうすると CPU は別のアドレスにジャンプしてしまいます。以下では、すでに直接ターゲットが確定している PC32 の計算のみをモデル化し、シンボル検索や PLT の選択はシミュレートしません。

def pc32(symbol, addend, place):
    displacement = symbol + addend - place
    if not -(1 << 31) <= displacement < (1 << 31):
        raise OverflowError("PC32 relocation out of range")
    return displacement.to_bytes(4, "little", signed=True)


assert pc32(0x2000, -4, 0x1001) == bytes.fromhex("fb 0f 00 00")
backward = pc32(0x1000, -4, 0x2001)
assert 0x2005 + int.from_bytes(backward, "little", signed=True) == 0x1000
for displacement in (-(1 << 31), (1 << 31) - 1):
    assert int.from_bytes(pc32(displacement, 0, 0), "little", signed=True) == displacement
for displacement in (-(1 << 31) - 1, 1 << 31):
    try:
        pc32(displacement, 0, 0)
    except OverflowError:
        pass
    else:
        raise AssertionError("truncated relocation accepted")

S と P を共通に平行移動してもオフセットは変化しないため、モジュール内部のいくつかの PC 相対参照がロードベースアドレスに合わせて再修正を必要としない理由が説明できます。しかし、モジュール外で定義されたシンボル、絶対アドレスフィールド、TLS などはそれぞれ独自のルールを持っています。1つの内部相対呼び出しから「PIC にはリロケーションがない」と一般化することはできません。

P、次の命令、そして −4、誰がすでに距離を補正したのか?

まず4バイトフィールド P をマーキングし、次に次の命令 P+4 をマーキングすると、addend の役割は明確になります。以下では、ターゲットを呼び出し点の前に配置することも、モジュール全体を平行移動することも、意図的に4を重複して減算することもできます。オフセットが範囲外の場合、切り捨てられた4バイトは生成されません。「ファイルに書き込める数字」であっても、CPU が正しいアドレスに到達できるとは限らないからです。

図を準備しています
P、次の命令、そして −4、誰がすでに距離を補正したのか?

PC32 には S+A−P が書き込まれ、典型的な call の A=−4 はフィールド位置を次の命令の基準に変換している。S と P を一緒に平行移動してもオフセットは変わらず、4を重複して減算すると誤ったアドレスにジャンプする。

最小限のオブジェクトファイル実験

独立した実験ディレクトリに2つのファイルを作成します。

/* caller.c */
extern int twice(int);
int main(void) { return twice(21) == 42 ? 0 : 1; }
/* twice.c */
int twice(int x) { return x * 2; }
cc -O0 -c caller.c -o caller.o
cc -O0 -c twice.c -o twice.o
readelf -sW caller.o
readelf -rW caller.o
objdump -dr caller.o
cc caller.o twice.o -o demo
./demo

まず未定義の twice シンボルを探し、次にそれに対応するリロケーションを確認し、最後にリンク後のプログラムがどのように実行されるかを観察します。具体的なオフセット、命令、PIE のデフォルト値はツールチェーンによって異なりますが、実験の重点はシンボル参照と修正フィールドを対応させることです。

静的ライブラリ:ルールに従ってオブジェクトメンバを抽出する

.a はオブジェクトファイルのアーカイブです。伝統的な Unix リンカは、アーカイブを処理する際に未解決の参照に基づいてメンバを抽出するため、コマンドラインの順序が重要になる場合があります。参照元を提供する定義を持つ静的ライブラリの前に配置するのが一般的な書き方です。

循環依存は、ライブラリグループ化による再スキャンなどのメカニズムで処理できます。GNU ld や他のリンカは対応するオプションをサポートしていますが、具体的なスキャン戦略は異なります。LLD はアーカイブ内の遅延シンボルを保存し、後続の参照時に抽出します。すべてのリンカを完全に同一の左から右への単一スキャンとして記述することはできません。

リンカの GC は、セクションやリロケーションなどの参照関係に基づいて、ルートから到達可能な内容を保持します。ルートにはエクスポートされたシンボルやリンクスクリプトの KEEP も含まれます。これは単に _start から逆アセンブルして call 命令を追跡するものではありません。関数やデータごとにセクションを分割することで、細粒度の回収が可能になりますが、すべての入力が自然に分割されているわけではありません。

1つのメンバがリンクに含まれても、その中のすべての関数が残るとは限らない

引き続き前述の caller.c を使用し、twice.c を拡張して2つの関数にします。twice は main から呼び出されますが、unused_helper は参照されません。GNU ld.bfd を使用して以下の実験を行い、リンカを固定して、アーカイブスキャン戦略をすべてのツールのルールと誤認しないようにします。

/* twice.c:前のバージョンを置き換え */
int twice(int x) { return x * 2; }
int unused_helper(void) { return 99; }
gcc -O0 -ffunction-sections -fdata-sections -c twice.c -o twice.o
ar rcs libtwice.a twice.o
gcc -fuse-ld=bfd caller.o ./libtwice.a -o with-unused
gcc -fuse-ld=bfd caller.o ./libtwice.a -Wl,--gc-sections -o trimmed
nm with-unused
nm trimmed
./with-unused
./trimmed

最初のリンクでは、caller.o による twice への参照により twice.o 全体が抽出されるため、2つの関数がすべて入力集合に含まれます。2回目のリンクでも同じメンバが抽出されますが、セクション回収により .text.unused_helper が保持グラフに含まれていないことが判明し、削除できます。両方のプログラムは 0 を返しますが、nm において unused_helper は未回収バージョンにのみ表示されるはずです。ここでは O0 を使用し LTO を無効にすることで、フロントエンドのインライン化や削除効果を説明に混入させないようにしています。

フェーズ粒度本例での決定
アーカイブ検索オブジェクトメンバtwice への参照を満たすために twice.o を抽出
セクション回収入力セクションとその参照グラフtwice があるセクションを保持し、未使用関数のセクションを削除
最終レイアウトとリロケーション保持内容のアドレスとフィールドmain の呼び出しに対して最終オフセットを修正

もし libtwice.a を caller.o の前に配置した場合、GNU ld.bfd はその時点で twice の未解決参照を持っていないため、メンバを抽出しない可能性があります。その結果、未定義参照のエラーになります。順序を元に戻せば解決します。この順序の問題を解決するために無条件で --whole-archive を使用すると入力範囲が拡大し、さらにそれ自体がすべてのセクションを後続の GC から守ることを保証するものではありません。GNU ld アーカイブおよびセクション回収オプション

2回の選択:まずオブジェクトメンバを通過させ、次にどのセクションを保持するかを決定 caller.o 未解決参照:twice twice.o を抽出 twice + unused_helper --gc-sections 到達可能な twice のみを保持 -ffunction-sections は細粒度の境界を提供する。ルートと参照グラフが保持結果を決定する。 アーカイブメンバが抽出されていない場合、内部にセクションが存在しても、後続のレイアウトに自動的に参加することはない。

1つの .o を抽出したのに、なぜ1つの関数のみ残るのか?

「リンク入力に含まれたこと」と「最終的に生き残ったこと」を異なる集合として描くことで、ライブラリの順序とセクション回収の両方を同時に説明できます。伝統的なアーカイブスキャン時に twice の未解決参照がまだ存在しない場合、メンバを抽出しない可能性があります。強制抽出した後でも、unused がある独立したセクションは GC 中に削除される可能性があります。ここでは GNU ld.bfd の教育用の境界を固定し、結果を LLD などの異なる実装にそのまま適用しないようにしています。

図を準備しています
1つの .o を抽出したのに、なぜ1つの関数のみ残るのか?

アーカイブ抽出はメンバ単位で行われ、セクション回収はルート到達可能な参照グラフに基づいて行われる。whole-archive はメンバを入力に強制するが、すべての関数セクションが保持されるわけではない。伝統的なアーカイブの順序が間違っていると、定義が全く抽出されない可能性がある。

GOT と PLT:間接アクセスと解決のタイミングを分けて考える

GOT はアドレススロットを提供し、PLT は呼び出しスタブを提供します。これらは一部の位置独立コードやダイナミック呼び出しパスの一部です。古典的な遅延関数バインディングでは、最初の呼び出しはターゲットを検索して対応するスロットを更新し、後続の呼び出しはすでに保存されたアドレスを利用します。

即時バインディングは、関連する解決をロードフェーズに前倒しします。例えば、Linux ダイナミックリンカは LD_BIND_NOW を通じて制御できます。しかし、解決が前倒しされたからといって、元の PLT 呼び出し命令が間接オーバーヘッドのない直接呼び出しに自動的に変更されるわけではありません。PLT を使用するかどうか、緩和できるかどうか、GOT を通じて呼び出すかどうかは、コンパイルとリンクの選択に依存します。Linux ld.so

ダイナミックシンボルのインターポージションも、検索スコープ、バインディング、可視性の影響を受けます。これは実装の置換やプリロードに柔軟性をもたらす一方で、インライン化やローカルバインディングの仮定を制限する可能性があります。すべての malloc 呼び出しが必ず同じパスを通ると断言することはできません。

ロードと dlopen

ダイナミック ELF 起動:マッピングとリロケーション後にのみプログラムロジックに入る カーネルは実行可能ファイルのプログラムヘッダを読み取る ロードセグメントに基づいてアドレス空間を構築し、インタプリタを識別する 1 ダイナミックリンカが依存関係と必要なリロケーションを処理する シンボル、TLS などを解決し、戦略に基づいてバインディングを処理する 2 ランタイム初期化を実行する 共有ライブラリの初期化と言語の起動ロジック 3 プログラムエントリから main などの言語エントリへ継続 main はカーネルが直接ジャンプする唯一のエントリ概念ではない 4 図は一般的な Linux ダイナミック ELF を範囲としている。静的、他の形式、およびプラットフォームのフローは異なる。

dlopen はプロセス実行中に共有オブジェクトをロードし、dlsym はシンボルを検索し、dlclose はロード参照を減少させます。実際のプラグインインターフェースでは、ABI、所有権、スレッド、アンロード条件を定義し、エラーをチェックする必要があります。関数ポインタやスレッドがまだライブラリコードを実行している間に、無秩序にアンロードすることはできません。dlopen マニュアル

ダイナミックロードによる既存ライブラリの使用と、JIT によるマシンコードの公開は異なります。JIT はメモリ上でオブジェクト、リロケーション、実行可能権限を処理でき、「.o を書き、.so にリンク、dlopen を実行する」というファイルフローを統一して通る必要はありません。JIT コンパイル を参照してください。

LTO:最適化がコンパイルユニット間の情報を見られるようにする

通常の独立したコンパイルでは、最適化器が見ることができる関数本体が制限されます。LTO は IR や関連する中間情報を保持し、リンクフェーズでシンボル解決結果と組み合わせてモジュール間の分析と最適化を実行し、その後マシンコードを生成してリンクを続行します。LTO がなくても、リンカはリロケーション、セクション回収、その他のリンク最適化を実行するため、「単に連結するだけ」と呼ぶことはできません。

Full LTO と ThinLTO の組織方法は異なります。ThinLTO はモジュールのサマリーを組み合わせてグローバル分析と選択的な関数インポートを実行し、その後モジュールバックエンドを並列実行し、キャッシュを使用することもできます。Clang は -flto=thin などの設定でこのモードを明示的に選択します。これは、gold や LLD の呼び出しにおける天然のデフォルトの LTO 方式ではありません。Clang ThinLTO ドキュメント

インライン化の可否はコストモデルや可視性などの制約によって決定され、「ホットパスの呼び出しのみをインポートする」ことに限りません。LTO がすべてのダイナミックプラグインや外部定義を把握しているわけでもなく、所谓的「全プログラム境界」はリンクとシンボルの仮定と組み合わせて考える必要があります。パフォーマンスの向上、メモリ使用量、リンク所要時間は実際のプロジェクトで測定すべきであり、一律の向上率を提供するものではありません。

問題特定のためのクイックリファレンス

現象優先チェック項目
undefined reference定義が生成されているか、ライブラリの順序、名前修飾、ABI と可視性
relocation overflowリロケーションの幅、レイアウト、コードモデル
起動時に共有ライブラリが見つからない依存関係の記録、検索パス、プラットフォームのロードルール
同名シンボルが予期しない実装にバインドされる検索スコープ、バージョン、プリロードと可視性
LTO が期待した効果を発揮しないコンパイルとリンクの設定、IR の利用可能性、最適化の境界

参考文献

Keywords: ELF, シンボル解決, リロケーション, PC32, PLT32, アーカイブ, GOT, PLT, 遅延バインディング, dlopen, LTO, ThinLTO