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

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

更新履歴(5件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
P/Invoke の宣言が境界で何を約束しているのかを示す図と、P/Invoke・C++/CLI・COM の住み分けを示す選定フローの図を追加しました。あわせて第10章の末尾を補足しています。逆方向(C/C++ から C# を呼ぶ)を一律に Native AOT の構成としていましたが、すでに動いている .NET プロセスへコールバックさせるだけなら通常のランタイムのままで足ります。独立したネイティブ DLL としてエクスポートする場合と分けて書きました。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
`Pack = 0` の説明を直しました。0は「アラインメントの上限をかけない」ではなく「現在のプラットフォームの既定パッキングサイズ」を指します。上限は存在するという前提で表と本文を書き直し、オフセットを手計算せずに`Marshal.OffsetOf`で実測するよう促す注意を加えました。
構造体のレイアウトについて、アーキテクチャごとの既定値をC#とC++で対比する表を追加しました。あわせてレイアウトを実際に確かめるコード例と、用語の説明を追加しています。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589929)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「C#からWin32 APIを安全に呼ぶ ── P/Invoke実務ガイド(DllImport / LibraryImport / CsWin32)」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589929 https://staging.comcomponent.com/blog/pinvoke-safe-guide/

DOI(最新版)
10.5281/zenodo.21589929
DOI(この版)
10.5281/zenodo.21732921

当ブログではこれまで、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 を軸に、実務で押さえるべきポイントを一通り整理します。

この記事で使う用語

先に、以降で断りなく使う用語をまとめておきます。

用語 意味
P/Invoke Platform Invoke(プラットフォーム呼び出し)。マネージドコードからアンマネージドな DLL の関数を呼び出す .NET の仕組みです
マーシャリング マネージドとアンマネージドの境界で、型の表現を相互に変換すること。文字列・構造体・配列・デリゲートの受け渡しがこれに当たります
IL スタブ DllImport の呼び出しに対して実行時にランタイムが生成する、マーシャリング処理を含む中間コード。JIT でコンパイルされてから実際の呼び出しに使われます1
Native AOT .NET アプリを発行時にネイティブコードへ事前コンパイルする発行方式。実行時にコードを生成する仕組みが使えないため、IL スタブ方式と相性が悪くなります1
トリミング 発行時に未使用のコードを削り、配置サイズを減らす機能。実行時に動的生成されるコードは追跡できません1
ブリッタブル型 マネージドとアンマネージドでビット表現が同じため、変換なしにそのまま渡せる型。詳細は第7章2

1. まず結論

長くなるので、特に事故が多い4点を先に挙げます。

  • .NET 7 以降なら DllImport ではなく LibraryImport を既定にします。 コンパイル時にマーシャリングコードを生成するため、Native AOT・トリミングに対応し、実行時の IL スタブ生成コストがなく、生成コードをデバッガーでステップ実行できます。アナライザー SYSLIB1054DllImport の書き換えどころを教えてくれます。13
  • ハンドルは生の IntPtr ではなく SafeHandle 派生クラスで持ちます。 GC によるハンドルの早期解放・二重解放・「リサイクル攻撃」を防ぐ、.NET のネイティブ相互運用の基本作法です。45
  • 文字列は StringMarshalling を明示し、StringBuilder は避けます。 StringBuilder マーシャリングは常にネイティブバッファーのコピーを伴い、非効率なうえに終端処理を誤りやすい機構です。6
  • SetLastError = true を付けたら、呼び出し直後に Marshal.GetLastPInvokeError() を読みます。 他の管理コードの実行がエラーコードを上書きする前に確保する必要があります。7

この4点が同じ場所に集まるのは偶然ではありません。P/Invoke の宣言は、境界をまたぐときの型・文字列・メモリとハンドルの寿命・エラーの受け取り方を、その数行だけで同時に約束しているからです。

ネイティブ側(Win32 API / 自社の C DLL)境界(マーシャラー)マネージド側(.NET)渡し方は値の性質で変わるコピーした値を渡すどちらが解放するかは宣言からは読めないAPI の仕様を見て決めるエクスポートされた関数ネイティブのメモリとハンドルDllImport / LibraryImport の宣言= 境界の契約はここに書いた内容がすべて型変換・呼び出し規約の調整変換先の一時バッファー呼び出し側の C# コードGC が動かすオブジェクト・ブリッタブルな配列 → 固定(pin)してそのまま渡す・文字列など非ブリッタブル → 変換してコピーする・デリゲート → 呼び出し中は寿命を保つSafeHandleネイティブハンドルの寿命を持つ

図1: 宣言の数行が「型・文字列・寿命・エラー」を同時に約束する。渡し方は値の性質で変わり(固定するか、コピーするか、寿命を保つだけか)、どれが外れても症状は「たまに落ちる」になる

残りは「知らないと踏むが、知っていれば数行で済む」類の論点です。結論だけ一覧にしておくので、詳細は該当章で確認してください。

論点 結論 詳細
Win32 API のシグネチャ 手で書き起こさず CsWin32 に生成させる。NativeMethods.txt に呼びたい関数名を並べるだけで、公式の Win32 メタデータからシグネチャ・定数・構造体が出てくる8 第3章
構造体レイアウト LayoutKind.Sequential を既定にし、Pack を明示するかどうかを意識する。Pack = 0(既定)は「上限なし」ではなく「プラットフォーム既定のパッキングサイズ」で、C++ の /Zp 既定値とは値の決まり方が別。.NET Framework と .NET 5+ の間でも変わり得る9 第7章
コールバック(デリゲート) ネイティブ側が使い終わるまで GC に回収されないよう寿命を管理する。static フィールドで保持するか GC.KeepAlive を使い、可能なら UnmanagedCallersOnly を優先する10 第8章
32bit/64bit ポインター系の型は IntPtr/nint で受ける。同一プロセスにビット数の異なる DLL は同居できないため、その要件は P/Invoke では解決できない 第9章
P/Invoke 以外の選択肢 素直な C インターフェースなら P/Invoke、C++ のクラスや所有権・例外が絡むなら C++/CLI、プロセス境界を越えるなら COM。競合ではなく住み分け 第10章

この記事の知識マップ

この記事はC#からWin32 APIをP/Invokeで安全に呼ぶための実務ポイントを、LibraryImportを軸に整理します。DllImportが実行時にILスタブを生成するのに対し、LibraryImportはコンパイル時にマーシャリングコードを生成するためNative AOTやトリミングに対応します。CsWin32は既定ではDllImportベースでシグネチャを自動生成し、HANDLEを適切なSafeHandle派生型として出力します。SafeHandleはハンドルの早期解放やリサイクル攻撃を防ぐ一方、生のIntPtrで保持するとこの事故が起こり得ます。文字列はStringMarshallingを明示し、構造体はStructLayout.Packの既定値がプラットフォームで異なる点を意識し、ブリッタブル型はそのまま高速に渡せます。コールバックはデリゲートの寿命をstatic保持かGC.KeepAliveで管理するか、UnmanagedCallersOnlyに置き換えます。異なるビット数のDLLをまたぐ要件はP/Invokeでは解決できず、COMが選択肢になります。

P/InvokeでWin32 APIを安全に呼ぶ知識マップLibraryImportがコンパイル時マーシャリング生成によりDllImportのILスタブに代わってNative AOTとトリミングに対応すること、CsWin32が既定でDllImportベースのシグネチャを自動生成しSafeHandle型を出力すること、SafeHandleがハンドルのリサイクル攻撃を防ぐこと、GetLastPInvokeErrorやStructLayout.Pack・StringMarshalling・ブリッタブル型・コールバックデリゲートの寿命管理が実務の要点であること、異なるビット数のDLLをまたぐ要件にはCOMが選択肢になることを示す図の後継利用する用いるのは非推奨推奨される対応推奨される対応用いるのは非推奨自動化する推奨される対応利用する推奨される対応防止する原因になり得る利用する利用する利用する利用する防止する原因になり得る推奨される対応前提とする推奨される対応P/InvokeLibraryImportDllImportILスタブNative AOTtrimming(トリミング)CsWin32SafeHandleハンドルのリサイクル攻撃StringMarshallingMarshal.GetLastPInvokeErrorStructLayoutAttribute.Packblittable型コールバックデリゲートの寿命管理関数ポインターのデリゲートがGCに回収されるクラッシュUnmanagedCallersOnly属性bitness一致要件COM(コンポーネントオブジェクトモデル)

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全21件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

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 では珍しくない実装)でそのポインターを確保していると、マーシャラーが誤ったアロケーターでメモリを解放してしまい、ヒープ破損やクラッシュにつながります。11 相手のヘッダーやドキュメントで CoTaskMemAlloc 互換の確保を明記していない限り、戻り値は string ではなく IntPtr で受け取り、対応する解放関数(または相手が要求する解放手順)を自分で呼ぶ設計にしてください。呼び出し側でバッファーを確保して渡す(前述の StringBuilder の代替である文字配列や、後述の [Out] バッファーパターン)ほうが、そもそもこの種の所有権の曖昧さを持ち込まずに済みます。

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

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

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

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

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

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

GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle

NativeMethods.txt の 1 行にはメソッド名のほか、型名・定数名・名前空間・モジュール名を書け、先頭に - を付けると除外になります。13

ビルド時に、これらの関数のP/Invokeシグネチャ(戻り値・パラメーター・SetLastError 指定を含む)が生成されます。既定では従来の DllImport ベースで生成される点に注意してください。Native AOT・トリミングを見据えるなら、プロジェクト直下に NativeMethods.json を置き、allowMarshaling を無効にすることで、ランタイムのマーシャラーに依存しないコード生成に切り替えられます。13 ファイルの中身は次の 2 行だけで足ります。

{
  "$schema": "https://aka.ms/CsWin32.schema.json",
  "allowMarshaling": false
}

$schema の行は必須ではありませんが、書いておくと多くの JSON エディターで補完・説明・検証が効くようになり、指定できる設定の一覧もそこから辿れます。13

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 サフィックス)を前提にしている場合、この既定値のまま呼ぶと文字化けやマルチバイト文字の欠落が起きます。14

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

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

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

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

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

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

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

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

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

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

