リンカスクリプトとリロケーション

このページの目次

リンカにはレイアウトの配置と参照の修正という2つの関連するタスクがある。スクリプトは入力セクションがどのように出力され、どこに配置されるかを決定し、リロケーションレコードは、どの命令やデータフィールドに何を埋め込むべきかを記録する。ファイル内の位置、実行時アドレス、およびロード時のコピー元位置は、別々に議論する必要がある。

リロケーションレコードには何が含まれるか

ELFリロケーションは、少なくとも修正対象の位置、タイプ、および関連シンボルを指定する。RELA形式では加算子(addend)が明示的に保存され、REL形式では修正対象のフィールドから暗黙的に加算子が提供される。どちらの形式を使用するかは対象のABIに依存し、すべてのプラットフォームを同じRELA形式で記述することはできない。

Sをシンボルアドレス、Aを加算子、Pをリロケーションフィールドの位置とする。x86-64の典型的な式には以下が含まれる:

タイプ主要計算説明
R_X86_64_64S + A64ビット絶対値を書き込む
R_X86_64_PC32S + A - P範囲チェック済みの32ビットPC相対値を書き込む
R_X86_64_PLT32L + A - PLはPLTエントリ;条件を満たせば直接ターゲットにバインド可能
R_X86_64_GOTPCRELGOTエントリアドレス + A - Pターゲットアドレスを格納するエントリの位置を取得

5バイトの相対callの場合、オフセットフィールドは命令の先頭から1バイト後に位置し、CPUは次の命令のアドレスを使用する。RELAでは一般的にA=-4であり、これはPをこの基準点に調整するためである。例えば、命令が0x1000、P=0x1001、ターゲットが0x1100の場合、オフセットは 0x1100 - 4 - 0x1001 = 0xfb となる;実行時には 0x1005 + 0xfb = 0x1100 となる。完全なリンクの例はリンクとロードを参照。

PLT32は、最終的に必ずPLTを経由するわけではない。ターゲットが直接バインド可能な場合、リンカは直接呼び出しとして解決できる。ダイナミックリンクも「未解決の参照すべてが.relata.pltに入る」わけではない。データ、GOT、TLSなどはそれぞれ対応するダイナミックリロケーションを使用し、一部の参照は静的リンク段階で既に解決されている。AMD64 ABI

.oファイル内のプレースホルダから最終オフセットへ

以下の実験はx86-64 ELFを対象としており、GNUスタイルアセンブリを使用する。独立したディレクトリにcaller.sを保存する:

.text
.globl caller
caller:
    call callee
    ret
.section .note.GNU-stack,"",@progbits

次にcallee.sを保存する:

.text
.globl callee
callee:
    ret
.section .note.GNU-stack,"",@progbits
as --64 caller.s -o caller.o
as --64 callee.s -o callee.o
readelf -rW caller.o
objdump -dr caller.o
ld -m elf_x86_64 -Ttext=0x1000 -e caller caller.o callee.o -o layout.elf
nm -n layout.elf
objdump -d layout.elf

layout.elfはレイアウトを観察するためだけであり、直接実行してはならない。callerのretは有効なプロセス開始/終了プロトコルではない。入力caller.oのオフセットフィールドは.textオフセット1にあり、一般的なレコードはR_X86_64_PLT32 callee - 4である。この実験ではcalleeは直接バインド可能であり、入力順序に従ってcallerの6バイトコードの直後に配置され、最終アドレスは0x1006となる。

x86-64 call:リロケーションフィールド位置とCPUの次のアドレスは4異なる 命令先頭 0x1000 E8:callオペコード、1バイト P = 0x1001 4バイトオフセットフィールド:01 00 00 00 次 0x1005 ret:1バイト S = 0x1006、A = -4:S + A - P = 1 CPUは0x1005からオフセット1を加えてcalleeに到達する;式におけるPはcall命令の先頭ではない。

