本机互操作性最佳做法 Native interoperability best practices

本文内容

.NET 提供了多种方式来自定义本机互操作性代码。本文包括 Microsoft .NET 团队为实现本机互操作性而遵循的指南。

通用指南General guidance

本部分中的指南适用于所有互操作方案。

  • ✔️ 请务必对方法和参数使用同一命名和大小写以作为要调用的本机方法。
  • ✔️ 请考虑对常数值使用同一命名和大小写。
  • ✔️ 请务必使用映射到最接近本机类型的 .NET 类型。例如,在 C# 中,当本机类型为 unsigned int 时使用 uint
  • ✔️ 请务必在所需行为与默认行为不同时仅使用 [In][Out] 属性。
  • ✔️ 请考虑使用 System.Buffers.ArrayPool 汇集本机数组缓冲区。
  • ✔️ 请考虑使用与本机库相同的名称和大小写包装类中的 P/Invoke 声明。
    • 这允许 [DllImport] 属性使用 C# nameof 语言功能传入本机库的名称,并确保本机库的名称拼写正确。

DllImport 属性设置DllImport attribute settings

设置默认建议详细信息
PreserveSigtrue保留默认设置将其显式设置为 False 时,失败的 HRESULT 返回值将变为异常(因此,定义中的返回值将变为 Null)。
SetLastErrorfalse取决于 API如果 API 使用 GetLastError,并使用 Marshal.GetLastWin32Error 获取值,则将其设置为 True。如果 API 设置一个表示其有错误的条件,则在进行其他调用之前获取错误以避免无意覆盖该错误。
CharSetCharSet.None,这会退回到 CharSet.Ansi 行为定义中存在字符串或字符时显式使用 CharSet.UnicodeCharSet.Ansi这将指定字符串的封送行为以及为 falseExactSpelling 的操作。请注意,CharSet.Ansi 在 Unix 上实际为 UTF8。大部分时间,Windows 使用 Unicode,而 Unix 使用 UTF8。有关更多信息,请查看有关字符集的文档
ExactSpellingfalsetrue将其设置为 True 并在运行时获得些许性能优势不会查找后缀为“A”或“W”的备用函数名称,具体取决于 CharSet 设置的值(“A”用于 CharSet.Ansi,“W”用于 CharSet.Unicode)。

字符串参数String parameters

当字符集为 Unicode 或参数显式标记为 [MarshalAs(UnmanagedType.LPWSTR)],并且通过值(不是 refout)传递字符串时,将固定该字符串并直接由本机代码使用(而非复制)。

除非明确想要对字符串进行 ANSI 处理,否则请务必将 [DllImport] 标记为 Charset.Unicode

❌ 请勿使用 [Out] string 参数。如果该字符串为暂存的字符串,则通过包含 [Out] 属性的值传递的字符串参数可能使运行时变得不稳定。请在 String.Intern 的文档中查看有关字符串暂存的详细信息。

❌ 避免使用 StringBuilder 参数。StringBuilder 封送始终创建本机缓冲区副本。因此,该操作的效率可能非常低。采取调用带有字符串的 Windows API 的典型方案:

  • 创建所需容量的 SB(分配托管容量){1}
  • 调用
    • 分配本机缓冲区 {2}
    • 如果为 [In]StringBuilder 参数的默认值),则复制内容
    • 如果为 [Out] {3}(也是 StringBuilder 的默认值),则将本机缓冲区复制到新分配的托管数组中
  • ToString() 分配其他托管数组 {4} 这是 {4} 分配,可从本机代码中获取字符串。可用来限制此操作的最佳方法是在其他调用中重用 StringBuilder,但这仍只能保存 1 个分配。最好从 ArrayPool 使用并缓存字符缓冲区 - 然后可以在后续调用中直接获得 ToString() 的分配。

StringBuilder 的另一个问题是它始终会将返回缓冲区备份复制到第一个 Null。如果传递的返回字符串未终止或为双 Null 终止字符串,则 P/Invoke 很可能不正确。

如果使用 StringBuilder,则最后一个问题是容量确实不会包括隐藏的 Null,该值始终计入互操作。人们常常会犯这个错误,因为大多数 API 希望缓冲区的大小包括 Null。这可能会导致产生浪费/不必要的分配。此外,此问题会阻止运行时优化 StringBuilder 封送以最大限度地减少副本。

✔️ 请考虑使用 ArrayPool 中的 char[]

