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 示例
cs
using System;
using System.Runtime.InteropServices;
namespace MyPInvoke_demo01{
internal class Program
{
//[DllImport("user32.dll")] 告诉 CLR:这个函数住在 user32.dll 这座大楼里
//CharSet = CharSet.Unicode 字符串使用 Unicode 编码,防止乱码
//public static extern 方法的实现在外部(C++ 那边),这里只是声明
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern int MessageBox(IntPtr hWnd, String text, String caption, uint type);
static void Main(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 里显式写清楚。
cs
[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 封装起来,这样即使发生异常,也能自动释放
cs
// 示例:封装一个文件句柄
public class SafeFileHandle : SafeHandleZeroOrMinusOneIsInvalid{
public SafeFileHandle() : base(true) { }
protected override bool ReleaseHandle() => CloseHandle(handle);
[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr hObject);
}
结构体、回调和字符串
1. 结构体互传 ------ 注意内存对齐
假设 C++ 里有这样一个结构体:
cs
struct Point3D {
float x;
float y;
float z;
};
C# 中要这样声明才能对得上:
cs
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Point3D{
public float X;
public float Y;
public float Z;
}
LayoutKind.Sequential: 按声明顺序排列字段。
Pack = 1: 按 1 字节对齐,防止编译器自动插入填充字节。
2. 回调函数 ------ 小心委托被 GC"吃掉"
可以把 C# 的方法作为函数指针传给 C++,让 C++ 在某些事件发生时回调它。比如遍历窗口:
cs
// 声明委托类型
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
// 实现回调方法
static bool MyCallback(IntPtr hWnd, IntPtr lParam){
Console.WriteLine($"找到窗口句柄: {hWnd}");
return true; // 继续枚举
}
// 调用
EnumWindows(MyCallback, IntPtr.Zero);
致命坑: 如果 MyCallback 委托没有被任何变量引用,GC 可能在你不知情的时候回收它,然后 C++ 那边回调时就会访问非法内存,程序直接崩溃。
解决方案: 用一个静态字段持有委托实例。
cs
private static EnumWindowsProc callback = MyCallback;
EnumWindows(callback, IntPtr.Zero);
3. 字符串的"读"与"写"
- 只读字符串: 直接用 string 参数,P/Invoke 会自动转成 const char*。
- 需要 C++ 填充的字符串缓冲区: 用 StringBuilder,并且预分配足够容量。
cs
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
var sb = new StringBuilder(256);
GetWindowText(someHwnd, sb, sb.Capacity);
.NET 7+ 的新福音:LibraryImport
LibraryImport采用源生成器技术,在编译时就生成封送代码,优点:
- 零运行时反射,性能更好。
- 支持 AOT(提前编译)场景。
- 类型检查更严格,错误更早发现。
LibraryImport写法:
cs
//注意:方法要声明为 partial static,不需要方法体。
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
static partial int MessageBox(IntPtr hWnd, string text, string caption, uint type);
总结
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/