動的リンクと ld.so

このページの目次

動的リンクでは、アドレスの選択の一部をロード時や呼び出し時に遅延させる。プログラムは依存関係とシンボルの要求を記録し、ローダーがオブジェクトを見つけ、マッピングを確立し、リロケーションを完了する。これにより、ABI を維持したままライブラリを独立して更新できる。同じライブラリファイルのクリーンなコードページは複数のプロセスで共有できるが、プライベートデータやコピーオンライトページは一概に共有されるとは限らない。

失敗のトラブルシューティングでは、以下の順で区別する必要がある。ライブラリファイルが見つかるか、目的のシンボルが正しいスコープ内にあるか、シンボルバージョンが一致するか、最後に呼び出し時の引数とオブジェクトのレイアウトが互換性があるか。ファイル名が同じでも、ABI が同じとは限らない。

ライブラリの検索、シンボルの検索、アドレスの書き込みは3つのステップ

DT_NEEDED は依存関係の名前を記録する。ライブラリが見つかった後、ローダーはダイナミックテーブルに基づいてシンボル、文字列、バージョン、リロケーション情報を位置特定する。リロケーションはローダーに対して「どこをどのように計算して書き込むか」を指示し、シンボル検索は、特定の名前が現在のスコープ内でどの定義に対応するかを決定する。

したがって、libfoo.so をロードしたからといって、foo への参照がすべてそれにバインドされるわけではない。挿入可能なグローバルシンボルは、他のオブジェクトの定義によって満たされる可能性がある。シンボルは関数だけでなく、データ、TLS、IFUNC などもそれぞれ処理要件がある。ELF 動的リンク

PLT/GOT が遅延バインディングをサポートする理由

古典的な PLT 遅延バインディング:初回解析、以降はキャッシュアドレスを使用 foo@PLT を呼び出し 呼び出し点は安定している GOT スロットを読み取る 間接ジャンプのターゲット 初回:ローダー スコープ内で foo を検索 GOT スロットに書き戻す 解決済みアドレスを保存 foo を実行 以降は通常ここへ直接飛ぶ 解決済み 従来の ELF PLT を例にメカニズムを示す;アーキテクチャ、リンクオプション、CET によってスタブのレイアウトは変わる。

従来の x86-64 PLT スタブは、まず GOT スロットを介してジャンプする。未解決の場合、これはローダーの解決パスを指し、ローダーが関数アドレスを決定し、通常はスロットを更新する。その後の呼び出しではこの結果を再利用する。スロットの保持数、命令形式、リロケーションインデックスのエンコーディングはアーキテクチャの実装詳細であり、すべての ELF における一般的なレイアウトと見なすべきではない。

遅延バインディングは、対応する関数呼び出しの再配置を主な対象とする。データ参照は、最初の読み取り時までアドレスの補完を任意に待つことはできない。-z now や LD_BIND_NOW は早期バインディングを要求する。-fno-plt などのコンパイルオプションも呼び出し形式を変更できる。したがって、「動的リンクの代償は、各関数の初回呼び出しが少し遅くなることだ」という記述は不十分である。

GOT は単なる「グローバル変数アドレスの配列」ではない。異なるターゲットやリロケーションタイプでは、関数アドレス、データアドレス、およびランタイムサポート情報がそれぞれの位置に配置される。errno はスレッドローカルオブジェクトであり、extern int errno を通常のグローバル変数の説明例として使用すべきではない。

初回呼出しで変わるスロットは?

foo を二回、その後 bar を呼ぶ。二回目の foo は解決済み値を使うが、bar の初回は解決器を通り得る。現在のスロットと直前の経路を分けて読む。

図を準備しています
初回呼出しで変わるスロットは?

遅延束縛は対象スロットごとに解決する。一方の呼出しで他方まで解決されるとは限らず、即時束縛はロード時に行う。

実際に動作する共有ライブラリの実験を構築する

libfoo.c:

int foo(int x) { return x + 1; }

main.c:

#include <stdio.h>
extern int foo(int);
int main(void)
{
    printf("%d\n", foo(41));
    return 0;
}
cc -fPIC -shared libfoo.c -Wl,-soname,libfoo.so.1 -o libfoo.so.1
ln -sfn libfoo.so.1 libfoo.so
cc main.c -L. -lfoo -Wl,--enable-new-dtags,-rpath,'$ORIGIN' -Wl,-z,lazy -o dyn-demo
./dyn-demo
readelf -dW dyn-demo
objdump -R dyn-demo
LD_DEBUG=libs,bindings ./dyn-demo
LD_BIND_NOW=1 LD_DEBUG=bindings ./dyn-demo

