本机互操作性最佳做法

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

通用指南

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

  • ✔️ 如果可能,在面向 .NET 7+ 时,请使用 [LibraryImport]
    • 在某些情况下,使用 [DllImport] 是合适的。 ID 为 SYSLIB1054 的代码分析器会告诉你何时出现这种情况。
  • ✔️ 请务必对方法和参数使用同一命名和大小写以作为要调用的本机方法。
  • ✔️ 请考虑对常数值使用同一命名和大小写。
  • ✔️ 请务必使用映射到最接近本机类型的 .NET 类型。 例如,在 C# 中,当本机类型为 unsigned int 时使用 uint
  • ✔️ 最好使用 .NET 结构而不是类来表达更高级别的本机类型。
  • ✔️ 在 C# 中将回调传递给非托管函数时,最好使用函数指针而不是 Delegate 类型。
  • ✔️ 务必对数组参数使用 [In][Out] 属性。
  • ✔️ 请务必在所需行为与默认行为不同时对其他类型仅使用 [In][Out] 属性。
  • ✔️ 请考虑使用 System.Buffers.ArrayPool<T> 来汇集本机数组缓冲区。
  • ✔️ 请考虑使用与本机库相同的名称和大小写包装类中的 P/Invoke 声明。
    • 这允许 [LibraryImport][DllImport] 属性使用 C# nameof 语言功能传入本机库的名称,并确保本机库的名称拼写正确。
  • ✔️ 请使用 SafeHandle 句柄来管理封装非托管资源的对象生存期。 有关详细信息,请参阅清理非托管资源
  • 请使用 ❌ AVOID 终结器来管理封装非托管资源的对象生存期。 有关详细信息,请参阅实现 Dispose 方法

LibraryImport 属性设置

ID 为 SYSLIB1054 的代码分析器可帮助指导使用 LibraryImportAttribute。 在大多数情况下,使用 LibraryImportAttribute 需要显式声明,而不是依赖于默认设置。 这种设计是有意的,有助于避免互操作方案中出现意外行为。

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)。

字符串参数

当按值(不是 refout)和以下任意一项传递时,string 由本机代码直接固定和使用(而不是复制):

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

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

DllImport 特定指南

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

✔️ 考虑避免使用 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 通过引用传递的 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 不包含在其他类型中并且通过值(不是 refout)作为参数以及以下任意一项传递,则其包含 blittable 内容:

你可以通过尝试创建固定 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 UCHAR byte
8 UINT8 byte
8 CCHAR byte
8 CHAR sbyte
8 CHAR sbyte
8 INT8 sbyte
16 CSHORT short
16 INT16 short
16 SHORT short
16 ATOM ushort
16 UINT16 ushort
16 USHORT ushort
16 WORD ushort
32 INT int
32 INT32 int
32 LONG int 请参见 CLongCULong
32 LONG32 int
32 CLONG uint 请参见 CLongCULong
32 DWORD uint 请参见 CLongCULong
32 DWORD32 uint
32 UINT uint
32 UINT32 uint
32 ULONG uint 请参见 CLongCULong
32 ULONG32 uint
64 INT64 long
64 LARGE_INTEGER long
64 LONG64 long
64 LONGLONG long
64 QWORD long
64 DWORD64 ulong
64 UINT64 ulong
64 ULONG64 ulong
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.HStringUnmanagedType.IInspectable 内置封送支持。 你必须重新编译使用此封送类型并针对以前的框架的二进制文件。 仍然可以封送这种类型,但你必须手动封送,如以下代码示例所示。 此代码将继续工作,并且还与以前的框架兼容。

public sealed class HStringMarshaler : ICustomMarshaler
{
    public static readonly HStringMarshaler Instance = new HStringMarshaler();

    public static ICustomMarshaler GetInstance(string _) => Instance;

    public void CleanUpManagedData(object ManagedObj) { }

    public void CleanUpNativeData(IntPtr pNativeData)
    {
        if (pNativeData != IntPtr.Zero)
        {
            Marshal.ThrowExceptionForHR(WindowsDeleteString(pNativeData));
        }
    }

    public int GetNativeDataSize() => -1;

    public IntPtr MarshalManagedToNative(object ManagedObj)
    {
        if (ManagedObj is null)
            return IntPtr.Zero;

        var str = (string)ManagedObj;
        Marshal.ThrowExceptionForHR(WindowsCreateString(str, str.Length, out var ptr));
        return ptr;
    }

    public object MarshalNativeToManaged(IntPtr pNativeData)
    {
        if (pNativeData == IntPtr.Zero)
            return null;

        var ptr = WindowsGetStringRawBuffer(pNativeData, out var length);
        if (ptr == IntPtr.Zero)
            return null;

        if (length == 0)
            return string.Empty;

        return Marshal.PtrToStringUni(ptr, length);
    }

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    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")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern int WindowsDeleteString(IntPtr hstring);

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern IntPtr WindowsGetStringRawBuffer(IntPtr hstring, out int length);
}

// Example usage:
[DllImport("api-ms-win-core-winrt-l1-1-0.dll", PreserveSig = true)]
internal static extern int RoGetActivationFactory(
    /*[MarshalAs(UnmanagedType.HString)]*/[MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef = typeof(HStringMarshaler))] string activatableClassId,
    [In] ref Guid iid,
    [Out, MarshalAs(UnmanagedType.IUnknown)] out object factory);

跨平台数据类型注意事项

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

C/C++ long

C/C++ long 和 C# long 的大小不一定相同。

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

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

相比之下,C# long 始终为 64 位。 因此,最好避免使用 C# long 与 C/C++ long 进行互操作。

(C/C++ long 的这个问题在 C/C++ charshortintlong long 上不存在,因为在所有这些平台上它们分别是 8、16、32 和 64 位。)

在 .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 结构中,则不会将固定缓冲区字段正确封送到本机代码。