[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 呼び出しを挟むと上書きされて失われるため、失敗を検知したその場で値を取得してください。15
  • 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 バイトという違いがあります)、意識せず使うと戻り値の半分が捨てられるバグを作り込みます。2

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

見落としやすいのが Pack フィールドです。公式ドキュメントによれば、配置の規則は次の 2 段です。9

  • 型全体のアラインメント = 「最大フィールドのサイズ」と「指定した Pack 値」のうち小さいほう
  • 各フィールドの配置境界 = 「自分自身のサイズ」と「型のアラインメント」のうち小さいほう

つまり Pack に小さい値(2 や 4 など)を明示すれば、C++ の #pragma pack(N) と同様にアラインメントの上限として働きます。問題は既定値である Pack = 0 のほうです。

7.1 「アーキテクチャ × 既定値」の対応

Pack = 0 は「上限をかけない」という意味ではありません。公式ドキュメントの言い方では、0 は「現在のプラットフォームの既定パッキングサイズ」を指します9 つまり上限は存在していて、その値を自分で決めていないだけです。「C++ の /Zp 既定値と同じ」でもありません。どちらもパッキングサイズの上限を指す点は同じですが、値の決まり方が別なので、片方の数字をもう片方に持ち込むと計算が合わなくなります。

処理系 既定値の中身 x86 x64 ARM / ARM64 ARM64EC
C# の Pack = 0(既定) 型全体のアラインメント = 「最大フィールドのサイズ」と「プラットフォーム既定のパッキングサイズ」の小さいほう9 同左 同左 同左 同左
C++ の /Zp(構造体メンバーアラインメントの上限)16 メンバーは「自身のサイズ」と「N バイト境界」の小さいほうに配置される 8 バイト 16 バイト 8 バイト 16 バイト