実験では 42 が出力されるべきである。リンク時に -lfoo が libfoo.so を探し、ライブラリの SONAME により実行ファイルには通常 libfoo.so.1 が記録される。RUNPATH 内の $ORIGIN により、ローダーはメインプログラムが存在するディレクトリでそれを見つける。シングルクォートは、シェルによる $ORIGIN の早期展開を防ぐ。LD_DEBUG には libc などのバインディングも表示されるため、foo に向けて自分の呼び出しを特定する必要がある。

検索パス:RUNPATH は孫依存関係に自動的に伝播しない

以下は glibc 動的ローダーに限定した説明である。スラッシュを含まない依存関係名の場合、概括的な検索順序は以下の通りである。

  1. 対応する DT_RUNPATH がない場合の、有効な DT_RPATH。
  2. LD_LIBRARY_PATH(安全実行モードでは無視される)。
  3. 依存関係オブジェクトの DT_RUNPATH。これはその直接の DT_NEEDED 依存関係に使用される。
  4. /etc/ld.so.cache。システムキャッシュと関連するハードウェア能力ディレクトリの選択を含む。
  5. デフォルトのシステムライブラリディレクトリ。パスとスキップの有無はアーキテクチャおよびリンクオプションの影響を受ける。

スラッシュを含む依存関係はパスとして処理され、同じ名前の検索は行われない。RPATH と RUNPATH の重要な違いの一つは伝播範囲である。メインプログラムの RUNPATH が libA を見つけられるからといって、libA が必要とする libB も見つかるわけではない。libA には通常、適切な検索設定が必要である。$ORIGIN は、そのマーカーを含むオブジェクトの位置に基づいて展開され、現在の作業ディレクトリではない。ld.so(8)

ビルド時の -L は、ランタイムのパスに自動的に書き込まれない。デプロイ時にライブラリをコピーしたがファイルが見つからない場合、まず NEEDED、RUNPATH、および実際のファイル名を確認し、その後シンボル問題を考慮する。

RUNPATH はそれを宣言したオブジェクトに従い、直接依存関係のみを解決する bin/app NEEDED: libA.so.1 lib/libA.so.1 NEEDED: libB.so.1 lib/libB.so.1 b() を提供 app の RUNPATH $ORIGIN/../lib → A が見つかる A 自身の RUNPATH $ORIGIN → A の直接依存 B を検索 環境パスやシステムキャッシュがヒットしないと仮定;app のディレクトリ設定は、A のための継続的な検索を自動的に代行しない。

「メインプログラムは A を見つけられるが、B が見つからない」を再現する

新しい実験ディレクトリに bin と lib を作成する。ここでは意図的に2つのライブラリを同じディレクトリに配置するが、第一版の A には RUNPATH を設定しない。3つのソースファイルは以下の通りである。

chain-b.c:

int b(void) { return 40; }

chain-a.c:

extern int b(void);
int a(void) { return b() + 2; }

chain-main.c:

#include <stdio.h>
extern int a(void);
int main(void) { printf("%d\n", a()); return 0; }
mkdir -p bin lib
cc -fPIC -shared chain-b.c -Wl,-soname,libB.so.1 -o lib/libB.so.1
cc -fPIC -shared chain-a.c -Llib -l:libB.so.1 \
  -Wl,-soname,libA.so.1 -o lib/libA.so.1
cc chain-main.c -Llib -l:libA.so.1 -Wl,-rpath-link,lib \
  -Wl,--enable-new-dtags,-rpath,'$ORIGIN/../lib' -o bin/app
readelf -dW bin/app
readelf -dW lib/libA.so.1
./bin/app

追加の LD_LIBRARY_PATH、プリロード、またはシステム内の同名ライブラリのヒットがないクリーンな環境では、最後のステップで libB.so.1 が見つからないと報告されるはずである。app の RUNPATH により A は見つかるが、B は A の直接依存関係である。リンク時の -rpath-link,lib は、リンカーが間接依存関係を見つけるのを助けるだけで、このディレクトリを A のランタイムパスとして書き込むことはない。GNU ld オプション

A を再構築し、A 自身の RUNPATH を追加する。

cc -fPIC -shared chain-a.c -Llib -l:libB.so.1 \
  -Wl,-soname,libA.so.1,--enable-new-dtags,-rpath,'$ORIGIN' \
  -o lib/libA.so.1
./bin/app

ここでは 42 が出力される。app が記録している名前は引き続き libA.so.1 なので、app の再リンクは不要である。A 内の $ORIGIN は lib があるディレクトリに展開される。別の作業ディレクトリから app を実行しても、このオブジェクトの位置を基準とする関係は変わらない。一方、dlopen("./plugin.so", ...) の相対パスは現在の作業ディレクトリを基準に解釈されるため、$ORIGIN と混同してはならない。