最終的なcallオフセットは1であり、リトルエンディアンのバイト列は01 00 00 00に対応する。.oを読み取る際、オフセット1は入力セクションからの相対位置である;最終リンク後に出力レイアウトと組み合わせてP=0x1001が得られる。入力リロケーションテーブルのoffsetを無条件にプロセス仮想アドレスとして扱うことはできない。

最終ターゲットの距離が対応する符号付きオフセットの範囲を超えた場合、リンカは対象のメカニズムに従って処理するか、エラーを報告しなければならない。高位を切り捨てて「実行可能なファイルを作ろう」としてはならない。コードモデル、レイアウトを変更するか、適切な間接シーケンスを選択することが意味のある修正である。

A=-4はどの4バイトを補償しているのか?

まずデフォルトターゲットで0xfbを検証し、対応するフィールドはfb 00 00 00である;次にターゲットを命令の前に移動し、負のオフセットの2の補数バイトを観察する。最後にコードブロック全体のベースアドレスのみを変更する:SとPは同時にシフトし、オフセットは変わらない。この不変量は、孤立した式を覚えるよりも、位置非依存参照を理解するのに役立つ。

図を準備しています
A=-4はどの4バイトを補償しているのか?

リロケーション位置Pは4バイトのオフセットフィールドを指すが、CPUのRIP相対基準はフィールドの後に位置する。A=-4はこれらを接続し、全体がシフトしても相対オフセットは変わらない。

AArch64:ページアドレスとページ内オフセットの連携

AArch64のADRPはページ粒度でアドレスを構築し、その後ADDまたはロード命令でページ内部分を処理する。R_AARCH64_ADR_PREL_PG_HI21はADRPのページ相対計算に対応し、ADRとADRPを同じ命令として混同するものではない。

R_AARCH64_ADD_ABS_LO12_NCは下位12ビットを提供する;R_AARCH64_LDST64_ABS_LO12_NCは64ビットロードまたはストアのスケーリング即値に対応し、アラインメントとスケーリングを無視してはならない。R_AARCH64_ADR_GOT_PAGEはGOTページ関連部分のみを担当し、通常は対応するロードの下位リロケーションも必要であり、1つのレコードだけで「ページアドレス+ロード」が完了するわけではない。Arm ELF ABI

ページ境界を跨ぐ際、ADRPとADDはどのように役割を分担するか?

初期状態のターゲットが 0x1fff なら、下位12ビットは 0xfff である。1バイト先の 0x2000 に移すと下位12ビットは 0 になり、ADRP が扱うページ基底アドレスは 0x1000 増える。ここでいう「ページ」は ADRP のアドレス計算で固定された 4 KiB の単位である。システムが別のページサイズを採用していても、式の12ビットを変更してはならない。

図を準備しています
ページ境界を跨ぐ際、ADRPとADDはどのように役割を分担するか?

ADRPはターゲットページと命令所在ページの差を計算し、ADDはターゲットの下位12ビットを補完する。境界を跨ぐ際、下位は0になり、ページ差が増加し、最終アドレスは連続したままとなる。

セクションとセグメントの役割分担

リンカはセクションを配置し、ローダはセグメントに基づいてマッピングを確立する a.o の .text / .rodata b.o の .text / .data 出力セクション:スクリプトに従ったマージとアラインメント プログラムヘッダ PT_LOAD:ファイル区間、メモリ区間、および権限

出力セクション名自体が実行時のページ権限を決定するわけではない。プログラムヘッダ、属性、ページアラインメント、およびリンカレイアウトが組み合わさってロードビューを形成する。レイアウトを確認する際は、readelf -SW と readelf -lW の両方を確認し、.text、.data があるからといって読み書き実行権限が必ず正しいとは限らない。

位置カウンター . は現在の出力位置を示し、ALIGN はアラインメントを調整し、ワイルドカードパターンが入力セクションを収集する。*(.text) は自動的に .text.foo を含むわけではなく、関数ごとのセクションを有効にする場合、通常は .text.* も必要である。

