C#からWin32 APIを安全に呼ぶ ── P/Invoke実務ガイド(DllImport / LibraryImport / CsWin32)

· · P/Invoke, DllImport, LibraryImport, CsWin32, C#, .NET, Win32, SafeHandle, ネイティブ相互運用, Windows開発, 技術相談

当ブログではこれまで、C++/CLIラッパーとP/Invokeの使い分けC# Native AOT DLLをC/C++から呼ぶ方法32bitアプリから64bit DLLを呼ぶCOMブリッジWindows DLL名前解決の仕組みなど、ネイティブ相互運用にまつわる記事を何本も書いてきました。ところが、その土台にある P/Invoke そのものをまとめて扱った記事がまだありませんでした。

P/Invoke は「DLL の関数を extern 宣言すれば呼べる」という手軽さの一方、文字列マーシャリング、ハンドルの寿命、エラーコードの取得、構造体レイアウトのどこかで一度は事故る技術でもあります。この記事では、.NET 7 以降の既定である LibraryImport を軸に、実務で押さえるべきポイントを一通り整理します。

1. まず結論

  • .NET 7 以降なら DllImport ではなく LibraryImport を既定にします。 コンパイル時にマーシャリングコードを生成するため、Native AOT・トリミングに対応し、実行時の IL スタブ生成コストがなく、生成コードをデバッガーでステップ実行できます。アナライザー SYSLIB1054DllImport の書き換えどころを教えてくれます。12
  • Win32 API を手で書き起こすなら CsWin32 を検討します。 NativeMethods.txt に呼びたい関数名を並べるだけで、公式の Win32 メタデータから LibraryImport シグネチャ・定数・構造体を生成してくれます。3
  • 文字列は StringMarshalling を明示し、StringBuilder は避けます。 StringBuilder マーシャリングは常にネイティブバッファーのコピーを伴い、非効率なうえに終端処理を誤りやすい機構です。4
  • ハンドルは生の IntPtr ではなく SafeHandle 派生クラスで持ちます。 GC によるハンドルの早期解放・二重解放・「リサイクル攻撃」を防ぐ、.NET のネイティブ相互運用の基本作法です。56
  • SetLastError = true を付けたら、呼び出し直後に Marshal.GetLastPInvokeError() を読みます。 他の管理コードの実行がエラーコードを上書きする前に確保する必要があります。7
  • 構造体は LayoutKind.Sequential を既定にし、Pack を明示するかどうかを意識します。 Pack = 0(既定)の実際のレイアウトは C++ コンパイラーの /Zp 既定値(x86/ARM/ARM64は8、x64/ARM64ECは16)とは別物で、.NET Framework と .NET 5+ の間でも変わり得ます。「既定なら合っているはず」という思い込みは禁物です。8
  • コールバック(デリゲート)は、ネイティブ側が使い終わるまで GC に回収されないよう寿命を管理します。 static フィールドで保持するか GC.KeepAlive を使い、可能なら UnmanagedCallersOnly を優先します。9
  • P/Invoke・C++/CLI ラッパー・COM 相互運用は競合ではなく住み分けです。 素直な C インターフェースなら P/Invoke、C++ のクラスや所有権・例外が絡むなら C++/CLI、プロセス境界(32/64bit ブリッジなど)を越えるなら COM、という判断軸は第 10 章の表にまとめます。

2. DllImport と LibraryImport ── どちらを使うか

DllImport は昔からある仕組みで、実行時にランタイムがマーシャリング用の IL スタブを生成し、JIT でコンパイルしてから呼び出します。生成が実行時である以上、Native AOT やトリミングのようにアセンブリを事前コンパイルする構成とは相性が悪く、生成コストそのものもゼロではありません。1

LibraryImport は .NET 7 で追加されたソースジェネレーターで、partial メソッドに対してコンパイル時にマーシャリングコードを生成します。生成されたコードは C# のソースとして存在するため、デバッガーでステップ実行でき、シグネチャの誤りはビルドエラーとして早期に検出されます。1

using System.Runtime.InteropServices;