有关字符串封送的详细信息,请参阅字符串的默认封送自定义字符串封送

Windows 特定对于 [Out] 字符串,CLR 将默认使用 CoTaskMemFree 来释放字符串,或对于标记为 UnmanagedType.BSTR 的字符串,使用 SysStringFree对于具有输出字符串缓冲区的大多数 API:传入的字符计数必须包括 Null。 如果返回的值小于传入的字符计数,则调用成功,并且该值是不带尾随 Null 的字符数。 否则,该计数是包括 Null 字符的缓冲区的所需大小。- 传入 5 个,获取 4 个:字符串包含 4 个字符,带有尾随 Null。- 传入 5 个,获取 6 个:字符串包含 5 个字符,需要包含 6 个字符的缓冲区来保存 Null。字符串的 Windows 数据类型

布尔参数和字段Boolean parameters and fields

布尔值很容易混淆。默认情况下,将 .NET bool 封送到 Windows BOOL,它在其中为包含 4 个字节的值。但是,C 和 C++ 中的 _Boolbool 类型是单字节。这可能会导致难以跟踪 bug,因为一半的返回值将被丢弃,这样可能只会更改结果。有关将 .NET bool 值封送到 C 或 C++ bool 类型的详细信息,请参阅有关自定义布尔字段封送的文档。

GUIDGUIDs

GUID 可在签名中直接使用。许多 Windows API 使用 GUID& 类型别名(例如,REFIID)。通过引用传递时,可以通过 ref 或使用 [MarshalAs(UnmanagedType.LPStruct)] 属性传递。

GUID通过引用传递的 GUID
KNOWNFOLDERIDREFKNOWNFOLDERID

❌ 请勿对除 ref GUID 参数以外的任何参数使用 [MarshalAs(UnmanagedType.LPStruct)]

Blittable 类型Blittable types

