C#でアンマネージドライブラリの読み込みをフックしてパス解決を好き放題する

に公開

C#アプリケーションでアンマネージドライブラリ(ネイティブライブラリ)を利用する際、DLLの解決で困ったことはありませんか?
例えば、依存DLLが標準の検索パスにない場合や、アプリケーションの実行ディレクトリとは異なる場所に配置されている場合、予期せぬDllNotFoundExceptionに悩まされることがあります。

私は最近、AviUtl2のプラグイン開発に取り組んでいて、まさにこの問題に直面しました。
C#で画像を読み込むためにSkiaSharpを使っているのですが、SkiaSharpは各プラットフォームに依存するネイティブライブラリを持っています。AviUtl2の実行ファイルとプラグインの配置ディレクトリが異なるため、プラグインがロードされた後、SkiaSharpの依存ライブラリが解決できないという問題が発生したのです!

しかし、aviutl2.exeが存在するディレクトリと、Pluginを配置するディレクトリは別の場所にあるため、パスを通さない限りSkiaSharpの依存ライブラリが解決できないのです…!

この問題に対処するために、「UnmanagedDllResolveHelper」という、アンマネージドDLLの読み込みに関する問題を解決するC#ライブラリを作成しました。https://github.com/yamachu/UnmanagedDllResolveHelper

このライブラリの実装を元に、各プラットフォームでの解決を深掘っていきましょう。

UnmanagedDllResolveHelperでの要素技術

今後同様の処理を自作したくなりそうな人に向けて、ぐぐると良さそうキーワードを載せておきます。

  • AssemblyLoadContext.ResolvingUnmanagedDllイベント
    • 現在のAssemblyLoadContextで既存のライブラリの解決ロジックが失敗した際に発火するイベント
    • このイベントをハンドルして、内部でパス解決をしている
  • libdl (Linux/macOS) / kernel32 (Windows)
    • 関数ポインタなどからモジュールハンドルを取得する関数
    • モジュールハンドルから、読み込まれたモジュールのパスを取得している

AssemblyLoadContextでのネイティブライブラリの読み込みアルゴリズムにResolvingUnmanagedDllが触れられていますので、併せてごらんください。

https://learn.microsoft.com/ja-jp/dotnet/core/dependency-loading/loading-unmanaged?wt.mc_id=DT-MVP-5002987

仕組みの解説

UnmanagedDllResolveHelperの中核となる仕組みは、AssemblyLoadContext.ResolvingUnmanagedDllイベントの活用と、プラットフォームごとのライブラリハンドル取得APIの利用にあります。

  1. AssemblyLoadContext.ResolvingUnmanagedDll

.NET Core 3.0以降では、AssemblyLoadContextクラスが導入され、アセンブリやネイティブライブラリのロード方法をより細かく制御できるようになりました。
特に、AssemblyLoadContext.ResolvingUnmanagedDllイベントは、ネイティブライブラリの解決が試みられた際に発生します。

このイベントの第2引数にロードしようとしているライブラリの名前が渡されます。
このハンドラ内で、そのライブラリ名を元に対応するIntPtr(ネイティブライブラリのOSハンドル)を返すことで、ネイティブライブラリの読み込みをフックすることが出来ます。

UnmanagedDllResolveHelperでは、このライブラリの名前と以下で説明するパス解決手法を用いて、対象のライブラリのフルパスを取得し、IntPtrを返しています。

  1. libdlやkernel32でのライブラリのパス解決

本ライブラリ(以下Helper)は、HelperをResolvingUnmanagedDllで使用しているアセンブリが配置されているディレクトリと同一のディレクトリを解決のパスとして定めています。
それを実現するための関数をプラットフォームに分けて解説します。

Windowsの場合: GetModuleHandleExおよび GetModuleFileName

Windowsでは、モジュールハンドル取得にGetModuleHandleEx関数が利用されます。
この関数は、指定されたモジュール名またはアドレスに対応するモジュールハンドルを取得します。
このアドレスは、UnmanagedDllResolveHelper自体がexportしている__DONT_CALL_UnmanagedDllResolveHelper_Platform_Windows__みたいな名前のシンボルの関数ポインタを使用しています。
これにより、このHelperを参照しているアセンブリのモジュールハンドルが取得できるのです。

さらに取得したモジュールハンドルをもとに、GetModuleFileNameでモジュールを含むファイルの完全修飾パスを取得しています。

Linux/macOSの場合: dladdr

LinuxやmacOSのようなUnix系OSでは、dladdr関数が利用されます。
dladdrは、指定されたアドレスがどの共有ライブラリに属しているかに関する情報を提供します。
この関数からはDl_infoという構造体を取得することが出来て、その中にdli_fnameという共有ライブラリのパスを示すメンバがあります。
これを元に、モジュールを含むファイルの完全修飾パスを取得しています。

  1. ネイティブライブラリのロード

2で得られたロード対象のライブラリのフルパスが取得できたため、そのディレクトリ名を抽出し、ResolvingUnmanagedDllイベントで渡されたライブラリ名を、プラットフォームに応じた拡張子をつけてファイルの存在有無を確認します。
存在が確認できたらNativeLibraryクラスを使用して、ネイティブライブラリのハンドルを取得しています。

まとめ

本記事ではUnmanagedDllResolveHelperの実装をもとに、ネイティブライブラリのパス解決のエッセンスを解説しました。
AssemblyLoadContext.ResolvingUnmanagedDllイベントを上手く利用すれば、DLLのパス解決のカスタマイズが出来ることが伝わったかと思います。

このライブラリでは同一のディレクトリしか読み込みの対象としていませんが、この仕組みを真似することで、より柔軟な解決ができると思います。
ぜひこのようなパス解決が必要なケースが発生した時にこの記事を思い出して、挑戦してほしいなと思います。


おまけ

実装するにあたりハマった箇所などをメモしておきます

  • NativeAOTでpublishする際、net8.0がTargetFrameworkだとLinux環境で読み込み時クラッシュする
  • AppContext.~によるパスの取得
    • ドキュメントに空文字で返るよって書いてあった

Discussion