本机互操作性最佳做法

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

通用指南

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

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

DllImport 属性设置

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

字符串参数

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

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

✔️ 考虑在 [DllImport] 中设置 CharSet 属性,以便运行时知道预期的字符串编码。

✔️ 当需要使用本机代码填充字符缓冲区时,考虑使用源自 ArrayPoolchar[]byte[] 数组。 这需要将参数作为 [Out] 传递。

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

  1. 创建所需容量的 StringBuilder(分配托管容量){1}
  2. 调用:
    1. 分配本机缓冲区 {2}
    2. 如果为 [In]StringBuilder 参数的默认值),则复制内容
    3. 如果为 [Out]{3}(也是 StringBuilder 的默认值),则将本机缓冲区复制到新分配的托管数组中
  3. ToString() 分配其他托管数组 {4}

这是 {4} 分配,可从本机代码中获取字符串。 可用来限制此操作的最佳方法是在其他调用中重用 StringBuilder,但这仍只能保存一个分配。 使用和缓存 ArrayPool 中的字符缓冲区要好得多。 然后,你可以在后续调用中只分配 ToString()

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

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

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

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

  • 传入 5 个,获取 4 个:字符串包含 4 个字符,带有尾随 Null。
  • 传入 5 个,获取 6 个:字符串包含 5 个字符,需要包含 6 个字符的缓冲区来保存 Null。 字符串的 Windows 数据类型

布尔参数和字段

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

GUID

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

GUID 通过引用传递的 GUID
KNOWNFOLDERID REFKNOWNFOLDERID

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

Blittable 类型

Blittable 类型是托管代码和本机代码中具有相同位级别表示形式的类型。 因此,无需将这些类型转换为其他格式即可往返本机代码进行封送,由于这样可以提高性能,因此应首选这些类型。 某些类型不是 blittable,但已知包含 blittable 内容。 当这些类型不包含在另一种类型中时,它们与 blittable 类型有类似的优化,但在结构字段中或用于 UnmanagedCallersOnlyAttribute 时,它们不被认为是 blittable 类型。

启用运行时封送时的 blittable 类型

  • byte, sbyte, short, ushort, int, uint, long, ulong, single, double
  • 具有实例字段只有 blittable 值类型的固定布局的结构
    • 固定的布局需要 [StructLayout(LayoutKind.Sequential)][StructLayout(LayoutKind.Explicit)]
    • 默认结构为 LayoutKind.Sequential

