前回の FlutterからRustを呼んでみた で、C++とRustから同じ形のライブラリを作りました。macOSでは .dylib、Windowsでは .dll が出てきます。
書きながら、ずっと引っかかっていたことがあります。この2つは同じものなのか、違うものなのか。ついでに言えば .a や .lib や .so もあって、拡張子だけで5種類あります。
調べると「静的ライブラリと動的ライブラリがあります」という説明にはすぐ辿り着きます。でも、それを読んでも何が嬉しくて使い分けるのかが分からないのです。
そこで今回は、小さなライブラリを1つ作って、静的と動的の両方で組み立て、実際に測りました。macOSとWindowsの両方でやっています。
結論から言うと、覚えるべき軸は「いつ名前が解決されるか」の1つだけでした。拡張子の違いは、そこから全部派生しています。
ついでに、予想が3回外れました。どれも記事の見どころになったので、そのまま書きます😅
拡張子を並べても、何も分からない
よくある説明はこうです。
| 静的 | 動的 | |
|---|---|---|
| macOS | .a | .dylib |
| Linux | .a | .so |
| Windows | .lib | .dll |
この表は正しいのですが、これを覚えても何も分かりません。「じゃあどっちを使えばいいのか」に答えていないからです。それに「Windowsで .dylib は扱えないのか」という素朴な疑問にも答えていません。これは後半で実際に試します。
eightがこの表で長く困っていたのは、Windowsの .lib だけ2つの意味があることでした。静的ライブラリも .lib、DLLと一緒に出てくるファイルも .lib。これは後半で実測します。
用語集
先に言葉を並べておきます。
| 用語 | 平たく言うと | なぜそれが効くのか |
|---|---|---|
| リンク | 「この名前の関数はここにある」という結びつけ作業 | ★これがいつ行われるかが、この記事の全部 |
| シンボル | 関数や変数につけられた名前の札 | リンクは札を突き合わせる作業。nm で一覧できる |
オブジェクトファイル(.o / .obj) | ソース1本をコンパイルした中間生成物 | ★静的リンクが取り込む単位がこれ(関数ではない) |
静的ライブラリ(.a / .lib) | オブジェクトファイルを束ねた書庫 | ビルド時に中身が本体へ写し取られる |
動的ライブラリ(.so / .dylib / .dll) | それ自体では起動できない、部品としてのプログラム | 起動時か実行中にOSが読み込んで結びつける |
| ローダ | プログラムを起動するときにOSで動く仕掛け | 動的ライブラリを探して結びつけるのはこの人 |
| インポートライブラリ(Windows) | DLLの入口の名簿だけが入った .lib | ★コードは1バイトも入っていない。静的ライブラリと紛らわしい |
| COMDAT(Windows) | 「ここは独立して捨ててよい塊です」という印 | ★これが付いていないと、リンカは要らないものを捨てられない |
★軸は1つだけ:いつ名前が解決されるか
種類がたくさんあるように見えますが、違いは「ml_version() という名前が、いつ実体と結びつくか」だけです。時点は3つあります。
この3つが、そのまま使い方の3通りになります。
- ビルド時(静的リンク)… ライブラリの中身が本体へ写し取られる。できあがった実行ファイルは、もうライブラリを必要としない
- 起動時(動的リンク)… 実行ファイルには「
ml_versionがどこかに要る」とだけ書いてある。OSのローダが探して結びつける - 実行中(実行時リンク)… プログラムが自分で「このファイルを読み込め」「この名前の関数をくれ」と頼む。プラグインの仕組みはこれ
この「時点」の違いが、差し替えができるか・起動が速いか・配るファイルが何個になるかを全部決めています。
実験の材料
小さなライブラリを1つ作りました。関数は4つだけです。
int ml_add(int a, int b); // 足す
long ml_fib(int n); // フィボナッチ
const char *ml_version(void); // 版を返す("1.0")
// ★アプリからは呼ばない関数。取り込みの単位を調べるために置いてある
int ml_unused_big(int seed);
仕掛けは最後の ml_unused_big です。中に 16 KiB の表を持たせてあります。
static const int TABLE[4096] = { 1 }; // 4096 × 4バイト = 16,384バイト
int ml_unused_big(int seed) {
int acc = seed;
for (int i = 0; i < 4096; i++) acc = (acc * 31 + TABLE[i]) % 1000003;
return acc;
}
アプリはこの関数を一度も呼びません。呼ばないものが実行ファイルに入るのかどうかを見るための重りです。
これを静的ライブラリと動的ライブラリの両方で作り、使う側を3通り用意しました。
★「いつ結びつくか」を目で見る
言葉で説明するより、これを見るのが早いと思います。
ライブラリだけを 1.0 から 2.0 に作り直して、アプリは再ビルドせずに実行しました。
[作り直す前]
app_static : ml_version = 1.0
app_dynamic : ml_version = 1.0
→ libmath.dylib だけを 2.0 で作り直した
[作り直した後]
app_static : ml_version = 1.0 ← 変わらない
app_dynamic : ml_version = 2.0 ← 追従した
app_runtime : ml_version = 2.0 ← 追従した
静的リンクだけが 1.0 のままです。ビルドしたときに、ライブラリの中身が実行ファイルへ写し取られてしまったからで、もう外のファイルを見ていません。
Windowsでも同じ結果でした。
ここが「なぜ動的にするのか」の答え
アプリを配ったあとで、ライブラリだけを差し替えて直せます。OSが提供している機能が動的ライブラリなのも同じ理由で、OSの更新でアプリを作り直さずに済みます。
逆に言えば、静的リンクは「配った時点の中身で固まる」。これは弱点にも強みにもなります。
★拡張子は何を決めているのか
ここで、冒頭の表に戻ります。macOSは .dylib、Linuxは .so、Windowsは .dll。
そうすると気になりませんか。Windowsで .dylib は扱えないのか。Macで .so は扱えないのか。
やってみるのが早いので、試しました。
まず、Macで作った .dylib を、名前だけ .so に変えて読み込ませます。
$ cp libmath.dylib /tmp/libmath_renamed.so
$ ./app_runtime /tmp/libmath_renamed.so
ml_version = 1.0
ml_add(2,3) = 5
普通に読めました。最初から .so という名前で作っても同じです。
$ file /tmp/libmath_renamed.so
/tmp/libmath_renamed.so: Mach-O 64-bit dynamically linked shared library arm64
中身は Mach-O のままです。名前を変えても中身は変わりませんし、OSは中身のほうを見ていました。
では逆に、本物のWindowsのDLLを持ってきたらどうなるか。手元のWhiskyのボトルから借りてきました。
$ file real_windows.dll
real_windows.dll: PE32 executable (DLL) (console) Intel 80386, for MS Windows
$ ./app_runtime /tmp/real_windows.dll
dlopen 失敗: dlopen(/tmp/real_windows.dll, 0x0002):
tried: '/tmp/real_windows.dll' (slice is not valid mach-o file)
読めません。「Mach-Oとして正しくない」と言われています。
ではこれを .dylib に改名したら?
$ cp real_windows.dll real_windows.dylib
$ ./app_runtime /tmp/real_windows.dylib
dlopen 失敗: dlopen(/tmp/real_windows.dylib, 0x0002):
tried: '/tmp/real_windows.dylib' (slice is not valid mach-o file)
まったく同じエラーでした。名前は1ミリも効いていません😅
★拡張子は、人間向けの目印でしかない
OSのローダが見ているのはファイルの先頭に書かれた形式の印です。世の中には3つあります。
Mach-O(macOS)/ELF(Linux)/PE-COFF(Windows)。
拡張子はその慣習的な名前づけであって、合っていなくても動くし、間違っていても直りません。
というわけで、冒頭の疑問への答えはこうなります。
| 疑問 | 答え |
|---|---|
Windowsで .dylib を扱えるか | 名前としては扱える(中身がPEなら読み込める)。Mac用のdylibは不可能 |
Macで .so を扱えるか | Mac用に作った .so なら扱える(上で実証)。Linux用の .so は不可能 |
静的ライブラリも同じ構図でした。.a の中を開くと、入っているのはそのOS向けの部品です。
$ ar x libmath.a && file mathlib.o
mathlib.o: Mach-O 64-bit object arm64
つまり「静的か動的か」はどのOSでも共通の考え方で、「どのOSで読めるか」は中身の形式が決めている。この2つは別の話でした。表を1枚にまとめると混ざってしまうところです。
ちなみに「読めない」と書きましたが、読めるようにする仕組みは存在します。Whisky や Game Porting Toolkit が、まさにPE形式をmacOS上で読み込んで動かすものでした。あれはこの形式の壁を翻訳して越えている、という位置づけになります。
中身を覗く:.a は書庫、.dylib は実行できないプログラム
それぞれ何者なのかを、コマンドで確かめます。
$ file out/libmath.a out/libmath.dylib
out/libmath.a: current ar archive random library
out/libmath.dylib: Mach-O 64-bit dynamically linked shared library arm64
静的ライブラリは「書庫」だと言われます。文字どおりで、中を開くとオブジェクトファイルがそのまま入っています。
$ ar t out/libmath.a $ ar t out/libmath_split.a
__.SYMDEF SORTED __.SYMDEF SORTED
mathlib.o ml_add.o
ml_fib.o
ml_version.o
ml_unused_big.o
左は全部を1つの .c にまとめた版、右は関数ごとにファイルを分けた版です。同じ4関数なのに、書庫の中身の数が違います。これが次章の伏線になります。
誰が何を持っていて、誰が何を欲しがっているかも見えます。
$ nm -gU out/libmath.dylib # このライブラリが外に出している関数
0000000000000378 T _ml_add
0000000000000380 T _ml_fib
00000000000003c8 T _ml_unused_big
00000000000003bc T _ml_version
$ nm -u out/app_dynamic # このアプリが外に求めている関数
_ml_add
_ml_fib
_ml_version
_printf
アプリ側に _ml_unused_big が無いのが分かります。呼んでいないので、求めてもいない。当然に見えますよね。ところが——
★★静的リンクは「関数」ではなく「ファイル」単位で取り込む
同じ4関数を、①1つのファイルにまとめた版と②関数ごとに分けた版で静的リンクしました。中身は同じなので、同じ大きさになるはずです。
ならないのです😳
| macOS | Windows | |
|---|---|---|
1つにまとめた .o / .obj | 50,136 バイト | 157,184 バイト |
| 関数ごとに分けた版 | 33,552 バイト | 140,800 バイト |
| 差 | 16,584 | 16,384 |
Windowsの差はぴったり 16,384 バイト。16 KiB です。仕込んだ表(int × 4096)の実体そのままでした。
理由はこうです。静的リンクが取り込む単位は「関数」ではなく「オブジェクトファイル」です。1つのファイルに全部入れてしまうと、そのうち1関数でも使えばファイルごと付いてきます。呼んでいない関数も、その関数が抱えている16 KiBの表も、まとめて。
分けておけば、要るファイルだけが取り込まれます。clang でも MSVC でも、既定では同じ挙動でした。
★「静的リンクは太る」は半分だけ本当
太るかどうかはライブラリの作り方で決まります。macOSでは、分割版(33,552)と動的版(33,560)が8バイト差まで縮みました。
「静的にすると大きくなる」と言われたら、そのライブラリがどう作られているかを見に行くのが正しい反応でした。
★分割できないなら、リンカに落としてもらう
ここで当然の疑問が出ます。他人が作ったライブラリは、分割し直せません。
使っていないものはリンカに捨ててもらえないのか。両OSで試しました。予想は2回とも外れました。
macOS:指定は1つだけでよかった
最初は「セクションに分ける指定(-ffunction-sections -fdata-sections)と、捨てる指定(-dead_strip)の両方が要る」と思っていました。切り分けたら違いました。
| コンパイル指定 | リンク指定 | サイズ(3回とも同値) | 未使用関数 |
|---|---|---|---|
| なし | なし | 50,136 | 残っている |
| なし | -Wl,-dead_strip | 33,544 | 落ちた |
-ffunction-sections | -Wl,-dead_strip | 33,544 | 落ちた |
-fdata-sections | -Wl,-dead_strip | 33,544 | 落ちた |
| 両方 | -Wl,-dead_strip | 33,544 | 落ちた |
セクション指定は1バイトも変えません。macOSのリンカはシンボル単位で「どこから辿り着けるか」を追えるので、あらかじめ切り分けておく必要がないのでした。
Windows:関数を分ける指定では、1バイトも減らなかった
Windowsには /Gy(関数を独立した塊にする)と /OPT:REF(使われていない塊を捨てる)があります。これで落ちるはず、と思って測りました。
| 指定 | 実行ファイル | .rdata | |
|---|---|---|---|
| A | 既定 | 157,184 | F000 |
| B | /Gy だけ | 157,184 | F000 |
| C | /Gy + /OPT:REF | 157,184 | F000 |
| D | 関数ごとに分割(参考) | 140,800 | B000 |
| E | /Gy + /Gw + /OPT:REF | 140,800 | B000 |
| F | /Gw + /OPT:REF(/Gy なし) | 140,800 | B000 |
Cは1バイトも減りませんでした。
理由が分かってみると当たり前でした。削りたいものが「関数」ではなく「データ」だったからです。
証拠は .text(コードが入る場所)です。A から F まですべて 15000 のまま動いていません。減ったのは .rdata(読み取り専用データ)の F000 → B000、つまり 16,384 バイトだけ。重りの正体は表そのものでした。
/Gyが独立した塊にするのは関数だけ。データはそのままなので、/OPT:REFに捨てる対象がない/Gwがデータを独立した塊にする。これを足して初めて落ちた- ★今回は
/Gyは無くてもよく、/Gw+/OPT:REFだけで分割版と1バイト差なく一致しました
★同じ「使っていないものを落とす」でも、落とせる粒度が違う
Windowsのリンカは「独立して捨ててよい」と印が付いた塊しか捨てられません。だから先に /Gy(関数)と /Gw(データ)で分けておく必要があります。
macOSのリンカはシンボル単位で辿れるので、その準備が要りません。
「同じことをする指定」に見えて、前提にしている仕組みが違いました。
| 関数を分ける | データを分ける | 落とす | |
|---|---|---|---|
| macOS | 不要 | 不要 | -Wl,-dead_strip |
| Windows | /Gy | /Gw | /OPT:REF |
| Linux | -ffunction-sections | -fdata-sections | -Wl,--gc-sections |
⚠ /OPT:REF はリリースビルドでは既定で有効ですが、増分リンクが有効だと無効化されます。今回は /INCREMENTAL:NO を併記しました。
起動にかかる時間
「動的リンクは起動が遅い」とよく言われます。どのくらい遅いのか測りました。
| macOS(3回の平均) | Windows(3ラウンドの中央値) | |
|---|---|---|
| 静的リンク | 1.517 ms | 6.218 ms |
| 静的(分割) | 1.494 ms | 6.278 ms |
| 動的リンク | 1.539 ms | 6.659 ms |
| 実行時リンク | 1.628 ms | 6.637 ms |
⚠ OSをまたいで絶対値を比べないでください。Windowsの数字にはPowerShell経由でプロセスを起動するぶんが乗っていて、測り方が揃っていません。順番と比率だけを読みます。
読めたことは2つです。
①「静的がいちばん速い」までは両OSで同じでした。ここは理屈どおりです。
②ところが、どこが同着になるかが逆でした。
- macOS:静的と動的の差は1.5%で、回によって順番が入れ替わります(ある回では静的1.536に対し動的1.530)。明確に遅いのは実行時リンクだけ(+7.3%)
- Windows:静的が明確に速い(+7.1%)。逆に動的と実行時が0.3%差で同着で、3ラウンドとも順番が入れ替わりました
どちらにしても数%の話です。1回起動するのに1〜7ミリ秒。「起動が遅いから静的にする」という理由づけは、この規模では弱いと言ってよさそうです。
★Windowsの .lib は2種類ある
冒頭で引っかかっていた話です。実測すると、はっきりしました。
| ファイル | 種類 | サイズ | 中身 |
|---|---|---|---|
math_static.lib | 静的ライブラリ | 18,340 | .text$mn(コード)+ .rdata 4004(例の16 KiBの表) |
math.lib | インポートライブラリ | 2,168 | .idata$2〜.idata$6 だけ |
8.5倍の差があります。そして決定的なのは、インポートライブラリのほうに.text が1バイトも無いことです。
Dump of file out\math.lib
File Type: LIBRARY
Exports
ordinal name
ml_add
ml_fib
ml_unused_big
ml_version
Summary
BA .debug$S
14 .idata$2
14 .idata$3
8 .idata$4
8 .idata$5
A .idata$6
名前と番号の対応表しか入っていません。コードはDLLの側にあります。この .lib は「DLLの入口の名簿」であって、ライブラリの本体ではないのでした。
だから .lib を渡されたとき、それだけで動くのか、DLLも一緒に要るのかは、拡張子では分かりません。サイズを見るか、中を覗くしかない。ここはWindowsのつまずきどころだと思います。
★★Windowsだけ、ヘッダが3つの場合を知っている必要がある
ここはeightが間違えた話です。
macOSとLinuxでは、static を付けない限り関数は外から見えます。ところがWindowsは「これは外に出す」と明示しないと出ません。そこで、こういう印を用意しました。
#if defined(_WIN32)
# if defined(MATHLIB_BUILD)
# define MLAPI __declspec(dllexport) // DLLを作る側
# else
# define MLAPI __declspec(dllimport) // DLLを使う側
# endif
#else
# define MLAPI
#endif
これで足りると思っていました。Windowsで組み立てたら、静的リンクが一度も通りませんでした。
main_s.obj : error LNK2019: 未解決の外部シンボル __imp_ml_add が関数 main で参照されました
out\app_static.exe : fatal error LNK1120: 3 件の未解決の外部参照
抜けていたのは「静的ライブラリを使う側」の場合でした。上の書き方だと、DLLを作るとき以外は必ず dllimport になります。すると main.c は __imp_ml_add(DLL経由の入口)を探しに行くのに、静的ライブラリに入っているのは素の ml_add なので、噛み合いません。
#if defined(_WIN32)
# if defined(MATHLIB_STATIC)
# define MLAPI // ★静的リンクで使う(これが抜けていた)
# elif defined(MATHLIB_BUILD)
# define MLAPI __declspec(dllexport) // DLLを作る
# else
# define MLAPI __declspec(dllimport) // DLLを使う
# endif
#else
# define MLAPI // macOS/Linux は全部これでよい
#endif
同じヘッダが3つの場合を知っていなければならない、というのがWindowsの姿でした。macOSとLinuxは1つも要りません。
もうひとつ外れた予想があります。「印を付けなければ何も出ないはず」と思って試したら、全部エクスポートされていました。
math_noexport.dll math.dll
1 ml_add 1 ml_add
2 ml_fib 2 ml_fib
3 ml_unused_big 3 ml_unused_big
4 ml_version 4 ml_version
ライブラリ側のソースも同じヘッダを読むので、印が dllimport に化けていました。つまり「持ち込むと宣言した関数を、その場で定義している」状態です。コンパイラはこう言いました。
warning C4273: 'ml_add': dll リンケージが一貫していません。
そして定義している側を優先して、結局エクスポート扱いにしていました。警告を読み飛ばすと、意図と逆のものができあがります。
本当に何も付けない版で作り直すと、期待どおりでした。
[note] NO import library - nothing exported
Dump of file math_truenoexport.dll
File Type: DLL
Summary ← エクスポートの節が丸ごと無い
そのDLLを使おうとすると、こうなります。
LINK : fatal error LNK1181: 入力ファイル 'math_truenoexport.lib' を開けません。
★「関数が見つからない」と怒られるのではなく、そもそも名簿ファイルが作られない。これがWindowsの答えでした。エラーメッセージだけ見ると原因が分かりにくいところです。
★「見つからない」ときの振る舞いがOSで違う
配布でいちばん転ぶのがここだと思います。動的ライブラリを消して、それぞれ実行しました。
macOS は、探しに行った場所を全部並べてくれます。
dyld[35485]: Library not loaded: @rpath/libmath.dylib
Referenced from: .../out/app_dynamic
Reason: tried: '.../out/libmath.dylib' (no such file),
'.../out/libmath.dylib' (no such file)
Windows は、リンクの仕方で出るものが変わりました。
| アプリ | math.dll を消したとき |
|---|---|
| 動的リンク(起動時) | ★終了コード 0xC0000135 だけ。標準出力にも標準エラーにも何も出ない |
| 実行時リンク | LoadLibrary 失敗: error 126(自分のコードで書いたメッセージ) |
| 静的リンク | 平然と動く(そもそもDLLを見ていない) |
動的リンクのほうは、プログラムが起動する前にOSのローダが落としているので、こちらのコードは1行も走りません。だからメッセージを出しようがないのでした。
★Windowsは、原因を教えてくれない
「配ったアプリが、相手の環境で何も言わずに起動しない」の正体は、たいていこれです。
自分でメッセージを出したいなら、実行時リンクにするしかありません。起動時リンクを選んだ時点で、この診断は手放しています。
結局どれを選ぶか
| こういうとき | 選ぶもの | 理由 |
|---|---|---|
| 配布物を1個で済ませたい | 静的リンク | 持っていくファイルが実行ファイルだけになる |
| 配ったあとに直したい | 動的リンク | ★ライブラリだけ差し替えれば追従する |
| OSの機能を使う | 動的リンク(選べない) | OS側が動的ライブラリで提供している |
| 後から機能を足せるようにしたい | 実行時リンク | プラグインの仕組みそのもの |
| 読み込み失敗を自分で扱いたい | 実行時リンク | ★起動時リンクだと何も言わずに落ちる(Windows) |
| 静的にしたらサイズが増えた | — | ★ライブラリの作り方を疑う。指定で落とせることも多い |
ひとつ言えるのは、起動時間を理由に選ぶ場面はあまり無さそうだということでした。差は数%で、しかもOSによってどこに出るかが変わります。
まとめ
- ★★拡張子は何も決めていない。macOSで作った
.dylibは.soに改名しても読めるが、本物のWindows DLLは.dylibに改名しても読めない(slice is not valid mach-o file)。決めているのは中身の形式(Mach-O / ELF / PE-COFF) - ★覚える軸は「いつ名前が解決されるか」の1つだけ。ビルド時(静的)・起動時(動的)・実行中(実行時)で、拡張子の違いはそこから派生している
- ★ライブラリだけ作り直すと、静的リンクだけが追従しない。1.0のまま変わらない様子がいちばん分かりやすい
- ★★静的リンクは「関数」ではなく「オブジェクトファイル」単位で取り込む。呼んでいない関数が16 KiBの表ごと付いてきた(Windowsの差はぴったり16,384バイト)
- ★「静的リンクは太る」はライブラリの作り方次第。分割版と動的版がmacOSで8バイト差まで縮んだ
- ★★分割できなくてもリンカに落としてもらえる。ただし前提が違う。macOSは
-dead_stripだけでよく、セクション指定は1バイトも変えない。Windowsは/Gy(関数)だけでは1バイトも減らず、/Gw(データ)が要った - ★今回の重りは丸ごとデータだった。
.textはA〜Fすべて動かず、減ったのは.rdataだけ - ★起動時間の差は数%。しかもどこが同着になるかがOSで逆(macOSは静的と動的、Windowsは動的と実行時)
- ★Windowsの
.libは2種類。静的ライブラリ18,340バイトに対しインポートライブラリは2,168バイトで、.textが1バイトも無い - ★★Windowsのヘッダは3つの場合を知っている必要がある(DLLを作る/DLLを使う/静的に使う)。macOSとLinuxは1つも要らない。ここを1つ落として
LNK2019になった - ⚠印を付けなければ出ない、とは限らない。
warning C4273を出したうえで、定義側を優先してエクスポートされていた - ★DLLが無いとき、Windowsの起動時リンクは何も言わない(終了コード
0xC0000135のみ)。macOSは探した場所を全部出す
調べる前は「拡張子がOSごとに違うだけだろう」と思っていました。測ってみると、同じ考え方で名前が違うだけのものと、仕組みから違うものがはっきり分かれていました。
前者が「静的と動的」、後者が「エクスポートの明示」と「落とせる粒度」です。前者だけ知っていれば大体足りますが、後者を知らないとWindowsで詰まります。今回eightが LNK2019 で止まったのが、まさにそれでした😅
そして予想が3回外れました。ヘッダの分岐・/Gy・macOSのセクション指定。3つとも「そういうものだろう」と思い込んでいた部分で、測らなければ気づかないままでした。思い込みの数だけ実験を用意しておくと、記事が勝手に面白くなるというのが今回の収穫です😊
使った材料は、Cのソース4本とビルドスクリプトだけです。手元のライブラリに差し替えれば、同じ表が作れると思います。
それでは、今回はここまで。最後までありがとうございました😊