実務上の注意は、この既定値を「上限なし」と読み替えてオフセットを手計算しないことです。とくに自然なアラインメントが大きいフィールドを含む構造体では、既定のままでも上限が効いて計算が合わなくなります。そこで辻褄を合わせようと Pack を当てずっぽうで明示すると、今度はネイティブ側と食い違ったまま固定されてしまい、P/Invoke ではいちばん危ない状態になります。オフセットに確信が持てないときは、7.2 節の Marshal.SizeOfMarshal.OffsetOf で実測してからネイティブ側のヘッダーと突き合わせてください。

さらに、C# 側の既定レイアウトはランタイムのバージョンによっても変わり得ます。公式ドキュメントには、decimal を含む構造体が内部フィールド構成の違いにより、既定パッキングでのサイズが .NET Framework では 28 バイト、.NET 5+ では 32 バイトになるという例が載っています。9

比較軸 変わるもの
32bit プロセス / 64bit プロセス ポインター系フィールドの幅(第9章)。それに引きずられて構造体全体のサイズも変わる
.NET Framework / .NET 5+ 一部の型を含む構造体の既定サイズ(上記 decimal の例)9
C# 側 / C++ 側 既定のアラインメント規則そのもの(上の表)

「既定値だから合っているはず」という思い込みは禁物、というのがここでの結論です。ネイティブ側のヘッダーが #pragma pack で明示的にパッキングサイズを変えている場合や、8 バイトを超える整列を要求するフィールドを含む DLL が相手の場合は、C# 側の Pack を明示するか、次節の方法で実際のフィールドオフセットを検証してから使ってください。ここを怠ると、フィールドオフセットがずれて サイレントにデータが化ける事故になります。

