在 C# 中安全调用 Win32 API —— P/Invoke 实务指南(DllImport / LibraryImport / CsWin32)

· · P/Invoke, DllImport, LibraryImport, CsWin32, C#, .NET, Win32, SafeHandle, 原生互操作, Windows开发, 技术咨询

本博客至今已写过C++/CLI 包装与 P/Invoke 的取舍从 C/C++ 调用 C# Native AOT DLL 的方法从 32bit 应用调用 64bit DLL 的 COM 桥接Windows DLL 名称解析机制等多篇与原生互操作相关的文章。不过,作为这些内容基础的 P/Invoke 本身,却还没有一篇文章专门整理过。

P/Invoke 一方面有”只要用 extern 声明 DLL 的函数就能调用”的便利性,另一方面也是在字符串封送、句柄的生命周期、错误码的获取、结构体布局这几个地方,迟早会踩到坑的技术。本文以 .NET 7 之后的默认选项 LibraryImport 为主线,一次性整理实务上该注意的要点。

1. 先说结论

  • .NET 7 之后应以 LibraryImport 取代 DllImport 作为默认选择。 由于是在编译时生成封送代码,因此能兼容 Native AOT、裁剪,没有运行时生成 IL stub 的开销,且生成的代码可以用调试器逐行执行。分析器 SYSLIB1054 会提示哪些 DllImport 该改写。12
  • 如果要手写 Win32 API,可以考虑 CsWin32。 只要在 NativeMethods.txt 中列出想调用的函数名,就能从官方的 Win32 metadata 生成 LibraryImport 签名、常量与结构体。3
  • 字符串要明确指定 StringMarshalling,避免使用 StringBuilder StringBuilder 的封送一定会复制到原生缓冲区,效率不高,而且容易在结尾处理上出错。4
  • 句柄要用 SafeHandle 派生类保存,而不是原始的 IntPtr 这是 .NET 原生互操作的基本做法,能防止 GC 造成的过早释放、重复释放,以及”回收攻击”。56
  • 加上 SetLastError = true 之后,要在调用后立即读取 Marshal.GetLastPInvokeError() 必须在其他托管代码的执行覆盖错误码之前把它取出来。7
  • 结构体应以 LayoutKind.Sequential 为默认,并意识到是否要明确指定 Pack Pack = 0(默认)的实际布局规则,和 C++ 编译器 /Zp 的默认值(x86/ARM/ARM64 为 8,x64/ARM64EC 为 16)并不是同一件事,而且在 .NET Framework 与 .NET 5+ 之间也可能不同。”反正是默认值,应该没问题”这种想法很危险。8
  • 回调(委托)要管理生命周期,避免在原生端还在使用时就被 GC 回收。 可以用 static 字段保存,或使用 GC.KeepAlive,条件允许的话优先采用 UnmanagedCallersOnly9
  • P/Invoke、C++/CLI 包装、COM 互操作不是互相竞争,而是各有分工。 单纯的 C 接口用 P/Invoke,涉及 C++ 类或所有权、异常的用 C++/CLI,需要跨越进程边界(如 32/64bit 桥接)的用 COM,这个判断标准整理在第 10 章的表格中。

2. DllImport 与 LibraryImport —— 该用哪一个

DllImport 是由来已久的机制,在运行时由运行库生成用于封送的 IL stub,再交由 JIT 编译后调用。由于生成过程发生在运行时,与 Native AOT、裁剪这类需要预先编译程序集的架构不太合拍,生成成本本身也并非零。1

LibraryImport 是 .NET 7 新增的源生成器,会针对 partial 方法在编译时生成封送代码。生成出来的代码以 C# 源代码的形式存在,因此可以用调试器逐行执行,签名上的错误也能在构建阶段就提早发现。1

using System.Runtime.InteropServices;

