静的リンク・動的リンク・動的ローディングの違い

今日のネタはリンカの仕事について。低レイヤな話としてありがちだが、いかんせんバイナリを入力にバイナリを出力する処理なので直感的に理解しづらい。ってことで調べながらメモる。

リンカ(linker)とは何か

リンカ とは、コンパイルされたファイル(オブジェクトファイル)同士をつないで実行可能なファイルを作るプログラムである。ほとんどの場合、ソースコードはファイルごとにコンパイルされ、ファイルごとにオブジェクトファイルが生成される。他方、プログラム中で外部ファイルで定義されたシンボル(変数名や関数名など)を参照する場合がある。たとえばライブラリ中に定義された関数を呼び出す場合などである。リンカの役割は、このようなファイル内を見ただけでは正体不明のシンボルと外部ファイルに含まれる定義を紐付けることである。

リンカには静的リンクと動的リンクの2種類がある。以下にそれぞれについてざっくりまとめる。

静的リンク

静的リンクは、正体不明のシンボルを定義しているすべてのオブジェクトファイルを、動かしたいオブジェクトファイルの中に静的にコピーするようなやり方である。後述する動的リンクと違い、コンパイル時にすべてのリンク作業を完了できるため、実行時にリンク関連のエラーが起こることがない。一方、あらゆる依存先のファイルを1つのオブジェクトファイルに詰め込むので、ファイルサイズが大きくなる傾向がある。

個人的なイメージとしては、Docker みたいなコンテナにすべての依存関係を封じ込めてしまう発想と似ていると思う。ホストのシステム全体で見ると同じ依存関係があちこちに出てきて冗長であるが、実行時にコケないという性質はかなり重宝される。ストレージの容量が飛躍的に増えた現代では静的リンクがベターな選択肢になることも多いと思われる。

ここからは静的リンクによって実行ファイルを作る実験をしてみる。C 言語でやってもつまらないので C++ と Rust の組み合わせでやってみる。ホストマシンは Ubuntu 20.04.4。

まずはライブラリ側のコードとして適当な C++ のプログラムを定義する。引き算をする minus 関数を定義しているだけである。このファイルを hello.cpp と名付ける。

extern "C" {
    int minus(int a, int b);
}

int minus(int a, int b) {
    return a - b;
}