逆に、Windows SDK のヘッダーをそのまま使っている素直な API で、フィールドがすべて 8 バイト以下の基本型であれば、既定のアラインメントのまま Pack を触らなくても実務上問題になることはほとんどありません。

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

7.2 レイアウトを実際に確かめる ── Marshal.OffsetOf

レイアウトの一致は、机上で数えるより実行して確かめるほうが確実です。次のコンソールアプリは、構造体のサイズと各フィールドのオフセットをそのまま出力します。確認したい構造体を貼り付けて実行し、ネイティブ側のヘッダーと突き合わせるための道具として使ってください。

// dotnet new console で作ったプロジェクトの Program.cs をこれで置き換える(.NET 8 / C# 12)
using System.Runtime.InteropServices;

Console.WriteLine($"プロセスのアーキテクチャ: {RuntimeInformation.ProcessArchitecture}");
Console.WriteLine($"IntPtr.Size            : {IntPtr.Size} バイト");
Console.WriteLine($"Marshal.SizeOf         : {Marshal.SizeOf<DeviceInfo>()} バイト");
Console.WriteLine("--- フィールドのオフセット ---");

foreach (var field in typeof(DeviceInfo).GetFields())
{
    IntPtr offset = Marshal.OffsetOf<DeviceInfo>(field.Name);
    Console.WriteLine($"{field.Name,-12} : {offset}");
}

// 検証したい構造体をここに貼り付ける。
// トップレベルステートメントより後ろに置く必要がある点に注意
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

Marshal.OffsetOf<T>(string fieldName) は、アンマネージドとしてマーシャリングされたときのフィールドの先頭からのバイトオフセットを返します。ネイティブ側では、C/C++ の offsetof マクロと sizeof で同じ値を出して比べます。

// 比較用。ネイティブ側のヘッダー(DeviceInfoの定義を含むもの)をインクルードする
#include <stdio.h>
#include <stddef.h>
#include "device.h"

int main(void)
{
    printf("sizeof(DeviceInfo)  = %zu\n", sizeof(DeviceInfo));
    printf("offsetof(DeviceId)  = %zu\n", offsetof(DeviceInfo, DeviceId));
    printf("offsetof(Flags)     = %zu\n", offsetof(DeviceInfo, Flags));
    printf("offsetof(Timestamp) = %zu\n", offsetof(DeviceInfo, Timestamp));
    return 0;
}

両者の値が全フィールドで一致していれば、そのプラットフォームではレイアウトが合っています。1 つでもずれていれば、Pack の指定か、フィールドの型・順序のどこかが間違っています。32bit と 64bit の両方を出荷するなら、両方のビット数でこの確認を行ってください。片方だけ合っている、というのがこの種の不具合の典型的な現れ方です。

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

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

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

// 呼び出し規約を明示する。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 とも親和性が高い書き方です。10

9. 32bit/64bit の差異

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

  • HANDLEHWNDLPARAM のようなポインター系の型は、32bit プロセスでは 4 バイト、64bit プロセスでは 8 バイトになります。.NET 側では IntPtr/UIntPtr(または nint/nuint)で受けるのが正しく、固定サイズの int/long で受けると 32bit か 64bit の一方でしか動かないコードになります。6
  • 構造体に上記のポインター系フィールドが含まれていると、構造体全体のサイズもビット数によって変わります。第 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、という順で考えると迷いません。この順序を図にすると、判断が分かれるのは最初の2問だけだと分かります。

