C# DllImportの使い方完全ガイド|P/InvokeでDLLを呼び出す手順とエラー解決法
はじめに
C#からWindows APIやC/C++で作成したDLLを呼び出したいときに使う代表的な仕組みがDllImportです。「csharp dllimport」で検索している方の多くは、外部DLLをC#から実行したい、既存のネイティブライブラリを.NETアプリに組み込みたい、またはDllNotFoundExceptionやEntryPointNotFoundExceptionなどのエラーを解決したいという目的を持っているはずです。
DllImportは便利ですが、C#だけで完結する通常のメソッド呼び出しとは違い、DLLの配置場所、関数名、呼び出し規約、文字コード、32bit/64bit、型変換、メモリ管理などを正しく合わせる必要があります。1つでも不一致があると、コンパイルは通っても実行時に例外が発生したり、最悪の場合はアプリケーションがクラッシュしたりします。
この記事では、C#のDllImportの基本構文から、P/InvokeでDLL関数を呼び出す手順、主要オプション、マーシャリング、実践コード、よくあるエラーと解決法、LibraryImportなどの代替手段までを体系的に解説します。
1. C# DllImportとは?P/InvokeでDLLを呼び出す基礎
1-1. DllImportの役割とP/Invokeの仕組み
DllImportは、C#などのマネージコードから、C/C++などで作成されたアンマネージDLLの関数を呼び出すために使う属性です。正式にはDllImportAttributeと呼ばれ、System.Runtime.InteropServices名前空間に含まれます。
Microsoftのドキュメントでは、DllImportAttributeは「アンマネージDLLによって公開されている静的エントリポイントを示す属性」と説明されています。つまり、DLL内にエクスポートされている関数を、C#側のメソッド宣言に結び付ける役割を持ちます。
この仕組みはP/Invoke、つまりPlatform Invocation Servicesと呼ばれます。C#のコードから見ると普通の静的メソッドを呼んでいるように見えますが、実際には.NETランタイムが引数や戻り値をネイティブ側の表現に変換し、DLL内の関数へ処理を渡しています。この変換処理をマーシャリングと呼びます。
たとえば、C#のstringは.NET管理下の文字列ですが、C/C++側ではchar*やwchar_t*などとして扱われます。DllImportでは、このような型の違いを明示的に指定しながら安全に橋渡しする必要があります。
1-2. 呼び出せるDLLと呼び出せないDLLの違い
DllImportで呼び出せるのは、基本的にアンマネージDLLにエクスポートされた関数です。代表例は次のとおりです。
| 呼び出せるもの | 例 |
|---|---|
| Windows API | user32.dll, kernel32.dll, advapi32.dllなど |
| C/C++で作成したネイティブDLL | extern "C"でエクスポートした関数 |
| サードパーティ製ネイティブライブラリ | 画像処理、計測機器制御、暗号化、圧縮ライブラリなど |
| Linux/macOSのネイティブライブラリ | .so, .dylibなど |
一方、通常のC#クラスライブラリとして作ったDLLは、DllImportで呼び出す対象ではありません。C#で作成したDLLを利用する場合は、Visual Studioやdotnetプロジェクトで「参照追加」を行い、名前空間をusingしてクラスやメソッドを呼び出します。
よくある誤解は、「DLLなら何でもDllImportで呼べる」というものです。しかし、.NETのマネージDLLとC/C++のアンマネージDLLは内部構造も呼び出し方法も異なります。DllImportはあくまでネイティブ関数を呼ぶための仕組みです。
1-3. マネージDLLとアンマネージDLLの違い
マネージDLLとは、.NETランタイム上で実行されるDLLです。C#やVB.NETで作成したクラスライブラリが該当します。ガベージコレクション、型安全性、例外処理、アセンブリメタデータなどが.NETランタイムによって管理されます。
アンマネージDLLとは、OSに直接ロードされるネイティブDLLです。CやC++で作成されることが多く、メモリ管理やポインタ操作を開発者が明示的に扱います。Windows APIの多くもアンマネージDLLとして提供されています。
違いを整理すると次のようになります。
| 種類 | 主な言語 | 呼び出し方法 | 実行環境 |
|---|---|---|---|
| マネージDLL | C#, VB.NET, F# | 参照追加 | .NETランタイム |
| アンマネージDLL | C, C++ | DllImport / P/Invoke | OSネイティブ |
C#からC#のDLLを使いたいなら参照追加、C#からC/C++のDLLを使いたいならDllImport、と考えるとわかりやすいです。
1-4. DllImportを使う主な場面
DllImportは、次のような場面でよく使われます。
| 場面 | 具体例 |
|---|---|
| Windows APIを使いたい | ウィンドウ制御、ファイル属性取得、プロセス制御 |
| 既存のC/C++ DLLを活用したい | 社内資産、古い業務ライブラリ、デバイス制御DLL |
| ハードウェア制御をしたい | 計測器、バーコードリーダー、カメラ、PLCなど |
| 高速なネイティブ処理を使いたい | 画像処理、音声処理、数値計算 |
| ベンダー提供DLLを呼び出したい | SDKに含まれるネイティブDLL |
特に製造業、医療機器、計測機器、Windowsデスクトップアプリ開発では、C#アプリからベンダー提供のDLLを呼び出すケースが少なくありません。
1-5. .NET Framework/.NET Core/.NET 6以降での違い
DllImport自体は、.NET Framework、.NET Core、.NET 5以降でも利用できます。ただし、実行環境や推奨される書き方には違いがあります。
.NET Frameworkは主にWindows向けで、Windows APIや既存のネイティブDLLを呼び出す用途で長く使われてきました。一方、.NET Core以降の.NETはWindows、Linux、macOSで動作するクロスプラットフォームな実行環境です。そのため、DllImportでもWindowsの.dllだけでなく、Linuxの.soやmacOSの.dylibを呼び出す設計が可能です。
また、.NET 7以降ではLibraryImportという新しいP/Invoke向け属性が導入され、可能であればDllImportよりLibraryImportを使うことが推奨されています。LibraryImportはソースジェネレーターによってマーシャリングコードをコンパイル時に生成する仕組みで、実行時にILスタブを生成する従来のDllImportとは動作が異なります。
ただし、既存コードや.NET Framework対応、簡単なWindows API呼び出しでは、今でもDllImportはよく使われています。実務では「既存資産や互換性重視ならDllImport」「.NET 7以降で新規実装するならLibraryImportも検討」と考えるのが現実的です。
2. C#でDllImportを使うための基本構文
2-1. using System.Runtime.InteropServicesを追加する
DllImportを使うには、まず次の名前空間を追加します。
C#using System;
using System.Runtime.InteropServices;
DllImportAttribute、MarshalAs、StructLayout、CallingConvention、CharSet、Marshal、IntPtr関連の処理など、P/Invokeで頻繁に使う型はSystem.Runtime.InteropServicesに含まれています。
2-2. DllImport属性とextern staticメソッドの書き方
基本形は次のとおりです。
C#[DllImport("DLL名")]
private static extern 戻り値の型 メソッド名(引数);
ポイントは3つです。
1つ目は、メソッドの上に[DllImport("...")]を付けることです。ここに呼び出したいDLL名を指定します。
2つ目は、メソッドにexternを付けることです。externは「メソッドの本体はC#側にはなく、外部に存在する」ことを表します。
3つ目は、通常staticにすることです。DllImportで宣言するメソッドは静的外部メソッドとして定義します。
たとえば、Windows APIのMessageBoxを呼び出す場合は次のようになります。
C#[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int MessageBox(
IntPtr hWnd,
string text,
string caption,
uint type);
2-3. DLL名・関数名・戻り値・引数の指定方法
DllImportでは、C#側のメソッド名とDLL内の関数名を一致させるのが基本です。
C#[DllImport("sample.dll")]
private static extern int Add(int a, int b);
この場合、sample.dll内にAddという関数がエクスポートされている必要があります。
C#側のメソッド名を変えたい場合は、EntryPointを使います。
C#[DllImport("sample.dll", EntryPoint = "Add")]
private static extern int AddNative(int a, int b);
戻り値と引数は、C/C++側の型に対応するC#型を指定します。たとえば、C側が次のような関数なら、
Cint Add(int a, int b);
C#側は次のように書きます。
C#[DllImport("sample.dll")]
private static extern int Add(int a, int b);
型が合っていないと、値が壊れたり、スタックが崩れたり、AccessViolationExceptionが発生したりします。DllImportでは「コンパイルが通ること」と「正しく呼び出せること」は別物です。
2-4. DllImportはクラス内に宣言する
DllImportメソッドはクラスや構造体の中に宣言します。一般的には、ネイティブ関数をまとめる専用クラスを作ると管理しやすくなります。
C#internal static class NativeMethods
{
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
internal static extern int MessageBox(
IntPtr hWnd,
string text,
string caption,
uint type);
}
呼び出す側は次のように使います。
C#NativeMethods.MessageBox(IntPtr.Zero, "Hello", "Title", 0);
外部DLLの呼び出し部分をNativeMethodsやWin32などのクラスに分離しておくと、アプリ本体のコードが読みやすくなり、エラー時の調査もしやすくなります。
2-5. 最小構成のサンプルコード
以下は、C#からWindows APIのMessageBoxを呼び出す最小構成のサンプルです。
C#using System;
using System.Runtime.InteropServices;
internal class Program
{
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int MessageBox(
IntPtr hWnd,
string text,
string caption,
uint type);
private static void Main()
{
MessageBox(IntPtr.Zero, "DllImportのテストです。", "C# P/Invoke", 0);
}
}
このコードをWindows環境で実行すると、メッセージボックスが表示されます。C#コードだけではなく、OSが提供するuser32.dll内の関数を呼び出している点がポイントです。
3. DllImportでDLL関数を呼び出す手順
3-1. 呼び出したいDLLと関数仕様を確認する
DllImportで最初に確認すべきなのは、DLLそのものではなく「関数仕様」です。次の情報を必ず確認します。
| 確認項目 | 内容 |
|---|---|
| DLL名 | sample.dll, user32.dllなど |
| 関数名 | Add, OpenDevice, GetStatusなど |
| 戻り値 | int, bool, void, ポインタなど |
| 引数 | 個数、順序、型、入力/出力 |
| 呼び出し規約 | __stdcall, __cdeclなど |
| 文字コード | ANSI, Unicode, UTF-8など |
| メモリ管理 | 誰が確保し、誰が解放するか |
| ビット数 | 32bit DLLか64bit DLLか |
ベンダー提供DLLの場合は、SDKのヘッダーファイル、API仕様書、サンプルコードを確認します。仕様書が曖昧な場合は、C/C++用のサンプルコードを見ると正しい引数や呼び出し順がわかることがあります。
3-2. C/C++側の関数宣言を確認する
たとえば、C/C++側に次のような関数があるとします。
Cextern "C" __declspec(dllexport)
int __stdcall Add(int a, int b);
この宣言から読み取れる情報は次のとおりです。
| C/C++側 | 意味 |
|---|---|
extern "C" | C++の名前修飾を避ける |
__declspec(dllexport) | DLLから関数をエクスポートする |
int | 戻り値は32bit整数 |
__stdcall | 呼び出し規約はStdCall |
Add | エクスポート関数名 |
int a, int b | 32bit整数の引数2つ |
この情報をC#側のDllImport宣言に反映します。
3-3. C#側の型に置き換える
C/C++の型をC#の型へ置き換えるときは、サイズと意味を合わせます。
たとえば、C/C++のintは多くの環境で32bitなので、C#ではintに対応します。doubleはC#でもdoubleです。ポインタは基本的にIntPtrを使います。
Cint Add(int a, int b);
C#private static extern int Add(int a, int b);
一方、longは注意が必要です。Windows APIのLONGはWindowsでは32bitであり、C#のlongは64bitです。Windows APIのLONGをC#で受ける場合は、原則としてintを使います。Microsoftのネイティブ相互運用ガイドでも、WindowsのLONGはC#のint、ポインタサイズに依存するHANDLEやHWNDなどはIntPtrを使う形で整理されています。
3-4. DllImport宣言を作成する
C/C++側が次の関数だとします。
Cextern "C" __declspec(dllexport)
int __stdcall Add(int a, int b);
C#側は次のように宣言します。
C#using System.Runtime.InteropServices;
internal static class NativeMethods
{
[DllImport("sample.dll", CallingConvention = CallingConvention.StdCall)]
internal static extern int Add(int a, int b);
}
__stdcallに対応するため、CallingConvention.StdCallを指定しています。呼び出し規約が一致しないと、特に32bit環境ではPInvokeStackImbalanceやクラッシュの原因になります。
3-5. C#コードからDLL関数を実行する
宣言できたら、通常の静的メソッドのように呼び出します。
C#int result = NativeMethods.Add(10, 20);
Console.WriteLine(result); // 30
ただし、実行時にはDLLが見つかる場所に配置されている必要があります。たとえば、実行ファイルと同じフォルダ、OSの検索パス、または明示的に設定したネイティブライブラリ検索パスにDLLが存在しなければ、DllNotFoundExceptionが発生します。
3-6. 戻り値とエラーコードを確認する
ネイティブAPIでは、戻り値が成功/失敗を示し、詳細なエラーは別途取得する設計がよくあります。Windows APIでは、失敗時にGetLastErrorでエラーコードを取得する関数が多く存在します。
C#で取得するには、DllImportにSetLastError = trueを付け、呼び出し直後にMarshal.GetLastWin32Error()を使います。
C#[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern uint GetFileAttributes(string lpFileName);
private const uint INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF;
public static void CheckFile(string path)
{
uint attributes = GetFileAttributes(path);
if (attributes == INVALID_FILE_ATTRIBUTES)
{
int error = Marshal.GetLastWin32Error();
Console.WriteLine($"エラーコード: {error}");
}
}
SetLastErrorを指定すべきかどうかは、呼び出すAPIの仕様によります。Microsoftのドキュメントでも、対象APIがGetLastErrorを使う場合はSetLastError = trueを指定し、別の呼び出しで上書きされる前にエラーを取得することが推奨されています。
4. DllImportの主要オプションと使い分け
4-1. EntryPointでDLL内の関数名を指定する
EntryPointは、DLL内で実際に呼び出す関数名を指定するオプションです。
C#[DllImport("sample.dll", EntryPoint = "Add")]
private static extern int AddNative(int a, int b);
C#側のメソッド名をAddNativeにしていても、DLL内ではAddを探します。
EntryPointは次のような場合に便利です。
| 使用場面 | 例 |
|---|---|
| C#側でわかりやすい名前に変えたい | OpenDeviceNativeなど |
| DLL内の関数名がC#の命名規則に合わない | open_deviceなど |
| ANSI版/Unicode版を明示したい | MessageBoxWなど |
| 名前修飾された関数を指定したい | _Func@8など |
ただし、名前修飾された関数を直接指定するより、可能であればC++側でextern "C"を付けて、わかりやすい関数名でエクスポートする方が保守しやすいです。
4-2. CallingConventionで呼び出し規約を指定する
CallingConventionは、関数呼び出し時に「引数をどの順序でスタックに積むか」「誰がスタックを片付けるか」などを決めるルールです。
主な値は次のとおりです。
| C# | C/C++側 | よくある用途 |
|---|---|---|
CallingConvention.Winapi | プラットフォーム既定 | 既定値 |
CallingConvention.StdCall | __stdcall | Windows API、Win32 DLL |
CallingConvention.Cdecl | __cdecl | Cライブラリ、可変長引数 |
CallingConvention.ThisCall | __thiscall | C++インスタンスメソッド系 |
CallingConvention.FastCall | __fastcall | 一部のネイティブコード |
C/C++側が__stdcallなら、C#側もStdCallにします。
C#[DllImport("sample.dll", CallingConvention = CallingConvention.StdCall)]
private static extern int Add(int a, int b);
C/C++側が__cdeclなら、C#側もCdeclにします。
C#[DllImport("sample.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int Add(int a, int b);
呼び出し規約の不一致は、DllImportのトラブルの中でも厄介です。特に32bit環境では、スタック不整合により実行時例外やクラッシュが発生します。
4-3. CharSetで文字コードを指定する
CharSetは、文字列や文字をDLLへ渡すときの文字コード、および関数名探索の挙動に関係します。
C#[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int MessageBox(
IntPtr hWnd,
string text,
string caption,
uint type);
主な値は次のとおりです。
| 値 | 意味 |
|---|---|
CharSet.Ansi | ANSI文字列として扱う |
CharSet.Unicode | Unicode、主にUTF-16として扱う |
CharSet.Auto | プラットフォームに応じて決定 |
CharSet.None | 文字セット指定なし |
Windows APIでは、文字列を扱う関数にA版とW版が存在することがあります。たとえばMessageBoxAはANSI版、MessageBoxWはUnicode版です。現代のWindowsアプリでは、基本的にCharSet.Unicodeを明示するのが安全です。
Microsoftのベストプラクティスでも、文字列や文字が含まれるDllImport定義ではCharSet.UnicodeまたはCharSet.Ansiを明示することが推奨されています。
4-4. SetLastErrorでWin32エラーを取得する
SetLastError = trueを指定すると、ネイティブ関数が設定した直近のエラーコードを.NET側で取得できるようになります。
C#[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr GetCurrentProcess();
エラーコードの取得には次を使います。
C#int errorCode = Marshal.GetLastWin32Error();
注意点は、DLL関数を呼び出した直後に取得することです。他のP/Invokeや一部のAPI呼び出しを挟むと、エラーコードが上書きされる可能性があります。
4-5. ExactSpellingの役割と注意点
ExactSpellingは、指定した関数名を完全一致で探すかどうかを制御します。
C#[DllImport("user32.dll", EntryPoint = "MessageBoxW", ExactSpelling = true)]
private static extern int MessageBoxW(
IntPtr hWnd,
string text,
string caption,
uint type);
ExactSpelling = falseの場合、CharSetの指定に応じてAやW付きの関数名を探すことがあります。たとえばMessageBoxと書いたとき、Unicode指定ならMessageBoxWを探すような挙動です。
Microsoftのベストプラクティスでは、ExactSpellingはtrueが推奨されています。ランタイムが代替名を探す必要がなくなるため、わずかな性能上の利点もあります。
実務では、関数名を明確にしたい場合は次のように書くとよいです。
C#[DllImport("user32.dll", EntryPoint = "MessageBoxW", ExactSpelling = true, CharSet = CharSet.Unicode)]
private static extern int MessageBox(
IntPtr hWnd,
string text,
string caption,
uint type);
4-6. PreserveSigを使うケース
PreserveSigは、HRESULTを返すネイティブメソッドの扱いに関係します。
既定値はtrueです。この場合、ネイティブ関数の戻り値をそのままC#側の戻り値として受け取ります。
C#[DllImport("some.dll", PreserveSig = true)]
private static extern int SomeComLikeFunction();
PreserveSig = falseにすると、失敗したHRESULTが例外に変換されることがあります。ただし、通常のWin32 APIや独自DLL呼び出しでは、既定値のまま使うケースが多いです。
Microsoftのドキュメントでも、PreserveSigはHRESULT戻り値を直接変換するか、自動的に例外へ変換するかに関係するフィールドとして説明されています。
5. 型変換とマーシャリングの基本
5-1. int/long/bool/doubleなど基本型の対応表
P/Invokeでは、C/C++側の型とC#側の型を正しく対応させる必要があります。代表的な対応は次のとおりです。
| C/C++側 | C#側 | 注意点 |
|---|---|---|
char | byte / sbyte | 符号の有無に注意 |
unsigned char | byte | 8bit |
short | short | 16bit |
unsigned short | ushort | 16bit |
int | int | 多くの環境で32bit |
unsigned int | uint | 32bit |
long | 要確認 | WindowsのLONGは通常int |
long long | long | 64bit |
float | float | 32bit浮動小数点 |
double | double | 64bit浮動小数点 |
void* | IntPtr | ポインタ |
char* | string / IntPtr / byte[] | 入出力方向に注意 |
wchar_t* | string / IntPtr | WindowsではUTF-16が多い |
bool | 要確認 | C++のboolは1byteの場合が多い |
特にboolは注意が必要です。.NETのboolは既定ではWindowsのBOOL、つまり4byte値としてマーシャリングされます。一方、C/C++のboolや_Boolは1byteであることが多く、不一致がバグの原因になります。
C++側が1byteのboolを返す場合は、次のように指定することがあります。
C#[return: MarshalAs(UnmanagedType.I1)]
[DllImport("sample.dll")]
private static extern bool IsEnabled();
Windows APIのBOOLなら、C#側はboolよりintで受けて明示的に判定する方が安全な場合もあります。
C#[DllImport("sample.dll")]
private static extern int IsEnabled();
bool enabled = IsEnabled() != 0;
5-2. string/StringBuilderの渡し方
C#のstringは不変の文字列です。ネイティブ側に入力文字列として渡すだけなら、stringを使うのが一般的です。
C#[DllImport("sample.dll", CharSet = CharSet.Unicode)]
private static extern int PrintText(string text);
ネイティブ側が文字列バッファに結果を書き込む場合は、以前はStringBuilderがよく使われていました。
C#[DllImport("sample.dll", CharSet = CharSet.Unicode)]
private static extern int GetName(StringBuilder buffer, int bufferSize);
var buffer = new StringBuilder(256);
int result = GetName(buffer, buffer.Capacity);
Console.WriteLine(buffer.ToString());
ただし、StringBuilderのマーシャリングは内部的なコピーが多く、効率が悪くなる場合があります。Microsoftのベストプラクティスでも、StringBuilderパラメータは避けることを検討し、可能ならchar[]やbyte[]バッファを使うことが推奨されています。
たとえばUnicode文字の出力バッファなら、次のようにchar[]を使えます。
C#[DllImport("sample.dll", CharSet = CharSet.Unicode)]
private static extern int GetName(
[Out] char[] buffer,
int bufferLength);
char[] buffer = new char[256];
int length = GetName(buffer, buffer.Length);
string name = new string(buffer, 0, length);
Console.WriteLine(name);
文字列は「入力だけ」なのか「出力される」のか「誰がメモリを確保するのか」を必ず確認してください。
5-3. 配列をDLLに渡す方法
配列を渡す場合は、配列本体と要素数をセットで渡す設計が一般的です。
C側の関数例です。
Cint Sum(int* values, int length);
C#側は次のように宣言します。
C#[DllImport("sample.dll")]
private static extern int Sum(
[In] int[] values,
int length);
呼び出し例です。
C#int[] values = { 1, 2, 3, 4, 5 };
int result = Sum(values, values.Length);
Console.WriteLine(result);
ネイティブ側が配列を書き換える場合は、[Out]または[In, Out]を使います。
C#[DllImport("sample.dll")]
private static extern void FillArray(
[Out] int[] values,
int length);
C#int[] values = new int[10];
FillArray(values, values.Length);
配列の長さをネイティブ側が知らないと、バッファオーバーランの原因になります。必ず長さを渡し、C/C++側でも範囲外に書き込まないようにします。
5-4. 構造体を渡す方法
構造体を渡す場合は、C#側でStructLayoutを指定します。基本はLayoutKind.Sequentialです。
C側の構造体例です。
Ctypedef struct Point
{
int X;
int Y;
} Point;
int GetLength(Point p);
C#側は次のように定義します。
C#[StructLayout(LayoutKind.Sequential)]
public struct Point
{
public int X;
public int Y;
}
[DllImport("sample.dll")]
private static extern int GetLength(Point point);
参照渡しする場合はrefを使います。
C#[DllImport("sample.dll")]
private static extern void MovePoint(ref Point point);
C#var p = new Point { X = 10, Y = 20 };
MovePoint(ref p);
構造体では、フィールドの順序、サイズ、アライメント、文字列や配列の扱いが重要です。C/C++側の構造体とC#側の構造体のメモリレイアウトが一致していないと、値がずれたりクラッシュしたりします。
5-5. ポインタとIntPtrの扱い方
C/C++側のポインタは、C#ではIntPtrで表すのが基本です。
Cvoid* CreateHandle();
void CloseHandle(void* handle);
C#[DllImport("sample.dll")]
private static extern IntPtr CreateHandle();
[DllImport("sample.dll")]
private static extern void CloseHandle(IntPtr handle);
呼び出し例です。
C#IntPtr handle = CreateHandle();
try
{
if (handle == IntPtr.Zero)
{
throw new InvalidOperationException("ハンドルの作成に失敗しました。");
}
// handleを使った処理
}
finally
{
if (handle != IntPtr.Zero)
{
CloseHandle(handle);
}
}
ポインタやハンドルは、誰が解放するのかが重要です。ネイティブ側で確保したメモリをC#側で勝手にMarshal.FreeHGlobalしてはいけません。必ずDLL側が提供する解放関数を使うか、仕様書に従ってください。
長期間保持するネイティブリソースには、SafeHandleの利用も検討します。Microsoftのベストプラクティスでも、アンマネージリソースを扱うオブジェクトのライフタイム管理にはSafeHandleが推奨されています。
5-6. ref/outを使うケース
C/C++側でポインタ引数を使って値を返す関数は、C#ではrefまたはoutで表現できます。
C側の例です。
Cint GetValue(int* value);
C#側では、初期値が必要ないならoutを使います。
C#[DllImport("sample.dll")]
private static extern int GetValue(out int value);
C#int resultCode = GetValue(out int value);
Console.WriteLine(value);
C側が入力値を受け取り、さらに書き換える場合はrefを使います。
Cint UpdateValue(int* value);
C#[DllImport("sample.dll")]
private static extern int UpdateValue(ref int value);
C#int value = 10;
UpdateValue(ref value);
Console.WriteLine(value);
使い分けは次のとおりです。
| C# | 用途 |
|---|---|
out | ネイティブ側から値を受け取る |
ref | C#側から渡した値をネイティブ側で読み書きする |
in | 読み取り専用の参照渡しを表現したい場合 |
5-7. MarshalAs属性が必要になるケース
MarshalAsは、既定のマーシャリングでは正しく表現できない型を明示的に指定するために使います。
たとえば、1byteのC++ boolを返す関数です。
C#[return: MarshalAs(UnmanagedType.I1)]
[DllImport("sample.dll")]
private static extern bool IsReady();
文字列を明示的にUnicodeとして渡す場合です。
C#[DllImport("sample.dll")]
private static extern void Print(
[MarshalAs(UnmanagedType.LPWStr)] string text);
構造体内の固定長文字列を扱う場合です。
C#[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct DeviceInfo
{
public int Id;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string Name;
}
配列を構造体内に固定長で持つ場合です。
C#[StructLayout(LayoutKind.Sequential)]
public struct DataBlock
{
public int Length;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
public int[] Values;
}
MarshalAsは強力ですが、指定を間違えるとMarshalDirectiveExceptionやメモリ破壊の原因になります。C/C++側の型とメモリレイアウトを確認したうえで使いましょう。
6. 実践サンプル:C#からDLLを呼び出すコード例
6-1. Windows APIをDllImportで呼び出す例
ここでは、Windows APIのGetTickCountを呼び出します。システム起動からの経過時間をミリ秒で取得する関数です。
C#using System;
using System.Runtime.InteropServices;
internal class Program
{
[DllImport("kernel32.dll")]
private static extern uint GetTickCount();
private static void Main()
{
uint tick = GetTickCount();
Console.WriteLine($"起動からの経過時間: {tick} ms");
}
}
次に、Unicode版のMessageBoxWを明示して呼び出す例です。
C#using System;
using System.Runtime.InteropServices;
internal class Program
{
[DllImport(
"user32.dll",
EntryPoint = "MessageBoxW",
ExactSpelling = true,
CharSet = CharSet.Unicode)]
private static extern int MessageBox(
IntPtr hWnd,
string text,
string caption,
uint type);
private static void Main()
{
MessageBox(IntPtr.Zero, "こんにちは", "DllImportサンプル", 0);
}
}
EntryPoint = "MessageBoxW"とExactSpelling = trueを指定することで、呼び出す関数名を明確にしています。
6-2. C++で作成した独自DLLをC#から呼び出す例
C++側で次のようなDLLを作成します。
C++// NativeLib.cpp
extern "C" __declspec(dllexport)
int __stdcall Add(int a, int b)
{
return a + b;
}
C#側では次のように呼び出します。
C#using System;
using System.Runtime.InteropServices;
internal class Program
{
[DllImport("NativeLib.dll", CallingConvention = CallingConvention.StdCall)]
private static extern int Add(int a, int b);
private static void Main()
{
int result = Add(3, 5);
Console.WriteLine(result); // 8
}
}
C++側でextern "C"を付けている点が重要です。これを付けないと、C++コンパイラによって関数名が修飾され、C#側からAddという名前で見つけられない可能性があります。
6-3. 文字列を受け渡しする例
C++側の例です。
C++#include <windows.h>
extern "C" __declspec(dllexport)
int __stdcall GetGreeting(wchar_t* buffer, int bufferLength)
{
const wchar_t* message = L"Hello from native DLL";
int i = 0;
for (; message[i] != L'\0' && i < bufferLength - 1; i++)
{
buffer[i] = message[i];
}
buffer[i] = L'\0';
return i;
}
C#側ではchar[]を使って受け取ります。
C#using System;
using System.Runtime.InteropServices;
internal class Program
{
[DllImport("NativeLib.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern int GetGreeting(
[Out] char[] buffer,
int bufferLength);
private static void Main()
{
char[] buffer = new char[256];
int length = GetGreeting(buffer, buffer.Length);
string message = new string(buffer, 0, length);
Console.WriteLine(message);
}
}
ネイティブ側で文字列を書き込む場合は、バッファ長を必ず渡し、終端文字分の領域も考慮します。
6-4. 構造体を受け渡しする例
C++側の構造体と関数です。
C++struct Point
{
int X;
int Y;
};
extern "C" __declspec(dllexport)
void __stdcall MovePoint(Point* point, int dx, int dy)
{
point->X += dx;
point->Y += dy;
}
C#側の定義です。
C#using System;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential)]
public struct Point
{
public int X;
public int Y;
}
internal class Program
{
[DllImport("NativeLib.dll", CallingConvention = CallingConvention.StdCall)]
private static extern void MovePoint(ref Point point, int dx, int dy);
private static void Main()
{
var point = new Point { X = 10, Y = 20 };
MovePoint(ref point, 5, -3);
Console.WriteLine($"X={point.X}, Y={point.Y}");
}
}
Point*に対応して、C#側ではref Pointを使っています。
6-5. コールバック関数を渡す例
ネイティブDLLにC#のコールバック関数を渡すこともできます。C++側の例です。
C++typedef void (__stdcall *Callback)(int value);
extern "C" __declspec(dllexport)
void __stdcall EnumerateValues(Callback callback)
{
for (int i = 0; i < 5; i++)
{
callback(i);
}
}
C#側ではデリゲートを定義します。
C#using System;
using System.Runtime.InteropServices;
internal class Program
{
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate void Callback(int value);
[DllImport("NativeLib.dll", CallingConvention = CallingConvention.StdCall)]
private static extern void EnumerateValues(Callback callback);
private static void Main()
{
Callback callback = OnValue;
EnumerateValues(callback);
GC.KeepAlive(callback);
}
private static void OnValue(int value)
{
Console.WriteLine($"Callback: {value}");
}
}
コールバックでは、デリゲートがガベージコレクションで回収されないように注意します。GC.KeepAlive(callback)を使って、呼び出しが終わるまで生存させています。
6-6. 32bit/64bit DLLを切り替える例
32bit DLLと64bit DLLを別々に用意している場合、プロセスのビット数に応じて読み込むDLLを変える必要があります。DllImportのDLL名は原則としてコンパイル時に固定されるため、単純な条件分岐では切り替えにくいです。
簡単な方法は、同じDLL名で配置フォルダを分け、実行時に適切な場所へ配置する方法です。
app/
MyApp.exe
x86/
NativeLib.dll
x64/
NativeLib.dll
より柔軟に切り替えたい場合は、NativeLibrary.LoadやNativeLibrary.SetDllImportResolverを使う方法があります。NativeLibraryはネイティブライブラリを管理するAPIで、ライブラリのロード、エクスポートシンボル取得、解放などを扱えます。
例として、アセンブリ単位でDLL解決をカスタマイズする場合は次のような考え方になります。
C#using System;
using System.Reflection;
using System.Runtime.InteropServices;
internal static class NativeResolver
{
public static void Register()
{
NativeLibrary.SetDllImportResolver(
Assembly.GetExecutingAssembly(),
Resolve);
}
private static IntPtr Resolve(
string libraryName,
Assembly assembly,
DllImportSearchPath? searchPath)
{
if (libraryName == "NativeLib")
{
string arch = Environment.Is64BitProcess ? "x64" : "x86";
string path = System.IO.Path.Combine(
AppContext.BaseDirectory,
arch,
"NativeLib.dll");
return NativeLibrary.Load(path);
}
return IntPtr.Zero;
}
}
DllImport側は次のように論理名で書けます。
C#[DllImport("NativeLib")]
private static extern int Add(int a, int b);
アプリ起動時にNativeResolver.Register()を呼んでおけば、ビット数に応じたDLLを読み込めます。
7. DllImportでよくあるエラーと解決法
7-1. DllNotFoundException:DLLが見つからない
DllNotFoundExceptionは、指定したDLLを.NETランタイムが見つけられないときに発生します。
主な原因は次のとおりです。
| 原因 | 対策 |
|---|---|
| DLLが実行フォルダにない | .exeまたはアプリの出力先に配置する |
| DLL名が間違っている | 大文字小文字、拡張子、ファイル名を確認 |
| 依存DLLが不足している | Dependency WalkerやDependenciesで確認 |
| PATHが通っていない | PATH追加または配置場所を変更 |
| x86/x64フォルダの切り替えミス | 実行プロセスのビット数に合わせる |
| Linux/macOSでライブラリ名が違う | .so, .dylibの名前を確認 |
注意すべきなのは、メッセージ上はNativeLib.dllが見つからないように見えても、実際にはNativeLib.dllが依存している別のDLLが不足している場合があることです。
7-2. EntryPointNotFoundException:関数名が見つからない
EntryPointNotFoundExceptionは、DLL自体は見つかったものの、指定した関数名がDLL内に存在しない場合に発生します。
主な原因は次のとおりです。
| 原因 | 対策 |
|---|---|
| 関数名のスペルミス | ヘッダーやエクスポート名を確認 |
| C++の名前修飾 | extern "C"を付ける |
__stdcallによる装飾名 | 実際のエクスポート名を確認 |
| ANSI/Unicode版の違い | MessageBoxA / MessageBoxWを確認 |
| 関数がエクスポートされていない | __declspec(dllexport)や.defファイルを確認 |
Windowsでは、Visual Studioのdumpbinでエクスポート名を確認できます。
cmddumpbin /exports NativeLib.dll
関数名がAddではなく_Add@8のように表示される場合、名前修飾が原因です。C++側でextern "C"を使う、またはC#側のEntryPointに実際のエクスポート名を指定します。
7-3. BadImageFormatException:32bit/64bitが一致しない
BadImageFormatExceptionは、C#アプリとネイティブDLLのビット数が一致していないときによく発生します。
たとえば、次の組み合わせはNGです。
| C#アプリ | DLL | 結果 |
|---|---|---|
| 64bit | 32bit DLL | 読み込めない |
| 32bit | 64bit DLL | 読み込めない |
対策は、プロジェクトのプラットフォームターゲットとDLLのビット数を合わせることです。
Visual Studioでは、プロジェクトの「ビルド」設定で次を確認します。
| 設定 | 意味 |
|---|---|
| Any CPU | 実行環境に応じて32/64bitが変わる |
| x86 | 32bitプロセスとして実行 |
| x64 | 64bitプロセスとして実行 |
| Prefer 32-bit | Any CPUでも32bit優先になる場合がある |
32bit DLLしかない場合は、C#アプリもx86でビルドします。64bit DLLを使うなら、C#アプリもx64でビルドします。
7-4. AccessViolationException:メモリ破壊や型定義ミス
AccessViolationExceptionは、保護されたメモリ領域へ不正アクセスしたときに発生します。P/Invokeでは、型定義やバッファサイズのミスによってよく起こります。
主な原因は次のとおりです。
| 原因 | 例 |
|---|---|
| 引数型が違う | longとintの取り違え |
| 構造体レイアウトが違う | StructLayout不足 |
| バッファサイズ不足 | 文字列や配列の書き込み超過 |
| ポインタが無効 | 解放済みハンドルを使用 |
| 呼び出し規約が違う | StdCallとCdeclの不一致 |
| メモリ解放方法が違う | DLL側確保メモリを誤った方法で解放 |
AccessViolationExceptionが出た場合は、C#側だけを見るのではなく、C/C++側の関数宣言、構造体定義、呼び出し規約、メモリ管理ルールを最初から見直す必要があります。
7-5. MarshalDirectiveException:マーシャリング指定ミス
MarshalDirectiveExceptionは、.NETが指定された型をネイティブ側へ変換できない場合に発生します。
よくある原因は次のとおりです。
| 原因 | 対策 |
|---|---|
| ジェネリック型を渡している | P/Invoke対象にしない |
| 構造体内の配列指定が不足 | MarshalAsでByValArrayを指定 |
| 文字列フィールドのサイズ不明 | ByValTStrとSizeConstを指定 |
| 非対応の型を指定 | IntPtrや明示的構造体に置き換える |
DllImportAttributeはジェネリック型のマーシャリングをサポートしていないため、P/Invokeに渡す型はシンプルで明確な構造にするのが基本です。
7-6. PInvokeStackImbalance:呼び出し規約の不一致
PInvokeStackImbalanceは、主に32bit環境で呼び出し規約が一致していない場合に発生します。
たとえば、C++側が__cdeclなのに、C#側でStdCallとして宣言している場合です。
C++extern "C" __declspec(dllexport)
int __cdecl Add(int a, int b);
C#側は次のように合わせる必要があります。
C#[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int Add(int a, int b);
呼び出し規約は「何となく」ではなく、DLLのヘッダーファイルや仕様書で確認してください。
7-7. 依存DLLが不足している場合の確認方法
DLL本体が存在しても、そのDLLが依存する別のDLLが不足していると読み込みに失敗します。
確認方法は次のとおりです。
| 方法 | 内容 |
|---|---|
| Dependencies | DLLの依存関係をGUIで確認 |
| dumpbin | Visual Studio付属ツールで依存関係を確認 |
| Process Monitor | 実行時にどのパスを探しているか確認 |
| ldd | Linuxで.soの依存関係を確認 |
| otool | macOSで.dylibの依存関係を確認 |
WindowsでVisual C++ランタイムが不足している場合も、DllNotFoundExceptionのように見えることがあります。ベンダーDLLを使う場合は、必要なランタイムや再頒布可能パッケージも確認しましょう。
7-8. DLLは読み込めるのに実行時に落ちる原因
DLLの読み込みには成功するのに、関数を呼び出した瞬間に落ちる場合は、次のような原因が考えられます。
| 原因 | 確認ポイント |
|---|---|
| 引数の順序が違う | C/C++の宣言と完全一致しているか |
| 型サイズが違う | int, long, IntPtrの違い |
| 構造体レイアウトが違う | StructLayout, Pack, 文字列フィールド |
| 呼び出し規約が違う | StdCall, Cdecl |
| 初期化関数を呼んでいない | SDKの利用手順を確認 |
| ハンドルが無効 | Open後にClose済みでないか |
| スレッド制約がある | UIスレッドや専用スレッドが必要か |
| メモリ所有権が違う | 確保・解放の担当が正しいか |
特にベンダー提供DLLでは、「最初に初期化関数を呼ぶ」「ログイン後にハンドルを取得する」「終了時に必ず解放する」といった順序が決まっていることがあります。P/Invoke宣言だけでなく、API利用手順も確認してください。
8. DllImportで失敗しないための確認ポイント
8-1. DLLの配置場所を確認する
まずはDLLの配置場所を確認します。一般的には、アプリケーションの実行ファイルと同じフォルダに置くのが最もわかりやすいです。
.NETアプリでは、ビルド後の出力先は通常次のような場所です。
bin/Debug/net8.0/
bin/Release/net8.0/
Windowsデスクトップアプリなら、.exeがあるフォルダにDLLを配置します。Visual Studioで常にコピーしたい場合は、DLLファイルのプロパティで「出力ディレクトリにコピー」を設定します。
XML<ItemGroup>
<None Include="NativeLib.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
8-2. C#アプリとDLLのビット数をそろえる
C#アプリとネイティブDLLのビット数は必ず一致させます。
確認すべき項目は次のとおりです。
| 確認対象 | 内容 |
|---|---|
| C#プロジェクト | x86 / x64 / Any CPU |
| DLL | 32bit / 64bit |
| 実行環境 | 32bit OS / 64bit OS |
| Visual Studio設定 | Prefer 32-bit |
Any CPUは便利ですが、ネイティブDLLを使うアプリではトラブルの原因になることがあります。使用するDLLが32bitならx86、64bitならx64に固定する方が安全です。
8-3. 関数名の装飾を確認する
C++で作成したDLLでは、関数名がコンパイラによって装飾されることがあります。
たとえば、C++側で単に次のように書いた場合です。
C++__declspec(dllexport)
int Add(int a, int b);
DLL内のエクスポート名が?Add@@YAHHH@Zのようになる可能性があります。C#側でAddを探しても見つかりません。
エクスポート名は次のコマンドで確認できます。
cmddumpbin /exports NativeLib.dll
関数名が想定と違う場合は、C++側にextern "C"を追加します。
8-4. C++側でextern "C"を指定する
C#から呼び出しやすいDLLを作るなら、C++側では次のようにextern "C"を付けるのが基本です。
C++extern "C" __declspec(dllexport)
int __stdcall Add(int a, int b)
{
return a + b;
}
extern "C"を付けることで、C++の名前修飾を避け、エクスポート名をシンプルにできます。
複数関数にまとめて適用する場合は、ヘッダーファイルで次のように書くこともあります。
C++#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllexport) int __stdcall Add(int a, int b);
__declspec(dllexport) int __stdcall Subtract(int a, int b);
#ifdef __cplusplus
}
#endif
8-5. 呼び出し規約をDLL側と一致させる
C++側とC#側の呼び出し規約を一致させます。
C++側が__stdcallなら、
C++extern "C" __declspec(dllexport)
int __stdcall Add(int a, int b);
C#側は次のようにします。
C#[DllImport("NativeLib.dll", CallingConvention = CallingConvention.StdCall)]
private static extern int Add(int a, int b);
C++側が__cdeclなら、
C++extern "C" __declspec(dllexport)
int __cdecl Add(int a, int b);
C#側は次のようにします。
C#[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int Add(int a, int b);
仕様書に呼び出し規約が書かれていない場合は、ヘッダーファイルのWINAPI、CALLBACK、__stdcall、__cdeclなどのマクロを確認してください。
8-6. 文字コードをANSI/Unicodeで合わせる
文字列を渡す関数では、文字コードの不一致が文字化けやクラッシュの原因になります。
C++側がwchar_t*を受け取るなら、C#側はCharSet.Unicodeを指定します。
C#[DllImport("NativeLib.dll", CharSet = CharSet.Unicode)]
private static extern void PrintText(string text);
C++側がchar*を受け取るなら、ANSIまたはUTF-8なのかを確認します。
C#[DllImport("NativeLib.dll", CharSet = CharSet.Ansi)]
private static extern void PrintText(string text);
ただし、CharSet.Ansiの扱いはプラットフォームによって異なることがあります。WindowsではANSIコードページ、Unix系ではUTF-8として扱われるケースがあります。クロスプラットフォームで文字列を扱うなら、明示的にUTF-8バイト配列として渡す設計も検討します。
8-7. メモリ確保と解放の責任範囲を明確にする
P/Invokeで最も危険なのが、メモリ管理の責任範囲が曖昧なまま実装することです。
次の点を必ず確認してください。
| 確認項目 | 例 |
|---|---|
| メモリを確保するのは誰か | C#側かDLL側か |
| メモリを解放するのは誰か | Free関数が用意されているか |
| どの解放関数を使うか | CoTaskMemFree, 独自FreeBufferなど |
| 文字列の終端はどうなるか | null終端か、長さ指定か |
| バッファサイズは誰が決めるか | C#側で確保して渡すか |
| ハンドルの有効期間 | OpenからCloseまで |
たとえば、DLL側が確保したメモリを返す場合、通常は同じDLL側に解放関数が用意されています。
Cchar* GetMessage();
void FreeMessage(char* p);
C#側では、取得したポインタを文字列化した後、必ず解放関数を呼びます。
C#[DllImport("NativeLib.dll")]
private static extern IntPtr GetMessage();
[DllImport("NativeLib.dll")]
private static extern void FreeMessage(IntPtr p);
IntPtr p = GetMessage();
try
{
string message = Marshal.PtrToStringAnsi(p) ?? string.Empty;
Console.WriteLine(message);
}
finally
{
if (p != IntPtr.Zero)
{
FreeMessage(p);
}
}
Marshal.FreeHGlobalやMarshal.FreeCoTaskMemを使うべきかどうかは、メモリの確保方法によります。DLLの仕様に従ってください。
9. DllImportの代替手段と使い分け
9-1. LibraryImportとの違い
.NET 7以降では、LibraryImportを使ったP/Invokeソース生成が利用できます。
DllImportは、実行時にマーシャリング用のILスタブを生成して呼び出しを行います。一方、LibraryImportはソースジェネレーターがコンパイル時にマーシャリングコードを生成します。これにより、Native AOTやトリミングとの相性が良くなり、生成されたコードをデバッグしやすくなるメリットがあります。
DllImportの例です。
C#[DllImport("NativeLib", EntryPoint = "Add")]
internal static extern int Add(int a, int b);
LibraryImportの例です。
C#[LibraryImport("NativeLib", EntryPoint = "Add")]
internal static partial int Add(int a, int b);
LibraryImportでは、メソッドをstatic partialにします。また、文字列マーシャリングではCharSetではなくStringMarshallingを使います。
C#[LibraryImport(
"NativeLib",
EntryPoint = "PrintText",
StringMarshalling = StringMarshalling.Utf16)]
internal static partial void PrintText(string text);
Microsoftのベストプラクティスでも、.NET 7以降をターゲットにする場合は、可能であればLibraryImportの利用が推奨されています。
ただし、LibraryImportにはDllImportと異なる制約もあります。たとえば、CallingConventionはLibraryImportAttributeではなくUnmanagedCallConvAttributeで指定します。また、プロジェクトでunsafeコードの許可が必要になるケースがあります。既存のDllImportコードを置き換えるときは、互換性を確認しながら進めましょう。
9-2. NativeLibrary.Loadを使う方法
DLLを動的に選択したい場合は、NativeLibrary.Loadを使えます。
C#IntPtr library = NativeLibrary.Load("NativeLib.dll");
関数ポインタを取得するにはNativeLibrary.GetExportを使います。
C#IntPtr proc = NativeLibrary.GetExport(library, "Add");
取得した関数ポインタをデリゲートに変換します。
C#[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int AddDelegate(int a, int b);
IntPtr library = NativeLibrary.Load("NativeLib.dll");
IntPtr proc = NativeLibrary.GetExport(library, "Add");
var add = Marshal.GetDelegateForFunctionPointer<AddDelegate>(proc);
int result = add(10, 20);
Console.WriteLine(result);
NativeLibrary.Free(library);
この方法は、次のようなケースに向いています。
| 使用場面 | 理由 |
|---|---|
| DLL名を実行時に変えたい | 設定ファイルで切り替える |
| x86/x64やOS別にロードしたい | 条件分岐できる |
| プラグイン的にDLLを扱いたい | 存在確認してから読み込める |
| 関数の有無を確認したい | TryGetExportを使える |
一方、単純に固定DLLの関数を呼びたいだけなら、DllImportの方が簡潔です。
9-3. CsWin32を使ってWin32 APIを安全に呼び出す方法
Windows APIを大量に呼び出す場合、自分でDllImport宣言を書くと、型定義ミスや構造体定義ミスが起こりやすくなります。そのような場合は、CsWin32の利用を検討できます。
CsWin32は、C#プロジェクトにWin32 APIのP/Invokeメソッドや関連型をソース生成で追加するためのツールです。MicrosoftのCsWin32リポジトリでも、Win32のP/InvokeやCOM Interop向けの型安全なバインディングを生成する仕組みとして説明されています。
手書きのDllImportでは、次のような問題が起こりがちです。
| 手書きの問題 | CsWin32の利点 |
|---|---|
| 型を間違える | 生成された型を使える |
| 構造体定義が面倒 | 関連型も生成される |
| Win32 API名を探すのが大変 | 必要なAPI名を指定して生成 |
SafeHandle対応が面倒 | より安全なラッパーを使える場合がある |
Win32 APIを1〜2個呼ぶ程度なら手書きでも十分ですが、大規模に使うならCsWin32の方が保守性が高くなります。
9-4. C#同士のDLLなら参照追加を使う
C#で作ったDLLをC#アプリから使うなら、DllImportではなく参照追加を使います。
たとえば、MyLibrary.dllに次のクラスがあるとします。
C#namespace MyLibrary
{
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
}
}
アプリ側ではプロジェクト参照またはDLL参照を追加し、次のように呼び出します。
C#using MyLibrary;
var calculator = new Calculator();
int result = calculator.Add(1, 2);
DllImportは不要です。
DllImportは「DLLを呼ぶための万能機能」ではなく、「アンマネージDLLのエクスポート関数を呼ぶための機能」です。C#同士なら.NETの通常の参照機構を使いましょう。
9-5. COM/NuGet/C++/CLIを検討すべきケース
DllImport以外の選択肢が適しているケースもあります。
| 手段 | 向いているケース |
|---|---|
| COM Interop | COMコンポーネントやActiveXを使う |
| NuGetパッケージ | 既に.NET向けラッパーが提供されている |
| C++/CLI | C++ライブラリとC#の橋渡しを柔軟に行いたい |
| gRPC / REST | 別プロセスや別マシンの機能を呼びたい |
| プロセス起動 | DLL化されていない既存EXEと連携したい |
| LibraryImport | .NET 7以降で新規P/Invokeを書く |
| NativeLibrary | DLLを動的に選択したい |
特にC++のクラス、テンプレート、複雑なオブジェクトをC#から直接扱いたい場合、DllImportだけでは扱いにくいことがあります。その場合は、C++側にC形式の薄いラッパー関数を用意するか、C++/CLIで.NET向けのラッパーを作ると保守しやすくなります。
10. DllImportに関するよくある質問
10-1. C#で作ったDLLをDllImportで呼び出せる?
通常のC#クラスライブラリDLLは、DllImportでは呼び出しません。C#で作ったDLLはマネージDLLなので、プロジェクト参照やアセンブリ参照で利用します。
DllImportは、C/C++などで作られたアンマネージDLLのエクスポート関数を呼び出すための機能です。
例外的に、Native AOTやアンマネージエクスポートの仕組みを使ってC#側からネイティブ公開する高度な方法もありますが、一般的なC# DLL利用ではありません。通常は「C# DLLなら参照追加」と覚えておけば問題ありません。
10-2. DllImportで相対パスや絶対パスは指定できる?
DllImportにはDLL名を指定するのが一般的です。
C#[DllImport("NativeLib.dll")]
private static extern int Add(int a, int b);
絶対パスを直接書くことも技術的には可能ですが、環境依存が強くなるためおすすめしません。
C#[DllImport(@"C:\libs\NativeLib.dll")]
private static extern int Add(int a, int b);
相対パスを指定する場合も、実行時のカレントディレクトリに依存するため注意が必要です。アプリの配置場所を基準にしたい場合は、NativeLibrary.LoadやSetDllImportResolverで明示的に解決する方が安全です。
10-3. DLLを動的に切り替えて読み込める?
固定的なDllImportだけでは、実行時にDLL名を柔軟に切り替えるのは苦手です。
動的に切り替えたい場合は、次の方法があります。
| 方法 | 特徴 |
|---|---|
NativeLibrary.Load | 実行時にパスを指定してロードできる |
NativeLibrary.SetDllImportResolver | DllImportの解決処理をカスタマイズできる |
| 同名DLLを配置で切り替える | シンプルだが管理に注意 |
| OS別ビルド/RID別配置 | .NETのpublish構成と相性がよい |
プラグイン方式や、x86/x64別、Windows/Linux別にDLLを切り替える場合は、NativeLibraryを使う設計が向いています。
10-4. private/publicのどちらで宣言すべき?
基本的にはprivateまたはinternalをおすすめします。
C#internal static class NativeMethods
{
[DllImport("NativeLib.dll")]
internal static extern int Add(int a, int b);
}
DllImportメソッドをそのままpublicにすると、アプリ内のどこからでもネイティブ関数を直接呼べてしまい、引数チェックやエラー処理が分散します。
推奨される設計は、DllImportメソッドをprivateにして、その上に安全なC#ラッパーメソッドを用意することです。
C#internal static class NativeCalculator
{
[DllImport("NativeLib.dll")]
private static extern int Add(int a, int b);
public static int AddSafe(int a, int b)
{
return Add(a, b);
}
}
ネイティブ呼び出しを直接公開せず、C#側で入力検証や例外変換を行うと保守しやすくなります。
10-5. unsafeコードは必ず必要?
必ずしも必要ではありません。多くのDllImportは、IntPtr、string、配列、構造体、ref、outを使えばunsafeなしで実装できます。
たとえば、次のコードはunsafe不要です。
C#[DllImport("NativeLib.dll")]
private static extern int GetValue(out int value);
一方、C#側でポインタを直接扱いたい場合はunsafeが必要になります。
C#[DllImport("NativeLib.dll")]
private static extern unsafe void Fill(int* values, int length);
通常の業務アプリでは、まずIntPtrや配列で表現し、どうしても必要な場合だけunsafeを検討するとよいです。
10-6. LinuxやmacOSでもDllImportは使える?
使えます。DllImportはWindows専用ではなく、.NETのP/InvokeとしてLinuxやmacOSのネイティブライブラリも呼び出せます。
たとえばLinuxの共有ライブラリを呼び出す場合です。
C#[DllImport("libm.so.6")]
private static extern double cos(double x);
macOSでは.dylibを呼び出すことがあります。
C#[DllImport("libSystem.dylib")]
private static extern int getpid();
ただし、OSごとにライブラリ名、関数名、文字コード、呼び出し規約、データ型のサイズが異なることがあります。クロスプラットフォーム対応する場合は、OS判定を行い、ライブラリ名や型定義を分ける設計が必要です。
10-7. DllImportとAdd Referenceの違いは?
DllImportと参照追加は、まったく別の仕組みです。
| 項目 | DllImport | Add Reference |
|---|---|---|
| 対象 | アンマネージDLL | マネージDLL |
| 主な言語 | C/C++ | C#, VB.NET, F# |
| 呼び出し単位 | エクスポート関数 | クラス、メソッド、型 |
| 型情報 | 自分でC#側に定義 | アセンブリメタデータから取得 |
| 代表用途 | Windows API、C++ DLL | C#ライブラリ利用 |
| エラー例 | DllNotFoundException | 参照不足、型解決エラー |
C/C++のDLLを呼ぶならDllImport、C#のDLLを使うなら参照追加です。この違いを理解しておくと、DLL連携の設計ミスを避けられます。
まとめ
C#のDllImportは、P/Invokeを使ってアンマネージDLLの関数を呼び出すための重要な機能です。Windows APIやC/C++で作成した既存DLL、ハードウェア制御用DLL、ベンダー提供SDKなどをC#アプリから利用できます。
基本構文はシンプルです。
C#[DllImport("NativeLib.dll")]
private static extern int Add(int a, int b);
しかし、実際に安定して動かすには、DLL名、関数名、戻り値、引数、呼び出し規約、文字コード、32bit/64bit、構造体レイアウト、メモリ管理を正確に合わせる必要があります。
特に重要な確認ポイントは次のとおりです。
| 確認ポイント | 内容 |
|---|---|
| DLLの種類 | マネージDLLではなくアンマネージDLLか |
| DLLの配置 | 実行時に見つかる場所にあるか |
| ビット数 | C#アプリとDLLがx86/x64で一致しているか |
| 関数名 | エクスポート名とEntryPointが一致しているか |
| 呼び出し規約 | StdCallやCdeclが一致しているか |
| 文字コード | ANSI/Unicode/UTF-8が一致しているか |
| 型変換 | C/C++側の型サイズとC#側の型が一致しているか |
| メモリ管理 | 確保と解放の責任が明確か |
DllNotFoundException、EntryPointNotFoundException、BadImageFormatException、AccessViolationExceptionなどのエラーは、ほとんどの場合、これらのどこかに不一致があります。エラーが出たら、C#コードだけでなく、DLLのヘッダー、エクスポート名、依存DLL、ビット数、API仕様書を順番に確認しましょう。
また、.NET 7以降で新しくP/Invokeを書く場合は、LibraryImportも有力な選択肢です。固定DLLを簡単に呼ぶならDllImport、コンパイル時生成やNative AOTを意識するならLibraryImport、実行時にDLLを切り替えたいならNativeLibrary.Load、Win32 APIを安全に大量利用したいならCsWin32、と使い分けるとよいでしょう。
DllImportは強力な反面、C#の型安全な世界とネイティブコードの世界を直接つなぐ機能です。小さな型定義ミスが大きな不具合につながるため、仕様を確認しながら、最小サンプルで動作確認し、少しずつ実装範囲を広げることが成功の近道です。