internal static partial class NativeMethods
{
    [LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
    internal static partial string ToLower(string str);
}

这个 string 返回值有一个容易被忽略的前提。封送器在复制完返回指针所指向的字符串后,一定会尝试释放那块内存。 在 Windows 上使用的是 CoTaskMemFree,因此如果原生端不是用 CoTaskMemAlloc(而是用静态缓冲区、mallocnew[] 等 C API 中常见的方式)分配这个指针,封送器就会用错误的分配器释放内存,导致堆损坏或崩溃。10 除非对方的头文件或文档明确表示分配方式与 CoTaskMemAlloc 兼容,否则建议把返回值改用 IntPtr 接收,并自行调用对应的释放函数(或对方要求的释放流程)。如果由调用方事先分配缓冲区再传入(前面提到的 StringBuilder 替代方案——字符数组,或后面会提到的 [Out] 缓冲区模式),就完全不会引入这种所有权含糊不清的问题。

LibraryImportDllImport 的主要区别如下。11

  • CharSet 被废弃,改用 StringMarshallingUtf16 / Utf8 / 自定义)。ANSI 被废弃,UTF-8 成为一级选项。
  • CallingConventionUnmanagedCallConvAttribute 取代。
  • ExactSpellingPreserveSig 没有对应项。入口点名称一律需要精确拼写,返回值的转换也一律直接进行。
  • 类与被调用的方法都必须是 partial,且项目需要启用 AllowUnsafeBlocks

DllImport 现在仍然必要的场合,是需要用到 LibraryImport 不支持的设置(例如部分 MarshalAs 指定)时。由于分析器在遇到不支持的设置时会以错误提示,实务上可以先写 LibraryImport,被拦下来时再改回 DllImport11

3. CsWin32 —— 不用手写签名的选择

一个个手动编写 Win32 API 的 DllImport/LibraryImport 声明,很容易在参数类型、常量值、结构体字段顺序上出错,而且风险会不断累积。CsWin32Microsoft.Windows.CsWin32)是一个源生成器,能从官方提供的 Win32 API metadata,自动生成想调用函数的签名、相关常量与结构体。3

用法很简单,只要在项目中添加 NuGet 包,并在 NativeMethods.txt 这个文本文件中列出想调用的函数名即可。

GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle

构建时就会生成这些函数的 P/Invoke 签名(包括返回值、参数、SetLastError 指定)。要注意的是,默认是基于传统的 DllImport 生成。 若考虑 Native AOT、裁剪,可以在 NativeMethods.json 中指定 allowMarshaling: false,切换成基于 LibraryImport 的源代码生成。3 由于 HANDLE 会输出为合适的 SafeHandle 派生类型,字符串也会用正确的 CharSet/StringMarshalling 输出,因此不会出现手写时常见的 CharSet 弄错或结构体字段顺序错误。

正如C++/CLI 包装发挥作用的场景一文所述,涉及 C++ 类、所有权、异常的复杂 DLL,适合插入一层薄薄的包装,但如果对方是单纯的 Win32 API(或近似 C 接口的 DLL),用 CsWin32 自动生成签名是最快、也最不容易出错的路径。虽然 CsWin32 无法用在自家的 DLL 上,但在那种情况下,仍可以参考生成出来的代码写法作为模板。

4. 字符串封送的陷阱

C#、VB、F# 编译器对于没有明确指定 CharSet 的 P/Invoke 声明,默认会分配 CharSet.NoneCharSet.None 的实际行为与 CharSet.Ansi 相同,在 Windows 上会以非 Unicode(本地化的代码页)方式封送。如果要调用的 Win32 API 是以 Unicode 版本(W 结尾)为前提,维持这个默认值调用就会出现乱码或多字节字符丢失的问题。12

LibraryImport 中,明确指定 StringMarshalling.Utf16 是基本做法。由于 ANSI 这个选项本身已被废弃,DllImport 时代常见的”交给默认值结果意外变成 ANSI”的事故,在结构上已经比较不容易发生。11