C/C++ から C# の処理を呼びたい動いている .NET へコールバックさせたいネイティブ DLL としてエクスポートしたいC# からネイティブを呼びたい公開している(In-proc / Out-of-proc)していないプロセスをまたぐ要求する要求しない同一プロセスでよいC 関数・構造体が中心C++ のクラス・所有権・例外呼ぶ向きはどちらか呼ばれる .NET はどういう形かデリゲートや関数ポインターを渡す(第8章)通常のランタイムでよいNative AOT + UnmanagedCallersOnly(P/Invoke ではない)相手はすでに COM を公開しているかCOM 相互運用同一プロセス内で完結するか相手側が COM を要求するか(VBA から使わせる など)COM のアウトプロセスサーバーを自分で用意する名前付きパイプ・ソケット・RPCなど既存の IPC で橋を架ける(9章)相手のインターフェースの形P/Invoke(LibraryImport、Win32 API なら CsWin32)C++/CLI ラッパー(Native AOT は使えない)

図2: 3つは競合ではなく住み分け。相手がすでに COM で公開されているなら同一プロセス内でも COM が素直な入口になり、逆にプロセスを分けるだけなら COM でなく既存の IPC でもよい

逆方向(C/C++ から C# の処理を呼びたい)は、さらに2つに分かれます。すでに動いている .NET プロセスへネイティブ側からコールバックさせたいだけなら、第8章のデリゲートや UnmanagedCallersOnly の関数ポインターを渡す形で足り、通常のランタイムのまま動きます。C# の処理を独立したネイティブ DLL としてエクスポートし、.NET を知らない側から読み込ませたい場合は、Native AOT で発行する構成になります。後者は「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 4 5 6

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

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

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

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

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

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

  8. 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

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

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

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

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

  13. CsWin32 公式ドキュメント, Getting Started. NativeMethods.txt の各行にメソッド名・型名・定数名・名前空間・モジュール名を書けること(先頭の - は除外指定)、プロジェクト直下に置く NativeMethods.json で設定を変更できること、allowMarshaling を無効にするとランタイムのマーシャラーに依存しないコード生成になること、$schemahttps://aka.ms/CsWin32.schema.json を指定すると JSON エディターで補完・説明・検証が効き、設定項目の一覧もそこにあることについて。  2 3

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

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

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

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

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

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

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

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

DllImportとLibraryImportはどちらを使うべきですか?
.NET 7以降ならLibraryImportを既定にします。DllImportが実行時にマーシャリング用のILスタブを生成するのに対し、LibraryImportはソースジェネレーターがコンパイル時にマーシャリングコードを生成するため、Native AOT・トリミングに対応し、生成コードをデバッガーでステップ実行できます。アナライザーSYSLIB1054がDllImportの書き換えどころを教えてくれます。LibraryImportが未対応の設定(一部のMarshalAs指定など)を使う場合のみDllImportに戻します。
P/InvokeでハンドルをIntPtrで持つのはなぜ危険ですか?
3つの問題があるためです。第一に、P/Invoke呼び出しの最中にGCがオブジェクトを回収してハンドルを閉じてしまう早期解放の競合が起こり得ます。第二に、Windowsはハンドル値を積極的に再利用するため、閉じたはずのハンドル値で無関係なリソースを操作してしまうリサイクル攻撃につながります。第三に、非同期例外によるハンドルリークが起こり得ます。SafeHandle派生クラスを使えば、参照カウントの自動管理と確実な解放保証によりこれらを防げます。
Win32 APIのシグネチャを手書きせずに済む方法はありますか?
CsWin32(Microsoft.Windows.CsWin32)というソースジェネレーターが使えます。NuGetパッケージを追加し、NativeMethods.txtというテキストファイルに呼びたい関数名を列挙するだけで、公式のWin32メタデータからシグネチャ・定数・構造体が自動生成されます。HANDLEは適切なSafeHandle派生型として出力されるため、手書きで起きがちなCharSetの取り違えやフィールド順序ミスを作り込めません。既定はDllImportベースの生成なので、Native AOTを見据えるならNativeMethods.jsonでallowMarshaling: falseを指定します。
P/InvokeとC++/CLIラッパー、COMはどう使い分けますか?
相手のDLLの性質とプロセス境界の有無で決まります。素直なCインターフェース(構造体・プリミティブ型中心)ならP/Invokeが最も低コストで、Win32 APIならCsWin32の併用が有効です。C++のクラス・所有権・例外・std::型が絡む複雑なDLLならC++/CLIラッパーを1枚挟みます。32bit/64bitブリッジのようにプロセス境界を越える場合やVBAなど他言語から使わせる場合はCOMが選択肢です。同一プロセスに異なるビット数のDLLは共存できないため、この要件はP/Invokeでは解決できません。

著者プロフィール

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

小村 豪

合同会社小村ソフト 代表

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

ブログ一覧に戻る