このシリーズは全 4 記事で構成されています。
- ① なぜポインターが必要か / unsafe の基本 /
*と&演算子 - ②
->演算子 /stackalloc/fixed/sizeof - ③ 実践例:ビットマップ画像処理
- ④ 本記事 — C++ DLL との相互運用
① で述べたように、C# でポインターを使うもう一つの主な理由は、ポインター型を引数に要求する C ベースの DLL や Windows API を呼び出すことです。この記事では C++ で実装された DLL を例に、C# 側でポインターをどう扱うかを解説します。
P/Invoke とは
P/Invoke(Platform Invocation Services)は .NET が提供するネイティブコード呼び出しの仕組みです。[DllImport] 属性を使って DLL のエクスポート関数を C# のメソッドとして宣言し、通常のメソッド呼び出しと同じように使えます。
C++ 関数の引数にポインターが含まれる場合、C# 側の宣言でも対応する型を正しく指定する必要があります。
題材にする C++ DLL
以下の 3 つの関数を持つ C++ DLL を想定します。
// MathLib.cpp(C++ 側のコード)
#include <cstring>
extern "C" {
// 2 つの整数を加算して結果をポインター経由で返す
__declspec(dllexport)
void Add(int a, int b, int* result)
{
*result = a + b;
}
// 配列の各要素を 2 倍にする(配列をポインターで受け取る)
__declspec(dllexport)
void DoubleArray(int* data, int length)
{
for (int i = 0; i < length; i++)
data[i] *= 2;
}
// 文字列を大文字に変換して出力バッファに書き込む
__declspec(dllexport)
void ToUpperCase(const char* input, char* output, int bufferSize)
{
for (int i = 0; i < bufferSize - 1 && input[i] != '\0'; i++)
output[i] = (char)toupper((unsigned char)input[i]);
output[bufferSize - 1] = '\0';
}
} // extern "C"
extern "C" は C++ の名前マングリング(名前修飾)を防ぐためのものです。C# の P/Invoke は関数名をそのまま使うため、C++ のマングリングが入ると名前が一致しなくなります。
アプローチ A:unsafe + ポインターで呼び出す
Add 関数の呼び出し
using System.Runtime.InteropServices;
class Program
{
// DLL の関数宣言(unsafe 版)
[DllImport("MathLib.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern unsafe void Add(int a, int b, int* result);
static unsafe void Main()
{
int result = 0;
Add(10, 20, &result);
Console.WriteLine($"10 + 20 = {result}"); // 30
}
}
int* をそのまま引数に使えるのがポインター方式のシンプルなところです。
DoubleArray 関数の呼び出し
配列をポインターで渡すには fixed でピン止めしてからアドレスを渡します。
[DllImport("MathLib.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern unsafe void DoubleArray(int* data, int length);
static unsafe void CallDoubleArray()
{
int[] numbers = { 1, 2, 3, 4, 5 };
fixed (int* p = numbers)
{
DoubleArray(p, numbers.Length);
}
// fixed を抜けるとピン止めが解除される
foreach (var n in numbers)
Console.Write($"{n} "); // 2 4 6 8 10
Console.WriteLine();
}
fixed が必要な理由は ② で解説した通りです。GC がオブジェクトを移動するとポインターが無効になるため、fixed で移動を禁止しています。
ToUpperCase 関数の呼び出し(文字列)
文字列は char*(非管理型の文字バッファ)を介してやり取りします。
[DllImport("MathLib.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern unsafe void ToUpperCase(byte* input, byte* output, int bufferSize);
static unsafe void CallToUpperCase()
{
string input = "hello, world!";
byte[] inputBytes = System.Text.Encoding.ASCII.GetBytes(input + "\0"); // null 終端
byte[] outputBytes = new byte[256];
fixed (byte* pIn = inputBytes)
fixed (byte* pOut = outputBytes)
{
ToUpperCase(pIn, pOut, outputBytes.Length);
}
string result = System.Text.Encoding.ASCII.GetString(outputBytes).TrimEnd('\0');
Console.WriteLine(result); // HELLO, WORLD!
}
アプローチ B:IntPtr + Marshal で呼び出す(unsafe 不要)
① で触れたように、多くのケースでは System.IntPtr と System.Runtime.InteropServices.Marshal クラスを使えば unsafe なしで同じことができます。
Add 関数の呼び出し(IntPtr 版)
// unsafe なし版
[DllImport("MathLib.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern void Add(int a, int b, IntPtr result);
static void CallAddSafe()
{
// アンマネージドヒープに int 1 個分のメモリを確保
IntPtr ptr = Marshal.AllocHGlobal(sizeof(int));
try
{
Add(10, 20, ptr);
int result = Marshal.ReadInt32(ptr);
Console.WriteLine($"10 + 20 = {result}"); // 30
}
finally
{
Marshal.FreeHGlobal(ptr); // 必ず解放
}
}
Marshal.AllocHGlobal はアンマネージドヒープにメモリを確保します(C の malloc 相当)。GC の管理外なので FreeHGlobal で必ず解放する必要があります。
DoubleArray 関数の呼び出し(IntPtr 版)
[DllImport("MathLib.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern void DoubleArray(IntPtr data, int length);
static void CallDoubleArraySafe()
{
int[] numbers = { 1, 2, 3, 4, 5 };
int byteSize = numbers.Length * sizeof(int);
IntPtr ptr = Marshal.AllocHGlobal(byteSize);
try
{
// 配列をアンマネージドメモリにコピー
Marshal.Copy(numbers, 0, ptr, numbers.Length);
DoubleArray(ptr, numbers.Length);
// 結果を配列にコピーして戻す
Marshal.Copy(ptr, numbers, 0, numbers.Length);
}
finally
{
Marshal.FreeHGlobal(ptr);
}
foreach (var n in numbers)
Console.Write($"{n} "); // 2 4 6 8 10
Console.WriteLine();
}
ToUpperCase 関数の呼び出し(IntPtr / 文字列マーシャリング版)
文字列の場合は [DllImport] の CharSet や MarshalAs を使えば、マーシャリングを自動化できます。
// 文字列を自動マーシャリング(ANSI 文字列として渡す)
[DllImport("MathLib.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
private static extern void ToUpperCase(
[MarshalAs(UnmanagedType.LPStr)] string input,
System.Text.StringBuilder output,
int bufferSize
);
static void CallToUpperCaseSafe()
{
var output = new System.Text.StringBuilder(256);
ToUpperCase("hello, world!", output, output.Capacity);
Console.WriteLine(output); // HELLO, WORLD!
}
StringBuilder を出力バッファとして渡すと、P/Invoke が自動的に char* に変換してくれます。入力文字列には [MarshalAs(UnmanagedType.LPStr)] を付けると ANSI(const char*)として渡されます。
2 つのアプローチの比較
| unsafe + ポインター | IntPtr + Marshal | |
|---|---|---|
unsafe キーワード |
必要 | 不要 |
| コードの簡潔さ | シンプル(ポインターをそのまま渡す) | やや冗長(AllocHGlobal/Copy/Free が必要) |
| メモリ管理の責任 | fixed の範囲で自動 |
FreeHGlobal を手動で呼ぶ必要がある |
| 配列の扱い | fixed でピン止め |
Marshal.Copy でコピー(オーバーヘッドあり) |
| 向いている用途 | 高頻度呼び出し・大きな配列 | 稀な呼び出し・チームが unsafe を避けたい場合 |
パフォーマンスが重要で配列が大きいときは unsafe + ポインター方式のほうが有利です(Marshal.Copy のコピーコストがない)。コードの安全性・可読性を重視するなら IntPtr + Marshal 方式が無難です。
呼び出し規約(Calling Convention)
DLL の関数を呼び出すとき、CallingConvention の指定が一致していないとスタックが壊れてクラッシュします。C 言語で書かれた DLL は通常 Cdecl、Win32 API は StdCall です。
// C ライブラリ → Cdecl
[DllImport("mylib.dll", CallingConvention = CallingConvention.Cdecl)]
// Win32 API → StdCall(デフォルトなので省略可)
[DllImport("kernel32.dll", CallingConvention = CallingConvention.StdCall)]
C++ のメンバー関数(__thiscall)は P/Invoke で直接呼び出せないため、extern "C" のラッパー関数を C++ 側に用意するのが一般的です。
まとめ
| トピック | ポイント |
|---|---|
| P/Invoke の基本 | [DllImport] で DLL 関数を宣言し、通常のメソッドとして呼び出す |
| ポインター引数(unsafe 版) | fixed でピン止めしてからポインターを渡す |
| ポインター引数(Marshal 版) | AllocHGlobal でアンマネージドメモリを確保し IntPtr を渡す |
| 文字列 | [MarshalAs] と StringBuilder を使えば自動マーシャリングが効く |
| 呼び出し規約 | Cdecl(C DLL)か StdCall(Win32 API)を DLL に合わせて指定する |
C++ の DLL と連携する際は、できる限り Marshal や自動マーシャリングを使って unsafe の範囲を最小化するのが C# らしい書き方です。ただしパフォーマンスが求められる場面では、ポインターを直接扱う unsafe 方式が有効な選択肢になります。