完全なデフォルトスクリプトは、起動配列、例外展開、TLS、ダイナミック情報などを処理する。.text/.data/.bss のみの教育用スクリプトで、通常のLinuxプログラムのデフォルトレイアウトを直接置き換えてはならない。まず ld --verbose を実行して現在のターゲットのデフォルトスクリプトを確認し、固定インストールパスを仮定してはならない。GNU ld スクリプト

デフォルトレイアウトにカスタムセクションを追加する

以下のGNU ld拡張スクリプトはデフォルトレイアウトを保持し、アプリケーションメタデータのみを挿入する。extra.ld として保存する:

SECTIONS
{
  .appmeta : ALIGN(8)
  {
    __appmeta_start = .;
    KEEP(*(.appmeta .appmeta.*))
    __appmeta_end = .;
  }
}
INSERT AFTER .rodata;

KEEP はマッチしたセクションがリンカごみ収集で削除されるのを防ぐが、コンパイラが必ず元の変数を生成するわけではないため、ソースコードでもused属性を使用する。meta.c として保存する:

__attribute__((section(".appmeta"), used))
const unsigned int app_version = 7;

int main(void) { return 0; }
gcc -O2 -ffunction-sections -fdata-sections meta.c \
  -Wl,--gc-sections,-T,extra.ld,-Map,meta.map -o meta
readelf -SW meta
readelf -lW meta
readelf -x .appmeta meta

INSERTにより、このスクリプトはデフォルトSECTIONSを拡張するものであり、通常の -T が完全なデフォルトスクリプトを置き換える動作とは異なる。2つのエクスポートシンボルはアドレスを記述するが、C変数ストレージを自動的に割り当てるわけではない。Cからこれらを使用する際は、リンカシンボル規約に従って宣言し、アドレスを取得する必要がある。GNU ld INSERT

この例は記載されたGNU ldの用法に適用される。リンカを変更する場合は、サポートの詳細と出力物を確認する必要がある。LLDは多くのGNUスクリプト動作と互換性があるが、明確な実装差異がある。LLD スクリプト説明

3つの異なる関門:変数の生成、アーカイブの抽出、セクションの保持

used、アーカイブ抽出、KEEPは異なる段階で発生する。usedはコンパイラに定義を保持させるものであり、KEEPはリンク処理に入ったマッチセクションを保護する。定義が静的アーカイブ内に隠れている場合、そのメンバーはまだ抽出されていないため、KEEPは抽出条件を置き換えることはできない。

そのため、別途registry.cを準備し、メタデータ定義のみを含む。entry.cをアプリケーションエントリとして準備する:

/* registry.c */
__attribute__((section(".appmeta"), used))
const unsigned int app_version = 7;
/* entry.c */
int main(void) { return 0; }
gcc -O2 -fdata-sections -c registry.c -o registry.o
ar rcs libregistry.a registry.o
gcc entry.c -L. -lregistry -Wl,--gc-sections,-T,extra.ld -o not-extracted
gcc entry.c -Wl,-u,app_version -L. -lregistry \
  -Wl,--gc-sections,-T,extra.ld -o extracted
nm not-extracted
nm extracted
readelf -x .appmeta extracted

最初のアプリケーションはapp_versionの未定義参照を持っていないため、アーカイブメンバーは通常抽出されない。2番目は-uでシンボル要求を導入し、アーカイブからregistry.oを抽出し、KEEPによってメタデータセクションが保持される。大型プラグインレジストリでも同様の問題に直面する。--whole-archiveで抽出範囲を広げることができるが、--no-whole-archiveで範囲をすぐに限定し、無関係なアーカイブ全体を引き込まないようにする必要がある。GNU ld オプション

また、extra.ldをコピーしてdrop.ldとし、KEEPラップのみを削除し、registry.oとentry.cを直接リンクして--gc-sectionsを有効にする。この場合、コンパイラは確かに変数を生成し、オブジェクトもリンクに入っているが、到達可能な参照のない.appmetaはごみ収集される可能性がある。この3番目のケースはusedでは修正できない。

