C#是一门托管语言,运行在.NET CLR之上,但操作系统的很多底层能力——窗口控制、内存管理、设备通信、注册表操作——都是以原生DLL中的Win32 API形式提供的。想在C#里用这些能力,就必须想办法跨过托管代码与非托管代码之间的边界。本文总结几种常见的调用方式,并重点讲清楚P/Invoke的细节和容易踩的坑。

一、DllImport + P/Invoke:最主流的调用方式
P/Invoke(Platform Invoke)是.NET提供的标准机制,通过DllImport特性声明一个外部函数,CLR在运行时会自动加载对应的DLL并把参数从托管内存封送到非托管内存。绝大多数场景下,这就是首选方案。
比如我们要调用user32.dll里的MessageBox函数,只需要写一个静态方法声明,并给方法加上DllImport特性:
using System.Runtime.InteropServices;
class Program
{
// 声明user32.dll中的MessageBox函数
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
static void Main()
{
MessageBox(IntPtr.Zero, "来自Win32 API的弹窗", "提示", 0);
}
}这段代码有几个关键点。CharSet = CharSet.Unicode告诉封送拆接器字符串按宽字符处理,对应Win32的MessageBoxW版本;如果不指定,默认是ANSI,会调用MessageBoxA,传中文时可能出现乱码。static extern是固定写法,表示方法体由外部DLL提供。IntPtr用来承接句柄类型(HWND、HANDLE等),这是跨语言调用中最通用的指针表示方式。
调用kernel32.dll同样如此,例如获取系统目录:
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
static extern uint GetSystemDirectory(StringBuilder sb, uint size);
static void Main()
{
var sb = new StringBuilder(260);
GetSystemDirectory(sb, 260);
Console.WriteLine(sb.ToString()); // 输出类似 C:\Windows\system32
}注意接收输出字符串时要传StringBuilder并预先分配容量,直接传string是拿不到返回内容的,因为string在托管堆上是不可变的。
二、动态调用:LoadLibrary + GetProcAddress
当DLL路径只有运行时才能确定,或者想在调用失败时优雅降级,静态DllImport就不太灵活了。这时可以走动态路线:用LoadLibrary加载库,用GetProcAddress拿到函数指针,再通过委托调用。
using System.Runtime.InteropServices;
delegate int AddDelegate(int a, int b);
class Program
{
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern IntPtr LoadLibrary(string fileName);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr GetProcAddress(IntPtr module, string procName);
static void Main()
{
IntPtr hMod = LoadLibrary("MyNativeLib.dll");
if (hMod == IntPtr.Zero)
{
Console.WriteLine("DLL加载失败");
return;
}
IntPtr pFunc = GetProcAddress(hMod, "Add");
var add = Marshal.GetDelegateForFunctionPointer<AddDelegate>(pFunc);
Console.WriteLine(add(3, 5)); // 输出 8
}
}这种写法的好处是控制权完全在自己手里:库不存在、函数找不到时可以捕获并处理,而不是像静态DllImport那样直接抛出DllNotFoundException导致程序崩溃。代价是代码量更多,类型安全性也弱一些,适合插件系统、按需加载等场景。
三、结构体、回调与常见坑
实际调用中,API往往要求传结构体指针或回调函数。结构体用StructLayout保证内存布局与C端一致,比如获取系统时间的GetSystemTime:
[StructLayout(LayoutKind.Sequential)]
struct SYSTEMTIME
{
public ushort Year, Month, DayOfWeek, Day, Hour, Minute, Second, Milliseconds;
}
[DllImport("kernel32.dll")]
static extern void GetSystemTime(out SYSTEMTIME time);
// 回调示例:枚举所有顶层窗口
delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);几个高频踩坑点值得单独强调。第一,结构体对齐问题:如果C端用了#pragma pack(1)之类的紧凑对齐,C#端必须配Pack = 1,否则字段错位、数据全乱。第二,回调函数调用完之前不能被GC回收,如果委托是临时变量,可能在非托管代码还在执行时就被回收了,标准做法是把它存成字段。第三,调用后如果需要检查错误码,给DllImport加上SetLastError = true,然后用Marshal.GetLastWin32Error()获取,不要用GetLastError()本身(中间任何一次API调用都可能覆盖它)。
另外,Win32类型到C#类型的映射要记牢:DWORD对应uint,BOOL对应C#的bool或int(部分API必须用int),LPVOID和句柄统一用IntPtr,字符串指针按方向分别用string(入参)、StringBuilder(出参)和IntPtr(双向或复杂场景)。拿不准时可以借助微软官方文档或PInvoke Interop Assistant工具自动生成签名。
四、其他可选方案对比
除了直接P/Invoke,还有一些替代思路。其一是使用系统自带的COM组件,通过tlbimp或VS的引用功能生成互操作程序集后像普通对象一样调用,适合Shell、WMI这类本身就是COM形式的接口。其二是寻找已有的托管封装库,比如很多注册表、窗口操作功能其实在.NET基类库(Microsoft.Win32.Registry)或社区包里已经封装好了,能用托管API就不要碰原生调用,省去内存管理的心智负担。其三是.NET 5以后支持直接调用未托管库并生成源代码的LibraryImport特性,编译期生成封送代码,性能更好且支持AOT编译,是DllImport的现代替代品。
| 方案 | 适用场景 | 优缺点 |
|---|---|---|
| DllImport静态声明 | 固定系统DLL的函数调用 | 简单直观;DLL缺失时启动调用会抛异常 |
| LoadLibrary动态调用 | 插件加载、运行时决定调用 | 灵活可控;代码冗长、类型安全性弱 |
| COM互操作 | Shell、Office等COM组件 | 面向对象;需要注册和清理COM对象 |
| 托管封装库 | 已有现成封装的功能 | 最省事;覆盖面有限 |
| LibraryImport | .NET 5+新项目 | 高性能支持AOT;旧框架不可用 |
总结一下:调系统API首选DllImport或LibraryImport,动态场景用LoadLibrary,能用托管封装就别碰底层。真正容易出问题的从来不是声明本身,而是字符集、结构体布局、句柄释放和回调生命周期这些细节,写之前查清楚目标函数的签名约定,能省掉大部分调试时间。
C#调用Win32 APIDllImportP/Invoke修改时间:2026-09-03 13:49:03