動的リンクと共有ライブラリの仕組み
同じlibcを何百プロセスで使ってもメモリが増えない理由が腑に落ちます。共有ライブラリの物理ページ共有、位置独立コード、シンボルのバージョニング、preload/RPATH/RUNPATHの解決順序を内部から解剖します。
- 1.共有ライブラリは位置独立コード(PIC)でビルドされ、読み取り専用のコードページを複数プロセスが同じ物理ページとして共有する。プロセスごとに異なる部分はGOTなどの書き込み可能データだけが持つ。
- 2.ld.soの検索順序はLD_PRELOAD→DT_RPATH(DT_RUNPATHが無い場合)→LD_LIBRARY_PATH→DT_RUNPATH→ld.so.cache→既定ディレクトリ。RUNPATHはRPATHを実質置き換え、解決範囲も変える。
- 3.シンボルバージョニングは同名シンボルの複数世代を共存させ、ABI互換を保つ。解決はグローバルスコープの先勝ちで、これがLD_PRELOADによる関数差し替えを成立させる。
なぜlibcは増えないのか
数百のプロセスが同じ libc.so.6 を使っていても、物理メモリ上に libc のコードは基本的に一つしか存在しません。この「共有」をどう実現し、プロセスごとに違う部分をどう分離しているか、検索パスの優先順位がなぜあの順なのか。動的リンクの内部はこの三点に集約できます。本稿はロードと再配置の流れ(ELFバイナリのロードとリンカ・ローダの内部 で解説)を前提に、その先の共有・PIC・バージョニング・検索順序を掘り下げます。
共有ライブラリの物理ページ共有
共有ライブラリ(.so)は ld.so が mmap でプロセスのアドレス空間に貼り付けます。重要なのは、コードセグメント(r-x)が読み取り専用かつファイルバックドなマッピングである点です。同じファイルを複数プロセスがマップすると、カーネルはページキャッシュ上の同一物理ページを各プロセスのページテーブルから参照させます。コードは書き換わらないので、何プロセスが起動しても物理ページは一つで済みます(メモリマップトファイル(mmap) の共有マッピングと同じ原理)。
問題は書き込みが生じる領域です。共有ライブラリにも初期値付きデータ(.data)や GOT(解決結果を書き込む表)があり、ここはプロセスごとに値が違わねばなりません。そこでデータセグメントは MAP_PRIVATE で貼られ、書き込みが起きたページだけコピーオンライト(CoW) で複製されます。
| 領域 | 保護属性 | マッピング | 共有の単位 |
|---|---|---|---|
| コード(.text) | r-x | ファイルバックド共有 | 全プロセスで物理ページ1つ |
| 読み取り専用データ(.rodata) | r-- | ファイルバックド共有 | 全プロセスで共有 |
| 初期化データ(.data) | rw- | MAP_PRIVATE(CoW) | 書き込み時にプロセス固有化 |
| GOT | rw-(RELRO前) | MAP_PRIVATE | プロセス固有(解決結果を書く) |
つまり「共有されるのは不変なコードと読み取り専用データ、プロセス固有になるのは書き換えるデータ」という線引きです。この分離が成り立つには、コード自体がプロセスごとに違う番地で動いても書き換え不要であること、すなわち位置独立であることが前提になります。
位置独立コード(PIC)の必要性
共有ライブラリはどの番地にロードされるか事前に決まりません。ASLR でロードごとに動きますし、複数の .so が衝突しない番地に並べる都合もあります。もしコードが絶対番地を直書きしていたら、ロード番地に応じて命令そのものを書き換える(テキスト再配置)必要が生じ、コードページがプロセスごとに変わって共有できなくなります。これを避けるのが PIC(Position Independent Code) です。
PIC は外部・グローバルなシンボル参照を、絶対番地ではなく次の二段で表現します。
- コード内参照は RIP 相対: 現在の命令位置からの相対オフセットでアクセスする。ロード番地が変わっても相対距離は不変なので、命令を書き換えずに済む。
- グローバルデータ/外部関数は GOT 経由: 番地が確定しない参照は GOT(Global Offset Table)という表を間接参照する。命令には「GOT の何番目」という固定オフセットだけを書き、実番地は ld.so が GOT に書き込む。
; 非PIC(絶対番地直書き)— ロード番地ごとに命令の書き換えが要る
mov rax, [0x404050] ; global_var の絶対アドレス
; PIC(GOT間接)— 命令は固定、GOTの中身だけ実番地で埋める
mov rax, [rip + global_var@GOTPCREL] ; GOTエントリをRIP相対で引く
mov rax, [rax] ; GOT経由で実体へ
ポイントは「書き換えるのはデータ(GOT)だけで、コードは不変」という構造です。GOT は書き込み可能データなのでプロセスごとに違う値を持てますが、コードページは全プロセスで共有したまま再配置を吸収できます。PIC のわずかな間接参照コストと引き換えに、コード共有という大きな利得が得られます。
共有ライブラリは事実上常に PIC でビルドされます(-fPIC)。一方、実行ファイル本体は PIE(Position Independent Executable)としてビルドして初めて位置独立になります。.so のコード共有を支えているのは PIC、本体の ASLR を支えているのは PIE という対応です。
シンボルのバージョニングと相互参照
共有ライブラリの存在意義は ABI 互換、つまりライブラリを差し替えても再コンパイルなしに既存バイナリが動くことです。しかし関数の挙動を変えたい場面では、古い呼び出し元を壊さずに新しい定義を提供したくなります。これを可能にするのがシンボルバージョニングです。
仕組みは、同名のシンボルに版(version node, 例 GLIBC_2.2.5, GLIBC_2.34)を付け、同じ名前で複数世代を一つの .so に共存させるものです。
- ライブラリ側は version script で各シンボルに版を割り当て、さらに**既定版(default version)**を一つ指定する。
- バイナリ側は、リンク時に見えていた版を参照として埋め込む(
memcpy@GLIBC_2.2.5のように@で版を固定)。 - 古いバイナリは古い版のシンボルへ、新しくリンクしたバイナリは新しい既定版へ結びつく。両方の実体が同じ
.soに入っているので、一つのライブラリで新旧を同時に満たせる。
シンボルテーブルでは name@version が非既定版、name@@version が既定版を表します。版指定なしで name を参照する新規リンクは @@ の既定版に解決され、過去にビルドされたバイナリは埋め込まれた @ の特定版に解決されます。これにより一つの名前で複数 ABI 世代を提供できます。
相互参照(あるライブラリ内のシンボルを別のオブジェクトが参照する)の解決は、グローバルスコープの探索順に従います。ld.so は読み込んだオブジェクトをロード順(幅優先)に並べたグローバルスコープを持ち、シンボルを探すとき**最初に見つかった定義を採用(先勝ち)**します。実行ファイル本体は通常スコープ先頭に来るため、本体が定義したシンボルが共有ライブラリ内の同名定義より優先されます。この「先勝ち」が、次節の LD_PRELOAD による差し替えを成立させる土台です。
preload/RPATH/RUNPATHの解決順序
ld.so が依存ライブラリ(DT_NEEDED に列挙された .so)を探すとき、検索ディレクトリには明確な優先順位があります。さらに、どのシンボル定義を採用するかというスコープ順と、どのディレクトリを見るかというパス順は別の話なので分けて理解します。
まずファイルを探すディレクトリの順序です。
| 順位 | 検索元 | 備考 |
|---|---|---|
| 1 | LD_PRELOAD | 明示指定の.soを最優先でロード。スコープ先頭付近に入る |
| 2 | DT_RPATH | DT_RUNPATHが存在する場合は完全に無視される(非推奨) |
| 3 | LD_LIBRARY_PATH | 環境変数。setuidバイナリでは無視される |
| 4 | DT_RUNPATH | バイナリ埋め込みパス。直接の依存にのみ適用 |
| 5 | ld.so.cache | ldconfigが生成するキャッシュ(/etc/ld.so.cache) |
| 6 | 既定ディレクトリ | /lib, /usr/lib(およびマルチアーキ派生) |
DT_RPATH と DT_RUNPATH は紛らわしいので内部差を押さえます。両者ともバイナリに埋め込むライブラリ検索パスですが、探索範囲と優先順位が異なります。
決定的な差は二つあります。第一に優先順位: DT_RPATH は LD_LIBRARY_PATH より先に効きますが、DT_RUNPATH は後に効きます。第二に伝播範囲: DT_RPATH は推移的な依存(依存の依存)の検索にも使われますが、DT_RUNPATH は直接の依存にのみ適用され、孫依存には伝播しません。そして DT_RUNPATH が一つでも存在すると DT_RPATH は完全に無視されます。新しいリンカは既定で DT_RUNPATH を出すため、孫依存を埋め込みパスで解決させたい場合は意図せず失敗します。
検索順序とは別に、シンボル解決のスコープ順で LD_PRELOAD は強力です。preload された .so はグローバルスコープのほぼ先頭に挿入されるため、先勝ち規則によって libc の malloc などと同名の関数を横取りできます。デバッガやプロファイラ、メモリチェッカが関数を差し替えられるのはこの仕組みです。逆に言えば、preload は任意コードを既存プロセスに割り込ませる経路でもあるため、setuid バイナリでは LD_PRELOAD と LD_LIBRARY_PATH は無視されます(昇格権限の奪取を防ぐため)。
「同じ.soを複数プロセスで使うとメモリは増えるか」にはコードは共有され増えない、データは書き込み時にCoWで増えるが正答。「RPATHとRUNPATHの違い」はRPATHはLD_LIBRARY_PATHより先・推移依存に効く/RUNPATHは後・直接依存のみ/RUNPATHがあればRPATHは無視を押さえる。「LD_PRELOADで関数を差し替えられる根拠」はシンボル解決の先勝ち規則。
つまずきポイント
- 「共有ライブラリ=メモリ完全共有」ではない。共有されるのは読み取り専用のコードと
.rodataだけです。.dataと GOT はプロセス固有で、書き込み時に CoW でページが分かれます。 - PIC と PIE を混同しない。PIC は共有ライブラリのコード共有を支える性質、PIE は実行ファイル本体を任意配置にして ASLR を効かせる性質です。
.soは常に PIC ですが、本体は PIE にしない限り位置依存です。 - バージョニングは名前マングリングではない。C++ のマングリングが型情報を名前に埋めるのに対し、シンボルバージョニングは ABI 世代を
@versionで区別する別機構です。同名関数の挙動変更を、再コンパイルなしに新旧両立させるためのものです。 - RUNPATH は孫依存に効かない。
$ORIGIN相対のDT_RUNPATHを埋め込んでも、それは直接依存にしか適用されません。依存の依存まで自前パスで解決させたいなら各オブジェクトに RUNPATH を持たせる必要があります。
まとめ
- 共有ライブラリのコードと読み取り専用データはファイルバックド共有マッピングで複数プロセスが同一物理ページを共有し、書き換わる
.dataと GOT だけが MAP_PRIVATE で CoW によりプロセス固有化される。 - PIC は RIP 相対参照と GOT 間接参照により、命令を書き換えずに任意番地で動くコードを実現し、これがコードページ共有の前提になる。
.soは常に PIC でビルドされる。 - シンボルバージョニングは
@versionで同名シンボルの複数 ABI 世代を一つの.soに共存させ、再コンパイルなしの新旧両立を可能にする。相互参照はグローバルスコープの先勝ちで解決される。 - 検索順序は LD_PRELOAD → DT_RPATH(DT_RUNPATH 不在時)→ LD_LIBRARY_PATH → DT_RUNPATH → ld.so.cache → 既定ディレクトリ。RUNPATH は RPATH より後に効き直接依存のみに適用され、RUNPATH があれば RPATH は無視される。
ロードと再配置の前段は ELFバイナリのロードとリンカ・ローダの内部、配置のランダム化と空間レイアウトは プロセスアドレス空間のレイアウトとASLR、実体ページがいつ割り当てられるかは デマンドページングとページフォルト処理 と合わせて読むと、ロード・共有・解決の全体像が一本につながります。
OS Article
動的リンクと共有ライブラリの仕組みを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
動的リンク
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
ld.soの検索順序はLD_PRELOAD→DT_RPATH(DT_RUNPATHが無い場合)→LD_LIBRARY_PATH→DT_RUNPATH→ld.so.cache→既定ディレクトリ。RUNPATHはRPATHを実質置き換え、解決範囲も変える。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「動的リンク / 共有ライブラリ」に近いか確認する。
- 強みである「共有ライブラリは位置独立コード(PIC)でビルドされ、読み取り専用のコードページを複数プロセスが同じ物理ページとして共有する。プロセスごとに異なる部分はGOTなどの書き込み可能データだけが持つ。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。