前回の ライブラリファイルの正体 で、.a / .lib / .so / .dylib / .dll の違いを整理しました。軸は「いつ名前が解決されるか」の1つだけ、という話です。
今回はその各論で、DLLに絞ります。前回書いたこと(静的と動的の違い、.lib が2種類あること、エクスポートの明示が要ること)は繰り返しません。代わりに、作る側・使う側として知りたいことを実際に手を動かして確かめました。
いちばん知りたかったのは、これです。
この記事で確かめたかったこと
DLLは何の言語で作れるのか。言語ごとに特性はあるのか。そして1つのDLLに複数の言語を混在させられるのか。
結論から言うと、混在はできました。C++とRustが1つのDLLに同居して、C++からRustの関数を呼べます。
ただ、そこに至るまでに予想していなかったことが起きました。同じソース・同じ手順なのに、macOSとWindowsで壊れ方が正反対だったのです😳
この記事は実験が6つあり、途中でOSも言語も切り替わります。迷子にならないよう、章タイトルに「どの実験か・どのOSか」を書いてあります。「承前」と付いている章は、前の章の続きです。
【前置き】DLLとは何か、なぜDLLにするのか
ここは前回の総論と重なるので、手短にいきます。
DLL(Dynamic Link Library)は、実行時に読み込まれる部品です。EXEと同じPEという形式でできていますが、自分では起動できません。誰かに読み込まれて初めて動きます。
なぜわざわざ分けるのか。理由は5つあります。
| 理由 | 中身 |
|---|---|
| 共有できる | 複数のプログラムが同じDLLを使える。OSの機能がDLLで提供されているのもこれ |
| ★差し替えられる | 本体を作り直さずに、DLLだけ更新して直せる(前回、実際に動かして確かめました) |
| ★言語をまたげる | この記事の本題。C++で書いた本体からRustの部品を呼ぶ、といったことができる |
| 後から足せる | プラグインの仕組みそのもの |
| 切り離せる | ライセンスや配布の都合で、本体と別にしたいとき |
用語集
| 用語 | 平たく言うと | なぜそれが効くのか |
|---|---|---|
| エクスポート | 「この関数は外から呼んでいい」と名簿に載せること | ★Windowsは載せたものしか呼べない。載せ忘れると使えないDLLができる |
| 名前修飾(マングリング) | コンパイラが関数名を変形すること。引数の型などを名前に埋め込む | ★同じ名前で引数違いの関数(多重定義)を区別するため。ただし言語をまたぐ邪魔になる |
extern "C" | 「この関数はCの流儀で扱って」という指定。名前修飾をしない | ★これが言語をまたぐときの共通語 |
静的ライブラリ(.lib / .a) | コンパイル済みの部品を束ねた書庫 | 今回はRust側をこれにして、C++のDLLに取り込む |
.def ファイル | 「この関数を公開する」と別ファイルで指定する方法 | ★ソースに印を書けないとき(今回のRust側)に効く |
| DllMain | DLLが読み込まれた/外されたときに呼ばれる関数 | 初期化と後片付けの場所。★思ったより頻繁に呼ばれる |
| IL(中間言語) | C#などが吐く、機械語の手前の表現 | ★C#のDLLが「同じ拡張子なのに別物」である理由 |
【下調べ】DLLは何の言語で作れるのか
結論としては、Cの流儀で関数を公開できる言語なら、だいたい何でも作れます。
| 言語 | DLLを作れるか | 特徴 |
|---|---|---|
| C | ◎ | いちばん素直。名前修飾がない |
| C++ | ◎ | ★extern "C" を付けないと名前が変形する(後述) |
| Rust | ◎ | crate-type = ["cdylib"]。★#[no_mangle] の意味に注意(後述) |
| Zig / Go / Delphi など | ◯ | いずれもCの流儀で公開できる。今回は試していません |
| C#(.NET) | △ | ★できあがるものが別物(後述) |
ここで大事なのは、「何語で書いたか」ではなく「Cの流儀で公開しているか」だけが問題だということです。実際に見てみます。
【実験1・両OS】同じ関数をC / C++ / Rustで書き、名簿に載る名前を比べる
同じ add を、C / C++ / Rust で書いて、DLLの名簿に載る名前を並べました。
C++の行だけ、記号だらけになっているのが分かると思います。読み戻すとこうです。
__Z15cpp_add_mangledii → cpp_add_mangled(int, int)
__Z8cpp_overi → cpp_over(int)
__Z8cpp_overii → cpp_over(int, int)
引数の型まで名前に埋め込まれています。C++には同じ名前で引数違いの関数を作れる仕組み(多重定義)があるので、名前だけでは区別がつかないのです。だから区別できるように変形しています。
ところが、この変形の仕方に決まりがありません。
同じ cpp_add_mangled(int, int) | 出てくる名前 |
|---|---|
| macOS(clang) | __Z15cpp_add_mangledii |
| Windows(MSVC) | ?cpp_add_mangled@@YAHHH@Z |
★だから「C++のまま公開したDLL」は使いにくい
名前の付け方がコンパイラごとに違うので、同じコンパイラで作った側からしか呼べません。
公開する関数に extern "C" を付けると、名前が素のままになります。これが事実上の共通語で、「DLLは言語をまたげる」の正体はここでした。
【実験2・まずmacOS】C++とRustを1つのライブラリに同居させる
では本題です。1つのDLLの中に、C++で書いた部分とRustで書いた部分を同居させられるのか。
やり方はこうしました。
- Rust側を 静的ライブラリ(
crate-type = ["staticlib"])として作る - C++側のDLLをビルドするときに、それを一緒にリンクする
- できあがった1つのDLLから、両方の関数を呼んでみる
Rust側はこう書きます。#[no_mangle] と extern "C" を付けるだけです。
#[no_mangle]
pub extern "C" fn mix_rust_add(a: c_int, b: c_int) -> c_int { a + b }
#[no_mangle]
pub extern "C" fn mix_rust_who() -> *mut c_char {
CString::new("rust").unwrap().into_raw()
}
C++側からは、ただのC関数として宣言して呼びます。
// Rust側の関数を宣言(C ABI なのでそのまま呼べる)
extern "C" {
int mix_rust_add(int a, int b);
char *mix_rust_who(void);
}
// ★C++ から Rust を呼ぶ(同じライブラリの中で言語をまたぐ)
MIXAPI int mix_cpp_calls_rust(int a, int b) { return mix_rust_add(a, b) * 10; }
できあがったライブラリを読み込んで、全部の関数を叩いた結果です。
mix_cpp_add(2,3) = 5
mix_rust_add(2,3) = 5
mix_cpp_calls_rust(2,3) = 50 ← C++からRustを呼んでいる
mix_cpp_who() = cpp
mix_rust_who() = rust
mix_rust_panic(1) = -1 ← Rustのパニックを受け止めた
mix_cpp_throw(1) = -1 ← C++の例外を受け止めた
できました。しかも、Rustのパニックと C++ の例外が同じライブラリの中に同居しても、互いを壊しません。それぞれ自分の側で受け止められています。
ここまでは、両OSとも同じ結果でした。問題はこの先です。
【承前・同じソースをWindowsで】明示をやめたら、正反対に壊れた
上の実験には、実は「公開する関数を明示する」という一手間を入れてあります。それを外すとどうなるか、両OSで試しました。
正反対でした。
macOS:黙っていると出過ぎる
公開する関数を指定しないと、1,688個が外に出ました。意図したのは9個です。
残りの1,679個は、Rustの標準ライブラリの内部でした。名前を見ると、エラー処理やメモリ管理といった、外に出す理由のないものが並んでいます。
原因は、C++側に付けた「外に出さない」という指定(-fvisibility=hidden)が、自分のソースにしか効かないことでした。あとからリンクしたRustの書庫は素通りです。
公開リストを渡すと、こうなります。
| 外に出た関数 | サイズ | 動くか | |
|---|---|---|---|
| 明示しない | 1,688 個 | 1,393,456 バイト | 動く |
| 明示する | 9 個 | 417,520 バイト | 動く |
サイズが3.3分の1になりました。動作は変わりません。
Windows:黙っていると出なさすぎる
同じことをWindowsでやったら、逆でした。5個しか出ませんでした。
=== mix.dll ===
関数が見つかりません
=== mix_tidy.dll ===
mix_cpp_add(2,3) = 5
mix_rust_add(2,3) = 5
mix_cpp_calls_rust(2,3) = 50
名簿を覗くと、C++側の5個だけで、Rust側の4個が1つも載っていません。
Dump of file out\mix.dll
ordinal hint RVA name
1 0 00001000 mix_cpp_add
2 1 00001010 mix_cpp_calls_rust
3 2 00001030 mix_cpp_free
4 3 00001040 mix_cpp_throw
5 4 00001060 mix_cpp_who
理由は、Windowsが__declspec(dllexport) を付けたものしか公開しないからです。C++側にはこれを付けていましたが、Rust側には付けようがありません。
★Rustの #[no_mangle] は「DLLから公開する」という意味ではない
これは「名前を変形しないでくれ」という指定でしかありません。
名前は素のままになりますが、DLLの名簿に載せる話とは別です。eightはここを取り違えていました😅
Windowsでは .def ファイルで公開する関数を並べて解決しました。
EXPORTS
mix_cpp_add
mix_cpp_calls_rust
mix_rust_add
mix_rust_who
...
★理由は逆なのに、対処は同じだった
macOSは出過ぎるので絞る。Windowsは出なさすぎるので足す。
どちらも 「公開する関数を明示的に並べる」という同じ一手で正しくなります。
片方のOSだけで試していたら、まったく違う結論を書くところでした。
【承前・macOSで切り分け】「外に出る」とは、結局どういう状態なのか
ここまで「1,688個が外に出た」「5個しか出ない」と書いてきました。この「外に出る」という言い方が、実はあいまいでした。
正確には「DLLの名簿(エクスポート表)に載る」という意味です。載っていれば、他のプログラムから名前で呼べます。
では、載っていない関数はどうなっているのか。ライブラリの中から消えているのでしょうか。
確かめました。C++の5個だけを名簿に載せて、Rustの4個は載せない版を作ります。そのうえで、2つのことを試しました。
mix_rust_add を名前で引く : ★見つからない
mix_cpp_calls_rust(2,3) : 50 ★C++経由ならRustが動いている
mix_rust_add は名前で探しても見つかりません。ところが、C++側の関数を経由すると 50 が返ってきます。この関数は中で mix_rust_add(2,3) を呼んで10倍しているので、Rustのコードは中にいて、ちゃんと動いていることになります。
★「外に出る」=公開されているかどうか。中にあるかどうかとは別
名簿に載っていない関数も、ライブラリの中では普通に動いています。
外から名前で呼べないだけです。「隠す」であって「消す」ではありません。
これが分かると、前の2章の数字の意味もはっきりします。
- macOSの1,688個は「Rustの内部までが呼べる状態で公開されていた」ということ。中に入っていたこと自体は問題ではありません
- Windowsの5個は「Rustのコードは入っているのに、名簿に載っていないから呼べなかった」ということ
⚠ ひとつ補足すると、名簿に載せないことで消える場合もあります。どこからも呼ばれないと分かれば、リンカが捨てることがあるからです。macOSでサイズが3.3分の1になったのはこれで、「隠したから消えた」ではなく「隠したうえで、誰も使っていないと分かったから消せた」という順番でした。
【実験4・Windows】今度はC#で作ってみる ── 同じ拡張子でも中身が違う
C#でもクラスライブラリを作ると .dll ができます。これは同じものなのか、覗いてみました。
Dump of file out\cs\Greet.dll
File Type: DLL
Summary
2000 .rsrc
2000 .text
エクスポートの節が丸ごとありません。C++やRustのDLLなら「この関数を公開しています」という名簿が出てくるところに、何も無い。
そして、区画が .text と .rsrc の2つだけです。サイズも3,584バイトしかありません(混在DLLは216,576バイトでした)。
見分ける目印は、ヘッダのこの行でした。
2000 [ 48] RVA [size] of COM Descriptor Directory
これがあれば、中身は機械語ではなくIL(中間言語)です。PEという器には入っていますが、実行するには.NETの土台が要ります。他言語からそのまま呼べるものではありません。
「C#でもDLLは作れますか」への答えは、「作れるが、それは違うDLLです」になります。
【実験5・Windows】DLLの中身の話に戻る ── DllMainはいつ呼ばれるのか
DLLには DllMain という、読み込みと解放のときに呼ばれる関数を置けます。実際にいつ呼ばれるのか、記録を取りました。
スレッドを1本作って終わらせるだけのプログラムで試すと、こうなります。
DLL_PROCESS_ATTACH ← 読み込まれた (thread=7812)
DLL_THREAD_ATTACH ← スレッドが増えた (thread=25992)
DLL_THREAD_DETACH ← スレッドが終わった (thread=25992)
DLL_PROCESS_DETACH ← 解放される (thread=7812)
★プロセスの開始と終了だけではありません。スレッドが増減するたびに呼ばれます。
スレッドIDが違う(7812 と 25992)ので、真ん中の2回は別のスレッドから呼ばれたことも分かります。ここに重い処理を書くと、スレッドを作るたびに走ることになります。
【実験6・Windows】DLLを消して実行する ── 自分でエラーを扱えるか(p979の宿題)
前回の記事は、こういう形で終わっていました。
「DLLが見つからないとき、Windowsの起動時リンクは何も言わずに終了コードだけ返す。自分でメッセージを出したいなら実行時リンクにするしかない」
これが正確ではありませんでした。第3の選択肢があります。
/DELAYLOAD を付けると、書き方は普通のリンクのままで、実際の読み込みが初回呼び出しまで遅れます。同じソースを2通りでリンクして、DLLを消して実行しました。
=== greeter.dll があるとき ===
--- normal --- プログラムは起動しました / dl_hello() = hello from DLL
--- delayed --- プログラムは起動しました / dl_hello() = hello from DLL
=== greeter.dll を消したとき ===
--- normal --- (1行も出ない)
exitcode = -1073741515
--- delayed --- プログラムは起動しました
★DLLを読み込めませんでした(コード 0xC06D007E)
★でもプログラムは生きているので、ここで案内を出せます
exitcode = 1
普通のリンクは、printf の1行目すら出ません。プロセスが立ち上がる前にローダが落としているので、こちらのコードは1行も走らないからです。
遅延読み込みなら、起動してから、呼んだ瞬間に自分で捕まえられます。書き方はこうです。
__try {
printf("dl_hello() = %s\n", dl_hello());
} __except (EXCEPTION_EXECUTE_HANDLER) {
printf("★DLLを読み込めませんでした(コード 0x%08lX)\n", GetExceptionCode());
return 1;
}
★LoadLibrary を書かなくていいのが効きます。呼び出しは今までどおりで、リンクの指定を足すだけです。
【ここから整理】具体的な作り方(4通り)
| 方法 | 書くもの | 向いている場面 |
|---|---|---|
__declspec(dllexport) | ソースに直接印を付ける | 自分でソースを持っているとき。いちばん素直 |
.def ファイル | 公開する関数名を並べた別ファイル | ★ソースに印を書けないとき(今回のRust側がこれ) |
| CMake | add_library(name SHARED ...) | 複数OSで同じ書き方にしたいとき |
| Rust | crate-type = ["cdylib"] | Rustだけで完結するとき |
今回の混在では、.def が決め手でした。Rust側のソースに __declspec は書けないので、外から名前を並べるしかありません。
【整理】使い方(3通り)
| 読み込み方 | 書き方 | DLLが無いとき |
|---|---|---|
| 暗黙的リンク(起動時) | .lib を一緒に渡すだけ | ★何も言わずに落ちる |
| 遅延読み込み | 同上+/DELAYLOAD | ★自分で捕まえられる |
| 明示的リンク(実行時) | LoadLibrary + GetProcAddress | 自分で捕まえられる |
他の言語から呼ぶときは、実行時リンクの形になります。前々回のFlutter で dart:ffi から呼んだのも、Pythonの ctypes も、やっていることは同じです。
import ctypes
lib = ctypes.CDLL("./mix_tidy.dll")
print(lib.mix_cpp_add(2, 3)) # 5
print(lib.mix_cpp_calls_rust(2, 3)) # 50
★Cの流儀で公開してさえいれば、呼ぶ側の言語は何でもいいというのが、ここまでの結論です。
【整理】使うメリット・作るメリットと、その代償
| いいこと | 代償 | |
|---|---|---|
| 使う側 | 本体を作り直さずに更新できる/共有できる/後から機能を足せる | ★配布物が増える/★無いと起動しない/版がずれると壊れる(DLL地獄) |
| 作る側 | 境界を切れる/★他言語から使ってもらえる | ★一度公開した関数の形を変えられない/デバッグしにくい/公開範囲の管理が要る |
今回いちばん重いと感じたのは、作る側の「公開範囲の管理」でした。macOSでは黙っていると1,688個も出てしまい、Windowsでは黙っていると必要なものが出ない。どちらも放っておいて正しくなることがありません。
つまずき集
★① #[no_mangle] を「公開する」の意味だと思っていた
本文に書いたとおりです。名前を変形しないだけで、DLLの名簿に載せる話ではありませんでした。macOSでは何も指定しなくても外に出てしまうので、Windowsで試すまで気づけませんでした。
★② 「外に出さない」指定は、自分のソースにしか効かない
macOSで -fvisibility=hidden を付けていたのに1,688個も出たのがこれです。あとからリンクした静的ライブラリの中身は素通りします。
③ /EHsc を付け忘れた
warning C4530: C++ 例外処理を使っていますが、アンワインド セマンティクスは有効にはなりません。
C++の例外を使うなら付けるべき指定です。今回は例外の実験がたまたま通ってしまいましたが、警告が出た時点で直すべきでした。
④ Rustを取り込むと、Windowsのリンクで足りないものが出る
Rustの標準ライブラリが ws2_32.lib などを要求するので、リンクするときに並べておく必要があります。忘れると「未解決の外部シンボル」が大量に出ます。
⑤ 32bitと64bitは混ぜられない
当たり前のようでいて、DLLを配るときに必ず踏むところです。ビルドしたときのビット数が違うと、読み込みの時点で失敗します。前回の記事で見たとおり、Windowsはその理由を教えてくれません。
まとめ
- ★DLLは「Cの流儀で関数を公開できる言語」なら何でも作れる。何語で書いたかは問題ではない
- ★C++はそのままだと名前が変形する(
?cpp_add_mangled@@YAHHH@Z)。しかも変形の仕方がコンパイラごとに違うので、C++のまま公開したDLLは同じコンパイラからしか呼べない。extern "C"が共通語になっている理由 - ★★1つのDLLにC++とRustを同居させられる。C++からRustを呼べるし、Rustのパニックと C++ の例外が同居しても互いを壊さない
- ★★ただし「公開する関数を明示しない」ときの壊れ方がOSで正反対だった
- macOS:1,688個が外に出る(Rust標準ライブラリの内部まで漏れる)。動くが1.4MBに膨らむ
- Windows:5個しか出ない(Rust側が1つも出ない)。そもそも呼べない
- ★理由は逆なのに、対処は同じ——公開する関数を明示的に並べる
- ★「外に出る」=名簿に載る(公開される)であって、中に入っているかどうかとは別。名簿から外したRustの関数も、C++経由なら中で動いていた(
mix_cpp_calls_rust(2,3) = 50)。「隠す」であって「消す」ではない - ★Rustの
#[no_mangle]は「DLLから公開する」ではない。名前を変形しないだけ - ★macOSの
-fvisibility=hiddenは自分のソースにしか効かない。リンクした静的ライブラリは素通り - ★C#のDLLは別物。エクスポートの節が丸ごと無く、区画は2つだけ、3,584バイト。目印は
COM Descriptor Directory - DllMainはスレッドが増減するたびに呼ばれる。プロセスの開始・終了だけではない
- ★★
/DELAYLOADなら、DLLが無くても自分でエラーを扱える。前回「起動時リンクを選んだら診断は手放すしかない」と書いたが、正確ではなかった
始める前にいちばん知りたかった「混在できるのか」は、できるという答えでした。ただ、面白かったのはそこではありません。
同じソース・同じ手順なのに、OSによって正反対に壊れたことです。macOSだけで試していたら「Rustを混ぜると要らないものまで出るので絞りましょう」と書いたはずですし、Windowsだけなら「Rust側が公開されないので.defを書きましょう」と書いたはずです。どちらも間違ってはいませんが、半分です。
両方で試して初めて、「公開する関数を明示する」という一手が、理由の違う2つの問題を同時に片付けていると分かりました。2台で測る値打ちが、いちばん出た回だったと思います😊
使った材料はCとC++とRustのソースが数本ずつで、どれも短いものです。同じ形で手元のライブラリを混ぜてみれば、同じ表が作れると思います。
それでは、今回はここまで。最後までありがとうございました😊