🎓 レベル:発展 | 重要度:B(推奨)
📎 前提:プロセスとスレッド・物理メモリと論理アドレス | 関連:仮想記憶とページング・CPUと命令実行
要点(BLUF)
- ソースは「コンパイル → アセンブル → リンク」で1つの実行ファイルになる。リンクは複数のオブジェクトファイルやライブラリを束ね、未解決のシンボル(関数・変数の参照)をつなぐ作業。
- 静的リンクは必要なライブラリを実行ファイルに丸ごと埋め込む。動的リンクは実行時にロード/共有する。後者は省メモリ・更新容易だが起動時の解決が要る。
- 実行ファイル(LinuxならELF)には「どのセクションをどのアドレスへ」が書かれ、ローダがそれをプロセスのアドレス空間(プロセスとスレッド)へ写して実行を始める。
概念 ── 「a+b」から動くプロセスまでの全工程
CPUと命令実行 で1行が複数命令になるのを見ました。では、複数のソースファイルやライブラリ呼び出しを含むプログラム全体は、どうやって1つの動くプロセスになるのか。工程は次の通りです。
flowchart LR
src["ソース(main.c, util.c)"] -->|"コンパイル+アセンブル"| obj["オブジェクト(main.o, util.o)"]
obj -->|"リンク(シンボル解決+再配置)"| exe["実行ファイル(ELF)"]
lib["ライブラリ(libc など)"] --> exe
exe -->|"exec → ローダ"| proc["プロセス(アドレス空間に展開)"]
- コンパイル/アセンブル:各ソースを機械語の断片(オブジェクトファイル)に。ただし他ファイルの関数を呼ぶ箇所は「あとで埋める穴」(未解決シンボル)として残る。
- リンク:複数のオブジェクトとライブラリを束ね、穴を実アドレス(または相対参照)で埋める(シンボル解決と再配置)。これで実行ファイルが完成。
- ロード:
exec(プロセスとスレッド)がローダを呼び、実行ファイルの中身をプロセスのアドレス空間へ写して、エントリポイントから実行を始める。
仕組み① ── リンクがやること(シンボル解決と再配置)
オブジェクトファイル main.o が util.o の関数 helper() を呼ぶとき、main.o の中では「helper のアドレスは未定(穴)」になっています。リンカは全オブジェクトを見渡して、
- シンボル解決:
helperがどこで定義されているか(util.o内)を突き止め、対応づける。 - 再配置:各オブジェクトを最終的なアドレス配置に並べ、穴に正しい参照を書き込む。
同名シンボルが2つあれば「重複定義」、どこにもなければ「未定義参照」エラー。C/C++のリンクエラーの大半はこの段階です。
仕組み② ── 静的リンク vs 動的リンク
ライブラリ(printf などを含む標準Cライブラリ等)の取り込み方に2通りあります。
flowchart TB
subgraph S["静的リンク"]
e1["実行ファイル(ライブラリのコードを丸ごと内包)"]
end
subgraph D["動的リンク"]
e2["実行ファイル(参照だけ持つ)"] -.->|"実行時に解決"| so["共有ライブラリ(libc.so / 1コピーを全プロセスで共有)"]
end
| 静的リンク | 動的リンク(共有ライブラリ) | |
|---|---|---|
| ライブラリの場所 | 実行ファイルに埋め込み | 別ファイル(.so/.dll)を実行時に読み込み |
| 実行ファイルサイズ | 大きい | 小さい |
| メモリ | プロセスごとに重複 | 1コピーを全プロセスで共有(仮想記憶とページング のページ共有) |
| ライブラリ更新 | 再リンクが必要 | 差し替えるだけで全アプリに反映 |
| 起動 | 速い(解決済み) | 起動時にシンボル解決の手間 |
動的リンクが現代の主流なのは、同じライブラリのコードページを全プロセスで物理的に1つだけ持てば済む(セグメンテーションとメモリ保護 の読み取り専用ページ共有)から。何百のプロセスが libc を使っても、物理メモリ上のコピーは1つです。
仕組み③ ── ELFとローダ
Linuxの実行ファイル形式が ELF(Executable and Linkable Format)。中身は「セクション/セグメント」に分かれ、.text(機械語=CPUと命令実行 のテキスト領域)、.data(初期値ありの大域変数)、.bss(初期値0の大域変数・ファイルには領域だけ確保)などが並びます。
ローダは exec の延長で、ELFのプログラムヘッダを見て「このセグメントを仮想アドレスのここへ、この権限(R/W/X)で」マップします。実際にはデマンドページング(仮想記憶とページング)で、最初は写像だけ設定し、実行が触れたページを順次ディスクから読み込みます。だから巨大な実行ファイルでも起動は速い。
flowchart LR
elf[".text / .data / .bss(ELFのセクション)"] -->|"ローダがマップ"| vm["プロセスのアドレス空間(テキスト/データ/BSS領域)"]
vm -->|"動的リンカが .so を解決"| run["main から実行開始"]
動的リンクの場合、ローダは続いて動的リンカ(ld.so)を起動し、必要な共有ライブラリをアドレス空間にマップしてシンボルを解決してから、main へ制御を渡します。
具体例 ── 実機で覗く
Linuxでは(対応ラボ):
ldd ./a.out… その実行ファイルが必要とする共有ライブラリ一覧(動的リンクの依存)。nm a.out… シンボル表(定義済み/未定義の関数・変数)。readelf -S a.out….text・.data・.bssなどのセクション構成。size a.out… text/data/bss の各サイズ。.bssがファイル上はサイズ0でも実行時にメモリを取るのが見える。
これらで「実行ファイルは単なる機械語の塊ではなく、配置とリンクの設計図」だと体感できます。
仕組みの直観 ── なぜこの設計か
- リンクを分ける理由:ソースを別々にコンパイルでき(変更したファイルだけ再コンパイル)、ライブラリを再利用できる。分割コンパイルの効率はリンクという後工程あってこそ。
- 動的リンクの理由:共有によるメモリ節約と、ライブラリ単独更新(セキュリティ修正を全アプリに一括反映)。代償が起動時解決と「依存地獄」(バージョン不整合)。
- ELFで領域と権限を記述する理由:ローダとMMU(セグメンテーションとメモリ保護)が、コードを読み取り専用&実行可、データを書き込み可&実行不可に設定でき、保護とデマンドページングを一気通貫で実現できる。
⚠️ よくある誤解・落とし穴
- 「コンパイルが通れば動く」→ コンパイル成功とリンク成功は別。未定義参照はリンク時に出る。
- 「実行ファイルにライブラリが必ず入っている」→ 動的リンクなら入っていない。実行時に
.soが無いと起動失敗(lddで確認)。 - 「
.bssはファイルを大きくする」→.bssはファイル上はサイズ情報だけ。実行時にゼロ初期化で確保(仮想記憶とページング)。 - 「ロード=全部を一度に読み込む」→ 実際はデマンドページングで触れた分だけ。
対応ラボ
cs-foundations-study/labs/03-05_linking.md(Linux: gcc で静的/動的リンクを作り分け、ldd・nm・readelf -S・size で依存とセクションを観察する手順)。
関連
- 展開先のアドレス空間レイアウトは プロセスとスレッド
- マップの実体(デマンドページング・共有)は 仮想記憶とページング
- セクションごとの権限は セグメンテーションとメモリ保護
- テキスト領域の中身(機械語)は CPUと命令実行