そしてこのファイルをライブラリとしてコンパイルする。まずは 拡張子.o のオブジェクトファイルを作って、拡張子 .aアーカイブファイルに追加する。静的リンクを使うときはアーカイブとしてまとめるのが一般的なやり方である。アーカイブ名を `libxxx.a" のようにするのは守るべき命名規則である。

$ g++ -c -o hello.o -static hello.cpp
$ ar rv libhello.a hello.o

次にこのライブラリを呼び出す Rust プログラムを以下のように定義する。リンク時に minus という関数が正体不明なので、このファイル単体では実行できない。

extern "C" {
    fn minus(a: i64, b: i64) -> i64;
}

fn main() {
    unsafe {
        println!("100-3 = {}", minus(100, 3));
    }
}

cargo を使って Rust プロジェクトをコンパイルする。このとき以下のようなビルドスクリプト build.rs を記述してやる。

fn main() {
    println!(r"cargo:rustc-link-search=native=/(省略)/clang_lib");
    println!(r"cargo:rustc-link-lib=static=hello");
}

1つ目の命令では、正体不明のライブラリがあったら clang_lib ディレクトリを探しに行けばよいことを教えている。ちなみに /lib/usr/lib はデフォルトで検索対象になっている。(man ld とかすればデフォルトのディレクトリを確認できる。)2つ目の命令でリンク方法が 静的(static)であることを教えている。

そして、プロジェクトをビルドし、その成果物であるプログラムを実行する。ちゃんとライブラリ側の minus 関数を呼び出して 100-3 を計算できたことが分かる。

$ cargo build
   Compiling invoke_c v0.1.0 (/(省略)/invoke_c)
    Finished dev [unoptimized + debuginfo] target(s) in 0.88s
$ target/debug/invoke_c 
100-3 = 97

静的リンクを実行して得られたオブジェクトファイルを覗いてみる。

$ readelf -s target/debug/invoke_c | grep minus
// Num:    Value          Size Type    Bind   Vis      Ndx Name
   572: 0000000000008025    22 FUNC    GLOBAL DEFAULT   14 minus

この結果から、オブジェクトファイルが持っているシンボルテーブルに含まれている minus というシンボルはオフセットが 0x8025 のアドレスに定義があることが分かる。

以上、静的リンクでした。

動的リンク

静的リンクとは対照的に、動的リンクではプログラムの実行開始直後にリンク処理が行われる。コンパイル時にはリンクは行われず、正体不明のシンボルはプログラムが実行されるまで正体不明のままである。ちなみに、この動的なリンク処理はオペレーティング・システムが担う役割である。

実行時に適切なライブラリが見つからないと実行時エラーが起きてしまう。一方で、ライブラリのコードを埋め込むわけではないので、複数のプログラムから利用されるライブラリが存在する場合はストレージ容量の節約になる。この利点が大きいので Linux では基本的に動的リンクが利用される。

ここからは先ほどと同様にして動的リンクの実験をしてみる。

まずは共有オブジェクト(shared object)としてライブラリのコードをコンパイルする。結果として拡張子 .so のファイルが得られる。

$ g++ -shared -o libhello.so hello.cpp 

cargo のビルドスクリプトは以下のようにする。dylib= によって動的リンクを行うことを示している。また、hello を指定することで実行時には libhello.so というライブラリが必要であることを示している。

fn main() {
    println!(r"cargo:rustc-link-search=native=/(省略)/clang_lib");
    println!(r"cargo:rustc-link-lib=dylib=hello");
}

そしてプロジェクトをビルドする。

$ cargo build
   Compiling invoke_c v0.1.0 (/(省略)/invoke_c)
    Finished dev [unoptimized + debuginfo] target(s) in 0.46s

まずは何も考えずにビルド成果物を実行してみる。

$ target/debug/invoke_c 
target/debug/invoke_c: error while loading shared libraries: 
libhello.so: cannot open shared object file: No such file or directory

このエラーメッセージは libhello.so という共有ライブラリが見つからないと言っている。この共有ライブラリがコンパイル時に指定されたんだから実行時に必要なはずだけど、それが見つからない状況である。ちなみに以下で libhello.so が必要であることは確認できる。

$ readelf -a target/debug/invoke_c | grep libhello.so
 0x0000000000000001 (NEEDED)             Shared library: [libhello.so]

この問題を解決する方法として、動的リンクによってライブラリを検索する場所を環境変数 LD_LIBRARY_PATH の値として設定すればよい。結果として以下のコマンドで実行に成功する。

$ LD_LIBRARY_PATH=/(省略)/clang_lib/ target/debug/invoke_c
100-3 = 97

ついでに生成されたオブジェクトファイルのシンボルテーブルを見てみる。

$ readelf -a target/debug/invoke_c | grep minus
// Num:    Value          Size Type    Bind   Vis      Ndx Name
   571: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND minus

以下の結果により minus というシンボルが未定義(UNDefined)であり、アドレスも適当な値として 0 で埋められていることが分かる。

また以下のコマンドの結果より、オペレーティング・システムがこのバイナリファイルを読んだときに動的にリンク処理を行うための情報が格納されていることが分かる。

$ readelf -a target/debug/invoke_c | grep ld-linux
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
 0x0000000000000001 (NEEDED)             Shared library: [ld-linux-x86-64.so.2]

インタプリタとして ld-linux-x86-64.so.2 が必要らしい。リンカなしでは生きてゆけない。

これで動的リンクについてはおしまい。

動的ローディング

最後に、動的リンクと混同されがちな 動的ローディング について説明する。ローディングとはオブジェクトファイルをメモリ上に載せる操作である。この時点でリンクとは別物である。

特に、動的リンクはオペレーティング・システムの仕事であるのに対して、動的ローディングはアプリケーションの仕事である。アプリが好きなタイミングで外部のオブジェクトファイルをメモリに載せて実行するような操作である。動的リンクはプログラム実行開始直後に行われるのに対し、動的ローディングは任意のタイミングで実行される可能性がある。

今日はもう眠いのでやりたくないが、動的ローディングの実験もしてみる。先ほど作った共有ライブラリを Python プログラムから呼び出す。

Python ファイルは以下の通りである。 これを main.py と名付ける。

import ctypes

libhello = ctypes.CDLL("/(省略)/libhello.so")
print("100-3 =", libhello.minus(100, 3))

ここでは CDLL という API によって動的ローディングを行っている。これは裏では dlopen などの API を呼び出している。そのソースコードこのあたり だと思われる。(ちなみに dlopen はシステムコールではなく共有ライブラリが提供する API である。最終的には mmap などのシステムコールが呼ばれる。)

プログラムを実行してみるとプログラムを動的にロードできていることが分かる。

$ python3 main.py 
100-3 = 97

めでたし。