P/Invoke 基础
P/Invoke 到底是何方神圣?
官方解释 P/Invoke 是可用于从托管代码访问非托管库中的结构、回调和函数的一种技术。 大多数 P/Invoke API 包含在以下两个命名空间中:System 和 System.Runtime.InteropServices。 使用这两个命名空间可提供用于描述如何与本机组件通信的工具。
P/Invoke 的全称是 Platform Invocation Services(平台调用服务)。名字很唬人,但你完全可以把它理解成 C# 和 C++ 之间的同声传译(C# 等 .NET 语言调用底层 C/C++ 原生函数的方法)。
想象一下这个场景
C#住在一栋“托管豪宅”里:垃圾有清洁工自动扫(GC),类型安全,住得很舒服。
C++则在“野外地摊”干活:速度快、能直接操作硬件,但什么都得自己动手(手动管理内存)。
现在,豪宅里的 C# 想用地摊上的一个现成工具(比如一个高效的图像缩放算法)。怎么办?
- 自己从零写一个?太累,而且大概率没人家 C++ 写得好。
- 直接冲出去拿?语言不通,内存布局对不上,一跑就崩。
这时候,P/Invoke 就是 C# 专门请来的翻译官:
- 帮 C# 找到那个 C++ 函数住在哪栋“大楼”(DLL 文件)。
- 把 C# 的参数翻译成 C++ 能听懂的样子(比如把 string 变成 char*)。
- 等 C++ 干完活,再把结果翻译回 C# 能用的格式。
- 顺便把临时申请的“地摊垃圾”清理掉。
跑通一个 P/Invoke 示例
usingSystem;usingSystem.Runtime.InteropServices;namespaceMyPInvoke_demo01{internalclassProgram{//[DllImport("user32.dll")] 告诉 CLR:这个函数住在 user32.dll 这座大楼里//CharSet = CharSet.Unicode 字符串使用 Unicode 编码,防止乱码//public static extern 方法的实现在外部(C++ 那边),这里只是声明[DllImport("user32.dll",CharSet=CharSet.Auto)]publicstaticexternintMessageBox(IntPtrhWnd,Stringtext,Stringcaption,uinttype);staticvoidMain(string[]args){// 调用MessageBox函数显示一个消息框//IntPtr.Zero 表示空指针,在这里代表没有父窗口MessageBox(IntPtr.Zero,"Hello, World!","MyPInvoke_demo01",0);}}}运行,你会看到一个标准的 Windows 消息框。你已经完成了一次跨语言的调用!
核心概念:翻译官的工作规则
1. 规则一:数据类型要对齐 —— Blittable 与非 Blittable
Blittable 类型(零成本翻译)
int, float, double, byte, long, IntPtr,以及只包含这些类型的结构体。直接扔过去,C++ 那边看都不看就接住了,速度极快。
非 Blittable 类型(需要转换)
string(编码可能不同)、bool(C# 1字节,Windows API 的 BOOL 是4字节)、char、object 等。每次调用都涉及内存分配和格式转换,有性能损耗。
开发建议:
- 图像数据尽量用 byte[],它是 Blittable 的,传递海量像素时效率最高。
- 结构体中尽量少用 string,改用固定长度的 char[] 或 byte[]。
2. 规则二:谁打电话,谁负责挂断 —— 调用约定
C++ 函数执行完后,谁负责清理“战场”(堆栈)?这叫做 调用约定 (Calling Convention)。
StdCall:被调用者(C++)自己清理。Windows API 默认用这个。
Cdecl:调用者(C#)负责清理。C/C++ 默认用这个,支持可变参数(比如 printf)。
如果你声明错了,程序不会报错,而是在某个随机时刻崩溃(尤其在 Release 下)。解决方案:永远在 DllImport 里显式写清楚。
[DllImport("mylib.dll",CallingConvention=CallingConvention.Cdecl)]3. 规则三:谁的垃圾谁倒 —— 内存管理
P/Invoke 不会自动释放 C++ 那边分配的内存。
| 谁分配的内存 | 谁来释放 |
|---|---|
| C# 的 new byte[1024] | GC 自动回收(或你手动 Marshal.FreeHGlobal) |
| C++ 里的 malloc() | 必须调用 free() |
| C++ 里的 new | 必须调用 delete |
| Windows API 的 GlobalAlloc | 必须调用 GlobalFree |
安全做法:对于 C++ 返回的指针,尽量用 SafeHandle 封装起来,这样即使发生异常,也能自动释放
// 示例:封装一个文件句柄publicclassSafeFileHandle:SafeHandleZeroOrMinusOneIsInvalid{publicSafeFileHandle():base(true){}protectedoverrideboolReleaseHandle()=>CloseHandle(handle);[DllImport("kernel32.dll")]privatestaticexternboolCloseHandle(IntPtrhObject);}结构体、回调和字符串
1. 结构体互传 —— 注意内存对齐
假设 C++ 里有这样一个结构体:
structPoint3D{floatx;floaty;floatz;};C# 中要这样声明才能对得上:
[StructLayout(LayoutKind.Sequential,Pack=1)]structPoint3D{publicfloatX;publicfloatY;publicfloatZ;}LayoutKind.Sequential:按声明顺序排列字段。
Pack = 1:按 1 字节对齐,防止编译器自动插入填充字节。
2. 回调函数 —— 小心委托被 GC“吃掉”
可以把 C# 的方法作为函数指针传给 C++,让 C++ 在某些事件发生时回调它。比如遍历窗口:
// 声明委托类型delegateboolEnumWindowsProc(IntPtrhWnd,IntPtrlParam);[DllImport("user32.dll")]staticexternboolEnumWindows(EnumWindowsProclpEnumFunc,IntPtrlParam);// 实现回调方法staticboolMyCallback(IntPtrhWnd,IntPtrlParam){Console.WriteLine($"找到窗口句柄:{hWnd}");returntrue;// 继续枚举}// 调用EnumWindows(MyCallback,IntPtr.Zero);致命坑:如果 MyCallback 委托没有被任何变量引用,GC 可能在你不知情的时候回收它,然后 C++ 那边回调时就会访问非法内存,程序直接崩溃。
解决方案:用一个静态字段持有委托实例。
privatestaticEnumWindowsProccallback=MyCallback;EnumWindows(callback,IntPtr.Zero);3. 字符串的“读”与“写”
- 只读字符串:直接用 string 参数,P/Invoke 会自动转成 const char*。
- 需要 C++ 填充的字符串缓冲区:用 StringBuilder,并且预分配足够容量。
[DllImport("user32.dll",CharSet=CharSet.Unicode)]staticexternintGetWindowText(IntPtrhWnd,StringBuilderlpString,intnMaxCount);varsb=newStringBuilder(256);GetWindowText(someHwnd,sb,sb.Capacity);.NET 7+ 的新福音:LibraryImport
LibraryImport采用源生成器技术,在编译时就生成封送代码,优点:
- 零运行时反射,性能更好。
- 支持 AOT(提前编译)场景。
- 类型检查更严格,错误更早发现。
LibraryImport写法:
//注意:方法要声明为 partial static,不需要方法体。[LibraryImport("user32.dll",StringMarshalling=StringMarshalling.Utf16)]staticpartialintMessageBox(IntPtrhWnd,stringtext,stringcaption,uinttype);总结
P/Invoke 是一扇门,门的一边是 .NET 的安逸世界,另一边是原生代码的广袤天地。掌握了它,你就能在 C# 里调用任何 C/C++ 库、任何 Windows API,甚至自己写一个 C++ DLL 给 C# 用。
但能力强也意味着责任重 —— 数据类型、调用约定、内存管理,哪一个细节没对齐,都可能换来一个 AccessViolationException(访问违规异常)。
集中管理:把所有 P/Invoke 声明放在一个名为 NativeMethods 的类里,方便维护。
显式优于隐式:永远写上 CallingConvention、CharSet、SetLastError,不要依赖默认值。
能用 Blittable 就用 Blittable:高频调用的函数,参数和返回值尽量用 int、float、byte[] 等,避免 string 和 bool。
谁分配谁释放:对于返回指针的函数,立刻看文档确认释放方式,并用 SafeHandle 或 try-finally 包裹。
善用工具:
- pinvoke.net:搜常见 Windows API 的正确签名。
- P/Invoke Interop Assistant:自动生成转换代码。
- Visual Studio 的“启用本机代码调试”:可以同时调试 C# 和 C++。
记住三条
P/Invoke 是翻译官。
能传 int 就别传 string,能传 byte[] 就别传 object。
出了问题先检查:调用约定?内存对齐?谁分配谁释放?
本文参考
Chatgpt https://chatgpt.com/
C# 官方文档 https://learn.microsoft.com/zh-cn/dotnet/standard/native-interop/pinvoke
DeepSeek https://chat.deepseek.com/