原生互通性最佳做法

.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 檔中指定的編譯器定義 () 明確使用 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 讓執行時間知道預期的字串編碼方式。

✔️ 當機器碼預期填滿字元緩衝區時,請考慮 char[]byte[] 陣列 ArrayPool 。 這需要將引數傳遞為 [Out]

✔️ 請考慮避免 StringBuilder 參數。 StringBuilder 封送「一律」會建立原生緩衝區複本。 因此,這麼做可能非常沒有效率。 請考慮呼叫接受字串之 Windows API 的典型案例:

  1. StringBuilder建立所需的容量 (配置受控容量) {1}
  2. 調用:
    1. 配置原生緩衝區 {2}
    2. 如果 [In] (參數的預設值 StringBuilder) ,則複製內容。
    3. 如果 [Out]{3} (也是) 的預設值 StringBuilder,將原生緩衝區複製到新配置的 Managed 陣列。
  3. ToString() 配置另一個 Managed 陣列 {4}

這是 {4} 從機器碼中取得字串的配置。 若要限制這一點,最好的作法是在另一個呼叫中重複使用 StringBuilder ,但這仍然只會儲存一個配置。 最好從 ArrayPool 使用和快取字元緩衝區。 然後,您可以關閉後續 ToString() 呼叫的 配置。

StringBuilder 的另一個問題是它一律會將傳回緩衝區備份複製到第一個 Null。 如果傳回的字串沒有中止,或者它是雙重 Null 結尾的字串,則您 P/Invoke 最佳的狀態會是不正確。

如果您「確實」使用 StringBuilder,最後一個陷阱是該容量包含隱藏的 Null (封送一律會計算)。 這經常被誤解,因為大部分 API 都想要緩衝區「包含」Null。 這會導致浪費/不必要的配置。 此外,此陷阱會防止執行階段最佳化 StringBuilder 封送以減少複本。

如需字串封送的詳細資訊,請參閱預設的字串封送自訂字串封送

Windows CLR預設會使用 CoTaskMemFree 的特定字串 [Out] 來釋放字串,或 SysStringFree 標示為 UnmanagedType.BSTR 的字串。 針對具有輸出字串緩衝區的大部分 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
KNOWNFOLDERID REFKNOWNFOLDERID

❌ 請勿用於 [MarshalAs(UnmanagedType.LPStruct)] GUID 參數以外的 ref 任何專案。

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 型別以 、 ref 或 傳址 in 方式傳遞時,或 out 當具有 blittable 內容的型別以傳值方式傳遞時,它們只會由封送器釘選,而不是複製到中繼緩衝區。

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

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

string如果它未包含在另一個型別中,而且會當做標記為 或 已 CharSet = CharSet.Unicode 設定的 [DllImport] 引數 [MarshalAs(UnmanagedType.LPWStr)] 傳遞,則會包含 blittable 內容。

您可以嘗試建立釘選 GCHandle 的 來查看類型是否為 blittable 或包含 blittable 內容。 如果該類型不是字串或被視為 Blittable,則 GCHandle.Alloc 會擲回 ArgumentException

停用執行時間封送處理時的 Blittable 類型

停用執行時間封送處理時,類型 blittable 的規則會大幅簡化。 所有屬於C# unmanaged類型且沒有標示 [StructLayout(LayoutKind.Auto)] 為 blittable 的欄位。 不是 C# unmanaged 類型的所有類型都不是 blittable。 停用執行時間封送處理時,不會套用具有 blittable 內容的型別概念,例如陣列或字串。 停用執行時間封送處理時,不支援上述規則未視為 blittable 的任何類型。

這些規則與內建系統主要不同之處在于使用 和 char 的情況 bool 。 停用封送處理時, 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 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資料類型

資料類型範圍

先前為內建支援的類型

移除型別的內建支援時,有罕見的實例。

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

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

C/C++ long

C/C++ long 和 C# long 不是相同的類型。 使用 C# long 與 C/C++ long 進行 Interop 幾乎永遠不會正確。

longC/C++ 中的類型定義為「至少 32」位。 這表示所需的位數目下限,但平臺可以視需要選擇使用更多位。 下表說明平臺之間 C/C++ long 資料類型所提供位的差異。

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

當原生函式定義在所有平臺上使用 long 時,這些差異可能會讓撰寫跨平臺 P/Invokes 變得困難。

在 .NET 6 和更新版本中,使用 CLongCULong 類型搭配 C/C++ longunsigned long 資料類型的 Interop。 下列範例適用于 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.MulticastDelegate 欄位從原生標記法封送 System.Delegate 處理至 Managed 物件。 使用特定的委派類型, System.Delegate 而不是 或 System.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 結構中,則該固定緩衝區欄位不會正確地封送至機器碼。