internal static partial class NativeMethods
{
    [LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
    internal static partial string ToLower(string str);
}

この string 戻り値には見落としやすい前提があります。マーシャラーは、戻ってきたポインターが指す文字列をコピーした後、そのメモリを常に解放しようとします。 Windows 上では CoTaskMemFree が使われるため、ネイティブ側が CoTaskMemAlloc 以外(静的バッファー、mallocnew[] など、C API では珍しくない実装)でそのポインターを確保していると、マーシャラーが誤ったアロケーターでメモリを解放してしまい、ヒープ破損やクラッシュにつながります。10 相手のヘッダーやドキュメントで CoTaskMemAlloc 互換の確保を明記していない限り、戻り値は string ではなく IntPtr で受け取り、対応する解放関数(または相手が要求する解放手順)を自分で呼ぶ設計にしてください。呼び出し側でバッファーを確保して渡す(前述の StringBuilder の代替である文字配列や、後述の [Out] バッファーパターン)ほうが、そもそもこの種の所有権の曖昧さを持ち込まずに済みます。

DllImport からの主な差分は次のとおりです。11

  • CharSet は廃止され、StringMarshalling(Utf16 / Utf8 / カスタム)に置き換わりました。ANSI は廃止され、UTF-8 が第一級のオプションになっています。
  • CallingConventionUnmanagedCallConvAttribute に置き換わりました。
  • ExactSpellingPreserveSig には相当するものがありません。エントリポイント名は常に正確な綴りを指定し、戻り値の変換は常に素直に行われます。
  • クラスと呼び出し先メソッドの両方を partial にする必要があり、プロジェクトには AllowUnsafeBlocks が必要です。

DllImport が今も必要になるのは、LibraryImport が未対応の設定(たとえば一部の MarshalAs 指定)を使う場合です。アナライザーがサポート外の設定を使おうとするとエラーで教えてくれるので、まず LibraryImport を書いてみて、弾かれたら DllImport に戻す、という進め方が現実的です。11

3. CsWin32 ── シグネチャを書き起こさない選択肢

Win32 API を 1 つずつ手で DllImport/LibraryImport 宣言していると、パラメーターの型・定数値・構造体のフィールド順を間違えるリスクが積み重なります。CsWin32(Microsoft.Windows.CsWin32)は、公式が提供する Win32 API のメタデータから、呼びたい関数のシグネチャ・関連する定数・構造体を自動生成するソースジェネレーターです。3

使い方は単純で、プロジェクトに NuGet パッケージを追加し、NativeMethods.txt というテキストファイルに呼びたい関数名を列挙するだけです。

GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle

ビルド時に、これらの関数のP/Invokeシグネチャ(戻り値・パラメーター・SetLastError 指定を含む)が生成されます。既定では従来の DllImport ベースで生成される点に注意してください。Native AOT・トリミングを見据えるなら、NativeMethods.jsonallowMarshaling: false を指定することで LibraryImport ベースのソース生成コードに切り替えられます。3 HANDLE は適切な SafeHandle 派生型として、文字列は正しい CharSet/StringMarshalling で出力されるため、手書きで起きがちな CharSet の取り違えや構造体フィールドの順序ミスをそもそも作り込めません。

C++/CLI ラッパーが効く場面で書いたとおり、C++ のクラス・所有権・例外が絡む複雑な DLL には薄いラッパーを挟むのが有効ですが、相手が素直な Win32 API(あるいはそれに準じた C インターフェースの DLL)なら、CsWin32 でシグネチャを自動生成するのが最短で事故が少ない経路です。自社の DLL に CsWin32 は使えませんが、その場合も生成されたコードの書きぶりをテンプレートとして参考にできます。

4. 文字列マーシャリングの罠

C#・VB・F# コンパイラーは、CharSet を明示しない P/Invoke 宣言に既定で CharSet.None を割り当てます。CharSet.None の実際の動作は CharSet.Ansi と同じで、Windows 上では非 Unicode(ローカライズされたコードページ)としてマーシャリングされます。呼び出す Win32 API が Unicode 版(W サフィックス)を前提にしている場合、この既定値のまま呼ぶと文字化けやマルチバイト文字の欠落が起きます。12

LibraryImport では StringMarshalling.Utf16 を明示するのが基本形です。ANSI という選択肢そのものが廃止されているため、DllImport 時代にありがちだった「既定値に任せて意図せず ANSI になる」という事故が構造的に起きにくくなっています。11

もう一つの罠が StringBuilder パラメーターです。「ネイティブ側が文字列バッファーを書き込んで返す」タイプの API でよく使われますが、StringBuilder のマーシャリングは常にネイティブバッファーへのコピーを発生させ、ToString() でさらにもう 1 回アロケーションが走ります。バッファーが [Out](既定)なら、呼び出しのたびに複数回のアロケーションが積み重なる非効率な機構です。加えて、返ってきたバッファーが NUL 終端されていない、あるいは二重 NUL 終端の文字列の場合に誤動作しやすいという癖もあります。頻度の高い呼び出しでは ArrayPool<char> からの文字配列を使うほうが安定します。4

[Out] string パラメーターも避けるべき指定です。文字列がインターン化されたものだった場合、ランタイムを不安定にする可能性があります。4

5. ハンドルの寿命管理 ── SafeHandleを使う理由

ファイルハンドル・レジストリキー・デバイスハンドルのようなネイティブリソースを生の IntPtr として持つのは、.NET のネイティブ相互運用では避けるべき設計です。理由は 3 つあります。5