確認位置第一版修正版
app の NEEDEDlibA.so.1不変
app の RUNPATH$ORIGIN/../lib不変
A の NEEDEDlibB.so.1不変
A の RUNPATHなし$ORIGIN
実行結果B が見つからない場合に起動失敗この例では 42 を出力

ディレクトリをすべて LD_LIBRARY_PATH に配置することでこの例が成功する場合もあるが、それはプロセスの検索環境を変更することになるため、A が独立デプロイ時に必要なパス情報を備えていることを示すものではない。トラブルシューティング時には、環境変数とオブジェクト内のダイナミックタグを別々に記録すること。

ライブラリを必要とする側の検索パスは?

検索設定は依存を要求するオブジェクトに属する。二本の辺は app と A が別々に要求し、二本目には A 自身の設定が必要。作業ディレクトリ変更で $ORIGIN の展開先は変わらない。

図を準備しています
ライブラリを必要とする側の検索パスは?

RUNPATH は所有オブジェクトの直接依存に使う。A が見つかっても B が見つかるとは限らず、$ORIGIN はタグ所有側のディレクトリ。

LD_PRELOAD の役割はシンボルの挿入

override.c を作成する。

int foo(int x) { return x + 100; }
cc -fPIC -shared override.c -o override.so
LD_PRELOAD="$PWD/override.so" ./dyn-demo

このデフォルトで挿入可能なシンボルの例では、141 が出力されるはずである。これは任意のコードを上書きできることを意味しない。静的バインディング、可視性の隠蔽、ライブラリ内での直接呼び出し、コンパイラによるインライン展開、または特定のリンク最適化により、呼び出しが挿入可能な動的検索を経由しない可能性がある。安全実行モードではプリロードも制限される。

LD_PRELOAD を用いて open をラップすることで観測や適応が可能だが、これによって完全な分離を実現できるわけではない。プログラムは他のインターフェースを呼び出したり、システムコールを直接実行したりする可能性がある。ここでの境界を理解することは、「プリロードが効いていない」のが検索の問題なのか呼び出し経路の問題なのかを判断するのに役立つ。

dlopen:オブジェクトのライフサイクルをインターフェースに組み込む

以下では、先ほどの libfoo.so.1 を引き続きロードする。POSIX の dlsym の結果は対応する関数ポインタとして使用できる。これは POSIX 環境における規約であり、任意の ISO C プラットフォームにおけるオブジェクト/関数ポインタ変換規則として一般化すべきではない。

#include <dlfcn.h>
#include <stdio.h>

int main(void)
{
    void *handle = dlopen("./libfoo.so.1", RTLD_NOW | RTLD_LOCAL);
    if (handle == NULL) {
        fprintf(stderr, "%s\n", dlerror());
        return 1;
    }
    dlerror();
    int (*foo)(int) = (int (*)(int))dlsym(handle, "foo");
    const char *error = dlerror();
    if (error != NULL) {
        fprintf(stderr, "%s\n", error);
        dlclose(handle);
        return 1;
    }
    /* この例では foo が呼び出し可能な関数であることが分かっている;一般的なシンボルの NULL 値は、必ずしも検索エラーを表すものではない。 */
    printf("%d\n", foo(41));
    if (dlclose(handle) != 0) {
        fprintf(stderr, "%s\n", dlerror());
        return 1;
    }
    return 0;
}

コンパイル:cc -Wall -Wextra plugin-host.c -ldl -o plugin-host。実行すると 42 が出力されるはずである。

RTLD_NOW は、必要な未定義シンボルを事前に解決する。RTLD_LAZY は、適用可能な関数バインディングのみを遅延させる。呼び出し側は適切なモードを選択する必要があり、LAZY がすべての呼び出しのデフォルト値であると漠然と言うことはできない。RTLD_LOCAL はシンボルをグローバルスコープに導入することを制限し、このハンドルしかオブジェクトを見られないという意味ではない。

dlerror で古いエラーをクリアし、dlsym の後に新しいエラーをチェックする。戻りポインタが NULL かどうかのみをチェックするのは、すべてのシンボルの状況をカバーするには不十分である。dlclose は参照カウントを減少させるが、即時アンロードを保証するものではない。実際にアンロードされたかどうかにかかわらず、ライフサイクルの保証が失われた後にキャッシュされた関数ポインタを引き続き使用してはならない。dlopen(3)、dlsym(3)

アンロード前に回収すべきものは関数ポインタだけではない

