原生互通性最佳做法

.NET 提供您各種自訂原生互通性程式碼的方式。 本文包含 Microsoft 的 .NET 小組在原生互通性上遵循的指導方針。

一般方針

本節中的指導方針適用於所有互通案例。

  • ✔️ 請這樣做 為您的方法和參數使用與要呼叫之原生方法相同的命名和大小寫。
  • ✔️ 請考慮 為常數值使用相同的命名和大小寫。
  • ✔️ 請這樣做 使用與原生類型對應相近的 .NET 類型。 例如在 C# 中,當原生類型是 unsigned int 時就使用 uint
  • ✔️「務必」偏好使用 .NET 結構來表示較高層級的原生類型,而非類別。
  • ✔️ 請對陣列參數使用 [In][Out] 屬性。
  • ✔️ 當您想要的行為與預設行為不同時,請只對其他型別使用 [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 此項目指定值為 false 時,字串的封送行為及 ExactSpelling 的作用。 請留意到,CharSet.Ansi 在 Unix 上實際是 UTF8。 Windows「多數」時候是使用 Unicode,而 Unix 是使用 UTF8。 請在字元集相關文件查看詳細資訊。
ExactSpelling false true 將此項目設為 True 可獲得些微效能好處,因為執行階段不會根據 CharSet 的設定,查看名稱尾碼是 "A" 或 "W" 的替代函式 ("A" 是 CharSet.Ansi 而 "W" 是 CharSet.Unicode)。

字串參數

當 CharSet 是 Unicode 時或引數明確標示為 [MarshalAs(UnmanagedType.LPWSTR)]「且」該字串是以傳值方式傳遞時 (不是 refout),則該字串會被固定並由機器碼直接使用 (而不是複製)。

❌「請勿」使用 [Out] string 參數。 使用 [Out] 屬性以傳值方式傳遞的字串參數,可能會使執行階段不穩定 (如果該字串是暫留字串)。 請在 String.Intern 的文件中查看字串暫留的詳細資訊。

✔️ 請「考慮」在 [DllImport] 中設定 CharSet 屬性,讓執行階段知道預期的字串編碼方式。

✔️ 當機器碼預期填滿字元緩衝區時,請「考慮」ArrayPool 中的 char[]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 型別的詳細資訊,請參閱自訂布林欄位封送文件。

GUID

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

具有 SOMETIMES Blittable 內容的類型:

  • string

當 Blittable 類型以具有 inrefout 的參考傳遞時,或當具有 Blittable 內容的類型以依值方式傳遞時,其只會由封送處理器釘選,而非複製到中繼緩衝區。

char 在一維陣列中時,當它所屬的類型明確地以 CharSet = CharSet.Unicode 標示為 [StructLayout] 時,它是 Blittable 的。

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

當其不是包含在其他類型中,且其作為引數傳遞並標示為 [MarshalAs(UnmanagedType.LPWStr)][DllImport] 已設定 CharSet = CharSet.Unicode 時,string 包含 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 位元組值,且一律會標準化,並根據 CharSetchar 對應至 1 或 2 位元組值。

✔️ 請這樣做 盡可能讓您的結構是 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 請參閱 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 資料類型

資料類型範圍

先前為內建支援的類型

移除類型的內建支援時,具有罕見的執行個體。

UnmanagedType.HStringUnmanagedType.IInspectable 內建封送處理支援已在 .NET 5 版中移除。 您必須重新編譯使用此封送處理類型以及以先前架構為目標的二進位檔。 您仍然可以封送處理此類型,但必須手動封送處理,如下列程式碼範例所示。 此程式碼將會繼續運作,且也與先前的架構相容。

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++ 語言中有一種類型,其定義方式具有自由度。 撰寫跨平台 Interop 時,可能會發生平台不同的情況,如果未考慮,可能會造成問題。

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*

✔️ 請這樣做 盡可能近似地對應受控結構與官方平台文件或標頭中所使用的圖形和名稱。

✔️ 請針對 Blittable 結構使用 C# sizeof(),而不是使用 Marshal.SizeOf<MyStruct>(),以改善效能。

❌ 避免使用類別透過繼承來表示複雜的原生類型。

❌ 請「避免」使用 System.DelegateSystem.MulticastDelegate 欄位來代表結構中的函式指標欄位。

由於 System.DelegateSystem.MulticastDelegate 沒有必要的簽章,因此不保證傳入的委派符合機器碼預期的簽章。 此外,在 .NET Framework 和 .NET Core 中,如果原生標記法中的欄位值不是包裝 Managed 委派的函式指標,則將包含其原生標記法中 System.DelegateSystem.MulticastDelegate 的結構封送處理至 Managed 物件時,可能會使執行階段不穩定。 在 .NET 5 和更新版本中,不支援將 System.DelegateSystem.MulticastDelegate 欄位從原生標記法封送處理至 Managed 物件。 使用特定的委派類型,而非 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 類型的固定緩衝區不會正確地被封送,因此需要將原陣列展開成多個個別欄位。 此外,在 .NET Framework 和 .NET Core 3.0 之前,如果結構包含的固定緩衝區欄位是巢狀地包含在非 Blittable 結構中,則該固定緩衝區欄位不會正確地封送至機器碼。