平台调用P/Invoke介绍
前提知识
一、手动监视和控制对象的生命周期:GCHandle
GCHandle主要用于提供从非托管内存访问托管对象的方式。通过其可以创建一个句柄,能用对应任意托管对象。
GCHandle分为四种类型:Weak,WeakTrackResurrection,Normal,Pinned
-
GCHandleType.Weak: 创建一个普通的弱引用,指向目标对象。通过调用
handle.Target == null来判断对象是否被回收var obj = new MyClass(); GCHandle handle = GCHandle.Alloc(obj, GCHandleType.Weak); // 当 obj 不再有强引用时: GC.Collect(); // 检查弱引用是否存活 if (handle.Target == null) { Console.WriteLine("对象已被回收"); } -
GCHandleType.WeakTrackResurrection:创建一个弱引用,并跟踪对象是否被复活若对象在终结阶段被复活(例如通过
GC.ReRegisterForFinalize或其他方式),WeakTrackResurrection的句柄仍然可以访问复活后的对象。public class ResurrectableClass { ~ResurrectableClass() { // 在终结器中复活对象 SomeGlobal.RevivedInstance = this; GC.ReRegisterForFinalize(this); } } var obj = new ResurrectableClass(); GCHandle handle = GCHandle.Alloc(obj, GCHandleType.WeakTrackResurrection); // 当 obj 被 GC 回收并复活后: GC.Collect(); // 检查弱引用是否存活(复活后仍可访问) if (handle.Target != null) { Console.WriteLine("对象已复活"); } -
GCHandleType.Normal: 对象不会被垃圾回收,需要手动调用Free进行释放。主要用于托管代码与非托管代码交互时,安全的传递上下文
using System; using System.Runtime.InteropServices; public class DataProcessor { public void LogMessage(string message) { Console.WriteLine($"日志: {message}"); } } public class Program { // 定义非托管回调委托 [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate void CallbackDelegate(string message, IntPtr context); // 非托管函数声明 [DllImport("NativeLib.dll")] public static extern void RegisterLogger(CallbackDelegate callback, IntPtr context); [DllImport("NativeLib.dll")] public static extern void TriggerLogging(string message); static void Main() { DataProcessor logger = new DataProcessor(); GCHandle handle = GCHandle.Alloc(logger, GCHandleType.Normal); IntPtr context = GCHandle.ToIntPtr(handle); // 注册回调和上下文 CallbackDelegate callback = (msg, ctx) => { GCHandle h = GCHandle.FromIntPtr(ctx); if (h.Target is DataProcessor processor) { processor.LogMessage(msg); } }; RegisterLogger(callback, context); // 触发非托管代码记录日志 TriggerLogging("测试非托管回调"); // 清理 handle.Free(); } }#include <iostream> #include <string> typedef void (*CallbackDelegate)(const char* message, void* context); static CallbackDelegate g_callback = nullptr; static void* g_context = nullptr; extern "C" __declspec(dllexport) void RegisterLogger(CallbackDelegate callback, void* context) { g_callback = callback; g_context = context; } extern "C" __declspec(dllexport) void TriggerLogging(const char* message) { if (g_callback != nullptr) { g_callback(message, g_context); // 触发回调,传回上下文 } } -
GCHandleType.Pinned: 对象不会被垃圾回收,并且不会被垃圾回收器移动。- 可通过调用
handle.AddrOfPinnedObject()获取对象实际内存地址,未被Pinned的GCHandle,调用该函数时,会报错。
- 可通过调用
二、平台特定的整数类型:IntPtr
IntPtr用于表示本机资源,主要存储句柄或内存地址,两者不可混用。其大小取决于操作系统是32bit还是64bit,等于当前系统指针占用大小。
句柄或内存地址,两者不可混用
int[] intArr = new int[] { 1, 2, 3 };
// ❌错误,此处获取的是句柄,无法直接转换为对象内存地址使用
// var handle = GCHandle.Alloc(intArr);
// IntPtr arrIntPtr = GCHandle.ToIntPtr(handle);
// ✔正确,需要通过Pinned对象,以获取其内存地址
var handle = GCHandle.Alloc(intArr, GCHandleType.Pinned);
IntPtr arrIntPtr = handle.AddrOfPinnedObject();
unsafe
{
int* ptr = (int*)arrIntPtr;
Console.WriteLine(ptr[0]);
}
Console.ReadLine();
// ❌错误输出:45425712(或者超出内存范围,抛出`System.AccessViolationException`)
// ✔正确输出: 1
作为句柄的情况:
-
窗口句柄(HWND)
- 用途:表示窗口的标识符,用于操作窗口(如最小化、关闭、发送消息等)。
- 示例:
// 获取记事本窗口句柄 IntPtr hWnd = FindWindow("Notepad", null); // 发送关闭消息 SendMessage(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
-
设备上下文句柄(HDC)
-
用途:表示图形绘制上下文,用于绘图操作(如GDI绘图)。
-
示例:
IntPtr hdc = GetDC(hWnd); // 获取窗口的设备上下文 Rectangle(hdc, 10, 10, 100, 100); // 绘制矩形 ReleaseDC(hWnd, hdc); // 释放句柄
-
-
文件或资源句柄(HANDLE)
-
用途:表示打开的文件、管道、设备等资源。
-
示例:
// 打开文件(返回句柄) IntPtr hFile = CreateFile("test.txt", GENERIC_READ, 0, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero); // 读取文件内容 byte[] buffer = new byte[1024]; ReadFile(hFile, buffer, buffer.Length, out int bytesRead, IntPtr.Zero); CloseHandle(hFile); // 必须手动关闭句柄
-
-
模块/库句柄(HMODULE/HINSTANCE)
-
用途:表示加载的DLL或可执行模块的内存基址。
-
示例:
// 加载DLL IntPtr hModule = LoadLibrary("user32.dll"); // 获取函数地址 IntPtr funcPtr = GetProcAddress(hModule, "MessageBoxW"); FreeLibrary(hModule); // 卸载库
-
-
GDI对象句柄(HBITMAP/HBRUSH/HPEN等)
-
用途:表示图形设备接口对象(如位图、画刷、画笔)。
-
示例:
// 创建红色画刷 IntPtr hBrush = CreateSolidBrush(0x0000FF); // 选入设备上下文 SelectObject(hdc, hBrush); DeleteObject(hBrush); // 必须删除对象
-
-
进程/线程句柄
-
用途:用于操作进程或线程(如终止、查询状态)。
-
示例:
// 打开进程句柄 IntPtr hProcess = OpenProcess(PROCESS_ALL_ACCESS, false, processId); TerminateProcess(hProcess, 0); // 终止进程 CloseHandle(hProcess);
-
-
内核对象句柄(HANDLE)
-
用途:表示互斥体(Mutex)、事件(Event)、信号量(Semaphore)等同步对象。
-
示例:
// 创建互斥体 IntPtr hMutex = CreateMutex(IntPtr.Zero, false, "MyMutex"); WaitForSingleObject(hMutex, INFINITE); // 等待获取所有权 ReleaseMutex(hMutex); CloseHandle(hMutex);
-
-
图标/光标句柄(HICON/HCURSOR)
-
用途:操作图标或光标资源。
-
示例:
// 加载系统图标 IntPtr hIcon = LoadIcon(IntPtr.Zero, IDI_APPLICATION); DestroyIcon(hIcon); // 销毁图标
-
-
注册表键句柄(HKEY)
-
用途:表示打开的注册表键。
-
示例:
IntPtr hKey; RegOpenKeyEx(HKEY_CURRENT_USER, "Software\\MyApp", 0, KEY_READ, out hKey); // 读取注册表值... RegCloseKey(hKey);
-
-
套接字句柄(SOCKET)
-
用途:表示网络通信的套接字(在Windows中定义为
SOCKET,实际类型为IntPtr)。 -
示例:
SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); closesocket(s); // 关闭套接字
-
作为内存地址的情况:
-
分配/释放非托管内存
-
场景:手动分配非托管内存块供非托管代码使用。
-
示例:
// 声明内存分配/释放API [DllImport("kernel32.dll")] private static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect); [DllImport("kernel32.dll")] private static extern bool VirtualFree(IntPtr lpAddress, uint dwSize, uint dwFreeType); // 使用示例 IntPtr buffer = VirtualAlloc(IntPtr.Zero, 4096, 0x1000 /*MEM_COMMIT*/, 0x04 /*PAGE_READWRITE*/); if (buffer != IntPtr.Zero) { // 传递给非托管函数 NonManagedProcessBuffer(buffer, 4096); VirtualFree(buffer, 0, 0x8000 /*MEM_RELEASE*/); // 必须释放 }
-
-
托管数组与非托管内存互拷
-
场景:在托管数组和非托管内存间复制数据。
-
示例:
// 分配非托管内存 IntPtr unmanagedArray = Marshal.AllocHGlobal(1024); // 托管数组 -> 非托管内存 byte[] managedData = new byte[1024]; Marshal.Copy(managedData, 0, unmanagedArray, managedData.Length); // 非托管内存 -> 托管数组 byte[] resultData = new byte[1024]; Marshal.Copy(unmanagedArray, resultData, 0, resultData.Length); Marshal.FreeHGlobal(unmanagedArray); // 释放
-
-
传递结构体内存地址
-
场景:将结构体序列化到非托管内存,供非托管代码解析。
-
示例:
[StructLayout(LayoutKind.Sequential)] public struct Point { public int X; public int Y; } // 声明接受结构体指针的非托管函数 [DllImport("NativeLib.dll")] private static extern void ProcessPoint(IntPtr pPoint); // 使用示例 Point point = new Point { X = 10, Y = 20 }; IntPtr ptr = Marshal.AllocHGlobal(Marshal.SizeOf<Point>()); Marshal.StructureToPtr(point, ptr, false); ProcessPoint(ptr); // 非托管代码直接读取ptr指向的内存 Marshal.DestroyStructure<Point>(ptr); Marshal.FreeHGlobal(ptr);
-
-
固定托管对象内存
-
场景:防止GC移动托管数组内存,直接传递地址。
-
示例:
byte[] managedBuffer = new byte[1024]; GCHandle handle = GCHandle.Alloc(managedBuffer, GCHandleType.Pinned); try { IntPtr pinnedAddress = handle.AddrOfPinnedObject(); NonManagedProcessData(pinnedAddress, managedBuffer.Length); // 安全传递地址 } finally { handle.Free(); // 必须解除固定 }
-
-
直接操作内存(unsafe代码)
-
场景:通过指针直接读写内存。
-
示例:
unsafe { int[] numbers = { 1, 2, 3 }; fixed (int* p = numbers) { IntPtr ptr = (IntPtr)p; NonManagedSumArray(ptr, numbers.Length); // 非托管函数求和 } }
-
-
接收非托管代码返回的堆内存
-
场景:非托管函数通过
malloc分配内存,C#负责释放。 -
示例:
[DllImport("NativeLib.dll")] private static extern IntPtr GenerateData(int size); // 返回malloc分配的内存 [DllImport("NativeLib.dll")] private static extern void FreeData(IntPtr ptr); // 调用free释放 // 使用示例 IntPtr dataPtr = GenerateData(1024); byte[] managedData = new byte[1024]; Marshal.Copy(dataPtr, managedData, 0, 1024); FreeData(dataPtr); // 必须调用对应的释放函数
-
-
处理图像像素数据
-
场景:操作位图的原始像素内存。
-
示例:
Bitmap bitmap = new Bitmap(800, 600); BitmapData data = bitmap.LockBits(new Rectangle(0, 0, 800, 600), ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb); try { IntPtr scan0 = data.Scan0; // 像素内存起始地址 NonManagedProcessPixels(scan0, data.Stride, data.Height); // 非托管图像处理 } finally { bitmap.UnlockBits(data); // 解锁内存 }
-
-
与COM对象交换指针
-
场景:传递COM接口指针的内存地址。
-
示例:
IMyComObject comObj = new MyComObject(); IntPtr comPtr = Marshal.GetComInterfaceForObject(comObj, typeof(IMyComObject)); try { NonManagedUseComInterface(comPtr); // 非托管代码通过指针调用COM方法 } finally { Marshal.Release(comPtr); // 减少引用计数 }
-
-
跨进程内存访问
-
场景:读写其他进程的内存地址。
-
示例:
[DllImport("kernel32.dll")] private static extern bool ReadProcessMemory( IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int nSize, out int lpNumberOfBytesRead ); // 使用示例 Process process = Process.GetProcessById(1234); IntPtr processHandle = OpenProcess(ProcessAccessFlags.VMRead, false, process.Id); byte[] buffer = new byte[4]; ReadProcessMemory(processHandle, new IntPtr(0x00400000), buffer, 4, out _); CloseHandle(processHandle);
-
-
自定义内存池管理
-
场景:高性能场景中重用非托管内存。
-
示例:
public class MemoryPool : IDisposable { private IntPtr _buffer; private const int BlockSize = 4096; public MemoryPool() { _buffer = Marshal.AllocHGlobal(BlockSize * 100); // 预分配100个块 } public IntPtr AllocateBlock() { // 返回下一个可用块的地址(简化逻辑) return _buffer + _currentIndex++ * BlockSize; } public void Dispose() { Marshal.FreeHGlobal(_buffer); } }
-
如何调用一个C++导出的DLL?
在.NET程序中,
P/Invoke是调用C/C++函数的一种方式。
简单示例程序
首先,创建一个本地方法print_line。
添加文件NativeLib.h:
#pragma once
// 使用 extern "C" 禁止名称修饰
#ifdef __cplusplus
extern "C" {
#endif
// 明确调用约定(如__stdcall或__cdecl)
#ifndef MYAPI
#define MYAPI __declspec(dllexport)
#endif
MYAPI void __cdecl print_line(const char* str);
#ifdef __cplusplus
}
#endif
然后,再添加NativeLib.cpp:
#include "NativeLib.h"
#include <stdio.h>
MYAPI void print_line(const char* str) {
printf("%s\n", str);
}
现在添加C#调用代码Program.cs
using System.Runtime.InteropServices;
namespace PInvokeTest_1_Basic
{
internal class Program
{
static void Main(string[] args)
{
print_line("Hello, PInvoke!");
}
[DllImport("NativeLibDll.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
private static extern void print_line(string str);
}
}
在这里,我们指定了需要导入进.NET类的C/C++函数。有以下几点需要关注的:
- 修饰符是
static extern。extern代表这个函数是从C/C++导入的;static是必要的,因为这个函数对类Program一无所知。 - 函数的名称需要与C/C++的函数名称保持一致。
- 参数
str是一个.NET类型(string)。P/Invoke自动将数据类型从.NET类型转换到C/C++类型。 - attribute
[DllImport]指定了能从中导入函数的DLL文件名。需要关注的是,DllImport使得你几乎能够控制导入的每个方面,例如指定一个不同的.NET方法名或者指定调用约定(calling convention)。 Ansi不同地区的系统中代表不同的编码。中文Windows使用GBK, 英文字符占用一个字节,非英文字符占用两个字节。
问题解决
1. 未能够加载DLL
你可能会看到一个异常DllNotFoundException,并带有类似“找不到指定模块。”的提示消息。

正如错误信息所示,DLL“NativeLib.dll”未能找到。
只需要将“NativeLib.dll”文件,复制到当前可执行文件运行目录即可。
2. 栈不平衡
你可能会遇到一个报错,PInvokeStackImbalance was detected

产生这种报错大概率是因为本地库使用另一种调用约定(calling convention)。默认情况下,C/C++使用__cdecl的调用约定,而[DllImport]默认使用的是__stdcall的调用约定。
解决方案:保证调用约定的一致性:
- 在
[DllImport]中指定正确的调用约定,例如[DllImport("NativeLib.dll", CallingConvention=CallingConvention.Cdecl)]。 - 在本地项目中修改默认的调用约定,可以在项目设置中
C/C++–>Advanced–>Calling Convention。 - 在C/C++函数前添加期望的调用约定,例如
void __stdcall print_line(const char* str)。这将只会改变这些函数的调用约定。
3. 找不到入口点
你可能会遇到一个报错,找不到名为“print_line”的入口点。

解决方案:
- 检查调用函数名称是否一致,可在
[DllImport]中指定函数入口点,例如[DllImport("NativeLibDll.dll", ..., EntryPoint = "print_line")];或者,直接在extern中保持函数名与函数入口点一致。 - 函数名可能在编译时被C++修饰,需要使用
extern "C"进行函数的导出。
其他类型的数据类型如何封送?
P/Invoke通过自动转化("marshalling")从托管代码到本机代码的数据类型,使得调用更加简单。反之亦然。
封送原始数据类型
原始数据类型(bool,int,double,...)是最简单易用的,它们会被直接映射到相对应的类型。
| C# type | C/C++ type | Bytes | Range |
|---|---|---|---|
bool | bool (with int fallback) | usually 1 | true or false |
char | wchar_t (or char if necessary) | 2 (1) | Unicode BMP |
byte | unsigned char | 1 | 0 to 255 |
sbyte | char | 1 | -128 to 127 |
short | short | 2 | -32,768 to 32,767 |
ushort | unsigned short | 2 | 0 to 65,535 |
int | int | 4 | -2 billion to 2 billion |
uint | unsigned int | 4 | 0 to 4 billion |
long | __int64 | 8 | -9 quintillion to 9 quintillion |
ulong | unsigned __int64 | 8 | 0 to 18 quintillion |
float | float | 4 | 7 significant decimal digits |
double | double | 8 | 15 significant decimal digits |
封送字符串
若需传递字符串,建议你将它们以Unicode(UTF-16)的编码形式进行传递(如果可能的话)。你需要像这样指定Char.Unicode:
[DllImport("NativeLib.dll", CharSet = CharSet.Unicode)]
private static extern void do_something(string str);
这需要C/C++的参数类型是wchar_t*
void do_something(const wchar_t* str);
封送数组
原始类型的数组能够直接传递。
[DllImport("NativeLib.dll")]
private static extern void do_something(byte[] data);
封送对象
若需传递对象,必须将其内存布局声明为顺序布局(Sequential):
[StructLayout(LayoutKind.Sequential)]
class MyClass {
...
}
这个保证了字段能够按照编写的顺序被存储。(没有这个Attribute,C#编译器会重新排列字段来优化数据结构)
然后直接使用这个对象的类型:
[DllImport("NativeLib.dll")]
private static extern void do_something(MyClass data);
这个对象会被按引用传递(struct*/struct&)给C的函数:
typedef struct {
...
} MyClass;
void do_something(MyClass* data);
本机Struct的字段顺序需要跟托管对象类型字段顺序保持一致。
封送结构
封送托管struct几乎和封送对象是一致的,唯一的不同是struct默认是按照复制传递。
所以对于struct,C/C++方法的前面是这样的:
void do_something(MyClass data);
当然,你也可以按照引用传递struct。在这种情况下,在C/C++中使用(MyClass* data)或(MyClass& data),并且在C#中使用(ref MyClass data)。
封送委托
委托是被直接封送的。唯一需要关注的是注意调用约定(calling convention)。默认的调用约定是Winapi(在Window中等同于StdCall)。如果你的本地库使用了不同的调用约定,你需要像这样指定:
// C#
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void MyDelegate(IntPtr value);
typedef void(__cdecl* MyDelegate)(void* value);
封送任意指针
任意指针(例如void *)会被按照IntPtr封送.
// C++
void do_something(void* ptr);
// C#
[DllImport("NativeLib.dll")]
private static extern void do_something(IntPtr ptr);
如何保证垃圾回收时,对象的地址不发生改变?
有时,一个C/C++函数需要保存你传递的数据,后续进行引用处理。如果这样的数据是一个托管对象(例如string或class), 你需要确保在本机代码仍然在使用或者存储的时候,它们没有被垃圾回收器回收。
这就是为什么要固定。它使得垃圾回收器不能删除或者移动该对象。
固定一个对象
我们可以使用GCHandle.Alloc,来固定一个托管对象:
// Pin "objectToBePinned"
GCHandle handle = GCHandle.Alloc(objectToBePinned, GCHandleType.Pinned);
objectToBePinned会保持固定状态,直到你调用Free():
// Unpin "objectToBePinned"
handle.Free();
在这个对象被取消固定后,它可以再一次:
- 被垃圾回收器移动(为了优化内存结构)。
- 被删除,如果它没有被引用。
注意:
Free()不会自动调用。如果你没有手动调用它,被固定对象的内存将永远不会被释放(导致内存泄漏)。- 你只需要固定对象(包括字符串)。你不能固定原始类型(例如
int)和结构(struct),因为它们保存在栈上,并且通过复制传递。尝试固定struct,只会固定struct的拷贝副本。 - 类和结构必须要带有Attribute:
[StructLayout(LayoutKind.Sequential)],来控制字段的内存布局。否则调用GCHandle.Alloc()将会抛出一个异常ArgumentException: “Object contains non-primitive or non-blittable data”。 - 如果你调用的方法不会存储传递的对象,来后续使用,那么你不需要固定对象。P/Invoke会在调用C/C++函数之前自动固定对象,并且在函数返回之后取消固定。所以手动固定对象的关键,实际上在于(何时)解除固定。
传递一个固定对象
固定对象后,通常需要将其传递给 C/C++ 函数。最简单的方式是在 P/Invoke 方法中直接指定托管类型:
// 直接使用 "MyType" 作为参数类型
[DllImport("NativeLib")]
private static extern void do_something(MyType myType);
然后调用这个方法:
GCHandle handle = GCHandle.Alloc(objectToBePinned, GCHandleType.Pinned);
do_something(objectToBePinned);
...
handle.Free();
另外一种方法是通过IntPtr传递(尽管和直接传递没有区别):
[DllImport("NativeLib")]
private static extern void do_something(IntPtr myType);
...
GCHandle handle = GCHandle.Alloc(objectToBePinned, GCHandleType.Pinned);
do_something(handle.AddrOfPinnedObject());
...
handle.Free();
固定并传递字符串
固定字符串和固定对象是一样的,需要注意的是:
在传递固定字符串是,你必须指定CharSet.Unicode。
否则 P/Invoke 会将字符串转换为 ASCII 格式(导致额外的内存拷贝)。
假设有以下 C 函数:
void do_something(void* str1, void* str2) {
printf("Equals: %s\n", (str1 == str2 ? "true" : "false"));
}
然后C#调用:
// ❌,将会输出 "false"
[DllImport("NativeLib", EntryPoint="do_something")]
private static extern void do_something1(string str1, IntPtr str2);
// ✔,将会输出 "true"
[DllImport("NativeLib", CharSet = CharSet.Unicode, EntryPoint="do_something")]
private static extern void do_something2(string str1, IntPtr str2);
...
string text = "my text";
GCHandle handle = GCHandle.Alloc(text, GCHandleType.Pinned);
// ❌,将会输出 "false"
do_something1(text, handle.AddrOfPinnedObject());
// ✔,将会输出 "true"
do_something2(text, handle.AddrOfPinnedObject());
验证固定对象是否被正确传递
正如前文所述,P/Invoke 可能会隐式复制对象,而非直接传递引用。
要确认是否发生复制,可以比较指针地址:在C#中可以调用handle.AddrOfPinnedObject().ToString()来获取被固定对象的地址。