プラグインがオブジェクトを作成し、ホストに返すと仮定する。オブジェクトのデストラクタ、仮想関数テーブル、またはコールバックは依然としてプラグインコードを指している可能性がある。ホストが初期の foo を呼び出さなくなったとしても、オブジェクトを破棄する際にプラグインへジャンプする可能性がある。したがって、インターフェースは create/destroy のペアで提供し、割り当てと解放の責任を明確にし、有効なライブラリ参照が最後に閉じられる前にオブジェクトを破棄することを要求するのがよい。

クローズプロセスは、前置条件付きの状態変化と見なせる。

状態許可される操作次状態への移行条件
作業受付オブジェクトの作成、呼び出しの送信、コールバックの登録入口での新しい作業の受信を停止
排空既存の呼び出しとタスクの終了を待機プラグインが実行を停止し、非同期コールバックが取り消され、実行中でない
回収プラグイン独自の destroy インターフェースを呼び出すプラグインコードを必要とするデータとコールバックがすべて解放された
ハンドル閉鎖dlclose、ホストのキャッシュ入口を削除以降、プラグインへの経路が一切存在しない

これらの手順にはホスト独自の同期プロトコルが必要である。RTLD_LOCAL は検索スコープに影響を与えるのみで、ホストがワーカースレッドを待機するものではなく、メモリ分離を構成するものでもない。コールバック取り消し API が「新しいコールバックを開始しない」ことのみを保証する場合、ホストは開始済みのコールバックの返却を待機しなければならない。オブジェクトに対して有効なライブラリ参照を保持することは一般的な設計であるが、オブジェクト間の循環やプラグイン終了時のタスク収束を処理する必要がある。

シンボルバージョンと IFUNC

GNU シンボルバージョンにより、同じ名前に異なるバージョンを関連付けることができる。foo@@VERS_2.0 は通常デフォルトバージョンを、foo@VERS_1.0 は非デフォルトの旧バージョンを表す。呼び出し側が記録したバージョンの要求は提供されなければならず、SONAME を変更したりソフトリンクを作成したりしても、引数、構造体レイアウト、またはバージョンの非互換性は修正されない。

IFUNC により、ローダーが実際の実装を選択できる。例えば、利用可能な CPU 特性に基づいて関数を選択する。解決のタイミングは非常に早い場合もあれば、特定のバインディングパスに関連する場合もある。ローダーは、通常のアプリケーション初期化が完了したと仮定してはならない。起動異常を読む際、IFUNC はチェックすべき早期実行パスの一つである。

ABI 互換性には、「シンボルが見つかる」以上のチェックが必要

プラグインの入口が依然として process と呼ばれているが、旧版は2つの整数を受け取り、新版は構造体ポインタを受け取るとする。ローダーはこの名前を正常に見つけることができるが、呼び出しは規約に適合しない可能性がある。ダイナミックシンボルテーブルは、C 関数の完全な引数型を自動的に検証してくれるわけではない。

階層例この階層を通過しても証明できないこと
ファイルの同一性SONAME が libfoo.so.1 に適合コンテンツが元の ABI を実装している
名前とバージョンfoo、必要なバージョンノードが存在引数、戻り値、およびオブジェクトレイアウトがホストの期待に合致
呼び出し契約固定幅フィールド、サイズ、呼び出し規約所有権と並行使用が正しい
ライフサイクル契約create/destroy のペア、クローズ前の排空新旧の実装のビジネスセマンティクスが完全に同一

カスタムプラグイン ABI では、入口で返されるインターフェーステーブルにバージョン番号とテーブルサイズを含め、ホストがオプションの尾部フィールドを読み取る前に存在確認を行えるようにする。この設計要件により、双方は安定した初期レイアウトと互換性戦略を事前に合意する必要がある。旧構造体の中間にフィールドを挿入してバージョン番号を上げても、旧ホストが自動的に安全に実行できるようにはならない。

クイックロケーション

現象最初に確認すべきこと
cannot open shared object fileNEEDED、SONAME、パス、ターゲットアーキテクチャ
undefined symbolダイナミックシンボルのエクスポート、依存関係、検索スコープ
version not foundreadelf -V の出力およびデプロイされたライブラリのバージョン
preload 無効果可視性、直接呼び出し、インライン展開、安全実行モード
dlclose 後の偶発的クラッシュオブジェクト、関数ポインタ、およびワーカースレッドのライフサイクル

readelf -dW、readelf --dyn-syms -W、および readelf -rW は、それぞれ依存関係、ダイナミックシンボル、リロケーションの問題に回答する。通常の nm が依存するシンボルテーブルは削除されている可能性がある。ダイナミックシンボルを確認するには nm -D を使用できる。本稿の実験は glibc を基準としており、musl のローダー実装および診断オプションは別途そのドキュメントで確認する必要がある。