C# DllImportの使い方完全ガイド|P/InvokeでDLLを呼び出す手順とエラー解決法

はじめに

C#からWindows APIやC/C++で作成したDLLを呼び出したいときに使う代表的な仕組みがDllImportです。「csharp dllimport」で検索している方の多くは、外部DLLをC#から実行したい、既存のネイティブライブラリを.NETアプリに組み込みたい、またはDllNotFoundExceptionEntryPointNotFoundExceptionなどのエラーを解決したいという目的を持っているはずです。

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 APIuser32.dll, kernel32.dll, advapi32.dllなど
C/C++で作成したネイティブDLLextern "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として提供されています。

違いを整理すると次のようになります。

種類主な言語呼び出し方法実行環境
マネージDLLC#, VB.NET, F#参照追加.NETランタイム
アンマネージDLLC, C++DllImport / P/InvokeOSネイティブ

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;

DllImportAttributeMarshalAsStructLayoutCallingConventionCharSetMarshalIntPtr関連の処理など、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側が次のような関数なら、

C
int 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の呼び出し部分をNativeMethodsWin32などのクラスに分離しておくと、アプリ本体のコードが読みやすくなり、エラー時の調査もしやすくなります。

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++側に次のような関数があるとします。

C
extern "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 b32bit整数の引数2つ

この情報をC#側のDllImport宣言に反映します。

3-3. C#側の型に置き換える

C/C++の型をC#の型へ置き換えるときは、サイズと意味を合わせます。

たとえば、C/C++のintは多くの環境で32bitなので、C#ではintに対応します。doubleはC#でもdoubleです。ポインタは基本的にIntPtrを使います。

C
int 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、ポインタサイズに依存するHANDLEHWNDなどはIntPtrを使う形で整理されています。

3-4. DllImport宣言を作成する

C/C++側が次の関数だとします。

C
extern "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#で取得するには、DllImportSetLastError = 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__stdcallWindows API、Win32 DLL
CallingConvention.Cdecl__cdeclCライブラリ、可変長引数
CallingConvention.ThisCall__thiscallC++インスタンスメソッド系
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.AnsiANSI文字列として扱う
CharSet.UnicodeUnicode、主に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の指定に応じてAW付きの関数名を探すことがあります。たとえばMessageBoxと書いたとき、Unicode指定ならMessageBoxWを探すような挙動です。

Microsoftのベストプラクティスでは、ExactSpellingtrueが推奨されています。ランタイムが代替名を探す必要がなくなるため、わずかな性能上の利点もあります。

実務では、関数名を明確にしたい場合は次のように書くとよいです。

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#側注意点
charbyte / sbyte符号の有無に注意
unsigned charbyte8bit
shortshort16bit
unsigned shortushort16bit
intint多くの環境で32bit
unsigned intuint32bit
long要確認WindowsのLONGは通常int
long longlong64bit
floatfloat32bit浮動小数点
doubledouble64bit浮動小数点
void*IntPtrポインタ
char*string / IntPtr / byte[]入出力方向に注意
wchar_t*string / IntPtrWindowsでは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側の関数例です。

C
int 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側の構造体例です。

C
typedef 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で表すのが基本です。

C
void* 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側の例です。

C
int 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を使います。

C
int 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ネイティブ側から値を受け取る
refC#側から渡した値をネイティブ側で読み書きする
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.LoadNativeLibrary.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でエクスポート名を確認できます。

cmd
dumpbin /exports NativeLib.dll

関数名がAddではなく_Add@8のように表示される場合、名前修飾が原因です。C++側でextern "C"を使う、またはC#側のEntryPointに実際のエクスポート名を指定します。

7-3. BadImageFormatException:32bit/64bitが一致しない

BadImageFormatExceptionは、C#アプリとネイティブDLLのビット数が一致していないときによく発生します。

たとえば、次の組み合わせはNGです。

C#アプリDLL結果
64bit32bit DLL読み込めない
32bit64bit DLL読み込めない

対策は、プロジェクトのプラットフォームターゲットとDLLのビット数を合わせることです。

Visual Studioでは、プロジェクトの「ビルド」設定で次を確認します。

設定意味
Any CPU実行環境に応じて32/64bitが変わる
x8632bitプロセスとして実行
x6464bitプロセスとして実行
Prefer 32-bitAny CPUでも32bit優先になる場合がある

32bit DLLしかない場合は、C#アプリもx86でビルドします。64bit DLLを使うなら、C#アプリもx64でビルドします。

7-4. AccessViolationException:メモリ破壊や型定義ミス