  • GC によるハンドルの早期解放。 ファイナライザーを実装したクラスがハンドルを IntPtr フィールドに持っていた場合、P/Invoke 呼び出しの最中に GC がそのオブジェクトを回収してハンドルを閉じてしまう競合が起こり得ます。
  • ハンドルのリサイクル攻撃。 Windows はハンドル値を積極的に再利用します。閉じたはずのハンドル値が別のリソースに再割り当てされている状態で古い IntPtr を使い続けると、無関係なリソースを操作してしまう深刻な事故につながります。
  • 非同期例外による漏れ。 スレッド中断のような非同期な割り込みが、ハンドルの取得とフィールドへの格納の間に発生すると、ハンドルリークが起こり得ます。

SafeHandle はこれらを解決するために設計された抽象クラスです。CriticalFinalizerObject を継承しており、AppDomain の異常終了時にも解放処理が確実に実行されることが保証されています。P/Invoke 呼び出しはハンドルの参照カウントを自動的に増減させるため、呼び出し中にハンドルがリサイクルされることもありません。5

自作の場合は Microsoft.Win32.SafeHandles 名前空間の SafeHandleZeroOrMinusOneIsInvalid などを継承し、ReleaseHandle() をオーバーライドします。ReleaseHandle() は「失敗しないこと」が前提の制約実行領域で動くため、複雑なロジックを書かず、単純な解放 API 呼び出しに留めるのが定石です。ファイナライザーを自前で書く必要はありません(むしろ避けるべきです)。6

6. エラー処理 ── SetLastErrorとGetLastPInvokeError

Win32 API の多くは、失敗時に SetLastError でスレッドローカルなエラーコードを設定し、呼び出し側は GetLastError で読み取ります。P/Invoke でこれを扱うには、DllImportAttribute.SetLastError(LibraryImport でも同名のプロパティ)を true にします。13

[LibraryImport("kernel32", EntryPoint = "SetCurrentDirectoryW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetCurrentDirectoryW(string path);

ここで注意すべき点が 2 つあります。

  • エラーコードは呼び出し直後に読む。 .NET(.NET Framework を除く)では、SetLastError = true の P/Invoke を呼ぶたびにエラー情報がいったんクリアされ、その呼び出し 1 回分の結果だけが保持されます。ログ出力や別の API 呼び出しを挟むと上書きされて失われるため、失敗を検知したその場で値を取得してください。13
  • Marshal.GetLastWin32Error() ではなく Marshal.GetLastPInvokeError() を使う。 .NET 6 以降ではこの 2 つは機能的に同一ですが、後者はクロスプラットフォームな意図を反映した新しい名前として推奨されています。7
if (!SetCurrentDirectoryW(path))
{
    int error = Marshal.GetLastPInvokeError();
    throw new Win32Exception(error);
}

7. 構造体マーシャリング ── ブリッタブル型とStructLayout

.NET とネイティブコードでビット表現が同じ型は「ブリッタブル」と呼ばれ、変換なしにそのまま渡せるため高速です。byteintlong などの基本型、ブリッタブルな値型だけで構成された固定レイアウトの構造体がこれに当たります。ブリッタブルな構造体は Marshal.SizeOf<T>() ではなく C# の sizeof() を使うほうが高速です。反対に bool はブリッタブルではなく(ネイティブの BOOL は 4 バイト、C/C++ の bool は 1 バイトという違いがあります)、意識せず使うと戻り値の半分が捨てられるバグを作り込みます。14

構造体のレイアウトは StructLayoutAttribute で制御します。既定は LayoutKind.Sequential(宣言順に配置)を使い、共用体(union)のようにフィールドの位置を明示したい場合だけ LayoutKind.Explicit を使います。8

見落としやすいのが Pack フィールドです。公式ドキュメントによれば、型全体のアラインメントは「最大フィールドのサイズ」と「指定した Pack 値」のうち小さいほう、各フィールドは「自分自身のサイズ」と「型のアラインメント」のうち小さいほうに合わせて配置されます。8 つまり Pack を明示的に小さい値(2 や 4 など)に設定すれば、C++ の #pragma pack(N) と同様にアラインメントの上限として働きます。一方、既定値 0 は「型全体のアラインメントを最大フィールドのサイズとする(それ以上の特別なキャップはかけない)」という意味であり、これは C++ コンパイラーの /Zp オプション(構造体メンバーアラインメント。x86・ARM・ARM64 では既定8バイト境界、x64・ARM64EC では既定16バイト境界)の既定値とは別の規則で、単純に同一視できません15 加えて、この既定レイアウトは .NET Framework と .NET 5+ の間でも変わり得ます。たとえば decimal を含む構造体は、内部フィールド構成の違いにより既定パッキングでのサイズが .NET Framework では 28 バイト、.NET 5+ では 32 バイトになるという例が公式ドキュメントに載っています。8 つまり「既定値だから合っているはず」というアーキテクチャ単位の思い込みは禁物です。ネイティブ側のヘッダーが #pragma pack で明示的にパッキングサイズを変えている、あるいは 8 バイトを超える整列を要求するフィールドを含む DLL を相手にする場合は、C# 側の Pack を明示するか、Marshal.OffsetOf などで実際のフィールドオフセットを検証してから使うべきです。ここを怠ると、フィールドオフセットがずれて サイレントにデータが化ける事故になります。逆に、Windows SDK のヘッダーをそのまま使っている素直な API で、フィールドがすべて8バイト以下の基本型であれば、既定のアラインメントのまま Pack を触らなくても実務上問題になることはほとんどありません。

// ネイティブ側のヘッダーが pack(4) を明示している場合の例
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

8. コールバック(デリゲート)の寿命管理

ネイティブ API に「完了したらこの関数を呼んでほしい」というコールバックを渡す場面は珍しくありません。管理コードでは delegate がその役割を担いますが、ここには GC 特有の罠があります。Marshal.GetFunctionPointerForDelegate でデリゲートから関数ポインターを取得しても、GC はその関数ポインターとデリゲートの関連を追跡しません。ネイティブ側がまだその関数ポインターを使っている間にデリゲートが回収されると、クラッシュにつながります。9

もう一つ見落としやすいのが呼び出し規約です。P/Invoke でデリゲートを関数ポインターとしてネイティブへ渡す場合、既定では「プラットフォームの既定の呼び出し規約」が使われますが、明示的に一致させたい場合は UnmanagedFunctionPointerAttribute をデリゲート型に付けます。16 x64/ARM/ARM64 では呼び出し規約は事実上1つしかないため意識しなくても実害は出にくいですが、Windows x86(32bit)では Stdcall(Win32 API の既定)と Cdecl(Unix由来のCライブラリに多い)が異なるため、相手のヘッダーが Cdecl を使っている場合に既定のままだとスタック破壊につながります。16

// 呼び出し規約を明示する。x86ビルドで相手がCdeclを使っている場合はここが必須
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MyCallback(int code);

private static readonly MyCallback s_callback = OnNativeEvent;  // static保持で寿命を確定させる

// [UnmanagedFunctionPointer]はコールバックが「呼ばれる」ときの規約であり、
// この呼び出し自体(RegisterCallbackというP/Invoke)の規約は別物。
// LibraryImportの既定はプラットフォーム既定(Windowsではstdcall相当)なので、
// 相手がCdeclのC DLLならこちらにも明示が必要
[LibraryImport("nativelib")]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial void RegisterCallback(MyCallback callback);

private static void OnNativeEvent(int code)
{
    // ...
}

// 呼び出し側
RegisterCallback(s_callback);
GC.KeepAlive(s_callback);  // 直後にスコープを外れる可能性がある変数を明示的に生かす

static フィールドに保持しておけば、アプリケーションの生存期間中は GC に回収されません。ネイティブ側がコールバックを 1 回の呼び出し中しか使わない(コールバックが返ってきたら関数ポインターを破棄する)ことが確実な場合に限り、ローカル変数 + GC.KeepAlive で寿命を延ばす、という軽量な書き方もできます。

公式のベストプラクティスでは、可能なら Delegate 型よりも UnmanagedCallersOnlyAttribute を付けた静的メソッドと関数ポインター(delegate*<...>) を使うことが推奨されています。デリゲートのマーシャリングに比べてオーバーヘッドが小さく、Native AOT とも親和性が高い書き方です。9

9. 32bit/64bit の差異

P/Invoke のシグネチャを 1 つ書くだけで、実行時には 32bit プロセスからも 64bit プロセスからも同じコードパスが使われます。ここで問題になりやすいのが、ネイティブ側の型の幅が プロセスのビット数に追従することです。

  • HANDLEHWNDLPARAM のようなポインター系の型は、32bit プロセスでは 4 バイト、64bit プロセスでは 8 バイトになります。.NET 側では IntPtr/UIntPtr(または nint/nuint)で受けるのが正しく、固定サイズの int/long で受けると 32bit か 64bit の一方でしか動かないコードになります。4
  • 構造体に上記のポインター系フィールドが含まれていると、構造体全体のサイズもビット数によって変わります。第 7 章の Pack の既定値がアーキテクチャで異なる点とあわせて、同じ構造体定義でも 32bit ビルドと 64bit ビルドでバイナリレイアウトが変わり得ることを前提にテストしてください。
  • 32bit の既存アプリから 64bit でしか動かない DLL の機能を使いたい、という要件そのものは P/Invoke では解決できません(同一プロセスに異なるビット数の DLL は共存できません)。この場合はプロセスを分離し、COM ブリッジや名前付きパイプで橋を架ける設計になります。実例は「32bitアプリから64bit DLLを呼ぶCOMブリッジ実例」を参照してください。
  • DLL がそもそも見つからない・意図しないバージョンがロードされるという問題は P/Invoke の話ではなく Windows のローダーの話です。「Windows DLL名前解決の仕組み」で検索順序と SxS の挙動を整理しているので、DllNotFoundException の原因調査ではあわせて確認してください。

10. 判断表 ── P/Invoke vs C++/CLIラッパー vs COM相互運用

C# からネイティブコードを呼ぶ手段は P/Invoke だけではありません。相手が C++ のクラス・所有権・例外を持つ複雑な DLL なら C++/CLI ラッパーが効きますし、プロセス境界(32/64bit ブリッジ、VBA などの他言語からの利用)を越えるなら COM が選択肢になります。

観点 P/Invoke(LibraryImport) C++/CLI ラッパー COM 相互運用
向いている相手 素直な C インターフェース(構造体・プリミティブ型中心) C++ のクラス、所有権、例外、std:: 型が絡む DLL プロセスをまたぐ相手、VBA などの他言語
実装コスト 低〜中(シグネチャ定義のみ) 中(ラッパー層をもう1枚書く) 高(インターフェース設計、レジストリ登録)
型安全性 中(手書きならシグネチャ誤りが実行時まで見えないことも。CsWin32で改善) 高(C++ の型をそのまま扱える) 中(IDL/型ライブラリで保証)
AOT/トリミング対応 ◎(LibraryImportなら) △(C++/CLIはNative AOT非対応)
例外の扱い ✕(戻り値かHRESULTで自前判定) ◎(C++例外を.NET例外に変換できる) ○(HRESULTがCOM例外に変換される)
プロセス境界越え ✕(同一プロセス内専用) ✕(同一プロセス内専用) ◎(アウトプロセスサーバーが可能)
デバッグのしやすさ ○(LibraryImportは生成コードをステップ実行可) ○(ネイティブ・管理コード双方をVSでデバッグ) △(参照カウントや登録絡みの問題は追いにくい)
学習コスト 中〜高(C++/CLI構文) 高(COMの規約全般)

「相手が C 関数ベースの Win32 API か、自社の素直な C DLL」なら P/Invoke(可能なら CsWin32)、「相手が C++ のクラスで、所有権や例外まで含めて自然にやり取りしたい」なら C++/CLI ラッパー(詳細は「C#からネイティブDLLを呼ぶ:C++/CLIラッパー vs P/Invoke」)、「そもそもプロセスをまたぐ、あるいは VBA から使わせたい」なら COM、という順で考えると迷いません。逆方向(C/C++ から C# の処理を呼びたい)の場合は P/Invoke ではなく、Native AOT の UnmanagedCallersOnly を使う構成になります。「C# Native AOT DLLをC/C++から呼び出す方法」を参照してください。

11. 実装例 ── LibraryImportによるハンドル操作とエラー処理

ここまでの内容を組み合わせた実装例です。架空のセンサー機器 SDK device.dll が公開する OpenDevice / CloseDevice / ReadDeviceData を、SafeHandle によるハンドル管理、LibraryImport によるコンパイル時マーシャリング、SetLastError + GetLastPInvokeError によるエラー処理を含めてラップします。

まず、ネイティブハンドルを保持する SafeHandle 派生クラスです。

using Microsoft.Win32.SafeHandles;

// device.dll のハンドルをラップする。GCの寿命とは独立して、
// ハンドルの二重解放・リサイクル攻撃・早期解放を防ぐ
internal sealed class DeviceSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // OpenDevice の戻り値として使われるため、パラメーターなしコンストラクターが必要
    public DeviceSafeHandle() : base(ownsHandle: true)
    {
    }

    protected override bool ReleaseHandle()
        // ReleaseHandle内は「失敗しない」ことが前提の制約実行領域。
        // 単純なネイティブ解放呼び出し1つに留める
        => DeviceNativeMethods.CloseDevice(handle);
}

続いて P/Invoke 宣言です。文字列は StringMarshalling.Utf16 を明示し、失敗し得る呼び出しはすべて SetLastError = true を付けます。

using System.Runtime.InteropServices;

internal static partial class DeviceNativeMethods
{
    private const string DeviceDll = "device.dll";

    // ハンドルを戻り値にすると、呼び出しに成功した瞬間からSafeHandleが
    // 寿命を追跡してくれる。失敗時はIsInvalidがtrueのハンドルが返る
    [LibraryImport(DeviceDll, EntryPoint = "OpenDevice",
        StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
    internal static partial DeviceSafeHandle OpenDevice(string devicePath);

    // SafeHandleのReleaseHandleから直接呼ぶための内部API。
    // handle は解放専用なので生のIntPtrで受ける
    [LibraryImport(DeviceDll, EntryPoint = "CloseDevice", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool CloseDevice(IntPtr handle);

    // buffer は呼び出し元が確保済みの配列。byte[]はブリッタブルなためピン留めされ、
    // ネイティブ側の書き込みは同じメモリに対して行われる。[Out]を明示すること自体は
    // 必須ではないが、意図を自己文書化するために付けておく
    [LibraryImport(DeviceDll, EntryPoint = "ReadDeviceData", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool ReadDeviceData(
        DeviceSafeHandle handle,
        [Out] byte[] buffer,
        int bufferLength,
        out int bytesRead);
}

最後に、これらを利用する側の薄いラッパーです。エラーコードは失敗を検知した直後に取得し、Win32Exception に包んで呼び出し元へ伝えます。

using System.ComponentModel;
using System.Runtime.InteropServices;

public sealed class DeviceConnection : IDisposable
{
    private readonly DeviceSafeHandle _handle;

    private DeviceConnection(DeviceSafeHandle handle) => _handle = handle;

    public static DeviceConnection Open(string devicePath)
    {
        DeviceSafeHandle handle = DeviceNativeMethods.OpenDevice(devicePath);
        if (handle.IsInvalid)
        {
            // 他のAPI呼び出しに上書きされる前に、失敗直後に取得する
            int error = Marshal.GetLastPInvokeError();
            handle.Dispose();
            throw new IOException(
                $"デバイスを開けませんでした: {devicePath} (Win32 error {error})",
                new Win32Exception(error));
        }
        return new DeviceConnection(handle);
    }

    public byte[] Read(int maxBytes)
    {
        var buffer = new byte[maxBytes];
        if (!DeviceNativeMethods.ReadDeviceData(_handle, buffer, buffer.Length, out int bytesRead))
        {
            int error = Marshal.GetLastPInvokeError();
            throw new IOException($"デバイスの読み取りに失敗しました (Win32 error {error})",
                new Win32Exception(error));
        }
        return bytesRead == buffer.Length ? buffer : buffer[..bytesRead];
    }

    // SafeHandle.Disposeを呼ぶだけでよく、ファイナライザーは書かない
    public void Dispose() => _handle.Dispose();
}

DeviceConnection を使う側は using で囲むだけでよく、ハンドルの解放漏れを気にする必要がありません。この構成のどこで何を検知してどう変換するかという原則は「例外処理でcatchとログはどこに置くべきか」で書いた層ごとの責務分担がそのまま当てはまります。ネイティブ層のエラーコードを P/Invoke 境界で例外に翻訳し、それより上の層では通常の .NET 例外として扱う、という一線をここで引くのがポイントです。

12. まとめ

P/Invoke は「DLL の関数を宣言すれば呼べる」という手軽さの裏で、文字列マーシャリング・ハンドルの寿命・エラーコードの取得タイミング・構造体レイアウトのどこかで一度は事故る技術です。.NET 7 以降であれば LibraryImport を既定にし、可能なら CsWin32 でシグネチャそのものを生成してもらう。文字列は StringMarshalling を明示し StringBuilder を避ける。ハンドルは SafeHandle で持つ。SetLastError を使ったら呼び出し直後にエラーコードを取得する。構造体は Pack の既定値がアーキテクチャで異なることを意識する。コールバックは寿命を明示的に管理する ── この記事に挙げたポイントは、どれも「知っていれば数行の対応で済むが、知らないと本番環境でしか再現しない不具合になる」種類のものです。

そして、P/Invoke で押し切るべきか、C++/CLI ラッパーや COM に切り替えるべきかの判断は、相手の DLL がどれだけ「C 的」か、プロセス境界を越える必要があるかで決まります。既存のネイティブ資産を C# から呼びたい、あるいは逆に C# の資産をネイティブから呼びたいという相談は、実際のヘッダーファイルや DLL の構造を見ながらでないと最適な構成が見えてこないことが多いので、迷ったらご相談ください。

関連記事

関連する相談領域

合同会社小村ソフトでは、C# とネイティブ DLL・Win32 API の境界設計、COM コンポーネントの開発・調査、既存のネイティブ資産と .NET をつなぐ移行案件の技術相談を扱っています。

参考リンク

  1. Microsoft Learn, Source generation for platform invokes. LibraryImportAttributeによるコンパイル時マーシャリング生成、DllImportの実行時ILスタブ生成との違い、Native AOT/トリミングとの親和性について。  2 3

  2. Microsoft Learn, SYSLIB diagnostics for p/invoke source generation. DllImportからLibraryImportへの書き換えを促すアナライザーSYSLIB1054をはじめとする診断IDの一覧について。 

  3. Microsoft Learn, Build a C# .NET app with WinUI 3 and Win32 interop. C#/Win32 P/Invoke Source Generator(Microsoft.Windows.CsWin32)の導入方法と、NativeMethods.txtに関数名を列挙してシグネチャを生成する手順について。  2 3

  4. Microsoft Learn, Native interoperability best practices. StringBuilderマーシャリングが常にネイティブバッファーのコピーを伴い非効率であること、[Out] string引数を避けるべきこと、SafeHandleを使いファイナライザーを避けるべきことについて。  2 3 4

  5. Microsoft Learn, SafeHandle Class. SafeHandleがハンドルの早期解放・リサイクル攻撃を防ぐ仕組みと、CriticalFinalizerObjectによる確実な解放保証について。  2 3

  6. Microsoft Learn, Native interoperability best practices - General guidance. アンマネージドリソースの寿命管理にSafeHandleを使い、ファイナライザーの利用を避けるべきという指針について。  2

  7. Microsoft Learn, Marshal.GetLastPInvokeError Method. SetLastError=trueが設定されたP/Invoke呼び出し直後のエラーコード取得方法と、.NET 6以降でGetLastWin32Errorより推奨されることについて。  2

  8. Microsoft Learn, StructLayoutAttribute.Pack Field. Pack既定値0が示す「現在のプラットフォームの既定パッキングサイズ」の意味と、フィールドのアラインメント計算規則について。  2 3 4

  9. Microsoft Learn, Native interoperability best practices - Prevent delegate collection with GC.KeepAlive. GetFunctionPointerForDelegateで取得した関数ポインターとデリゲートの関連をGCが追跡しないこと、GC.KeepAliveによる寿命延長、UnmanagedCallersOnlyの利用推奨について。  2 3

  10. Microsoft Learn, Default Marshalling Behavior - Memory management with the interop marshaller. マーシャラーがアンマネージドコードが確保したメモリを常に解放しようとすること、WindowsではCoTaskMemFreeが使われるため、CoTaskMemAlloc以外で確保されたメモリではIntPtrを使い手動で解放する必要があることについて。 

  11. Microsoft Learn, Source generation for platform invokes - Differences from DllImport. CharSetがStringMarshallingに置き換わったこと、CallingConventionの代わりにUnmanagedCallConvAttributeを使うこと、ExactSpelling/PreserveSigに相当がないことについて。  2 3

  12. Microsoft Learn, Charsets and marshalling. CharSetを明示しない場合にC#・Visual Basic・F#コンパイラーが既定でCharSet.Noneを割り当てること、CharSet.NoneがCharSet.Ansiと同じ動作(非Unicodeでのマーシャリング)であることについて。 

  13. Microsoft Learn, DllImportAttribute.SetLastError Field. SetLastErrorをtrueにした場合の.NET上での挙動(呼び出しごとにエラー情報がクリアされること)について。  2

  14. Microsoft Learn, Native interoperability best practices - Blittable types. ブリッタブル型の定義、boolがブリッタブルでないことによる罠、ブリッタブルな構造体でsizeof()を使う利点について。 

  15. Microsoft Learn, /Zp (Struct Member Alignment). C++コンパイラーの構造体メンバーアラインメントの既定値が、x86/ARM/ARM64で8バイト境界、x64/ARM64ECで16バイト境界であることについて。 

  16. Microsoft Learn, Unmanaged calling conventions. Windows x86ではStdcallとCdeclが異なる既定の呼び出し規約になること、x64/ARM/ARM64では呼び出し規約が事実上1つしかないこと、UnmanagedFunctionPointerAttributeで呼び出し規約を明示できることについて。  2

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る