前回の ライブラリファイルの正体 で、.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側)に効く
DllMainDLLが読み込まれた/外されたときに呼ばれる関数初期化と後片付けの場所。★思ったより頻繁に呼ばれる
IL(中間言語)C#などが吐く、機械語の手前の表現★C#のDLLが「同じ拡張子なのに別物」である理由

【下調べ】DLLは何の言語で作れるのか

結論としては、Cの流儀で関数を公開できる言語なら、だいたい何でも作れます

言語DLLを作れるか特徴
Cいちばん素直。名前修飾がない
C++extern "C" を付けないと名前が変形する(後述)
Rustcrate-type = ["cdylib"]。★#[no_mangle] の意味に注意(後述)
Zig / Go / Delphi などいずれもCの流儀で公開できる。今回は試していません
C#(.NET)できあがるものが別物(後述)

ここで大事なのは、「何語で書いたか」ではなく「Cの流儀で公開しているか」だけが問題だということです。実際に見てみます。

【実験1・両OS】同じ関数をC / C++ / Rustで書き、名簿に載る名前を比べる

同じ add を、C / C++ / Rust で書いて、DLLの名簿に載る名前を並べました。

同じ関数でも、外に出る名前が違う 「言語をまたげる」の正体は、名前をCの形に揃えていることだけ 書き方 macOS(clang) Windows(MSVC) C _c_add c_add C++ そのまま __Z15cpp_add_mangledii ?cpp_add_mangled@@YAHHH@Z C++ 多重定義(1) __Z8cpp_overi ?cpp_over@@YAHH@Z C++ 多重定義(2) __Z8cpp_overii ?cpp_over@@YAHHH@Z C++ extern "C" _cpp_add_plain cpp_add_plain Rust #[no_mangle] _rust_add rust_add ★引数まで名前に埋め込まないと、多重定義を区別できない(緑は素の名前のまま)。 ★★同じC++でもコンパイラが違えば名前が違う = C++のまま公開した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で書いた部分を同居させられるのか。

やり方はこうしました。

  1. Rust側を 静的ライブラリcrate-type = ["staticlib"])として作る
  2. C++側のDLLをビルドするときに、それを一緒にリンクする
  3. できあがった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で試しました。

公開する関数を明示しないと、OSで正反対に壊れる 同じソース・同じ構成で、1つのライブラリにC++とRustを同居させた macOS ★黙っていると出過ぎる 明示しない 1,688 個 が外に出た 1,393,456 バイト 動く 明示する 9 個 が外に出た 417,520 バイト 動く Windows ★黙っていると出なさすぎる 明示しない 5 個 が外に出た 211,456 バイト ★使えない 明示する 9 個 が外に出た 216,576 バイト 動く ★macOSはRust標準ライブラリの内部まで漏れて1,688個。動くが1.4MBに膨らむ。 ★Windowsは dllexport が付いたC++の5個だけ。Rust側が1つも出ず、そもそも呼べない ★理由は逆なのに、対処は同じ ── 公開する関数を明示する。
同じソース・同じ構成なのに、黙っていたときの壊れ方が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 ができます。これは同じものなのか、覗いてみました。

同じ「.dll」でも、C#のものは中身が違う dumpbin で覗くと、何が入っていないかで分かる C++ / Rust の DLL セクション .text / .rdata / .data / .pdata / .reloc … エクスポート表 ある(関数の名簿) 中身 機械語 サイズ 216,576 バイト C#(.NET)の DLL セクション .text / .rsrc の2つだけ エクスポート表 ★丸ごと無い 中身 IL(中間表現) サイズ 3,584 バイト ★見分ける目印は COM Descriptor Directory。これがあれば中身はILで、他言語からそのままは呼べない。
同じ「.dll」でも、C#のものは中身がILで、エクスポートの節が無い
Dump of file out\cs\Greet.dll
File Type: DLL

  Summary
        2000 .rsrc
        2000 .text

エクスポートの節が丸ごとありません。C++やRustのDLLなら「この関数を公開しています」という名簿が出てくるところに、何も無い。

そして、区画が .text.rsrc2つだけです。サイズも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の選択肢があります。

DLLが無いとき、自分でエラーを扱えるか p979で「起動時リンクは何も言わない」と分かった。第3の選択肢を試す 普通の暗黙的リンク プロセスが立つ前にローダが落とす (1行も出ない) exitcode = -1073741515 (0xC0000135) /DELAYLOAD を付ける 呼んだ瞬間まで読み込みを遅らせる プログラムは起動しました ★DLLを読み込めませんでした(0xC06D007E) ★でもプログラムは生きているので、案内を出せます ★書き方は暗黙的リンクのまま(LoadLibrary を書かなくてよい)。リンク指定を足すだけ。 「起動時リンクを選んだら診断は手放すしかない」は正確ではなかった。
DLLが無いとき、遅延読み込みなら自分でエラーを扱える

/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側がこれ)
CMakeadd_library(name SHARED ...)複数OSで同じ書き方にしたいとき
Rustcrate-type = ["cdylib"]Rustだけで完結するとき

今回の混在では、.def が決め手でした。Rust側のソースに __declspec は書けないので、外から名前を並べるしかありません。

【整理】使い方(3通り)

読み込み方書き方DLLが無いとき
暗黙的リンク(起動時).lib を一緒に渡すだけ★何も言わずに落ちる
遅延読み込み同上+/DELAYLOAD自分で捕まえられる
明示的リンク(実行時)LoadLibraryGetProcAddress自分で捕まえられる

他の言語から呼ぶときは、実行時リンクの形になります。前々回のFlutterdart: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のソースが数本ずつで、どれも短いものです。同じ形で手元のライブラリを混ぜてみれば、同じ表が作れると思います。

それでは、今回はここまで。最後までありがとうございました😊