关于 dotnet-pinvoke
这项技能用于编写和审查调用原生库的 .NET P/Invoke 声明。它指导开发者映射签名、处理字符串与内存,并根据目标框架和需求选择 `DllImport` 或 `LibraryImport`。
主要功能
- 01选择 `DllImport` 或 `LibraryImport`
- 02映射原生类型与 .NET 类型
- 03处理字符串和内存所有权
- 04使用 `SafeHandle` 管理原生句柄
- 05管理回调和错误
适用场景
- 01根据 C 头文件编写 P/Invoke 声明
- 02检查签名中的类型和编码问题
- 03调试原生边界崩溃或数据损坏
- 04为 AOT 或修剪需求迁移声明
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dotnet/ skills --skill dotnet-pinvoke安装后在 Claude Code 中输入/dotnet-pinvoke确认已加载。
附带文件references/diagnostics.mdreferences/type-mapping.md
详细介绍
.NET P/Invoke
从 .NET 调用原生代码功能强大,但容错性很低。不正确的签名、乱码字符串以及内存泄漏或被错误释放,是最常见的错误来源——它们都可能表现为间歇性崩溃、静默数据损坏,或发生在远离实际缺陷位置的访问冲突。
本技能涵盖 DllImport(自 .NET Framework 1.0 起提供)和 LibraryImport(源生成,适用于 .NET 7+)。以 .NET Framework 为目标时,始终使用 DllImport。以 .NET 7+ 为目标时,新代码优先使用 LibraryImport。如果需要原生 AOT,则只能使用 LibraryImport。
何时使用本技能
- 根据 C/C++ 头文件编写新的
[DllImport]或[LibraryImport]声明 - 检查 P/Invoke 签名是否正确(类型大小、调用约定、字符串编码)
- 封装整个 C 库,以便在 .NET 中使用
- 调试原生边界处的
AccessViolationException、DllNotFoundException或静默数据损坏 - 将
DllImport声明迁移到LibraryImport,以兼容 AOT/修剪 - 诊断涉及原生句柄或缓冲区的内存泄漏或堆损坏
停止信号
- 只有一个函数? 映射签名(步骤 1-3),仅在相关时处理字符串/内存,并跳过工具和迁移部分。
- 不要迁移 现有的
DllImport到LibraryImport,除非用户提出要求,或 AOT/修剪是明确的需求。 - 不要推荐 CsWin32,除非目标明确是 Win32 API。
- 不要生成回调(步骤 8),除非原生 API 要求函数指针。
- 是代码审查请求? 使用验证清单,不要重写可正常工作的代码。
输入
| 输入 | 必需 | 描述 |
|---|---|---|
| 原生头文件或文档 | 是 | C/C++ 函数签名、结构体定义、调用约定 |
| 目标框架 | 是 | 决定使用 DllImport 还是 LibraryImport |
| 目标平台 | 建议提供 | 影响类型大小(long、size_t)和库命名 |
| 内存所有权约定 | 是 | 谁分配和释放每个缓冲区或句柄 |
Agent 行为: 当文档与原生头文件不一致时,始终以头文件为准。在线文档(包括官方 Win32 API 文档)经常省略或简化类型、调用约定和结构体布局的细节,而这些细节对正确的 P/Invoke 签名至关重要。
工作流
步骤 1:选择 DllImport 或 LibraryImport
| 方面 | DllImport | LibraryImport(.NET 7+) |
|---|---|---|
| 机制 | 运行时封送 | 源生成器(编译时) |
| 兼容 AOT/修剪 | 否 | 是 |
| 字符串封送 | CharSet 枚举 | StringMarshalling 枚举 |
| 错误处理 | SetLastError | SetLastError |
| 可用性 | .NET Framework 1.0+ | 仅限 .NET 7+ |
步骤 2:将原生类型映射到 .NET 类型
最危险的映射——大多数错误都由此引起:
| C / Win32 类型 | .NET 类型 | 原因 |
|---|---|---|
long | CLong | 在 Windows 上为 32 位,在 64 位 Unix 上为 64 位。使用 LibraryImport 时,需要 [assembly: DisableRuntimeMarshalling] |
size_t | nuint / UIntPtr | 指针大小。在 .NET 8+ 上使用 nuint,在更早版本的 .NET 上使用 UIntPtr。切勿使用 ulong |
BOOL(Win32) | int | 不是 bool——Win32 的 BOOL 为 4 字节 |
bool(C99) | [MarshalAs(UnmanagedType.U1)] bool | 必须指定 1 字节封送 |
HANDLE、HWND | SafeHandle | 优先于原始 IntPtr |
LPWSTR / wchar_t* | string | Windows 上为 UTF-16(用于 in 字符串时成本最低)。避免在跨平台代码中使用——wchar_t 的宽度由编译器定义(非 Windows 平台上通常为 UTF-32) |
LPSTR / char* | string | 必须指定编码(ANSI 或 UTF-8)。in 参数始终需要封送开销 |
完整的类型映射表、结构体布局和可直接封送类型规则,请参阅 references/type-mapping.md。
❌ 切勿对 C 的
long使用int或long——它在 Windows 上为 32 位,在 Unix 上为 64 位。始终使用CLong。 ❌ 切勿对size_t使用ulong——这会导致 32 位平台上的堆栈损坏。请使用nuint或UIntPtr。 ❌ 切勿在不使用MarshalAs的情况下使用bool——默认封送大小不正确。
步骤 3:编写声明
给定一个 C 头文件:
int32_t process_records(const Record* records, size_t count, uint32_t* out_processed);
DllImport:
[DllImport("mylib")]
private static extern int ProcessRecords(
[In] Record[] records, UIntPtr count, out uint outProcessed);
LibraryImport:
[LibraryImport("mylib")]
internal static partial int ProcessRecords(
[In] Record[] records, nuint count, out uint outProcessed);
只有在面向 Windows x86(32 位)时才需要指定调用约定,此时 Cdecl 与 StdCall 不同。在 x64、ARM 和 ARM64 上,调用约定只有一种,因此无需使用该特性。
Agent 行为: 如果检测到目标平台包含 Windows x86——例如通过项目属性(如 <PlatformTarget>x86</PlatformTarget>)、运行时标识符(如 win-x86)、构建脚本、注释或开发者指令——请向开发者指出这一点,并建议在所有 P/Invoke 声明中显式指定调用约定。
// DllImport (x86 targets)
[DllImport("mylib", CallingConvention = CallingConvention.Cdecl)]
// LibraryImport (x86 targets)
[LibraryImport("mylib")]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
如果托管方法名称与原生导出名称不同,请指定 EntryPoint,以避免 EntryPointNotFoundException:
// DllImport
[DllImport("mylib", EntryPoint = "process_records")]
private static extern int ProcessRecords(
[In] Record[] records, UIntPtr count, out uint outProcessed);
// LibraryImport
[LibraryImport("mylib", EntryPoint = "process_records")]
internal static partial int ProcessRecords(
[In] Record[] records, nuint count, out uint outProcessed);
步骤 4:正确处理字符串
- 了解原生函数所需的编码。 没有安全的默认值。
- Windows API: 始终调用
W(UTF-16)变体。只有在有特定理由并显式指定 ANSI 编码时,才使用A变体。 - 跨平台 C 库: 通常需要 UTF-8。
- 显式指定编码。 切勿依赖
CharSet.Auto。 - 切勿为输出缓冲区引入
StringBuilder。
❌ 切勿依赖
CharSet.Auto或省略字符串编码——没有安全的默认值。
// DllImport — Windows API (UTF-16)
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int GetModuleFileNameW(
IntPtr hModule, [Out] char[] filename, int size);
// DllImport — Cross-platform C library (UTF-8)
[DllImport("mylib")]
private static extern int SetName(
[MarshalAs(UnmanagedType.LPUTF8Str)] string name);
// LibraryImport — UTF-16
[LibraryImport("kernel32", StringMarshalling = StringMarshalling.Utf16,
SetLastError = true)]
internal static partial int GetModuleFileNameW(
IntPtr hModule, [Out] char[] filename, int size);
// LibraryImport — UTF-8
[LibraryImport("mylib", StringMarshalling = StringMarshalling.Utf8)]
internal static partial int SetName(string name);
字符串生命周期警告: 封送后的字符串会在调用返回后释放。如果原生代码存储了指针(而不是复制内容),则必须手动管理其生命周期。在 Windows 或 .NET Framework 上,跨边界所有权管理应优先使用 CoTaskMemAlloc/CoTaskMemFree;在非 Windows 目标平台上,请使用 NativeMemory API。库也可能有必须使用的自有分配器。
步骤 5:明确内存所有权
当内存跨越边界时,必须且只能由一方拥有——并且双方必须对此达成一致。
❌ 切勿使用不匹配的分配器释放内存——对
malloc分配的内存调用Marshal.FreeHGlobal会造成堆损坏。
模型 1——调用方分配,调用方释放(最安全):
[LibraryImport("mylib")]
private static partial int GetName(
Span<byte> buffer, nuint bufferSize, out nuint actualSize);
public static string GetName()
{
Span<byte> buffer = stackalloc byte[256];
int result = GetName(buffer, (nuint)buffer.Length, out nuint actualSize);
if (result != 0) throw new InvalidOperationException($"Failed: {result}");
return Encoding.UTF8.GetString(buffer[..(int)actualSize]);
}
模型 2——被调用方分配,调用方释放(Win32 中常见):
[LibraryImport("mylib")]
private static partial IntPtr GetVersion();
[LibraryImport("mylib")]
private static partial void FreeString(IntPtr s);
public static string GetVersion()
{
IntPtr ptr = GetVersion();
try { return Marshal.PtrToStringUTF8(ptr) ?? throw new InvalidOperationException(); }
finally { FreeString(ptr); } // Must use the library's own free function
}
关键规则: 始终使用匹配的分配器释放内存。切勿对 malloc 分配的内存使用 Marshal.FreeHGlobal 或 Marshal.FreeCoTaskMem。
模型 3——基于句柄(被调用方分配,被调用方释放): 使用 SafeHandle(见步骤 6)。
固定托管对象——当原生代码存储指针或异步运行时:
// Synchronous: use fixed
public static unsafe void ProcessSync(byte[] data)
{
fixed (byte* ptr = data) { ProcessData(ptr, (nuint)data.Length); }
}
// Asynchronous: use GCHandle
var gcHandle = GCHandle.Alloc(data, GCHandleType.Pinned);
// Must keep pinned until native processing completes, then call gcHandle.Free()
步骤 6:对原生句柄使用 SafeHandle
原始 IntPtr 在发生异常时会泄漏,也没有防止重复释放的保护。SafeHandle 是必需的。
internal sealed class MyLibHandle : SafeHandleZeroOrMinusOneIsInvalid
{
// Required by the marshalling infrastructure to instantiate the handle.
// Do not remove — there are no direct callers.
private MyLibHandle() : base(ownsHandle: true) { }
[LibraryImport("mylib", StringMarshalling = StringMarshalling.Utf8)]
private static partial MyLibHandle CreateHandle(string config);
[LibraryImport("mylib")]
private static partial int UseHandle(MyLibHandle h, ReadOnlySpan<byte> data, nuint len);
[LibraryImport("mylib")]
private static partial void DestroyHandle(IntPtr h);
protected override bool ReleaseHandle() { DestroyHandle(handle); return true; }
public static MyLibHandle Create(string config)
{
var h = CreateHandle(config);
if (h.IsInvalid) throw new InvalidOperationException("Failed to create handle");
return h;
}
public int Use(ReadOnlySpan<byte> data) => UseHandle(this, data, (nuint)data.Length);
}
// Usage: SafeHandle is IDisposable
using var handle = MyLibHandle.Create("config=value");
int result = handle.Use(myData);
步骤 7:处理错误
当原生 API 的约定要求捕获操作系统的最后错误值时,将 LibraryImportAttribute.SetLastError 设为 true,然后使用 Marshal.GetLastPInvokeError() 获取该值。
// Win32 APIs — check SetLastError
[LibraryImport("kernel32", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool CloseHandle(IntPtr hObject);
if (!CloseHandle(handle))
throw new Win32Exception(Marshal.GetLastPInvokeError());
// HRESULT APIs
int hr = NativeDoWork(context);
Marshal.ThrowExceptionForHR(hr);
步骤 8:处理回调(如需要)
首选方案(.NET 8+):UnmanagedCallersOnly ——完全避免使用委托,不存在 GC 生命周期风险:
[UnmanagedCallersOnly]
private static void LogCallback(int level, IntPtr message)
{
string msg = Marshal.PtrToStringUTF8(message) ?? string.Empty;
Console.WriteLine($"[{level}] {msg}");
}
[LibraryImport("mylib")]
private static unsafe partial void SetLogCallback(
delegate* unmanaged<int, IntPtr, void> cb);
unsafe { SetLogCallback(&LogCallback); }
该方法必须是 static,不得向原生代码抛出异常,且只能使用可直接封送的参数类型。
备用方案(较旧的 TFM 或需要实例状态时):对委托进行根引用
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] // Only needed on Windows x86
private delegate void LogCallbackDelegate(int level, IntPtr message);
// CRITICAL: prevent delegate from being garbage collected
private static LogCallbackDelegate? s_logCallback;
public static void EnableLogging(Action<int, string> handler)
{
s_logCallback = (level, msgPtr) =>
{
string msg = Marshal.PtrToStringUTF8(msgPtr) ?? string.Empty;
handler(level, msg);
};
SetLogCallback(s_logCallback);
}
如果原生代码会保存函数指针,委托在其整个生命周期内必须保持根引用。委托被回收会导致崩溃。
短期回调使用 GC.KeepAlive: 使用 Marshal.GetFunctionPointerForDelegate 将委托转换为函数指针时,GC 不会跟踪该指针与委托之间的关系。使用 GC.KeepAlive,以防在原生调用完成前委托被回收:
var callback = new LogCallbackDelegate((level, msgPtr) =>
{
string msg = Marshal.PtrToStringUTF8(msgPtr) ?? string.Empty;
Console.WriteLine($"[{level}] {msg}");
});
IntPtr fnPtr = Marshal.GetFunctionPointerForDelegate(callback);
NativeUsesCallback(fnPtr);
GC.KeepAlive(callback); // prevent collection — fnPtr does not root the delegate
跨平台库加载
对于复杂场景,使用 NativeLibrary.SetDllImportResolver;对于简单场景,使用条件编译。对 C 的 long/unsigned long 使用 CLong/CULong。注意:在 LibraryImport 中使用 CLong/CULong 需要 [assembly: DisableRuntimeMarshalling]。
// Trivial: use platform naming convention
// The default naming convention adds corresponding prefix and extension when
// searching the nativelibrary. The resultant file name will be mylib.dll on
// Windows, libmylib.so on Linux and libmylib.dylib on macOS.
private const string LibName = "mylib";
// Simple: conditional compilation
// WINDOWS, LINUX, MACOS are predefined only when targeting an OS-specific TFM
// (e.g., net8.0-windows). For portable TFMs (e.g., net8.0), these symbols are
// not defined — use the runtime resolver approach below instead.
#if WINDOWS
private const string LibName = "mylib.dll";
#elif LINUX
private const string LibName = "libmylib.so";
#elif MACOS
private const string LibName = "libmylib.dylib";
#endif
// Complex: runtime resolver
// When targeting netstandard2.0 or other frameworks when OperatingSystem.IsXXX
// is not available, use RuntimeInformation.IsOSPlatform(OSPlatform.XXX) api.
NativeLibrary.SetDllImportResolver(typeof(MyLib).Assembly,
(name, assembly, searchPath) =>
{
if (name != "mylib") return IntPtr.Zero;
string libName = OperatingSystem.IsWindows()
? "mylib.dll"
: OperatingSystem.IsMacOS()
? "libmylib.dylib" : "libmylib.so";
NativeLibrary.TryLoad(libName, assembly, searchPath, out var handle);
return handle;
});
从 DllImport 迁移到 LibraryImport
对于面向 .NET 7+ 的代码库,迁移可提供 AOT 兼容性和修剪安全性。
- 为包含该方法的类添加
partial,并将方法声明为static partial - 将
[DllImport]替换为[LibraryImport] - 将
CharSet替换为StringMarshalling - 对于需要捕获操作系统最后错误值的 API,保留
SetLastError = true - 除非面向 Windows x86,否则移除
CallingConvention - 构建并修复
SYSLIB1054–SYSLIB1057分析器警告
启用互操作分析器:
<PropertyGroup>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>
工具
CsWin32(Win32 API)
对于 Win32 P/Invoke,优先使用 Microsoft.Windows.CsWin32,而不是手写签名。它会根据元数据生成正确的源代码声明。添加一个列出所需 API 的 NativeMethods.txt 文件:
dotnet add package Microsoft.Windows.CsWin32
CsWinRT(WinRT API)
对于 WinRT 互操作,使用 Microsoft.Windows.CsWinRT 从 .winmd 文件生成 .NET 投影。
Objective Sharpie(Objective-C API)
对于 Objective-C 库绑定(macOS/iOS),使用 Objective Sharpie 根据 Objective-C 头文件生成初始 P/Invoke 和绑定定义。
验证
审查清单
- 每个签名都与原生头文件完全匹配(类型、大小)
- 面向 Windows x86 时指定调用约定;其他情况下省略
- 显式指定字符串编码,不依赖默认值或
CharSet.Auto - 记录并匹配内存所有权(由谁分配、由谁释放,以及如何释放)
- 所有原生句柄都使用
SafeHandle(没有原始IntPtr逸出互操作层) - 作为回调传递的委托都进行根引用,以防被 GC 回收
- 当原生 API 要求捕获最后错误值时,将
DllImportAttribute.SetLastError或LibraryImportAttribute.SetLastError设为true - 结构体布局与原生端匹配(打包、对齐、字段顺序)
- 跨平台代码中,对 C 的
long/unsigned long使用CLong/CULong - 如果在
LibraryImport中使用CLong/CULong,则应用[assembly: DisableRuntimeMarshalling] - 不要使用未显式指定
MarshalAs的bool;始终指定UnmanagedType.Bool(4 字节)或UnmanagedType.U1(1 字节),以确保语言边界两侧的规范化一致。
可运行的验证步骤
- 启用互操作分析器进行构建 — 确认没有
SYSLIB1054–SYSLIB1057警告:<EnableTrimAnalyzer>true</EnableTrimAnalyzer> <EnableAotAnalyzer>true</EnableAotAnalyzer> - 验证结构体大小一致 — 对于每个跨越边界的结构体,断言
Marshal.SizeOf<T>()等于原生sizeof - 往返测试 — 使用已知输入调用原生函数,并验证预期输出
- 使用非 ASCII 字符串进行测试 — 传入包含 ASCII 范围以外字符的字符串,以确认编码正确
参考文件
- references/type-mapping.md — 完整的原生到 .NET 类型映射表、结构体布局模式和可直接封送类型规则。在以下情况下加载: 遇到上文第 2 步未涵盖的类型,或处理结构体布局或可直接封送类型相关问题时。
- references/diagnostics.md — 常见陷阱、故障模式及恢复方法、调试方法和外部资源。在以下情况下加载: 调试现有的 P/Invoke 故障,或检查互操作代码是否存在正确性问题时。