简介
在 .NET 里,只要你开始接触这些场景:
- 调用
Win32 API - 复用已有的
C/C++动态库 - 对接系统底层能力或硬件驱动
- 调用高性能原生库,例如压缩、图像、音视频、加密库
- 处理句柄、指针、非托管内存
你大概率都会走到一个关键能力:
csharp
P/Invoke
一句话先说透:
P/Invoke的本质,是让托管代码通过运行时提供的互操作机制,去调用非托管动态库中的函数。
它真正难的地方,其实不是"怎么声明一个外部方法",而是:
- 类型怎么映射;
- 字符串怎么编码;
- 结构体内存布局是否一致;
- 谁负责分配内存,谁负责释放;
- 回调会不会被
GC回收; - 平台差异会不会让调用在另一台机器上直接崩掉。
所以这篇文章重点不是只讲 DllImport 语法,而是讲清楚:
P/Invoke到底是什么;DllImport和LibraryImport怎么选;- 封送(
Marshalling)到底在做什么; - 结构体、字符串、句柄、回调应该怎么处理;
- 实战里最容易踩的坑有哪些。
P/Invoke 到底是什么?
P/Invoke 全称是:
text
Platform Invocation Services
可以先用一句最直白的话理解:
P/Invoke就是 C# 调用原生函数的桥梁。
例如下面这个经典写法:
csharp
using System;
using System.Runtime.InteropServices;
internal static class NativeMethods
{
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
internal static extern int MessageBox(
IntPtr hWnd,
string text,
string caption,
uint type);
}
调用时:
csharp
NativeMethods.MessageBox(IntPtr.Zero, "Hello", "PInvoke", 0);
对于调用方来说,它看起来像一个普通静态方法。
但底层其实发生了这些事:
- 运行时定位目标动态库;
- 找到对应导出函数;
- 把托管参数转换成非托管表示;
- 执行原生调用;
- 再把返回值或输出参数转换回托管世界。
这个"转换和桥接"的过程,就是 P/Invoke 的核心。
为什么需要 P/Invoke?
因为 .NET 再强,也不可能把所有操作系统能力、历史原生库、底层硬件接口都重新包装一遍。
所以在很多场景里,P/Invoke 不是"炫技",而是现实需求:
- 调用系统 API,例如窗口、进程、句柄、文件、注册表;
- 复用已有的
C/C++库; - 使用厂商只提供原生接口的 SDK;
- 访问托管世界之外的底层能力。
一次调用到底经历了什么?
从高层往下看,可以把它理解成这样:
text
C# 方法声明
-> 运行时定位动态库和导出符号
-> 封送参数
-> 切换到非托管调用
-> 执行原生函数
-> 封送返回值/输出参数
-> 回到托管代码
也就是说,P/Invoke 从来不只是"调一下 DLL"这么简单。
它真正麻烦的地方在于:
- 托管类型和原生类型未必一致;
- 托管对象会被
GC移动; - 不同平台对字符串、结构体对齐、调用约定的默认值也可能不同。
DllImport 和 LibraryImport 怎么选?
这是现在写 P/Invoke 最值得单独讲的一点。
DllImport
这是最传统、最经典的写法。
csharp
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern uint GetCurrentThreadId();
优点是:
- 兼容范围广;
- 老项目里最常见;
- 资料最多。
LibraryImport
这是较新的写法,基于源生成器生成互操作代码。
csharp
using System.Runtime.InteropServices;
internal static partial class NativeMethods
{
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial uint GetCurrentThreadId();
}
它的特点是:
- 更现代;
- 对
AOT/NativeAOT更友好; - 某些场景下性能和诊断体验更好;
- 更适合新项目统一使用。
实战里怎么选?
可以先记这个原则:
- 老项目、兼容性优先:
DllImport - 新项目、现代
.NET、互操作代码较多:优先LibraryImport
但无论你用哪一个,真正决定成败的都不是特性名字,而是:
- 签名是否和原生函数一致;
- 封送规则是否正确;
- 内存所有权是否清晰。
P/Invoke 最核心的问题:封送到底是什么?
P/Invoke 里最关键的词,不是 DLL,而是:
csharp
Marshalling
也就是:
托管表示和非托管表示之间的转换。
因为很多类型在两边并不是天然一样的。
例如:
string在托管世界和char*/wchar_t*并不等价;bool在托管代码里和 WindowsBOOL也未必完全一样;- 结构体的字段顺序、填充、对齐如果不一致,读出来就是错的;
- 数组、缓冲区、回调、句柄也都各有自己的规则。
所以写 P/Invoke 时,最重要的习惯是:
先看原生签名,再决定托管签名,而不是反过来。
基础类型怎么映射?
先记几个最常见的:
| 原生概念 | 常见托管类型 |
|---|---|
int32 |
int |
uint32 |
uint |
| 指针 / 句柄 | IntPtr / nint |
| 缓冲区指针 | IntPtr、数组、Span<T> 对应方案 |
| 结构体 | [StructLayout] struct |
| 字符串指针 | string、StringBuilder、IntPtr |
这里最容易犯的错误之一是"看名字猜类型"。
例如在 Windows 世界里:
LONG通常是 32 位,不是 C# 的long- 句柄不是
int,而更接近IntPtr
所以真正可靠的依据始终是目标平台的原生文档和头文件定义。
字符串为什么是重灾区?
因为原生世界里的字符串并不统一。
最常见的就有:
char*wchar_t*LPSTRLPWSTR- 定长字符数组
而在 C# 里,大家最容易直接写成:
csharp
string
问题是:
- 编码可能不一致;
- 调用的导出函数可能有
A/W版本差异; - 原生函数可能要写入缓冲区,而
string是不可变的。
1. 传入只读字符串
如果原生函数只是读取字符串,最常见写法是:
csharp
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
internal static extern int MessageBox(
IntPtr hWnd,
string text,
string caption,
uint type);
这里的重点是:
- 明确指定
CharSet - 不要把字符串编码交给模糊默认值碰运气
2. 接收可写字符串缓冲区
如果原生函数要往缓冲区里写内容,更常见的是:
csharp
using System.Text;
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
internal static extern int GetWindowText(
IntPtr hWnd,
StringBuilder text,
int count);
调用时:
csharp
var buffer = new StringBuilder(256);
GetWindowText(hWnd, buffer, buffer.Capacity);
string title = buffer.ToString();
这类场景下用 StringBuilder,通常比直接用 string 更符合原生 API 语义。
结构体为什么一定要小心?
因为结构体一旦布局不一致,结果往往不是"报错",而是:
- 数据错位;
- 字段值异常;
- 调用时崩溃;
- 偶发性内存损坏。
所以结构体必须显式描述布局。
最常见的写法是:
csharp
[StructLayout(LayoutKind.Sequential)]
internal struct POINT
{
public int X;
public int Y;
}
如果原生函数使用这个结构体:
csharp
[DllImport("user32.dll")]
internal static extern bool GetCursorPos(out POINT point);
那么调用时就能正确按顺序映射字段。
什么时候要关注 Pack?
当原生结构体明确指定了对齐方式,或者你发现字段总是错位时,就必须关注:
csharp
[StructLayout(LayoutKind.Sequential, Pack = 1)]
也就是说,结构体能不能正常工作,不只取决于字段顺序,还取决于:
- 对齐方式;
- 填充字节;
- 字段类型大小。
联合体该怎么表示?
如果原生代码里用的是 union,一般要改用显式布局:
csharp
[StructLayout(LayoutKind.Explicit)]
internal struct INPUT_UNION
{
[FieldOffset(0)]
public MOUSEINPUT MouseInput;
[FieldOffset(0)]
public KEYBDINPUT KeyboardInput;
}
这里的关键不是语法,而是:
- 多个字段共享同一块内存;
- 必须用
FieldOffset显式指定偏移。
句柄为什么推荐 SafeHandle?
很多原生 API 会返回句柄,例如:
- 文件句柄;
- 进程句柄;
- 窗口句柄;
- 套接字句柄;
很多初学者会直接用:
csharp
IntPtr
当然能用,但它有个明显问题:
- 它只是一块值;
- 不表达所有权;
- 不会自动释放;
- 很容易忘记调用释放函数。
这就是为什么实战里更推荐:
csharp
SafeHandle
例如:
csharp
using Microsoft.Win32.SafeHandles;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
internal static extern SafeFileHandle CreateFile(
string fileName,
uint desiredAccess,
uint shareMode,
IntPtr securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
IntPtr templateFile);
它的核心价值是:
- 把释放逻辑和句柄绑在一起;
- 降低资源泄漏风险;
- 更符合托管代码的资源管理习惯。
回调为什么也是高风险点?
因为很多原生 API 不只是让你传参数,还会让你传一个函数指针。
在 C# 里,这通常对应:
csharp
delegate
例如:
csharp
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
internal delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
internal static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam);
调用时:
csharp
EnumWindowsProc callback = (hWnd, lParam) =>
{
Console.WriteLine(hWnd);
return true;
};
EnumWindows(callback, IntPtr.Zero);
这里最重要的风险不是"怎么写委托",而是:
必须确保委托在原生代码使用期间不会被
GC回收。
所以更稳妥的做法通常是:
- 保持强引用;
- 不要把委托临时内联后就不管;
- 明确回调生命周期。
SetLastError 是干什么的?
很多 Windows API 调用失败后,并不会直接把完整错误信息作为返回值给你,而是把错误码放在"最后错误"位置。
这时你通常要这样声明:
csharp
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern bool CloseHandle(IntPtr handle);
然后在失败后读取错误码:
csharp
int errorCode = Marshal.GetLastWin32Error();
也就是说:
- 没有
SetLastError = true - 你拿到的错误码就未必可靠
这是很多 Win32 互操作代码排查失败原因时的关键。
调用约定为什么不能乱写?
原生函数的调用约定,决定了:
- 参数怎么传;
- 栈由谁清理;
- 调用边界怎么解释。
如果你声明错了,结果往往不是普通异常,而可能是:
- 栈损坏;
- 随机崩溃;
- 调用返回后莫名其妙出错。
常见写法例如:
csharp
[DllImport("mylib.dll", CallingConvention = CallingConvention.Cdecl)]
internal static extern int Add(int x, int y);
所以调用约定不能凭经验猜,必须和原生导出函数保持一致。
什么时候会用到 unsafe、IntPtr 和手动内存管理?
当自动封送不够用,或者你需要更精细地控制内存时,就可能会走到底层方案。
例如:
- 原生函数返回一块指针;
- 你要手动分配缓冲区;
- 需要处理二进制结构;
- 要明确谁分配、谁释放。
最常见的一个模式是:
csharp
IntPtr buffer = Marshal.AllocHGlobal(1024);
try
{
// 调用原生函数
}
finally
{
Marshal.FreeHGlobal(buffer);
}
这里最重要的不是 AllocHGlobal 本身,而是:
谁申请的内存,必须由正确的一侧、用正确的方式释放。
否则就是典型的内存泄漏或非法释放问题。
P/Invoke 最容易踩的坑有哪些?
1. 签名看起来像对,其实类型错了
这是最多见的坑。
例如把:
HANDLE写成intLONG写成long- 缓冲区写成
string
结果往往不是编译不过,而是运行时表现很怪。
2. 结构体布局不一致
没有 StructLayout、Pack 不匹配、字段顺序错,都可能让调用结果直接失真。
3. 字符串编码错
尤其是在:
ANSI/UnicodeA/W后缀函数- Windows 和 Unix 平台差异
这些地方最容易出问题。
4. 内存所有权不清
不知道是谁分配、谁释放,是互操作代码里最危险的问题之一。
5. 回调委托被回收
这个问题经常是"偶发崩溃",最难排查。
6. 误把 P/Invoke 当成零成本调用
每次跨托管/非托管边界都有开销。
如果你在热点路径里高频细粒度调用原生函数,成本可能并不小。
所以在性能敏感场景里,更值得优化的往往是:
- 减少调用次数;
- 合并批量操作;
- 尽量使用 blittable 类型;
- 减少不必要的字符串和复杂结构封送。
新项目里更推荐怎样组织代码?
一个很实用的习惯是:
- 按原生库分组;
- 单独放
NativeMethods或按 DLL 拆分类; - 让互操作签名尽量集中;
- 在托管层再包一层更友好的业务 API。
例如:
csharp
internal static partial class Kernel32
{
[LibraryImport("kernel32.dll", SetLastError = true)]
internal static partial uint GetCurrentThreadId();
}
internal static partial class User32
{
[LibraryImport("user32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
internal static partial int MessageBox(
IntPtr hWnd,
string text,
string caption,
uint type);
}
这样做的好处是:
- 原生签名集中管理;
- 更容易排查平台差异;
- 更适合后续统一替换或封装。
一个非常实用的判断标准
如果你准备写一个 P/Invoke 声明,先问自己五个问题:
- 原生函数的真实签名是什么?
- 参数和返回值的所有权归谁?
- 字符串到底是什么编码?
- 结构体布局和对齐方式是否一致?
- 这个句柄或回调的生命周期由谁负责?
五个问题里只要有两个答不稳,就不要急着写代码,先回到原生文档和头文件确认。
面试里高频怎么答?
如果面试官问:
"P/Invoke 的本质是什么?"
一个比较稳妥的回答可以是:
P/Invoke是 .NET 提供的托管到非托管调用机制。开发者通过DllImport或LibraryImport声明原生函数,运行时负责定位动态库、完成参数封送、跨边界调用以及返回值转换。真正的难点不在声明本身,而在封送、结构体布局、字符串编码、句柄生命周期和内存所有权。
如果继续追问"最常见的坑是什么",就接着答:
- 签名映射错误;
- 字符串编码错误;
- 结构体布局不一致;
- 内存释放责任不清;
- 回调委托被回收。
这基本就是 P/Invoke 最核心的知识点了。
总结
P/Invoke 的本质,不是"在 C# 里写个 DLL 声明",而是:
在托管世界和原生世界之间搭一座边界清晰、类型正确、生命周期可控的桥。
最值得记住的其实只有四句话:
P/Invoke解决的是托管代码调用原生代码的问题;- 真正的难点在封送、内存布局和生命周期,而不是特性语法;
- 新项目可以优先考虑
LibraryImport,但签名正确永远比"用哪个特性"更重要; - 一旦涉及字符串、结构体、句柄、回调和手动内存管理,就必须格外谨慎。
如果把它当成"声明个 extern 方法就完事"的小功能,通常迟早会踩坑。
如果把它当成"跨托管/非托管边界的系统工程",你的互操作代码通常会稳很多。