具备 blittable 内容的类型:

  • Blittable 基元类型的非嵌套一维数组(例如,int[]
  • 具有实例字段只有 blittable 值类型的固定布局的类
    • 固定的布局需要 [StructLayout(LayoutKind.Sequential)][StructLayout(LayoutKind.Explicit)]
    • 默认情况下类为 LayoutKind.Auto

不是 blittable:

  • bool

有时为 blittable:

  • char

具备有时为 blittable 内容的类型:

  • string

当 blittable 类型通过 inrefout 的引用传递时,或者当具有 blittable 内容的类型通过值传递时,它们只是由编组器固定,而不是被复制到中间缓冲区。

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

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

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

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

禁用运行时封送时的 blittable 类型

运行时封送被禁用时,判断哪些类型是 blittable 的规则要简单得多。 属于 C#unmanaged 类型且没有任何字段标记为 [StructLayout(LayoutKind.Auto)] 的所有类型都是 blittable。 所有非 C# unmanaged 类型的类型都不可 blittable。 禁用运行时封送时,具有 blittable 内容的类型(例如数组或字符串)的概念不适用。 禁用运行时封送时,上述规则认为不可写入 blittable 的任何类型都不受支持。

这些规则主要在使用 boolchar 的情况下不同于内置系统。 禁用封送时,bool 作为 1 字节值传递且未标准化,char 始终作为 2 字节值传递。 启用运行时封送时,bool 可以映射到 1、2 或 4 字节值并始终进行规范化,并且 char 映射到 1 或 2 字节值(取决于 CharSet)。

✔️ 尽可能使结构为 blittable。

有关详细信息,请参见:

使托管对象保持活动状态

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

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

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

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

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

GCHandle handle = GCHandle.Alloc(obj);
SomeNativeEnumerator(callbackDelegate, GCHandle.ToIntPtr(handle));

// In the callback
GCHandle handle = GCHandle.FromIntPtr(param);
object managedObject = handle.Target;

// After the last callback
handle.Free();

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

常见的 Windows 数据类型

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

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

宽度 Windows C# 替代项
32 BOOL int bool
8 BOOLEAN byte [MarshalAs(UnmanagedType.U1)] bool
8 BYTE byte
8 CHAR sbyte
8 UCHAR byte
16 SHORT short
16 CSHORT short
16 USHORT ushort
16 WORD ushort
16 ATOM ushort
32 INT int
32 LONG int 请参见 CLongCULong
32 ULONG uint 请参见 CLongCULong
32 DWORD uint
64 QWORD long
64 LARGE_INTEGER long
64 LONGLONG long
64 ULONGLONG ulong
64 ULARGE_INTEGER ulong
32 HRESULT int
32 NTSTATUS int

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

已签名的指针类型(使用 IntPtr 未签名的指针类型(使用 UIntPtr
HANDLE WPARAM
HWND UINT_PTR
HINSTANCE ULONG_PTR
LPARAM SIZE_T
LRESULT
LONG_PTR
INT_PTR

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

Windows 数据类型

数据类型范围

以前内置的支持类型

删除对类型的内置支持的情况很少见。

.NET 5 版本中移除了 UnmanagedType.HString 内置封送支持。 你必须重新编译使用此封送类型并针对以前的框架的二进制文件。 仍然可以封送这种类型,但你必须手动封送,如以下代码示例所示。 此代码将继续工作,并且还与以前的框架兼容。

static class HSTRING
{
    public static IntPtr FromString(string s)
    {
        Marshal.ThrowExceptionForHR(WindowsCreateString(s, s.Length, out IntPtr h));
        return h;
    }

    public static void Delete(IntPtr s)
    {
        Marshal.ThrowExceptionForHR(WindowsDeleteString(s));
    }

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll", CallingConvention = CallingConvention.StdCall, ExactSpelling = true)]
    private static extern int WindowsCreateString(
        [MarshalAs(UnmanagedType.LPWStr)] string sourceString, int length, out IntPtr hstring);

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll", CallingConvention = CallingConvention.StdCall, ExactSpelling = true)]
    private static extern int WindowsDeleteString(IntPtr hstring);
}

// Usage example
IntPtr hstring = HSTRING.FromString("HSTRING from .NET to WinRT API");
try
{
    // Pass hstring to WinRT or Win32 API.
}
finally
{
    HSTRING.Delete(hstring);
}

跨平台数据类型注意事项

C/C++ 语言中的某些类型在定义方式上具有一定的自由度。 在编写跨平台互操作时,可能会出现平台不同的情况,如果不考虑这种情况,可能会导致问题。

C/C++ long

C/C++ long 和 C# long 不是同一类型。 使用 C# long 与 C/C++ long 互操作基本上永远是错误的。

C/C++ 中的 long 类型被定义为具有“至少 32”位。 这意味着所需的位数最少,但平台可以根据需要选择使用更多位数。 下表说明了平台之间为 C/C++ long 数据类型提供的位的差异。

平台 32 位 64 位
Windows 32 32
macOS/*nix 32 64

当本机函数被定义为在所有平台上使用 long 时,这些差异会使编写跨平台的 P/ invoke 变得困难。

在 .NET 6 及更高版本中,使用 CLongCULong 类型与 C/C++ longunsigned long 数据类型进行互操作。 以下示例适用于 CLong,但你可以使用 CULong 以类似的方式来抽象化 unsigned long

// Cross platform C function
// long Function(long a);
[DllImport("NativeLib")]
extern static CLong Function(CLong a);

// Usage
nint result = Function(new CLong(10)).Value;

面向 .NET 5 和更早版本时,你应该声明单独的 Windows 和非 Windows 签名来处理问题。

static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);

// Cross platform C function
// long Function(long a);

[DllImport("NativeLib", EntryPoint = "Function")]
extern static int FunctionWindows(int a);

[DllImport("NativeLib", EntryPoint = "Function")]
extern static nint FunctionUnix(nint a);

// Usage
nint result;
if (IsWindows)
{
    result = FunctionWindows(10);
}
else
{
    result = FunctionUnix(10);
}

结构

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

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

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

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

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

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

❌ 避免使用 System.DelegateSystem.MulticastDelegate 字段来表示结构中的函数指针字段。

由于 System.DelegateSystem.MulticastDelegate 没有必需的签名,因此它们不能保证传入的委托将与本机代码所需的签名匹配。 此外,在 .NET Framework 和 .NET Core 中,如果本机表示形式的字段值不是包装托管委托的函数指针,则将包含 System.DelegateSystem.MulticastDelegate 的结构从其本机表示形式封送到托管对象这一操作可能会导致运行时不稳定。 在 .NET 5 及更高版本中,不支持将 System.DelegateSystem.MulticastDelegate 字段从本机表示形式封送到托管对象。 使用特定委托类型,而不是 System.DelegateSystem.MulticastDelegate

固定缓冲区

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

typedef struct _SYSTEM_PROCESS_INFORMATION {
    ULONG NextEntryOffset;
    ULONG NumberOfThreads;
    BYTE Reserved1[48];
    UNICODE_STRING ImageName;
...
} SYSTEM_PROCESS_INFORMATION

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

internal unsafe struct SYSTEM_PROCESS_INFORMATION
{
    internal uint NextEntryOffset;
    internal uint NumberOfThreads;
    private fixed byte Reserved1[48];
    internal Interop.UNICODE_STRING ImageName;
    ...
}

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