另一个陷阱是 StringBuilder 参数。这种写法常见于”原生端写入字符串缓冲区后返回”类型的 API,但 StringBuilder 的封送一定会产生对原生缓冲区的复制,ToString() 又会再多一次分配。如果缓冲区是 [Out](默认),每次调用都会累积多次分配,是相当低效的机制。此外,返回的缓冲区如果没有以 NUL 结尾,或是双重 NUL 结尾的字符串,也容易造成误判。如果调用频率高,改用来自 ArrayPool<char> 的字符数组会更稳定。4

[Out] string 参数也是应避免的指定方式。如果字符串是被驻留(intern)过的,可能导致运行库不稳定。4

5. 句柄的生命周期管理 —— 为什么要用 SafeHandle

文件句柄、注册表键、设备句柄这类原生资源,以纯粹的 IntPtr 保存,是 .NET 原生互操作中应该避免的设计。理由有三个。5

  • GC 造成的过早释放。 如果一个实现了终结器(finalizer)的类把句柄存在 IntPtr 字段中,P/Invoke 调用进行期间,GC 可能回收该对象并关闭句柄,造成竞争。
  • 句柄的回收攻击。 Windows 会积极重复利用句柄值。如果在句柄已重新分配给其他资源的状态下,还继续使用旧的 IntPtr,就会导致操作到不相关资源的严重事故。
  • 异步异常导致的泄漏。 如果像线程中断这类异步的中断,发生在获取句柄与存入字段之间,就可能造成句柄泄漏。

SafeHandle 就是为了解决这些问题而设计的抽象类。它继承自 CriticalFinalizerObject,即使在 AppDomain 异常终止时,也能保证释放处理一定会被执行。P/Invoke 调用会自动增减句柄的引用计数,因此调用过程中句柄也不会被回收再利用。5

自行实现时,通常会继承 Microsoft.Win32.SafeHandles 命名空间中的 SafeHandleZeroOrMinusOneIsInvalid 等类,并重写 ReleaseHandle()。由于 ReleaseHandle() 运行在”前提是不能失败”的约束执行区域,因此惯例是不要写复杂的逻辑,只做单纯的释放 API 调用。不需要(甚至应该避免)自己编写终结器。6

6. 错误处理 —— SetLastError 与 GetLastPInvokeError

多数 Win32 API 在失败时会通过 SetLastError 设置线程本地的错误码,调用方则用 GetLastError 读取。要在 P/Invoke 中处理这个机制,需要把 DllImportAttribute.SetLastErrorLibraryImport 也有同名属性)设为 true13