AccessViolationExceptionは、保護されたメモリ領域へ不正アクセスしたときに発生します。P/Invokeでは、型定義やバッファサイズのミスによってよく起こります。

主な原因は次のとおりです。

原因
引数型が違うlongintの取り違え
構造体レイアウトが違うStructLayout不足
バッファサイズ不足文字列や配列の書き込み超過
ポインタが無効解放済みハンドルを使用
呼び出し規約が違うStdCallCdeclの不一致
メモリ解放方法が違うDLL側確保メモリを誤った方法で解放

AccessViolationExceptionが出た場合は、C#側だけを見るのではなく、C/C++側の関数宣言、構造体定義、呼び出し規約、メモリ管理ルールを最初から見直す必要があります。

7-5. MarshalDirectiveException:マーシャリング指定ミス

MarshalDirectiveExceptionは、.NETが指定された型をネイティブ側へ変換できない場合に発生します。

よくある原因は次のとおりです。

原因対策
ジェネリック型を渡しているP/Invoke対象にしない
構造体内の配列指定が不足MarshalAsByValArrayを指定
文字列フィールドのサイズ不明ByValTStrSizeConstを指定
非対応の型を指定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が不足していると読み込みに失敗します。

確認方法は次のとおりです。

方法内容
DependenciesDLLの依存関係をGUIで確認
dumpbinVisual Studio付属ツールで依存関係を確認
Process Monitor実行時にどのパスを探しているか確認
lddLinuxで.soの依存関係を確認
otoolmacOSで.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
DLL32bit / 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を探しても見つかりません。

エクスポート名は次のコマンドで確認できます。

cmd
dumpbin /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);

仕様書に呼び出し規約が書かれていない場合は、ヘッダーファイルのWINAPICALLBACK__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側に解放関数が用意されています。

C
char* 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.FreeHGlobalMarshal.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と異なる制約もあります。たとえば、CallingConventionLibraryImportAttributeではなく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 InteropCOMコンポーネントやActiveXを使う
NuGetパッケージ既に.NET向けラッパーが提供されている
C++/CLIC++ライブラリとC#の橋渡しを柔軟に行いたい
gRPC / REST別プロセスや別マシンの機能を呼びたい
プロセス起動DLL化されていない既存EXEと連携したい
LibraryImport.NET 7以降で新規P/Invokeを書く
NativeLibraryDLLを動的に選択したい

特に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.LoadSetDllImportResolverで明示的に解決する方が安全です。

10-3. DLLを動的に切り替えて読み込める?

固定的なDllImportだけでは、実行時にDLL名を柔軟に切り替えるのは苦手です。

動的に切り替えたい場合は、次の方法があります。

方法特徴
NativeLibrary.Load実行時にパスを指定してロードできる
NativeLibrary.SetDllImportResolverDllImportの解決処理をカスタマイズできる
同名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は、IntPtrstring、配列、構造体、refoutを使えば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と参照追加は、まったく別の仕組みです。

項目DllImportAdd Reference
対象アンマネージDLLマネージDLL
主な言語C/C++C#, VB.NET, F#
呼び出し単位エクスポート関数クラス、メソッド、型
型情報自分でC#側に定義アセンブリメタデータから取得
代表用途Windows API、C++ DLLC#ライブラリ利用
エラー例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が一致しているか
呼び出し規約StdCallCdeclが一致しているか
文字コードANSI/Unicode/UTF-8が一致しているか
型変換C/C++側の型サイズとC#側の型が一致しているか
メモリ管理確保と解放の責任が明確か

DllNotFoundExceptionEntryPointNotFoundExceptionBadImageFormatExceptionAccessViolationExceptionなどのエラーは、ほとんどの場合、これらのどこかに不一致があります。エラーが出たら、C#コードだけでなく、DLLのヘッダー、エクスポート名、依存DLL、ビット数、API仕様書を順番に確認しましょう。

また、.NET 7以降で新しくP/Invokeを書く場合は、LibraryImportも有力な選択肢です。固定DLLを簡単に呼ぶならDllImport、コンパイル時生成やNative AOTを意識するならLibraryImport、実行時にDLLを切り替えたいならNativeLibrary.Load、Win32 APIを安全に大量利用したいならCsWin32、と使い分けるとよいでしょう。

DllImportは強力な反面、C#の型安全な世界とネイティブコードの世界を直接つなぐ機能です。小さな型定義ミスが大きな不具合につながるため、仕様を確認しながら、最小サンプルで動作確認し、少しずつ実装範囲を広げることが成功の近道です。