使用 dotnet-pinvoke 技能正确编写 .NET P/Invoke 与 LibraryImport 原生互操作声明【免费下载链接】skillsRepository for skills to assist AI coding agents with .NET and C#项目地址: https://gitcode.com/GitHub_Trending/skills17/skills调用原生 C/C 代码是 .NET 开发中功能强大但容错率极低的一个领域签名错误、字符串乱码、内存泄漏或重复释放往往表现为难以定位的间歇性崩溃、静默数据损坏或远离真正缺陷的访问冲突。本文以本仓库dotnet-advanced插件中的 dotnet-pinvoke 技能 为骨架系统讲解从 C/C 头文件写出正确 P/Invoke 声明的完整流程——包括DllImport与LibraryImport的选型、危险类型映射、字符串编组、内存所有权契约、SafeHandle、回调、跨平台库加载与 AOT 迁移帮助读者具备在 .NET 与原生边界处写出可靠互操作代码并诊断崩溃、泄漏与内存损坏的实战能力。何时使用该技能dotnet-pinvoke技能Frontmatter 中的描述定位于正确地从 .NET 调用原生 C/C 库明确给出以下适用场景USE FOR与禁忌场景DO NOT USE FOR适用场景根据 C/C 头文件编写新的[DllImport]或[LibraryImport]声明审查已有 P/Invoke 签名的正确性类型尺寸、调用约定、字符串编码将一个完整的 C 库封装为 .NET 可用调试AccessViolationException、DllNotFoundException或原生边界处的静默数据损坏为 AOT/裁剪兼容性把DllImport迁移到LibraryImport诊断涉及原生句柄或缓冲区的内存泄漏、堆损坏。禁忌场景DO NOT USE FORCOM 互操作、C/CLI 混合模式程序集、以及没有任何原生依赖的纯托管代码。技能同时定义了清晰的停止信号避免 Agent 过度发挥单个函数只需映射签名步骤 1-3即可无需走完工具与迁移章节用户未明确要求或 AOT/裁剪不是显式需求时不迁移已有DllImport目标不是 Win32 API 时不推荐 CsWin32原生 API 不需要函数指针时不生成回调审查请求应使用验证清单而非重写可用代码。技能输入要求输入是否必需说明原生头文件或文档是C/C 函数签名、结构体定义、调用约定目标框架是决定使用DllImport还是LibraryImport目标平台推荐影响类型尺寸long、size_t与库命名内存所有权契约是每个缓冲区或句柄由谁分配、由谁释放技能特别强调一条 Agent 行为准则当文档与原生头文件不一致时永远信任头文件。在线文档包括官方 Win32 API 文档经常省略或简化对正确 P/Invoke 签名至关重要的类型、调用约定和结构体布局细节。Step 1选择 DllImport 还是 LibraryImport这是声明互操作代码的第一步决定后续所有写法。两者核心差异如下方面DllImportLibraryImport.NET 7机制运行时编组源代码生成器编译期AOT / 裁剪安全否是字符串编组CharSet枚举StringMarshalling枚举错误处理SetLastErrorSetLastPInvokeError可用性.NET Framework 1.0仅 .NET 7选型规则目标为 .NET Framework 时始终用DllImport目标为 .NET 7 时新代码优先用LibraryImport当原生 AOT 是硬性需求时LibraryImport是唯一选择因为运行时编组在 AOT 场景下不可用。这一选型决策在本仓库的评测用例中有直接体现tests/dotnet-advanced/dotnet-pinvoke/eval.yaml给出了同一个 C 头文件的两个评测场景——目标为 .NET 8 时要求输出LibraryImport、static partial、nuint目标为 .NET Frameworkx86时则要求输出DllImport、static extern、UIntPtr因为nuint在 .NET Framework 上不可用。这正是技能输入中目标框架决定选型的落地验证。Step 2映射原生类型到 .NET 类型类型映射是 P/Invoke 中绝大多数 bug 的源头。技能给出最危险映射表C / Win32 类型.NET 类型原因longCLong在 Windows 上是 32 位、64 位 Unix 上是 64 位。用LibraryImport时需要[assembly: DisableRuntimeMarshalling]size_tnuint/UIntPtr指针尺寸。.NET 8 用nuint更早版本用UIntPtr。绝不要用ulongBOOLWin32int不是bool——Win32BOOL是 4 字节boolC99[MarshalAs(UnmanagedType.U1)] bool必须指定 1 字节编组HANDLE、HWNDSafeHandle优先于裸IntPtrLPWSTR/wchar_t*stringWindows 上是 UTF-16in字符串成本最低。跨平台代码中避免使用——wchar_t宽度由编译器决定非 Windows 平台通常是 UTF-32LPSTR/char*string必须指定编码ANSI 或 UTF-8。作为in参数总是有编组成本三条硬性红线❌NEVER用int或long表示 Clong——它在 Windows 上是 32 位、Unix 上是 64 位必须用CLong。 ❌NEVER用ulong表示size_t——在 32 位平台上会导致栈损坏必须用nuint或UIntPtr。 ❌NEVER不带MarshalAs使用bool——默认编组尺寸是错的。完整的类型映射表、结构体布局与 blittable 类型规则见 references/type-mapping.md当遇到 Step 2 未覆盖的类型、或处理结构体布局与 blittable 问题时需要加载它。补充完整原始类型映射type-mapping.md 提供了更完整的原始类型对照int/int32_t→int、uint32_t→uint、int64_t→long、uint64_t→ulong、DWORD→uint、HRESULT→int某些工具会投影为枚举、float→float、double→double危险类型还包括intptr_t→nint指针尺寸与void*→void*需要unsafe上下文。补充blittable 类型与结构体布局Blittable 类型的托管与非托管布局完全一致编组零开销byte、sbyte、short、ushort、int、uint、long、ulong、float、double、nint、nuint以及仅含 blittable 字段的结构体。启用[assembly: DisableRuntimeMarshalling]后bool1 字节与char2 字节char16_t也被视为 blittable。非 blittable未启用DisableRuntimeMarshalling时bool、char、string、decimal以及任何带MarshalAs的类型。结构体布局三种典型写法// Sequential 顺序布局最常见 [StructLayout(LayoutKind.Sequential)] internal struct Vec3 { public float X, Y, Z; } // Explicit 显式布局用于联合体 // C: typedef union { int32_t i; float f; } Value; [StructLayout(LayoutKind.Explicit, Size 4)] internal struct Value { [FieldOffset(0)] public int I; [FieldOffset(0)] public float F; } // 非默认 packing // C: #pragma pack(push, 1) [StructLayout(LayoutKind.Sequential, Pack 1)] internal struct PackedHeader { public byte Magic; public uint Size; // 偏移 1而非 4 public ushort Flags; // 偏移 5而非 8 }Step 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);注意LibraryImport版本要求方法声明为static partial以及所在类为partial这一细节与eval.yaml中的static partial评分项完全一致。调用约定调用约定只有在目标是Windows x8632 位时才需要指定因为那里Cdecl与StdCall存在差异在 x64、ARM、ARM64 上只有单一调用约定属性是多余的。Agent 行为准则如果通过项目属性如PlatformTargetx86/PlatformTarget、运行时标识如win-x86、构建脚本、注释或开发者说明检测到 Windows x86 是目标平台必须向开发者提示并在所有 P/Invoke 声明上显式指定调用约定// DllImportx86 目标 [DllImport(mylib, CallingConvention CallingConvention.Cdecl)] // LibraryImportx86 目标 [LibraryImport(mylib)] [UnmanagedCallConv(CallConvs [typeof(CallConvCdecl)])]EntryPoint当托管方法名与原生导出名不一致时必须指定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);Step 4正确处理字符串字符串编组是另一大 bug 源。技能给出五条规则弄清原生函数期望的编码——不存在安全的默认值Windows API总是调用WUTF-16变体A变体必须有特定理由并显式指定 ANSI 编码跨平台 C 库通常期望 UTF-8显式指定编码——绝不依赖CharSet.Auto绝不为输出缓冲区引入StringBuilder。// DllImport — Windows APIUTF-16 [DllImport(kernel32.dll, CharSet CharSet.Unicode, SetLastError true)] private static extern int GetModuleFileNameW( IntPtr hModule, [Out] char[] filename, int size); // DllImport — 跨平台 C 库UTF-8 [DllImport(mylib)] private static extern int SetName( [MarshalAs(UnmanagedType.LPUTF8Str)] string name); // LibraryImport — UTF-16 [LibraryImport(kernel32, StringMarshalling StringMarshalling.Utf16, SetLastPInvokeError 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 目标使用NativeMemoryAPI。如果库有自己的分配器必须使用该分配器。Step 5建立内存所有权契约内存跨过边界时必须且只能有一方拥有它——且双方必须达成一致。技能强调❌NEVER用不匹配的分配器释放——对malloc分配的内存调用Marshal.FreeHGlobal就是堆损坏。模型 1 —— 调用方分配、调用方释放最安全[LibraryImport(mylib)] private static partial int GetName( Spanbyte buffer, nuint bufferSize, out nuint actualSize); public static string GetName() { Spanbyte 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); } // 必须使用库自身的释放函数 }关键规则始终用匹配的分配器释放。绝不要对malloc分配的内存使用Marshal.FreeHGlobal或Marshal.FreeCoTaskMem。模型 3 —— 基于句柄被调方分配、被调方释放使用SafeHandle见 Step 6。固定托管对象——当原生代码保存指针或以异步方式运行时// 同步使用 fixed public static unsafe void ProcessSync(byte[] data) { fixed (byte* ptr data) { ProcessData(ptr, (nuint)data.Length); } } // 异步使用 GCHandle var gcHandle GCHandle.Alloc(data, GCHandleType.Pinned); // 必须保持固定直到原生处理完成然后调用 gcHandle.Free()Step 6为原生句柄使用 SafeHandle裸IntPtr在异常时会泄漏且没有双重释放保护。技能明确表示SafeHandle是没得商量的non-negotiable。internal sealed class MyLibHandle : SafeHandleZeroOrMinusOneIsInvalid { // 编组基础设施实例化句柄所必需不要删除——没有直接调用者 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, ReadOnlySpanbyte 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(ReadOnlySpanbyte data) UseHandle(this, data, (nuint)data.Length); } // 用法SafeHandle 实现了 IDisposable using var handle MyLibHandle.Create(configvalue); int result handle.Use(myData);SafeHandle派生类要点私有构造函数由编组基础设施调用ReleaseHandle调用库自身的销毁函数using语句保证异常路径也能正确释放。Step 7错误处理// Win32 API —— 检查 SetLastError [LibraryImport(kernel32, SetLastPInvokeError true)] [return: MarshalAs(UnmanagedType.Bool)] internal static partial bool CloseHandle(IntPtr hObject); if (!CloseHandle(handle)) throw new Win32Exception(Marshal.GetLastPInvokeError()); // HRESULT API int hr NativeDoWork(context); Marshal.ThrowExceptionForHR(hr);对应关系DllImport用SetLastError trueMarshal.GetLastWin32Error()LibraryImport用SetLastPInvokeError trueMarshal.GetLastPInvokeError()。HRESULT 返回值统一走Marshal.ThrowExceptionForHR。Step 8处理回调按需首选方案.NET 8UnmanagedCallersOnly——完全避免委托没有 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* unmanagedint, IntPtr, void cb); unsafe { SetLogCallback(LogCallback); }UnmanagedCallersOnly方法必须是static不得向原生代码抛出异常且只能使用 blittable 参数类型。回退方案旧框架或需要实例状态时带根引用的委托[UnmanagedFunctionPointer(CallingConvention.Cdecl)] // 仅 Windows x86 需要 private delegate void LogCallbackDelegate(int level, IntPtr message); // 关键防止委托被垃圾回收 private static LogCallbackDelegate? s_logCallback; public static void EnableLogging(Actionint, 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); // 防止回收——fnPtr 不会根引用委托跨平台库加载复杂场景用NativeLibrary.SetDllImportResolver简单场景用条件编译。Clong/unsigned long用CLong/CULong注意LibraryImport搭配CLong/CULong需要[assembly: DisableRuntimeMarshalling]。// 极简使用平台命名约定 // 默认命名约定在搜索原生库时会添加对应前缀和扩展名。 // 结果是 Windows 上为 mylib.dllLinux 上为 libmylib.somacOS 上为 libmylib.dylib。 private const string LibName mylib; // 简单条件编译 // WINDOWS、LINUX、MACOS 仅在面向特定 OS 的 TFM如 net8.0-windows时预定义。 // 对可移植 TFM如 net8.0这些符号未定义——改用下面的运行时解析器方案。 #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 // 复杂运行时解析器 // 面向 netstandard2.0 或其它没有 OperatingSystem.IsXXX 的框架时 // 使用 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把SetLastError true替换为SetLastPInvokeError true除非目标是 Windows x86否则移除CallingConvention构建并修复SYSLIB1054–SYSLIB1057分析器警告启用互操作分析器PropertyGroup EnableTrimAnalyzertrue/EnableTrimAnalyzer EnableAotAnalyzertrue/EnableAotAnalyzer /PropertyGroup工具链CsWin32Win32 APIMicrosoft.Windows.CsWin32包从元数据源代码生成正确的声明优于手写签名。用NativeMethods.txt列出所需 API安装命令为dotnet add package Microsoft.Windows.CsWin32。CsWinRTWinRT APIMicrosoft.Windows.CsWinRT从.winmd文件生成 .NET 投影。Objective SharpieObjective-C API用于从 Objective-C 头文件macOS/iOS生成初始 P/Invoke 与绑定定义。验证审查清单与可运行步骤审查清单每个签名与原生头文件完全一致类型、尺寸目标为 Windows x86 时指定调用约定否则省略字符串编码显式指定——不依赖默认值或CharSet.Auto内存所有权已文档化且匹配谁分配、谁释放、用什么所有原生句柄使用SafeHandle裸IntPtr不逃出互操作层作为回调传入的委托已根引用防止 GC 回收使用 OS 错误码的 API 已设置SetLastError/SetLastPInvokeError结构体布局与原生匹配packing、对齐、字段顺序跨平台代码中 Clong/unsigned long使用CLong/CULong若LibraryImport搭配CLong/CULong已应用[assembly: DisableRuntimeMarshalling]没有不带MarshalAs的bool——始终指定UnmanagedType.Bool4 字节或UnmanagedType.U11 字节以确保跨语言边界的规范化可运行验证步骤启用互操作分析器构建——确认零SYSLIB1054–SYSLIB1057警告EnableTrimAnalyzertrue/EnableTrimAnalyzer EnableAotAnalyzertrue/EnableAotAnalyzer验证结构体尺寸一致——对每个跨越边界的结构体断言Marshal.SizeOfT()等于原生sizeof往返测试——用已知输入调用原生函数并验证期望输出非 ASCII 字符串测试——传入含 ASCII 范围外字符的字符串确认编码正确调试与故障排查当出现互操作故障时references/diagnostics.md 提供了系统化的排查依据。常见陷阱速查陷阱影响解决方案size_t用int64 位栈损坏用nuint.NET 8或旧框架的UIntPtrClong用longWindows32 位上错误用CLong/CULongLibraryImport需[assembly: DisableRuntimeMarshalling]bool不带MarshalAs编组尺寸错误指定UnmanagedType.Bool4B或U11B隐式字符串编码非 ASCII 字符损坏总是指定CharSet或StringMarshalling释放用错分配器堆损坏使用库自身的释放函数句柄用裸IntPtr异常时泄漏使用SafeHandle子类委托回调被 GC 回收原生代码崩溃在委托生命周期内保持根引用缺少SetLastError错误码过期Win32 API 设置SetLastError true结构体 packing 不匹配字段偏移错误Pack与原生#pragma pack匹配托管对象当void*GC 期间对象移动用GCHandle或fixed固定失败模式与诊断症状可能原因诊断方法DllNotFoundException运行时找不到库检查库名、路径与平台。用NativeLibrary.TryLoad手动测试加载。Linux 上检查LD_LIBRARY_PATH或rpathEntryPointNotFoundException导出名不匹配检查原生二进制导出表Windows 用dumpbin /exportsLinux 用nm -D。检查名称改编没有extern C的 CAccessViolationException签名不匹配、释放后使用或缺少固定逐字节比对托管与原生签名。用Marshal.SizeOfT()对比原生sizeof检查结构体尺寸。验证内存生命周期静默数据损坏类型尺寸或编码错误在边界处添加临时日志。对比Marshal.SizeOfT()与原生结构体尺寸。用已知输入/输出对测试间歇性崩溃GC 移动了未固定对象或回收了委托确保回调被根引用。任何跨调用保存的指针用GCHandle或fixed。在调试器下启用托管调试助手MDA运行释放时堆损坏分配器不匹配确认原生侧使用的分配器并用匹配函数释放。绝不混用malloc/free与CoTaskMemAlloc/CoTaskMemFree或Marshal.FreeHGlobal通用调试流程在同时启用原生与托管调试的调试器下复现.NET 8 设置DOTNET_EnableDiagnostics1用 dotnet-dump 或 dotnet-trace 做事后分析验证结构体布局对每个跨越边界的结构体Marshal.SizeOfT()必须等于原生sizeof仅 .NET Framework为pInvokeStackImbalance和invalidOverlappedToPinvoke启用托管调试助手MDAs。技能在本仓库中的定位dotnet-pinvoke是 dotnet-advanced 插件下的四个技能之一另有csharp-scripts、nuget-trusted-publishing、vectorization。该插件定位为面向小众场景的高级 .NET 与 C# 技能当前版本为 0.2.2见 plugin.json。技能的评测用例位于 tests/dotnet-advanced/dotnet-pinvoke/eval.yaml通过从 C 头文件生成声明的刺激场景与输出匹配、rubric 双重评分验证 Agent 是否能正确选择LibraryImport/DllImport、映射size_t到nuint/UIntPtr、声明static partial/static extern——与本文讲解的每一步一一对应。总结写出正确的 P/Invoke 声明不是背诵语法而是一套可执行的工程流程先按目标框架选型DllImportvsLibraryImport再按头文件逐类型映射尤其警惕long/size_t/BOOL/bool显式指定字符串编码与调用约定用所有权契约模型管理内存用SafeHandle管句柄、用根引用或UnmanagedCallersOnly管回调最后用分析器、尺寸断言与往返测试收尾验证。当签名、编码或生命周期三者中任何一个含糊时AccessViolationException、静默数据损坏与间歇性崩溃就只是时间问题。【免费下载链接】skillsRepository for skills to assist AI coding agents with .NET and C#项目地址: https://gitcode.com/GitHub_Trending/skills17/skills创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考