.NET 提供了多种方式来自定义本机互操作性代码。 本文包括 Microsoft .NET 团队为实现本机互操作性而遵循的指南。
通用指南
本部分中的指南适用于所有互操作方案。
- ✔️ 如果可能,在面向 .NET 7+ 时,请使用 [LibraryImport]。- 在某些情况下,使用 [DllImport]是合适的。 ID 为 SYSLIB1054 的代码分析器会告诉你何时出现这种情况。
 
- 在某些情况下,使用 
- ✔️ 请务必为您的方法和参数使用与要调用的本机方法相同的命名和大小写。
- ✔️ 请考虑对常数值使用同一命名和大小写。
- ✔️ DO 定义与 C 函数参数匹配的 P/Invoke 和函数指针签名。
- ✔️ 请务必使用映射到最接近本机类型的 .NET 类型。 例如,在 C# 中,当本机类型为 uint时使用unsigned int。
- ✔️ 更倾向于使用 .NET 结构而不是类来表达更高级别的原生类型。
- ✔️ 在 C# 中将回调传递给非托管函数时,最好使用函数指针而不是 Delegate类型。
- ✔️ 务必对数组参数使用 [In]和[Out]属性。
- ✔️ 请务必在所需行为与默认行为不同时对其他类型仅使用 [In]和[Out]属性。
- ✔️ 请考虑使用 System.Buffers.ArrayPool<T> 来汇集本机数组缓冲区。
- ✔️ 请考虑使用与本机库相同的名称和大小写包装类中的 P/Invoke 声明。
- 这使您的 [LibraryImport]或[DllImport]属性能够使用 C#nameof语言特性传递本机库的名称,并确保您没有拼错本机库的名称。
 
- 这使您的 
- ✔️ 务必使用 SafeHandle句柄来管理用于封装非托管资源的对象的生命周期。 有关详细信息,请参阅清理非托管资源。
- ❌ 避免使用终结器来管理用于封装非托管资源的对象的生命周期。 有关详细信息,请参阅实现 Dispose 方法。
LibraryImport 属性设置
ID 为 SYSLIB1054 的代码分析器可帮助指导使用 LibraryImportAttribute。 在大多数情况下,使用 LibraryImportAttribute 需要显式声明,而不是依赖于默认设置。 这种设计是有意的,有助于避免互操作方案中出现意外行为。
DllImport 属性设置
| 设置 | 默认 | 建议 | 详细信息 | 
|---|---|---|---|
| PreserveSig | true | 保留默认值 | 将其显式设置为 False 时,失败的 HRESULT 返回值将变为异常(因此,定义中的返回值将变为 Null)。 | 
| SetLastError | false | 取决于 API | 如果 API 使用 GetLastError,并使用 Marshal.GetLastWin32Error 获取值,则将其设置为 True。 如果 API 设置了一个指示有错误的条件,应在进行其他调用之前先获取该错误,以避免该错误被无意中覆盖。 | 
| CharSet | 编译器定义(在字符集文档中指定) | 定义中存在字符串或字符时显式使用 CharSet.Unicode或CharSet.Ansi | 这将指定字符串的封送行为以及为 ExactSpelling时false的操作。 请注意,CharSet.Ansi在 Unix 上实际为 UTF8。 大部分时间,Windows 使用 Unicode,而 Unix 使用 UTF8。 有关更多信息,请查看有关字符集的文档。 | 
| ExactSpelling | false | true | 将其设置为 True 并在运行时获得些许性能优势不会查找后缀为“A”或“W”的备用函数名称,具体取决于 CharSet设置的值(“A”用于CharSet.Ansi,“W”用于CharSet.Unicode)。 | 
字符串参数
当按值(不是 string 或 ref)和以下任意一项传递时,out 由本机代码直接固定和使用(而不是复制):
- LibraryImportAttribute.StringMarshalling 定义为 Utf16。
- 该参数显式标记为 [MarshalAs(UnmanagedType.LPWSTR)]。
- DllImportAttribute.CharSet 为 Unicode。
❌ 不要使用 [Out] string 参数。 如果该字符串为暂存的字符串,则通过包含 [Out] 属性的值传递的字符串参数可能使运行时变得不稳定。 请在 String.Intern 的文档中查看有关字符串暂存的详细信息。
✔️ 当需要使用本机代码填充字符缓冲区时,考虑使用源自 char[] 的 byte[] 或 ArrayPool 数组。 这需要将参数作为 [Out] 传递。
DllImport 特定指南
✔️ 考虑在 CharSet 中设置 [DllImport] 属性,以便运行时知道预期的字符串编码。
✔️ 考虑避免使用 StringBuilder 参数。 StringBuilder 封送始终会创建本机缓冲区副本。 因此,该操作的效率可能非常低。 以调用需要字符串的 Windows API 为例:
- 创建所需容量的 StringBuilder(分配托管容量){1}。
- 调用:- 分配本机缓冲区 {2}。
- 如果为 [In](StringBuilder参数的默认值),则复制内容
- 如果为 [Out]{3}(也是StringBuilder的默认值),则将本机缓冲区复制到新分配的托管数组中。
 