[LibraryImport("kernel32", EntryPoint = "SetCurrentDirectoryW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetCurrentDirectoryW(string path);

这里有两点需要特别注意。

  • 错误码要在调用后立即读取。 在 .NET(.NET Framework 除外)中,每次调用 SetLastError = true 的 P/Invoke,错误信息都会先被清除,只保留这一次调用的结果。如果中间插入了日志输出或其他 API 调用,就会被覆盖而丢失,因此检测到失败时要立即取值。13
  • 要用 Marshal.GetLastPInvokeError(),而不是 Marshal.GetLastWin32Error() 在 .NET 6 之后这两者功能上相同,但后者作为体现跨平台意图的新名称被推荐使用。7
if (!SetCurrentDirectoryW(path))
{
    int error = Marshal.GetLastPInvokeError();
    throw new Win32Exception(error);
}

7. 结构体封送 —— 可位复制类型与 StructLayout

.NET 与原生代码中位表示方式相同的类型称为”可位复制(blittable)”类型,因为不需要转换就能直接传递,所以速度快。byteintlong 等基本类型,以及只由可位复制的值类型构成的固定布局结构体都属于这一类。可位复制的结构体应该用 C# 的 sizeof(),而不是 Marshal.SizeOf<T>(),速度会更快。相反地,bool 并不是可位复制的(原生的 BOOL 是 4 字节,C/C++ 的 bool 是 1 字节,两者不同),如果不特别留意,容易埋下返回值被砍掉一半的 bug。14

结构体的布局由 StructLayoutAttribute 控制。默认使用 LayoutKind.Sequential(按声明顺序排列),只有像联合体(union)那样需要明确指定字段位置时,才使用 LayoutKind.Explicit8

容易被忽略的是 Pack 字段。根据官方文档,类型整体的对齐方式,取”最大字段的大小”与”指定的 Pack 值”两者中较小的一个;各字段则取”自身的大小”与”类型的对齐”两者中较小的一个来排列。8 也就是说,如果把 Pack 明确设为较小的值(例如 2 或 4),就会像 C++ 的 #pragma pack(N) 一样作为对齐的上限。另一方面,默认值 0 的意思是”类型整体的对齐等于最大字段的大小(不设额外上限)”,这和 C++ 编译器 /Zp 选项(结构体成员对齐。x86、ARM、ARM64 默认为 8 字节边界,x64、ARM64EC 默认为 16 字节边界)的默认值是不同的规则,不能简单地视为同一件事。15 而且这个默认布局在 .NET Framework 与 .NET 5+ 之间也可能不同。举例来说,官方文档中提到,包含 decimal 的结构体,由于内部字段构成的差异,默认打包下的大小在 .NET Framework 中是 28 字节,在 .NET 5+ 中则是 32 字节。8 换句话说,”反正是默认值,架构上应该都一致”这种基于架构的想法很危险。如果原生端的头文件用 #pragma pack 明确改变了打包大小,或包含要求超过 8 字节对齐的字段,就应该在 C# 端明确指定 Pack,或用 Marshal.OffsetOf 等方式验证实际的字段偏移量后再使用。如果疏忽这一点,字段偏移量就会错位,变成数据悄悄损坏的事故。反过来说,如果是直接使用 Windows SDK 头文件、字段都是 8 字节以下基本类型的单纯 API,即使不去调整 Pack、维持默认对齐,实务上几乎不会出问题。

// 原生端头文件明确指定 pack(4) 的例子
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

8. 回调(委托)的生命周期管理

把”完成后请调用这个函数”的回调传给原生 API 的场景并不少见。托管代码中由 delegate 负责这个角色,但这里有一个 GC 特有的陷阱。即使用 Marshal.GetFunctionPointerForDelegate 从委托获取函数指针,GC 并不会追踪这个函数指针与委托之间的关联。如果原生端还在使用该函数指针,而委托却已被回收,就会导致崩溃。9

另一个容易被忽略的是调用约定。通过 P/Invoke 把委托以函数指针传给原生端时,默认会使用”平台的默认调用约定”,如果要明确指定一致的约定,可以在委托类型上加上 UnmanagedFunctionPointerAttribute16 在 x64/ARM/ARM64 上,调用约定实际上只有一种,不特别留意也很少造成实际损害;但在 Windows x86(32bit)上,Stdcall(Win32 API 的默认值)与 Cdecl(常见于 Unix 系 C 库)是不同的,如果对方的头文件用的是 Cdecl,却维持默认值,就会导致栈损坏。16

// 明确指定调用约定。x86构建中若对方用Cdecl,这里是必须的
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MyCallback(int code);

private static readonly MyCallback s_callback = OnNativeEvent;  // 以static保存以确定生命周期

// [UnmanagedFunctionPointer]是回调"被调用"时的约定,
// 这次调用本身(RegisterCallback这个P/Invoke)的约定是另一件事。
// LibraryImport的默认值是平台默认值(在Windows上相当于stdcall),
// 如果对方是Cdecl的C DLL,这里也需要明确指定
[LibraryImport("nativelib")]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial void RegisterCallback(MyCallback callback);

private static void OnNativeEvent(int code)
{
    // ...
}

// 调用端
RegisterCallback(s_callback);
GC.KeepAlive(s_callback);  // 明确保持一个可能立刻脱离作用域的变量存活

只要保存在 static 字段中,应用程序存活期间就不会被 GC 回收。如果能确定原生端只在一次调用中使用该回调(回调一回来就会丢弃函数指针),也可以用局部变量搭配 GC.KeepAlive 这种更轻量的写法来延长生命周期。

官方最佳实践建议,条件允许的话,应优先使用 标记了 UnmanagedCallersOnlyAttribute 的静态方法与函数指针(delegate*<...>,而不是 Delegate 类型。相比委托封送,这种方式的额外开销更小,也与 Native AOT 兼容性更好。9

9. 32bit/64bit 的差异

只要编写一份 P/Invoke 签名,运行时无论是 32bit 还是 64bit 进程都会用到同一份代码路径。这里容易出问题的地方在于,原生端类型的宽度会随进程的位数变化

  • HANDLEHWNDLPARAM 这类指针型类型,在 32bit 进程中是 4 字节,在 64bit 进程中是 8 字节。.NET 端应该用 IntPtr/UIntPtr(或 nint/nuint)接收才正确,如果用固定大小的 int/long 接收,就会变成只能在 32bit 或 64bit 其中一边运行的代码。4
  • 如果结构体包含上述指针型字段,整个结构体的大小也会因位数而变化。加上第 7 章提到的 Pack 默认值在不同架构下会不同,同一份结构体定义在 32bit 构建与 64bit 构建中,二进制布局可能不同,测试时要以此为前提。
  • 想从 32bit 的现有应用调用只能在 64bit 运行的 DLL 功能,这个需求本身无法靠 P/Invoke 解决(同一个进程中无法共存不同位数的 DLL)。这种情况需要分离进程,设计成通过 COM 桥接或命名管道搭桥。实例请参考从 32bit 应用调用 64bit DLL 的 COM 桥接实例
  • DLL 根本找不到、或加载了非预期版本的问题,并不是 P/Invoke 的话题,而是 Windows 加载器的话题。Windows DLL 名称解析机制整理了搜索顺序与 SxS 的行为,调查 DllNotFoundException 的原因时可以一并参考。

10. 判断表 —— P/Invoke vs C++/CLI 包装 vs COM 互操作

从 C# 调用原生代码的手段不只有 P/Invoke。如果对方是带有 C++ 类、所有权、异常的复杂 DLL,C++/CLI 包装更合适;如果要跨越进程边界(32/64bit 桥接、供 VBA 等其他语言使用),COM 就会成为选项。

视角 P/Invoke(LibraryImport) C++/CLI 包装 COM 互操作
适合的对象 单纯的 C 接口(以结构体、基本类型为主) 涉及 C++ 类、所有权、异常、std:: 类型的 DLL 需要跨进程的对象、VBA 等其他语言
实现成本 低~中(只需定义签名) 中(需再写一层包装) 高(接口设计、注册表注册)
类型安全性 中(手写时签名错误可能要到运行时才会发现,CsWin32 可改善) 高(可直接使用 C++ 的类型) 中(由 IDL/类型库保证)
AOT/裁剪支持 ◎(若用 LibraryImport) △(C++/CLI 不支持 Native AOT)
异常处理 ✕(要靠返回值或 HRESULT 自行判定) ◎(可将 C++ 异常转换为 .NET 异常) ○(HRESULT 会转换为 COM 异常)
跨进程边界 ✕(仅限同一进程内) ✕(仅限同一进程内) ◎(可做成跨进程服务器)
调试便利性 ○(LibraryImport 生成的代码可逐行执行) ○(可用 VS 同时调试原生与托管代码) △(引用计数或注册相关的问题较难追踪)
学习成本 中~高(C++/CLI 语法) 高(COM 整套规约)

如果”对方是基于 C 函数的 Win32 API,或自家单纯的 C DLL”,就用 P/Invoke(可能的话搭配 CsWin32);如果”对方是 C++ 类,连所有权、异常都想自然地往来”,就用 C++/CLI 包装(详见从 C# 调用原生 DLL:C++/CLI 包装 vs P/Invoke);如果”本来就要跨进程,或想让 VBA 也能用”,就用 COM,按这个顺序想就不会迷路。反方向(想从 C/C++ 调用 C# 的处理)则不是用 P/Invoke,而是用 Native AOT 的 UnmanagedCallersOnly 这种架构。请参考从 C/C++ 调用 C# Native AOT DLL 的方法

11. 实现示例 —— 用 LibraryImport 操作句柄与处理错误

以下是结合前面内容的实现示例。假设一个虚构的传感器设备 SDK device.dll,公开了 OpenDevice / CloseDevice / ReadDeviceData,我们用 SafeHandle 管理句柄、LibraryImport 进行编译时封送、SetLastError + GetLastPInvokeError 处理错误,把它们包装起来。

首先是保存原生句柄的 SafeHandle 派生类。

using Microsoft.Win32.SafeHandles;

// 包装device.dll的句柄。与GC的生命周期分开,
// 防止句柄的重复释放、回收攻击、过早释放
internal sealed class DeviceSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // 因为会作为OpenDevice的返回值使用,需要无参数构造函数
    public DeviceSafeHandle() : base(ownsHandle: true)
    {
    }

    protected override bool ReleaseHandle()
        // ReleaseHandle内部是"前提是不能失败"的约束执行区域。
        // 只做单纯的原生释放调用
        => DeviceNativeMethods.CloseDevice(handle);
}

接着是 P/Invoke 声明。字符串明确指定 StringMarshalling.Utf16,所有可能失败的调用都加上 SetLastError = true

using System.Runtime.InteropServices;

internal static partial class DeviceNativeMethods
{
    private const string DeviceDll = "device.dll";

    // 把句柄当作返回值,一旦调用成功,SafeHandle就会开始
    // 追踪生命周期。失败时返回的是IsInvalid为true的句柄
    [LibraryImport(DeviceDll, EntryPoint = "OpenDevice",
        StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
    internal static partial DeviceSafeHandle OpenDevice(string devicePath);

    // 供SafeHandle的ReleaseHandle直接调用的内部API。
    // handle只用于释放,因此用原始IntPtr接收
    [LibraryImport(DeviceDll, EntryPoint = "CloseDevice", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool CloseDevice(IntPtr handle);

    // buffer是调用方事先分配好的数组。byte[]是可位复制的,因此会被固定(pin),
    // 原生端的写入会针对同一块内存进行。明确标注[Out]本身并非
    // 必须,但可以自我说明意图,所以保留
    [LibraryImport(DeviceDll, EntryPoint = "ReadDeviceData", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool ReadDeviceData(
        DeviceSafeHandle handle,
        [Out] byte[] buffer,
        int bufferLength,
        out int bytesRead);
}

最后是使用这些方法的薄包装层。错误码会在检测到失败的当下立即获取,包装成 Win32Exception 传给调用方。

using System.ComponentModel;
using System.Runtime.InteropServices;

public sealed class DeviceConnection : IDisposable
{
    private readonly DeviceSafeHandle _handle;

    private DeviceConnection(DeviceSafeHandle handle) => _handle = handle;

    public static DeviceConnection Open(string devicePath)
    {
        DeviceSafeHandle handle = DeviceNativeMethods.OpenDevice(devicePath);
        if (handle.IsInvalid)
        {
            // 在被其他API调用覆盖之前,于失败后立即获取
            int error = Marshal.GetLastPInvokeError();
            handle.Dispose();
            throw new IOException(
                $"无法打开设备: {devicePath} (Win32 error {error})",
                new Win32Exception(error));
        }
        return new DeviceConnection(handle);
    }

    public byte[] Read(int maxBytes)
    {
        var buffer = new byte[maxBytes];
        if (!DeviceNativeMethods.ReadDeviceData(_handle, buffer, buffer.Length, out int bytesRead))
        {
            int error = Marshal.GetLastPInvokeError();
            throw new IOException($"读取设备失败 (Win32 error {error})",
                new Win32Exception(error));
        }
        return bytesRead == buffer.Length ? buffer : buffer[..bytesRead];
    }

    // 只需调用SafeHandle.Dispose,不需要编写终结器
    public void Dispose() => _handle.Dispose();
}

使用 DeviceConnection 的一方只要用 using 包起来就好,不需要担心遗漏释放句柄。至于在这套架构中要在哪里检测什么、如何转换,这个原则和「异常处理中 catch 与日志该放在哪里」一文提到的分层职责划分完全适用。在 P/Invoke 边界把原生层的错误码翻译成异常,往上的层级则以常规的 .NET 异常来处理,这条界线就是在这里划分的。

12. 总结

P/Invoke 在”声明 DLL 的函数就能调用”这种便利性的背后,是一项在字符串封送、句柄的生命周期、错误码获取时机、结构体布局这几个地方,迟早会踩到坑的技术。.NET 7 之后应以 LibraryImport 为默认,条件允许的话交给 CsWin32 生成签名本身。字符串要明确指定 StringMarshalling,避免使用 StringBuilder。句柄要用 SafeHandle 保存。用了 SetLastError 就要在调用后立即获取错误码。结构体要意识到 Pack 的默认值在不同架构下会不同。回调要明确管理生命周期 —— 本文列出的这些要点,都是”知道的话几行代码就能解决,不知道的话就会变成只在生产环境才会重现的 bug”这种类型的问题。

而该用 P/Invoke 硬撑下去,还是改用 C++/CLI 包装或 COM,取决于对方的 DLL 有多”像 C”,以及是否需要跨越进程边界。想从 C# 调用既有的原生资产,或反过来想从原生调用 C# 的资产,这类需求往往要看过实际的头文件或 DLL 结构才能判断最优架构,如果拿不定主意,欢迎与我们咨询。

相关文章

相关咨询领域

合同会社小村软件(KomuraSoft LLC)处理 C# 与原生 DLL、Win32 API 之间的边界设计、COM 组件的开发与调查,以及连接现有原生资产与 .NET 的迁移项目技术咨询。

参考链接

  1. Microsoft Learn, Source generation for platform invokes。关于 LibraryImportAttribute 在编译时生成封送代码、与 DllImport 运行时生成 IL stub 的区别、与 Native AOT/裁剪的兼容性。  2 3

  2. Microsoft Learn, SYSLIB diagnostics for p/invoke source generation。关于促使将 DllImport 改写为 LibraryImport 的分析器 SYSLIB1054 及其他诊断 ID 列表。 

  3. Microsoft Learn, Build a C# .NET app with WinUI 3 and Win32 interop。关于 C#/Win32 P/Invoke Source Generator(Microsoft.Windows.CsWin32) 的导入方法,以及在 NativeMethods.txt 中列出函数名以生成签名的步骤。  2 3

  4. Microsoft Learn, Native interoperability best practices。关于 StringBuilder 封送一定会复制到原生缓冲区而效率不高、应避免 [Out] string 参数、应使用 SafeHandle 并避免终结器。  2 3 4

  5. Microsoft Learn, SafeHandle Class。关于 SafeHandle 防止句柄过早释放、回收攻击的机制,以及 CriticalFinalizerObject 带来的确实释放保证。  2 3

  6. Microsoft Learn, Native interoperability best practices - General guidance。关于非托管资源的生命周期管理应使用 SafeHandle,并避免使用终结器的指引。  2

  7. Microsoft Learn, Marshal.GetLastPInvokeError Method。关于设置 SetLastError=true 的 P/Invoke 调用后如何获取错误码,以及 .NET 6 之后比 GetLastWin32Error 更受推荐。  2

  8. Microsoft Learn, StructLayoutAttribute.Pack Field。关于 Pack 默认值 0 所代表”当前平台的默认打包大小”的含义,以及字段对齐的计算规则。  2 3 4

  9. Microsoft Learn, Native interoperability best practices - Prevent delegate collection with GC.KeepAlive。关于 GC 不会追踪 GetFunctionPointerForDelegate 获取的函数指针与委托之间的关联、用 GC.KeepAlive 延长生命周期,以及建议使用 UnmanagedCallersOnly。  2 3

  10. Microsoft Learn, Default Marshalling Behavior - Memory management with the interop marshaller。关于封送器一定会尝试释放非托管代码分配的内存、Windows 上使用 CoTaskMemFree、非 CoTaskMemAlloc 分配的内存需要用 IntPtr 手动释放。 

  11. Microsoft Learn, Source generation for platform invokes - Differences from DllImport。关于 CharSet 被 StringMarshalling 取代、CallingConvention 改用 UnmanagedCallConvAttribute、ExactSpelling/PreserveSig 没有对应项。  2 3

  12. Microsoft Learn, Charsets and marshalling。关于未明确指定 CharSet 时 C#、Visual Basic、F# 编译器默认会分配 CharSet.None,以及 CharSet.None 与 CharSet.Ansi 行为相同(以非 Unicode 方式封送)。 

  13. Microsoft Learn, DllImportAttribute.SetLastError Field。关于将 SetLastError 设为 true 时在 .NET 上的行为(每次调用错误信息都会被清除)。  2

  14. Microsoft Learn, Native interoperability best practices - Blittable types。关于可位复制类型的定义、bool 不是可位复制类型带来的陷阱,以及在可位复制结构体上使用 sizeof() 的好处。 

  15. Microsoft Learn, /Zp (Struct Member Alignment)。关于 C++ 编译器结构体成员对齐的默认值在 x86/ARM/ARM64 为 8 字节边界、x64/ARM64EC 为 16 字节边界。 

  16. Microsoft Learn, Unmanaged calling conventions。关于 Windows x86 上 Stdcall 与 Cdecl 是不同的默认调用约定、x64/ARM/ARM64 上调用约定实际上只有一种,以及可用 UnmanagedFunctionPointerAttribute 明确指定调用约定。  2

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

DllImport 和 LibraryImport 应该用哪一个?
.NET 7 之后应以 LibraryImport 为默认选择。DllImport 是在运行时由运行库生成用于封送的 IL stub,而 LibraryImport 则由源生成器在编译时生成封送代码,因此能兼容 Native AOT、裁剪(trimming),生成的代码也可以用调试器逐行执行。分析器 SYSLIB1054 会提示哪些 DllImport 该改写。只有在需要用到 LibraryImport 不支持的设置(例如部分 MarshalAs 指定)时,才退回使用 DllImport。
为什么用 IntPtr 保存句柄在 P/Invoke 中很危险?
有三个问题。第一,P/Invoke 调用进行期间,GC 可能回收对象并关闭句柄,造成过早释放的竞争。第二,Windows 会积极重复利用句柄值,导致用已经关闭的句柄值去操作到不相关资源的回收攻击。第三,异步异常可能造成句柄泄漏。使用 SafeHandle 派生类,可以通过自动的引用计数管理与确实的释放保证来防止这些问题。
有没有不用手写 Win32 API 签名的方法?
可以使用 CsWin32(Microsoft.Windows.CsWin32)这个源生成器。只要添加 NuGet 包,在 NativeMethods.txt 这个文本文件中列出想调用的函数名,就能从官方的 Win32 metadata 自动生成签名、常量与结构体。HANDLE 会输出为合适的 SafeHandle 派生类型,因此不会出现手写时常见的 CharSet 弄错或字段顺序错误。默认是基于 DllImport 生成,若考虑 Native AOT,可在 NativeMethods.json 中指定 allowMarshaling: false。
P/Invoke 和 C++/CLI 包装、COM 该怎么区分使用?
取决于对方 DLL 的性质,以及是否需要跨越进程边界。简单的 C 接口(以结构体、基本类型为主)最适合用 P/Invoke,如果是 Win32 API,配合 CsWin32 效果更好。涉及 C++ 的类、所有权、异常、std:: 类型的复杂 DLL,就用一层 C++/CLI 包装。像 32bit/64bit 桥接那样要跨越进程边界,或要让 VBA 等其他语言使用时,COM 是选项。同一个进程中无法共存不同位数的 DLL,这个需求光靠 P/Invoke 无法解决。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表