Native AOTでC#をネイティブDLLとしてC/C++から呼ぶ知識マップ(知識グラフ)
C#のNative AOTはUnmanagedCallersOnly属性を付けたメソッドをCのエントリポイントとして公開でき、既存のC/C++アプリからin-processでC#のロジックだけを呼び出せる構成を実現する。ただし境界に出せるのはblittableな型に限られるため、実務ではstringやList<T>を直接見せずcreate/destroy/operateのようなハンドルベースのC APIに落とし、呼び出し規約を明示し、例外の代わりにstatus codeでエラーを表現する設計が推奨される。この構成はC/C++がC#ロジックを呼ぶ向きに適する一方、C#からCを呼ぶならP/Invoke、C#からC++を自然に扱うならC++/CLI、32bit/64bitやプロセス境界を越えたい場合やアンロードしたい場合はCOM/IPCの方が向く。ハンドルの採番を32bit幅で使い回すと一周時に無関係なインスタンスを指す危険があり、世代番号の埋め込みが対策になる。
Native AOT
.NETアプリを単一のネイティブバイナリへ事前コンパイルする発行方式。非対応リストにC++/CLIが明記されている。
UnmanagedCallersOnly属性
static メソッドに付与すると、そのメソッドをネイティブコードから直接呼び出せるCのエントリポイントとして公開できる.NETの属性。
blittable型
マネージドコードとアンマネージドコードの間でメモリ表現が同一で、変換なしにそのまま受け渡せる型。UnmanagedCallersOnlyを付けたメソッドのシグネチャに使える型はこれに限られる。
呼び出し規約(calling convention)
関数呼び出しで引数をレジスタとスタックのどちらでどう渡すか、戻り値をどこに置くか、呼び出し後にスタックを戻すのが呼ぶ側か呼ばれる側かを定める約束。
C ABI(Application Binary Interface)
コンパイル済みのバイナリ同士が実行時にどう噛み合うかを、呼び出し規約・型のレイアウト・名前とリンクという3つの約束で定める、機械語レベルの取り決め。
C言語のABI
Native AOT publish用のネイティブツールチェーン
Native AOTのpublishで必要になる、最後のネイティブリンクを行うためのプラットフォーム別ツール一式(Windowsでは Visual Studio の C++ワークロード、Linuxではclangなど)。
C++ワークロード
.NET(Core以降)
.NET Frameworkの後継となる、クロスプラットフォーム対応の.NETランタイム(.NET 5以降)。
モダンな.NET
C/C++からC#ロジックをin-processで呼ぶ要件
既存のネイティブアプリ本体は残したまま、判定ロジックや文字列処理などの一部だけをC#に寄せ、同一プロセス内から呼び出したいという要件。
P/Invoke
C#などの.NETコードからアンマネージドなC関数群を呼び出すための、.NETの標準的な相互運用機構。
C#からCの関数群を呼ぶ要件
C#のコードから既存のアンマネージドなC関数群を呼び出したいという要件。
C++/CLI
ネイティブC++とマネージド.NETの両方を1つのプロジェクトで扱えるMicrosoftの言語拡張。ネイティブDLLとC#の間に薄いラッパー層を作るのに使われる。
C++/CLIラッパー
C#からC++ライブラリを自然に扱う要件
C++のクラスや所有権、std::wstringのようなC++固有の型・例外をC#側で自然に扱いたいという要件。
COM(Component Object Model)
プロセス内外・言語をまたいでオブジェクトを利用可能にするMicrosoftのバイナリ相互運用規格で、32bit/64bitやプロセス境界を越える橋渡しにも使われる。
32bit/64bitまたはプロセス境界を越える要件
32bitプロセスと64bitプロセスの間、または別プロセスとの間でコードやデータをやり取りしたいという要件で、in-processのDLL呼び出しだけでは越えられない。
プラグインを後でアンロードしたい要件
一度読み込んだネイティブ共有ライブラリを、プロセスを終了せずに後からメモリから解放したいという要件。
エラーをstatus codeで返す設計
ネイティブ境界を越える関数の戻り値をエラーコードとし、実データはoutバッファやポインタ引数で返す、managed例外を境界に漏らさないための設計方針。
error code + out引数
ハンドルベースのC API設計(create/destroy/operate)
ネイティブ側にはintptr_t相当のハンドルだけを見せ、状態本体は.NET側で保持しながらcreate/add/get/destroyのようなフラットな関数に分解するAPI設計パターン。
create/destroy/operateパターン
ハンドル採番カウンターのオーバーフロー
long型の採番カウンターをnint(32bitビルドでは32bit幅)へキャストする際に上位ビットが黙って捨てられ、採番が2^32を超えると値が一周する現象。
ハンドル一周による衝突
採番用カウンターが上限に達して同じハンドル値に戻ってきたとき、辞書の重複キー検査をすり抜けて、ネイティブ側が持ったままの古いハンドルが無関係な新しいインスタンスを指すようになる状態。
世代番号を埋め込んだハンドル設計
ハンドルの下位ビットを連番、上位ビットを世代番号とし、destroyのたびに世代を進めることで、同じ連番が巡ってきても値が一致しないようにするハンドル設計。
dumpbin
DLLやライブラリのエクスポート・インポート情報を表示できる、Visual Studio付属のコマンドラインツール。
インポートライブラリ(.lib)
DLLの関数を実行時のLoadLibrary/GetProcAddressを介さず、リンク時に静的に解決できるようにするための.libファイル。
Native AOTで発行するC#のクラスライブラリは、UnmanagedCallersOnlyを付けたメソッドをCのエントリポイントとして公開できる。
UnmanagedCallersOnlyを付けたメソッドのシグネチャには、blittableな型しか使えない。
呼び出し規約はUnmanagedCallersOnlyのCallConvsパラメーターで、cdeclなどを明示的に固定できる。
C ABIは呼び出し規約・型のレイアウト・名前とリンクという3つの約束から成り、呼び出し規約はその構成要素の1つ。
exportされるのはあくまでCの関数入口であり、境界面は.NETではなくC ABIとして設計する必要がある。
Native AOTのpublishには、ネイティブのツールチェーンが別途必要で、無いとC#のコンパイルではなく最後のネイティブリンクの段階で失敗する。
Native AOTは.NET SDKのpublish機能であり、.NET(Core以降)であることを前提とする。
C/C++からC#の処理をin-processで呼びたいという要件では、Native AOT + UnmanagedCallersOnlyが有力な選択肢になる。
C#からCの関数群を呼ぶ場合は、向きが素直なP/Invokeが自然な選択肢になる。
C#からC++ライブラリを自然に扱いたい場合は、C++の型や所有権、例外を吸収しやすいC++/CLIが向く。
32bit/64bitやプロセス境界を越えたい場合は、in-process DLLでは越えられないためCOM/IPCの方が筋がよい。
32bit/64bitを橋渡ししたい、またはプロセス境界を越えたい場合には、in-process DLLであるNative AOTの構成は向かない。
Native AOTで発行した共有ライブラリはアンロード前提では使わないほうがよい。
managed例外をそのまま呼び出し元へ漏らさず、戻り値をstatus code、実データをoutポインタ引数で返す設計が、UnmanagedCallersOnlyの境界では実務上推奨される。
create/destroy/operateのようなフラットなC APIに落として寿命管理とエラーコードを明示する設計が、UnmanagedCallersOnlyの境界では実務上安定する。
32bitビルドで採番カウンターが2^32を超えて一周すると、破棄済みの辞書キーに同じハンドル値が戻ってきて再利用され、無関係な新しいインスタンスを指すようになり得る。
下位を連番、上位を世代番号とし destroy のたびに世代を進めることで、同じ連番が巡ってきても値が一致しなくなり衝突を防げる。
Native AOTで発行したDLLに目的の関数が実際にexportされているかは、dumpbin /exportsで確認できる。
呼び出し側を静的リンクにしたい場合のインポートライブラリは、Native AOTがpublishしたDLL(またはそのexport名一覧)を前提に作成する。ただしインポートライブラリ一般がNative AOTを前提とするわけではなく、この対応関係はNative AOTが発行したDLLに対して作成する場合に限られる。
「C#からCの関数群を呼ぶ」場面ではP/Invokeが有力候補として挙げられており、Native AOT + UnmanagedCallersOnlyはこれとは逆向き(C/C++からC#)の場面のための構成である。
C++のクラスやstd::vectorや例外をそのまま扱いたい場合は、C++/CLIかネイティブ側ラッパーのほうが自然で、Native AOTのC ABI越しの構成には向かない。
ハンドルベースのAPI設計で採番カウンターを32bit幅のnintへキャストすると、32bitビルドでは採番が一周し得る。