Blittable 类型是托管代码和本机代码中具有相同位级别表示形式的类型。因此,无需将这些类型转换为其他格式即可往返本机代码进行封送,由于这样可以提高性能,因此应首选这些类型。

  • byte, sbyte, short, ushort, int, uint, long, ulong, single, double
  • Blittable 类型的非嵌套一维数组(例如,int[]
  • 具有实例字段只有 blittable 值类型的固定布局的结构和类

    • 固定的布局需要 [StructLayout(LayoutKind.Sequential)][StructLayout(LayoutKind.Explicit)]
    • 默认情况下结构为 LayoutKind.Sequential,类为 LayoutKind.Auto 不是 blittable:
  • bool 有时为 blittable:

  • charstring 通过引用传递 blittable 类型时,这些类型只会被封送处理程序固定,而不会复制到中间缓冲区。(类在本质上通过引用传递,结构在与 refout 结合使用时会通过引用传递。)

如果 char 位于一维数组中,或者如果它是包含使用 CharSet = CharSet.Unicode[StructLayout] 显式标记的类型的一部分,则该类型为 blittable。

  1. [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
  2. public struct UnicodeCharStruct
  3. {
  4. public char c;
  5. }

如果 string 不包含在其他类型中,并且作为使用 [MarshalAs(UnmanagedType.LPWStr)] 标记的参数传递或 [DllImport] 已设置 CharSet = CharSet.Unicode,则该类型为 blittable。

可以通过尝试创建固定的 GCHandle 来查看类型是否为 blittable。如果该类型不是字符串或被视为 blittable,则 GCHandle.Alloc 将引发 ArgumentException

✔️ 尽可能使结构为 blittable。

有关详细信息,请参见:

使托管对象保持活动状态Keeping managed objects alive

GC.KeepAlive() 将确保对象保持在作用域内,直到采用 KeepAlive 方法。

HandleRef 允许封送处理程序在 P/Invoke 的持续时间内使对象保持活动状态。方法签名中可以使用该类型,而不是 IntPtrSafeHandle 可有效地替换此类,应改为使用此类型。

GCHandle 允许固定托管对象和获取指向该类型的本机指针。基本模式是:

  1. GCHandle handle = GCHandle.Alloc(obj, GCHandleType.Pinned);
  2. IntPtr ptr = handle.AddrOfPinnedObject();
  3. handle.Free();

固定不是 GCHandle 的默认设置。其他主要模式是通过本机代码将引用传递到托管对象并返回到托管代码(通常使用回调)。模式如下:

  1. GCHandle handle = GCHandle.Alloc(obj);
  2. SomeNativeEnumerator(callbackDelegate, GCHandle.ToIntPtr(handle));
  3. // In the callback
  4. GCHandle handle = GCHandle.FromIntPtr(param);
  5. object managedObject = handle.Target;
  6. // After the last callback
  7. handle.Free();

请务必注意需要显式释放 GCHandle 以避免内存泄漏。

常见的 Windows 数据类型Common Windows data types

下面是 Windows API 中常用的数据类型列表以及调用 Windows 代码时要使用的 C# 类型。

以下类型在 32 位和 64 位 Windows 上具有相同大小,而不管其名称为何。

宽度WindowsC (Windows)C#替代项
32BOOLintintbool
8BOOLEANunsigned charbyte[MarshalAs(UnmanagedType.U1)] bool
8BYTEunsigned charbyte
8CHARcharsbyte
8UCHARunsigned charbyte
16SHORTshortshort
16CSHORTshortshort
16USHORTunsigned shortushort
16WORDunsigned shortushort
16ATOMunsigned shortushort
32INTintint
32LONGlongint
32ULONGunsigned longuint
32DWORDunsigned longuint
64QWORDlong longlong
64LARGE_INTEGERlong longlong
64LONGLONGlong longlong
64ULONGLONGunsigned long longulong
64ULARGE_INTEGERunsigned long longulong
32HRESULTlongint
32NTSTATUSlongint

以下类型(指针)遵循平台的宽度。对其使用 IntPtr/UIntPtr

已签名的指针类型(使用 IntPtr未签名的指针类型(使用 UIntPtr
HANDLEWPARAM
HWNDUINT_PTR
HINSTANCEULONG_PTR
LPARAMSIZE_T
LRESULT
LONG_PTR
INT_PTR

Windows PVOID,这是一个 C void,可以作为 IntPtrUIntPtr 进行封送,但在可能的情况下更倾向于 void

Windows 数据类型

数据类型范围

结构Structs

托管结构是在堆栈上创建的,在返回方法之前不会将其删除。按照定义,它们是“固定的”(不会被 GC 移动)。如果本机代码不会在当前方法末尾之外使用指针,则也可以使用不安全代码块中的地址。

Blittable 结构的性能更好,因为它们可以由封送层直接使用。尝试使结构为 blittable(例如,避免 bool)。有关详细信息,请参阅 Blittable 类型部分。

如果结构为 blittable,请使用 sizeof() 而不是 Marshal.SizeOf<MyStruct>(),以获得更好的性能。如上所述,可以通过尝试创建固定的 GCHandle 来验证该类型是否为 blittable。如果该类型不是字符串或被视为 blittable,则 GCHandle.Alloc 将引发 ArgumentException

指向定义中的结构的指针必须通过 ref 传递或使用 unsafe*

✔️ 请尽可能将托管结构与官方平台文档或标题中使用的形状和名称匹配。

✔️ 请务必使用 C# sizeof() 而不是 blittable 结构的 Marshal.SizeOf<MyStruct>(),以提高性能。

INT_PTR Reserved1[2] 等数组必须封送到两个 IntPtr 字段(Reserved1aReserved1b)。当本机数组为基元类型时,可以使用 fixed 关键字更明确地进行编写。例如,SYSTEM_PROCESS_INFORMATION 在本机标头中类似如下内容:

  1. typedef struct _SYSTEM_PROCESS_INFORMATION {
  2. ULONG NextEntryOffset;
  3. ULONG NumberOfThreads;
  4. BYTE Reserved1[48];
  5. UNICODE_STRING ImageName;
  6. ...
  7. } SYSTEM_PROCESS_INFORMATION

可以在 C# 中编写如下内容:

  1. internal unsafe struct SYSTEM_PROCESS_INFORMATION
  2. {
  3. internal uint NextEntryOffset;
  4. internal uint NumberOfThreads;
  5. private fixed byte Reserved1[48];
  6. internal Interop.UNICODE_STRING ImageName;
  7. ...
  8. }

但是,固定的缓冲区有一些问题。不会正确封送非 blittable 类型的固定缓冲区,因此就地数组需要扩大到多个单独字段。此外,在早于 3.0 的 .NET Framework 和 .NET Core 中,如果包含固定缓冲区字段的结构嵌套在非 blittable 结构中,则不会将固定缓冲区字段正确封送到本机代码。