VMAとLMA:実行位置は必ずしもロード元ではない

組み込みファームウェアでは、初期化データをFlashに保存し、起動時にRAMにコピーすることがある。このデータセクションのVMAは実行時アドレスであり、LMAは初期コンテンツのロードアドレスであり、ATまたはAT>によって関係性を指定できる。スクリプトはレイアウトのみを記述し、起動コードはコピーと.bssのゼロクリアを実行する必要がある。

Linux ELFユーザープログラムは主にプログラムヘッダに基づいてマッピングを行うため、ボートFlash/RAMコピーモデルをダイナミックローダに直接適用してはならない。NOLOAD、SHT_NOBITS、ファイルサイズとメモリサイズも、ツールとロードモデルに応じて個別に確認する必要がある。GNU ld:出力セクションLMA

アドレス帳を使って初期化コピーを理解する

.dataセクションの実行アドレスVMAが0x20000000、初期コンテンツがFlashのLMAで0x08004000、サイズが16バイトであると仮定する。電源投入後、変数にアクセスする前に前のアドレスを使用する。起動コードは、後者のアドレスから始まる16バイトをコピーする必要がある。VMAのみをRAMに変更し、対応する初期コンテンツとコピー動作がない場合、変数はコンパイル時の初期値を自動的に取得しない。

オブジェクトイメージに初期バイトが必要か起動段階での動作
初期化済み.data必要ロード元から実行位置へコピー
ゼロ初期化.bssNOBITSで表すのが一般的で、同等サイズのゼロバイトは保存しない対象の実行モデルに従ってゼロクリア
.text / 読み取り専用定数必要その場で実行/読み取り、またはプラットフォームに応じてコピー

スクリプトはLOADADDR、ADDR、SIZEOFを使用してこれらの境界をエクスポートでき、起動コードは対象の規約に従って使用する。通常のLinux ELFの場合、PT_LOADのp_fileszとp_memszがファイル範囲とメモリ範囲をどのように定義するかを確認する必要がある。ダイナミックローダが一般的にこのFlash/RAMコピー表を実行するわけではない。出力セクションのロードアドレス

リラクセーション:最終レイアウトが判明した後の命令シーケンスの書き換え

リンクリラクセーションは、最終距離、シンボルバインド、およびリロケーションタイプを利用して、特定のシーケンスを同等かつより適切なシーケンスに置き換える。例えば、ターゲットが直接バインド可能な場合、x86-64のGOTアクセスの一部を直接アドレス計算にリラクセーションできる。RISC-Vの遠方呼び出しシーケンスは、距離が適切であれば短縮できる。

x86命令は可変長エンコーディングであり、「固定サイズなのでリラクセーションがない」というわけではない。リラクセーションが可能かどうかは、対象のABI、リロケーションフラグ、リンカサポート、およびオプションに依存する。命令数が減っても、プログラム全体が必ずしも速くなるとは限らない。

トラブルシューティング順序

readelf -rW file.o
objdump -dr file.o
nm -u file.o
gcc file.o other.o -Wl,-Map,output.map,--trace -o prog

まず、どのシンボルを参照しており、どのファイルが定義を提供しているかを確認し、次に入力順序、可視性、バージョン、アーカイブ抽出、およびレイアウトを確認する。リロケーションオーバーフローは通常、エンコーディング範囲またはコードモデルが満たされていないことを意味し、結果を安易に切り捨ててはならない。シンボルは解決されているが実行時にずれている場合、ABI、PIE、ロードマッピング、およびダイナミックリロケーションを再確認する必要がある。

GNU ld、LLD、または他のリンカを選択する際は、対象プラットフォーム、スクリプト、LTO形式、診断、および実際のリンクリソースの測定に基づいて選択し、固定の「数倍速い」という結論を避ける。

キーワード:linker script, relocation, REL, RELA, GOT, PLT, section, segment, VMA, LMA, KEEP, relaxation, LLD