- ToString()会分配另一托管数组 {4}。
这是 {4} 分配,可从本机代码中获取字符串。 可用来限制此操作的最佳方法是在其他调用中重用 StringBuilder,但这仍只能保存一个分配。 使用和缓存 ArrayPool 中的字符缓冲区要好得多。 然后,你可以在后续调用中只分配 ToString()。
StringBuilder 的另一个问题是它始终会将返回缓冲区复制回第一个空值处。 如果传递的返回字符串未终止或为双 Null 终止字符串,则 P/Invoke 很可能不正确。
如果使用 StringBuilder,则最后一个问题是容量确实不会包括隐藏的 Null,该值始终计入互操作。 人们常常会犯这个错误,因为大多数 API 希望缓冲区的大小包括空字符。 这可能会导致产生浪费/不必要的分配。 此外,此问题会阻止运行时优化 StringBuilder 封送以最大限度地减少副本。
有关字符串封送的详细信息,请参阅字符串的默认封送和自定义字符串封送。
特定于 Windows 对于
[Out]字符串,CLR 将默认使用CoTaskMemFree来释放字符串,或对于标记为SysStringFree的字符串,使用UnmanagedType.BSTR。 对于具有输出字符串缓冲区的大多数 API: 传入的字符计数必须包括空字符。 如果返回的值小于传入的字符计数,则调用成功,并且该值是 不带尾随 Null 的字符数。 否则,该计数是包括 Null 字符的缓冲区的所需大小。
- 传入 5 个,获取 4 个:字符串包含 4 个字符,带有尾随 Null。
- 传入 5 个,获取 6 个:字符串包含 5 个字符,需要包含 6 个字符的缓冲区来保存空字符。 字符串的 Windows 数据类型
布尔参数和字段
布尔值很容易混淆。 默认情况下,将 .NET bool 封送到 Windows BOOL,它在其中为包含 4 个字节的值。 但是,C 和 C++ 中的 _Bool 和 bool 类型是单字节。 这可能会导致难以跟踪 bug,因为一半的返回值将被丢弃,这样可能只会更改结果。 有关将 .NET bool 值封送到 C 或 C++ bool 类型的更多信息,请参阅有关自定义布尔字段封送的文档。
GUIDs
GUID 可在签名中直接使用。 许多 Windows API 使用 GUID& 类型别名(例如,REFIID)。 当方法签名包含引用参数时,请将 ref 关键字或 [MarshalAs(UnmanagedType.LPStruct)] 属性放在 GUID 参数声明中。
| GUID | 通过引用传递的 GUID | 
|---|---|
| KNOWNFOLDERID | REFKNOWNFOLDERID | 
❌ 不要将 [MarshalAs(UnmanagedType.LPStruct)] 用于除 ref GUID 参数之外的任何参数。
Blittable 类型
Blittable 类型是托管代码和本机代码中具有相同位级别表示形式的类型。 因此,无需将这些类型转换为其他格式即可往返本机代码进行封送,由于这样可以提高性能,因此应首选这些类型。 某些类型不是 blittable,但已知包含 blittable 内容。 当这些类型不包含在另一种类型中时,它们与 blittable 类型有类似的优化,但在结构字段中或用于 UnmanagedCallersOnlyAttribute 时,它们不被认为是 blittable 类型。
启用运行时封送时的 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 类型通过 in、ref 或 out 的引用传递时,或者当具有 blittable 内容的类型通过值传递时,它们只是由编组器固定,而不是被复制到中间缓冲区。
              如果 char 位于一维数组中,或者如果它是包含使用 [StructLayout] 的 CharSet = CharSet.Unicode 显式标记的类型的一部分,则该类型为 blittable。
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct UnicodeCharStruct
{
    public char c;
}
              如果 string 不包含在其他类型中并且通过值(不是 ref 或 out)作为参数以及以下任意一项传递,则其包含 blittable 内容:
- StringMarshalling 定义为 Utf16。
- 该参数显式标记为 [MarshalAs(UnmanagedType.LPWSTR)]。
- CharSet 为 Unicode。
你可以通过尝试创建固定 GCHandle 来查看类型是否可 blittable 或是否包含 blittable 内容。 如果该类型不是字符串或被视为 blittable,则 GCHandle.Alloc 将引发 ArgumentException。
禁用运行时封送时的 blittable 类型
当运行时封送被禁用时,判断哪些类型是 blittable 的规则要简单得多。 属于 C#unmanaged 类型且没有任何字段标记为 [StructLayout(LayoutKind.Auto)] 的所有类型都是 blittable。 所有非 C# unmanaged 类型的类型都不可 blittable。 禁用运行时封送时,具有 blittable 内容的类型(例如数组或字符串)的概念不适用。 禁用运行时封送时,上述规则认为不可写入 blittable 的任何类型都不受支持。
这些规则在使用 bool 和 char 的情况下,主要不同于内置系统。 禁用封送时,bool 作为 1 字节值传递且未标准化,char 始终作为 2 字节值传递。 启用运行时封送时,bool 可以映射到 1、2 或 4 字节值并始终进行规范化,并且 char 映射到 1 或 2 字节值(取决于 CharSet)。
✔️ 尽可能使结构为 blittable。
有关详细信息,请参见:
使托管对象保持活动状态
GC.KeepAlive() 将确保对象保持在作用域内,直到调用 KeepAlive 方法。
HandleRef 允许封送处理程序在 P/Invoke 的持续时间内使对象保持活动状态。 方法签名中可以使用该类型,而不是 IntPtr。 SafeHandle 可有效地替换此类,且应改为使用此类型。
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 | 请参见 CLong和CULong。 | 
| 32 | LONG32 | int | |
| 32 | CLONG | uint | 请参见 CLong和CULong。 | 
| 32 | DWORD | uint | 请参见 CLong和CULong。 | 
| 32 | DWORD32 | uint | |
| 32 | UINT | uint | |
| 32 | UINT32 | uint | |
| 32 | ULONG | uint | 请参见 CLong和CULong。 | 
| 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*,可以作为 IntPtr 或 UIntPtr 进行封送,但在可能的情况下更倾向于 void*。
以前内置的支持类型
很少有情况会删除对某个类型的内置支持。
.NET 5 版本中移除了 UnmanagedType.HString 和 UnmanagedType.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++ char、short、int 和 long long 上不存在,因为在所有这些平台上它们分别是 8、16、32 和 64 位。)
在 .NET 6 及更高版本中,使用 CLong 和 CULong 类型与 C/C++ long 和 unsigned 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 和 *。
✔️ 请尽可能将托管结构与官方平台文档或头文件中使用的形状和名称匹配。
✔️ 在处理 blittable 结构时,请使用 C# sizeof() 而不是 Marshal.SizeOf<MyStruct>(),以提高性能。
❌ 不要依赖于 .NET 运行时库公开的结构类型的内部表示形式,除非有明确记录。
❌ 避免使用类通过继承来表达复杂的原生类型。
❌ 避免使用 System.Delegate 或 System.MulticastDelegate 字段来表示结构中的函数指针字段。
由于 System.Delegate 和 System.MulticastDelegate 没有必需的签名,因此它们不能保证传入的委托将与本机代码所需的签名匹配。 此外,在 .NET Framework 和 .NET Core 中,将包含 System.Delegate 或 System.MulticastDelegate 的结构从其本机表示形式封送到托管对象时,如果本机表示形式中的字段值不是包装托管委托的函数指针,这一操作可能会导致运行时不稳定。 在 .NET 5 及更高版本中,不支持将 System.Delegate 或 System.MulticastDelegate 字段从本机表示形式封送到托管对象。 使用特定委托类型,而不是 System.Delegate 或 System.MulticastDelegate。
固定缓冲区
INT_PTR Reserved1[2] 等数组必须封送到两个 IntPtr 字段(Reserved1a 和 Reserved1b)。 当本机数组为原始类型时,可以使用 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 中,如果包含固定缓冲区字段的结构体嵌套在非可平移的结构体中,则固定缓冲区字段将无法正确封送到本机代码。