在 C# 中安全呼叫 Win32 API — P/Invoke 實務指南(DllImport / LibraryImport / CsWin32)
· 小村 豪 · P/Invoke, DllImport, LibraryImport, CsWin32, C#, .NET, Win32, SafeHandle, 原生互通, Windows開發, 技術顧問
本部落格至今已寫過C++/CLI 包裝與 P/Invoke 的取捨、從 C/C++ 呼叫 C# Native AOT DLL 的方法、從 32bit 應用呼叫 64bit DLL 的 COM 橋接、Windows DLL 名稱解析的機制等多篇與原生互通有關的文章。不過,作為這些內容基礎的 P/Invoke 本身,卻還沒有一篇文章專門整理過。
P/Invoke 一方面有「只要用 extern 宣告 DLL 的函式就能呼叫」的簡便性,另一方面也是在字串封送、控制代碼的生命週期、錯誤碼的取得、結構配置這幾個地方,遲早會踩到坑的技術。本文以 .NET 7 之後的預設選項 LibraryImport 為主軸,一次整理實務上該留意的要點。
1. 先講結論
- .NET 7 之後應以
LibraryImport取代DllImport作為預設。 因為是在編譯時期產生封送程式碼,所以能對應 Native AOT、修剪,沒有執行時期產生 IL stub 的成本,且產生的程式碼可以用偵錯器逐步執行。分析器SYSLIB1054會告訴你哪些DllImport該改寫。12 - 若要手寫 Win32 API,可以考慮 CsWin32。 只要在
NativeMethods.txt中列出想呼叫的函式名稱,就能從官方的 Win32 metadata 產生LibraryImport簽章、常數與結構。3 - 字串要明確指定
StringMarshalling,避免使用StringBuilder。StringBuilder的封送一定會複製到原生緩衝區,效率不佳,而且容易在結尾處理上出錯。4 - 控制代碼要用
SafeHandle派生類別保存,而不是原始的IntPtr。 這是 .NET 原生互通的基本作法,能防止 GC 造成的過早釋放、重複釋放,以及「回收攻擊」。56 - 加上
SetLastError = true之後,要在呼叫後立即讀取Marshal.GetLastPInvokeError()。 必須在其他受管理程式碼的執行覆寫錯誤碼之前把它取出來。7 - 結構應以
LayoutKind.Sequential為預設,並意識到是否要明確指定Pack。Pack = 0(預設)的實際配置規則,和 C++ 編譯器/Zp的預設值(x86/ARM/ARM64 為 8,x64/ARM64EC 為 16)並不是同一件事,而且在 .NET Framework 與 .NET 5+ 之間也可能不同。「反正是預設值,應該沒問題」這種想法很危險。8 - 回呼(委派)要管理生命週期,避免在原生端還在使用時就被 GC 回收。 可以用
static欄位保存,或使用GC.KeepAlive,若可行則優先採用UnmanagedCallersOnly。9 - P/Invoke、C++/CLI 包裝、COM 互通不是互相競爭,而是各有分工。 單純的 C 介面用 P/Invoke,牽涉 C++ 類別或所有權、例外的用 C++/CLI,需要跨越處理程序邊界(如 32/64bit 橋接)的用 COM,這個判斷準則整理在第 10 章的表格中。
2. DllImport 與 LibraryImport — 該用哪一個
DllImport 是由來已久的機制,在執行時期由執行環境產生封送用的 IL stub,再交由 JIT 編譯後呼叫。由於產生過程發生在執行時期,與 Native AOT、修剪這類需要事先編譯組件的架構不太搭,產生成本本身也並非零。1
LibraryImport 是 .NET 7 新增的原始碼產生器,會針對 partial 方法在編譯時期產生封送程式碼。產生出來的程式碼以 C# 原始碼的形式存在,因此可以用偵錯器逐步執行,簽章上的錯誤也能在建置階段就提早偵測到。1
using System.Runtime.InteropServices;
internal static partial class NativeMethods
{
[LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
internal static partial string ToLower(string str);
}
這個 string 回傳值有一個容易被忽略的前提。封送器在複製完回傳指標所指向的字串後,一定會嘗試釋放那塊記憶體。 在 Windows 上使用的是 CoTaskMemFree,因此如果原生端不是用 CoTaskMemAlloc(而是用靜態緩衝區、malloc、new[] 等 C API 中常見的方式)配置這個指標,封送器就會用錯誤的配置器釋放記憶體,導致堆積損毀或當機。10 除非對方的標頭或文件明確表示配置方式與 CoTaskMemAlloc 相容,否則建議把回傳值改用 IntPtr 接收,並自行呼叫對應的釋放函式(或對方要求的釋放程序)。若由呼叫端事先配置緩衝區再傳入(前面提到的 StringBuilder 替代方案——字元陣列,或後面會提到的 [Out] 緩衝區模式),就完全不會引入這種所有權含糊不清的問題。
LibraryImport 與 DllImport 的主要差異如下。11
CharSet被廢除,改用StringMarshalling(Utf16/Utf8/ 自訂)。ANSI 被廢除,UTF-8 成為第一級選項。CallingConvention被UnmanagedCallConvAttribute取代。ExactSpelling與PreserveSig沒有對應項目。進入點名稱一律需要精確拼寫,回傳值的轉換也一律直接進行。- 類別與被呼叫的方法都必須是
partial,且專案需要開啟AllowUnsafeBlocks。
DllImport 現在仍然必要的場合,是需要用到 LibraryImport 不支援的設定(例如部分 MarshalAs 指定)。由於分析器在遇到不支援的設定時會以錯誤提示,實務上可以先寫 LibraryImport,被擋下來時再改回 DllImport。11
3. CsWin32 — 不用手寫簽章的選擇
一個一個手動撰寫 Win32 API 的 DllImport/LibraryImport 宣告,很容易在參數型別、常數值、結構欄位順序上出錯,而且風險會不斷累積。CsWin32(Microsoft.Windows.CsWin32)是一個原始碼產生器,能從官方提供的 Win32 API metadata,自動產生想呼叫函式的簽章、相關常數與結構。3
用法很單純,只要在專案加入 NuGet 套件,並在 NativeMethods.txt 這個文字檔中列出想呼叫的函式名稱即可。
GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle
建置時就會產生這些函式的 P/Invoke 簽章(包含回傳值、參數、SetLastError 指定)。要注意的是,預設是以傳統的 DllImport 為基礎產生。 若考慮 Native AOT、修剪,可以在 NativeMethods.json 指定 allowMarshaling: false,切換成以 LibraryImport 為基礎的原始碼產生。3 由於 HANDLE 會輸出成適當的 SafeHandle 派生型別,字串也會用正確的 CharSet/StringMarshalling 輸出,因此不會出現手寫時常見的 CharSet 弄錯或結構欄位順序錯誤。
正如C++/CLI 包裝發揮作用的場合一文所述,牽涉到 C++ 類別、所有權、例外的複雜 DLL,適合插入一層薄薄的包裝,但如果對方是單純的 Win32 API(或近似 C 介面的 DLL),用 CsWin32 自動產生簽章是最快、也最不容易出錯的路徑。雖然 CsWin32 無法用在自家的 DLL 上,但那種情況下仍可以參考產生出來的程式碼寫法作為範本。
4. 字串封送的陷阱
C#、VB、F# 編譯器對於沒有明確指定 CharSet 的 P/Invoke 宣告,預設會指派 CharSet.None。CharSet.None 的實際行為和 CharSet.Ansi 相同,在 Windows 上會以非 Unicode(本地化的字碼頁)方式封送。如果要呼叫的 Win32 API 是以 Unicode 版本(W 結尾)為前提,維持這個預設值呼叫就會出現亂碼或多位元組字元遺失的問題。12
在 LibraryImport 中,明確指定 StringMarshalling.Utf16 是基本作法。由於 ANSI 這個選項本身已被廢除,DllImport 時代常見的「交給預設值結果意外變成 ANSI」的事故,在結構上已經比較不容易發生。11
另一個陷阱是 StringBuilder 參數。這種寫法常見於「原生端寫入字串緩衝區後回傳」型別的 API,但 StringBuilder 的封送一定會產生對原生緩衝區的複製,ToString() 又會再多一次配置。如果緩衝區是 [Out](預設),每次呼叫都會累積多次配置,是相當沒效率的機制。此外,回傳的緩衝區若沒有以 NUL 結尾,或是雙重 NUL 結尾的字串,也容易造成誤判。若呼叫頻率高,改用來自 ArrayPool<char> 的字元陣列會更穩定。4
[Out] string 參數也是應避免的指定方式。如果字串是被 intern 過的,可能導致執行環境不穩定。4
5. 控制代碼的生命週期管理 — 為什麼要用 SafeHandle
檔案控制代碼、登錄檔金鑰、裝置控制代碼這類原生資源,以純粹的 IntPtr 保存,是 .NET 原生互通中應該避免的設計。理由有三個。5
- GC 造成的過早釋放。 若一個實作了完成項(finalizer)的類別把控制代碼存在
IntPtr欄位中,P/Invoke 呼叫進行期間,GC 可能回收該物件並關閉控制代碼,造成競爭。 - 控制代碼的回收攻擊。 Windows 會積極重複使用控制代碼值。如果在控制代碼已重新配置給其他資源的狀態下,還繼續使用舊的
IntPtr,就會導致操作到不相關資源的嚴重事故。 - 非同步例外導致的洩漏。 若像執行緒中斷這類非同步的中斷,發生在取得控制代碼與存入欄位之間,就可能造成控制代碼洩漏。
SafeHandle 就是為了解決這些問題而設計的抽象類別。它繼承自 CriticalFinalizerObject,即使在 AppDomain 異常終止時,也保證釋放處理一定會被執行。P/Invoke 呼叫會自動增減控制代碼的參照計數,因此呼叫過程中控制代碼也不會被回收再利用。5
自行實作時,通常會繼承 Microsoft.Win32.SafeHandles 命名空間中的 SafeHandleZeroOrMinusOneIsInvalid 等類別,並覆寫 ReleaseHandle()。由於 ReleaseHandle() 執行於「前提是不能失敗」的條件約束執行區域,因此定則是不要寫複雜的邏輯,只做單純的釋放 API 呼叫。不需要(甚至應該避免)自己撰寫完成項。6
6. 錯誤處理 — SetLastError 與 GetLastPInvokeError
多數 Win32 API 在失敗時會透過 SetLastError 設定執行緒本地的錯誤碼,呼叫端則用 GetLastError 讀取。要在 P/Invoke 中處理這個機制,需要把 DllImportAttribute.SetLastError(LibraryImport 也有同名屬性)設為 true。13
[LibraryImport("kernel32", EntryPoint = "SetCurrentDirectoryW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetCurrentDirectoryW(string path);
這裡有兩點要特別注意。
- 錯誤碼要在呼叫後立即讀取。 在 .NET(.NET Framework 除外)中,每次呼叫
SetLastError = true的 P/Invoke,錯誤資訊都會先被清除,只保留這一次呼叫的結果。如果中間插入了記錄輸出或其他 API 呼叫,就會被覆寫而遺失,因此偵測到失敗時要立即取值。13 - 要用
Marshal.GetLastPInvokeError(),而不是Marshal.GetLastWin32Error()。 在 .NET 6 之後這兩者功能上相同,但後者作為反映跨平台意圖的新名稱受到推薦。7
if (!SetCurrentDirectoryW(path))
{
int error = Marshal.GetLastPInvokeError();
throw new Win32Exception(error);
}
7. 結構封送 — 可位元複製型別與 StructLayout
.NET 與原生程式碼中位元表示方式相同的型別稱為「可位元複製(blittable)」,因為不需要轉換就能直接傳遞,所以速度快。byte、int、long 等基本型別,以及只由可位元複製的值型別構成的固定配置結構都屬於這一類。可位元複製的結構應該用 C# 的 sizeof(),而不是 Marshal.SizeOf<T>(),速度會更快。相反地,bool 並不是可位元複製的(原生的 BOOL 是 4 位元組,C/C++ 的 bool 是 1 位元組,兩者不同),若不特別留意,容易埋下回傳值被砍半丟棄的 bug。14
結構的配置由 StructLayoutAttribute 控制。預設使用 LayoutKind.Sequential(依宣告順序配置),只有像共用體(union)那樣需要明確指定欄位位置時,才使用 LayoutKind.Explicit。8
容易被忽略的是 Pack 欄位。根據官方文件,型別整體的對齊,取「最大欄位的大小」與「指定的 Pack 值」兩者中較小的一個;各欄位則取「自身的大小」與「型別的對齊」兩者中較小的一個來配置。8 也就是說,如果把 Pack 明確設為較小的值(例如 2 或 4),就會像 C++ 的 #pragma pack(N) 一樣作為對齊的上限。另一方面,預設值 0 的意思是「型別整體的對齊等於最大欄位的大小(不設額外上限)」,這和 C++ 編譯器 /Zp 選項(結構成員對齊。x86、ARM、ARM64 預設為 8 位元組邊界,x64、ARM64EC 預設為 16 位元組邊界)的預設值是不同的規則,不能單純視為同一件事。15 而且這個預設配置在 .NET Framework 與 .NET 5+ 之間也可能不同。舉例來說,官方文件中提到,包含 decimal 的結構,由於內部欄位結構的差異,預設封裝下的大小在 .NET Framework 是 28 位元組,在 .NET 5+ 則是 32 位元組。8 換句話說,「反正是預設值,架構上應該都一致」這種想法很危險。如果原生端的標頭用 #pragma pack 明確改變了封裝大小,或包含要求超過 8 位元組對齊的欄位,就應該在 C# 端明確指定 Pack,或用 Marshal.OffsetOf 等方式驗證實際的欄位位移後再使用。若疏忽這一點,欄位位移就會錯開,變成資料悄悄損毀的事故。反過來說,如果是直接使用 Windows SDK 標頭、欄位都是 8 位元組以下基本型別的單純 API,即使不去動 Pack、維持預設對齊,實務上幾乎不會出問題。
// 原生端標頭明確指定 pack(4) 的例子
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
public int DeviceId;
public uint Flags;
public long Timestamp;
}
8. 回呼(委派)的生命週期管理
把「完成後請呼叫這個函式」的回呼傳給原生 API 的場景並不少見。受管理程式碼中由 delegate 負責這個角色,但這裡有一個 GC 特有的陷阱。即使用 Marshal.GetFunctionPointerForDelegate 從委派取得函式指標,GC 並不會追蹤這個函式指標與委派之間的關聯。如果原生端還在使用該函式指標,而委派卻已被回收,就會導致當機。9
另一個容易被忽略的是呼叫慣例。透過 P/Invoke 把委派以函式指標傳給原生端時,預設會使用「平台的預設呼叫慣例」,若要明確指定一致的慣例,可以在委派型別上加上 UnmanagedFunctionPointerAttribute。16 在 x64/ARM/ARM64 上,呼叫慣例實際上只有一種,不特別留意也很少造成實際損害;但在 Windows x86(32bit)上,Stdcall(Win32 API 的預設)與 Cdecl(常見於 Unix 系 C 函式庫)是不同的,如果對方的標頭用的是 Cdecl,卻維持預設值,就會導致堆疊損毀。16
// 明確指定呼叫慣例。x86 建置中若對方用 Cdecl,這裡是必須的
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MyCallback(int code);
private static readonly MyCallback s_callback = OnNativeEvent; // 以static保存以確定生命週期
// [UnmanagedFunctionPointer]是回呼「被呼叫」時的慣例,
// 這次呼叫本身(RegisterCallback這個P/Invoke)的慣例是另一件事。
// LibraryImport的預設是平台預設值(在Windows上相當於stdcall),
// 若對方是Cdecl的C DLL,這裡也需要明確指定
[LibraryImport("nativelib")]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial void RegisterCallback(MyCallback callback);
private static void OnNativeEvent(int code)
{
// ...
}
// 呼叫端
RegisterCallback(s_callback);
GC.KeepAlive(s_callback); // 明確保持一個可能立刻脫離範圍的變數存活
只要保存在 static 欄位中,應用程式存活期間就不會被 GC 回收。如果能確定原生端只在一次呼叫中使用該回呼(回呼一回來就會捨棄函式指標),也可以用區域變數搭配 GC.KeepAlive 這種較輕量的寫法來延長生命週期。
官方最佳實踐建議,若可行,應優先使用 標示 UnmanagedCallersOnlyAttribute 的靜態方法與函式指標(delegate*<...>),而不是 Delegate 型別。相比委派封送,這種方式的額外負擔更小,也與 Native AOT 相容性更好。9
9. 32bit/64bit 的差異
只要撰寫一份 P/Invoke 簽章,在執行時期無論是 32bit 或 64bit 處理程序都會用到同一份程式碼路徑。這裡容易出問題的地方在於,原生端型別的寬度會跟著處理程序的位元數變化。
HANDLE、HWND、LPARAM這類指標型型別,在 32bit 處理程序中是 4 位元組,在 64bit 處理程序中是 8 位元組。.NET 端應該用IntPtr/UIntPtr(或nint/nuint)接收才正確,若用固定大小的int/long接收,就會變成只能在 32bit 或 64bit 其中一邊運作的程式碼。4- 如果結構包含上述指標型欄位,整個結構的大小也會因位元數而變化。加上第 7 章提到的
Pack預設值在不同架構下會不同,同一份結構定義在 32bit 建置與 64bit 建置中,binary 配置可能不同,測試時要以此為前提。 - 想從 32bit 的既有應用呼叫只能在 64bit 運作的 DLL 功能,這個需求本身無法靠 P/Invoke 解決(同一個處理程序中無法共存不同位元數的 DLL)。這種情況需要分離處理程序,設計成透過 COM 橋接或具名管道搭橋。實例請參考從 32bit 應用呼叫 64bit DLL 的 COM 橋接實例。
- DLL 根本找不到、或載入了非預期版本的問題,並不是 P/Invoke 的議題,而是 Windows 載入器的議題。Windows DLL 名稱解析的機制整理了搜尋順序與 SxS 的行為,調查
DllNotFoundException的原因時可以一併參考。
10. 判斷表 — P/Invoke vs C++/CLI 包裝 vs COM 互通
從 C# 呼叫原生程式碼的手段不只有 P/Invoke。如果對方是帶有 C++ 類別、所有權、例外的複雜 DLL,C++/CLI 包裝比較合適;若要跨越處理程序邊界(32/64bit 橋接、供 VBA 等其他語言使用),COM 就會成為選項。
| 觀點 | P/Invoke(LibraryImport) | C++/CLI 包裝 | COM 互通 |
|---|---|---|---|
| 適合的對象 | 單純的 C 介面(以結構、基本型別為主) | 牽涉 C++ 類別、所有權、例外、std:: 型別的 DLL |
需要跨處理程序的對象、VBA 等其他語言 |
| 實作成本 | 低~中(只需定義簽章) | 中(需再寫一層包裝) | 高(介面設計、登錄檔登錄) |
| 型別安全性 | 中(手寫時簽章錯誤可能要到執行時期才會發現,CsWin32 可改善) | 高(可直接使用 C++ 的型別) | 中(由 IDL/型別庫保證) |
| AOT/修剪對應 | ◎(若用 LibraryImport) | △(C++/CLI 不支援 Native AOT) | △ |
| 例外處理 | ✕(要靠回傳值或 HRESULT 自行判定) | ◎(可將 C++ 例外轉換為 .NET 例外) | ○(HRESULT 會轉換為 COM 例外) |
| 跨處理程序邊界 | ✕(僅限同一處理程序內) | ✕(僅限同一處理程序內) | ◎(可做成跨處理程序伺服器) |
| 偵錯便利性 | ○(LibraryImport 產生的程式碼可逐步執行) | ○(可用 VS 同時偵錯原生與受管理程式碼) | △(參照計數或登錄相關的問題較難追蹤) |
| 學習成本 | 低 | 中~高(C++/CLI 語法) | 高(COM 整套規約) |
如果「對方是 C 函式為基礎的 Win32 API,或自家單純的 C DLL」,就用 P/Invoke(可能的話搭配 CsWin32);如果「對方是 C++ 類別,連所有權、例外都想自然地往來」,就用 C++/CLI 包裝(詳見從 C# 呼叫原生 DLL:C++/CLI 包裝 vs P/Invoke);如果「本來就要跨處理程序,或想讓 VBA 也能用」,就用 COM,照這個順序想就不會迷路。反方向(想從 C/C++ 呼叫 C# 的處理)則不是用 P/Invoke,而是用 Native AOT 的 UnmanagedCallersOnly 這種架構。請參考從 C/C++ 呼叫 C# Native AOT DLL 的方法。
11. 實作範例 — 用 LibraryImport 操作控制代碼與處理錯誤
以下是結合前面內容的實作範例。假設一個虛構的感測器裝置 SDK device.dll,公開了 OpenDevice / CloseDevice / ReadDeviceData,我們用 SafeHandle 管理控制代碼、LibraryImport 進行編譯時期封送、SetLastError + GetLastPInvokeError 處理錯誤,把它們包裝起來。
首先是保存原生控制代碼的 SafeHandle 派生類別。
using Microsoft.Win32.SafeHandles;
// 包裝 device.dll 的控制代碼。與 GC 的生命週期分開,
// 防止控制代碼的重複釋放、回收攻擊、過早釋放
internal sealed class DeviceSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
// 因為會作為 OpenDevice 的回傳值使用,需要無參數建構函式
public DeviceSafeHandle() : base(ownsHandle: true)
{
}
protected override bool ReleaseHandle()
// ReleaseHandle 內部是「前提是不能失敗」的條件約束執行區域。
// 只做單純的原生釋放呼叫
=> DeviceNativeMethods.CloseDevice(handle);
}
接著是 P/Invoke 宣告。字串明確指定 StringMarshalling.Utf16,所有可能失敗的呼叫都加上 SetLastError = true。
using System.Runtime.InteropServices;
internal static partial class DeviceNativeMethods
{
private const string DeviceDll = "device.dll";
// 把控制代碼當成回傳值,一旦呼叫成功,SafeHandle就會開始
// 追蹤生命週期。失敗時回傳的是IsInvalid為true的控制代碼
[LibraryImport(DeviceDll, EntryPoint = "OpenDevice",
StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
internal static partial DeviceSafeHandle OpenDevice(string devicePath);
// 供SafeHandle的ReleaseHandle直接呼叫的內部API。
// handle只用於釋放,因此用原始IntPtr接收
[LibraryImport(DeviceDll, EntryPoint = "CloseDevice", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool CloseDevice(IntPtr handle);
// buffer是呼叫端事先配置好的陣列。byte[]是可位元複製的,因此會被固定(pin),
// 原生端的寫入會針對同一塊記憶體進行。明確標示[Out]本身並非
// 必須,但可以自我說明意圖,所以保留
[LibraryImport(DeviceDll, EntryPoint = "ReadDeviceData", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool ReadDeviceData(
DeviceSafeHandle handle,
[Out] byte[] buffer,
int bufferLength,
out int bytesRead);
}
最後是使用這些方法的薄包裝層。錯誤碼會在偵測到失敗的當下立即取得,包成 Win32Exception 傳給呼叫端。
using System.ComponentModel;
using System.Runtime.InteropServices;
public sealed class DeviceConnection : IDisposable
{
private readonly DeviceSafeHandle _handle;
private DeviceConnection(DeviceSafeHandle handle) => _handle = handle;
public static DeviceConnection Open(string devicePath)
{
DeviceSafeHandle handle = DeviceNativeMethods.OpenDevice(devicePath);
if (handle.IsInvalid)
{
// 在被其他API呼叫覆寫之前,於失敗後立即取得
int error = Marshal.GetLastPInvokeError();
handle.Dispose();
throw new IOException(
$"無法開啟裝置: {devicePath} (Win32 error {error})",
new Win32Exception(error));
}
return new DeviceConnection(handle);
}
public byte[] Read(int maxBytes)
{
var buffer = new byte[maxBytes];
if (!DeviceNativeMethods.ReadDeviceData(_handle, buffer, buffer.Length, out int bytesRead))
{
int error = Marshal.GetLastPInvokeError();
throw new IOException($"讀取裝置失敗 (Win32 error {error})",
new Win32Exception(error));
}
return bytesRead == buffer.Length ? buffer : buffer[..bytesRead];
}
// 只需呼叫SafeHandle.Dispose,不需要撰寫完成項
public void Dispose() => _handle.Dispose();
}
使用 DeviceConnection 的一方只要用 using 包起來就好,不需要擔心遺漏釋放控制代碼。至於在這個架構中要在哪裡偵測什麼、如何轉換,這個原則和「例外處理中 catch 與記錄該放在哪裡」一文提到的分層職責劃分完全適用。在 P/Invoke 邊界把原生層的錯誤碼翻譯成例外,往上的層級則以一般的 .NET 例外來處理,這條界線就是在這裡劃分的。
12. 總結
P/Invoke 在「宣告 DLL 的函式就能呼叫」這種簡便性的背後,是一項在字串封送、控制代碼的生命週期、錯誤碼取得時機、結構配置這幾個地方,遲早會踩到坑的技術。.NET 7 之後應以 LibraryImport 為預設,若可行則交給 CsWin32 產生簽章本身。字串要明確指定 StringMarshalling,避免使用 StringBuilder。控制代碼要用 SafeHandle 保存。用了 SetLastError 就要在呼叫後立即取得錯誤碼。結構要意識到 Pack 的預設值在不同架構下會不同。回呼要明確管理生命週期 —— 本文列出的這些要點,都是「知道的話幾行就能解決,不知道的話就會變成只在正式環境才會重現的錯誤」這種類型的問題。
而該用 P/Invoke 硬撐下去,還是改用 C++/CLI 包裝或 COM,取決於對方的 DLL 有多「C 味」,以及是否需要跨越處理程序邊界。想從 C# 呼叫既有的原生資產,或反過來想從原生呼叫 C# 的資產,這類需求往往要看過實際的標頭檔或 DLL 結構才能判斷最佳架構,若拿不定主意,歡迎與我們討論。
相關文章
- 從 C# 呼叫原生 DLL:C++/CLI 包裝 vs P/Invoke
- 從 C/C++ 呼叫 C# Native AOT DLL 的方法
- 從 32bit 應用呼叫 64bit DLL 的 COM 橋接實例
- Windows DLL 名稱解析的機制 - 搜尋順序與 SxS
相關顧問服務
合同會社小村軟體(KomuraSoft LLC)處理 C# 與原生 DLL、Win32 API 之間的邊界設計、COM 元件的開發與調查,以及銜接既有原生資產與 .NET 的移轉專案技術顧問。
參考連結
-
Microsoft Learn, Source generation for platform invokes。關於 LibraryImportAttribute 在編譯時期產生封送程式碼、與 DllImport 執行時期產生 IL stub 的差異、與 Native AOT/修剪的相容性。 ↩ ↩2 ↩3
-
Microsoft Learn, SYSLIB diagnostics for p/invoke source generation。關於促使將 DllImport 改寫為 LibraryImport 的分析器 SYSLIB1054 及其他診斷 ID 清單。 ↩
-
Microsoft Learn, Build a C# .NET app with WinUI 3 and Win32 interop。關於 C#/Win32 P/Invoke Source Generator(Microsoft.Windows.CsWin32)的導入方法,以及在 NativeMethods.txt 列出函式名稱以產生簽章的步驟。 ↩ ↩2 ↩3
-
Microsoft Learn, Native interoperability best practices。關於 StringBuilder 封送一定會複製到原生緩衝區而效率不佳、應避免 [Out] string 引數、應使用 SafeHandle 並避免完成項。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, SafeHandle Class。關於 SafeHandle 防止控制代碼過早釋放、回收攻擊的機制,以及 CriticalFinalizerObject 帶來的確實釋放保證。 ↩ ↩2 ↩3
-
Microsoft Learn, Native interoperability best practices - General guidance。關於非受管理資源的生命週期管理應使用 SafeHandle,並避免使用完成項的指引。 ↩ ↩2
-
Microsoft Learn, Marshal.GetLastPInvokeError Method。關於設定 SetLastError=true 的 P/Invoke 呼叫後如何取得錯誤碼,以及 .NET 6 之後比 GetLastWin32Error 更受推薦。 ↩ ↩2
-
Microsoft Learn, StructLayoutAttribute.Pack Field。關於 Pack 預設值 0 所代表「目前平台的預設封裝大小」的意義,以及欄位對齊的計算規則。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Native interoperability best practices - Prevent delegate collection with GC.KeepAlive。關於 GC 不會追蹤 GetFunctionPointerForDelegate 取得的函式指標與委派之間的關聯、用 GC.KeepAlive 延長生命週期,以及建議使用 UnmanagedCallersOnly。 ↩ ↩2 ↩3
-
Microsoft Learn, Default Marshalling Behavior - Memory management with the interop marshaller。關於封送器一定會嘗試釋放非受管理程式碼配置的記憶體、Windows 上使用 CoTaskMemFree、非 CoTaskMemAlloc 配置的記憶體需要用 IntPtr 手動釋放。 ↩
-
Microsoft Learn, Source generation for platform invokes - Differences from DllImport。關於 CharSet 被 StringMarshalling 取代、CallingConvention 改用 UnmanagedCallConvAttribute、ExactSpelling/PreserveSig 沒有對應項目。 ↩ ↩2 ↩3
-
Microsoft Learn, Charsets and marshalling。關於未明確指定 CharSet 時 C#、Visual Basic、F# 編譯器預設會指派 CharSet.None,以及 CharSet.None 與 CharSet.Ansi 行為相同(以非 Unicode 方式封送)。 ↩
-
Microsoft Learn, DllImportAttribute.SetLastError Field。關於將 SetLastError 設為 true 時在 .NET 上的行為(每次呼叫錯誤資訊都會被清除)。 ↩ ↩2
-
Microsoft Learn, Native interoperability best practices - Blittable types。關於可位元複製型別的定義、bool 不是可位元複製型別帶來的陷阱,以及在可位元複製結構上使用 sizeof() 的好處。 ↩
-
Microsoft Learn, /Zp (Struct Member Alignment)。關於 C++ 編譯器結構成員對齊的預設值在 x86/ARM/ARM64 為 8 位元組邊界、x64/ARM64EC 為 16 位元組邊界。 ↩
-
Microsoft Learn, Unmanaged calling conventions。關於 Windows x86 上 Stdcall 與 Cdecl 是不同的預設呼叫慣例、x64/ARM/ARM64 上呼叫慣例實際上只有一種,以及可用 UnmanagedFunctionPointerAttribute 明確指定呼叫慣例。 ↩ ↩2
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
用 Native AOT 把 C# 做成原生 DLL 的方法 - 用 UnmanagedCallersOnly 從 C/C++ 呼叫
從現有 C/C++ 應用程式以 in-process 方式呼叫 C# 邏輯時,本文示範以 Native AOT 將類別庫發佈為原生 DLL,並用 UnmanagedCallersOnly 公開 cdecl 進入點。透過 handle、錯誤碼與扁平 C ABI 設計交界面,整...
把OSI參考模型看得明明白白 ── 解剖一個HTTP請求的七層結構
不靠死記硬背來理解OSI參考模型,而是看真實的東西。本文用C#組出一個承載HTTP GET請求的Ethernet訊框並加以解剖,透過十六進位傾印和Wireshark確認L2〜L7如何以位元組序列的形式一層層物理地嵌套在一起。文章從實務角度整理各層與.NET API(Http...
如何理解 Windows 的工作階段隔離 ── Session 0・RDP・多使用者同時執行
整理 Windows 應用程式開發者容易混淆的「工作階段」概念。說明服務無法顯示 UI 的 Session 0 隔離原因、RDP 連線時工作階段的行為、具名物件的工作階段隔離,以及共用 PC・RDS 環境常見的設計失誤,從實務角度解說。
防止 Windows 應用程式重複啟動 ── 具名 Mutex 與重複執行時的視窗前置
整理業務用 Windows 應用程式的常見需求「不讓同一個應用程式啟動兩次」,如何用具名 Mutex 實作。涵蓋 Global\ 與 Local\ 命名空間差異在 RDP 環境中的陷阱、擁有執行緒限制與 AbandonedMutexException、考量 SetForeg...
在 WinForms/WPF 應用程式中導入 Entra ID 驗證 ── MSAL.NET 與 WAM 代理的實務架構
從實務角度整理在 WinForms/WPF 桌面應用程式中導入 Entra ID(原 Azure AD)驗證的做法。內容涵蓋公用客戶端的概念、ROPC 廢除現況、應用程式註冊、MSAL.NET 的 AcquireTokenSilent 模式、WAM 代理、權杖快取持久化,以...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
32 位元 / 64 位元互通
整理 32 位元 / 64 位元互通、原生邊界與相關 Windows 設計判斷的主題頁面。
常見問題
整理諮詢這個主題時常見的問題。
- DllImport 和 LibraryImport 該用哪一個?
- .NET 7 之後應以 LibraryImport 為預設。DllImport 是在執行時期由執行環境產生封送用的 IL stub,而 LibraryImport 則由原始碼產生器在編譯時期產生封送程式碼,因此能對應 Native AOT、修剪(trimming),且產生的程式碼可以用偵錯器逐步執行。分析器 SYSLIB1054 會告訴你哪些 DllImport 該改寫。只有在需要 LibraryImport 不支援的設定(例如部分 MarshalAs 指定)時才回頭用 DllImport。
- 為什麼用 IntPtr 保存控制代碼在 P/Invoke 中很危險?
- 有三個問題。第一,P/Invoke 呼叫進行期間,GC 可能回收物件並關閉控制代碼,造成過早釋放的競爭。第二,Windows 會積極重複使用控制代碼值,導致用已經關閉的控制代碼值去操作到不相關資源的回收攻擊。第三,非同步例外可能造成控制代碼洩漏。使用 SafeHandle 派生類別,可以透過自動的參照計數管理與確實的釋放保證來防止這些問題。
- 有沒有不用手寫 Win32 API 簽章的方法?
- 可以使用 CsWin32(Microsoft.Windows.CsWin32)這個原始碼產生器。只要加入 NuGet 套件,在 NativeMethods.txt 這個文字檔中列出想呼叫的函式名稱,就能從官方的 Win32 metadata 自動產生簽章、常數與結構。HANDLE 會輸出成適當的 SafeHandle 派生型別,因此不會發生手寫時常見的 CharSet 弄錯或欄位順序錯誤。預設是以 DllImport 為基礎產生,若考慮 Native AOT,可在 NativeMethods.json 指定 allowMarshaling: false。
- P/Invoke 和 C++/CLI 包裝、COM 該怎麼區分使用?
- 取決於對方 DLL 的性質與是否需要跨越處理程序邊界。單純的 C 介面(以結構、基本型別為主)最適合用 P/Invoke,若是 Win32 API 則搭配 CsWin32 效果更好。若牽涉到 C++ 的類別、所有權、例外、std:: 型別的複雜 DLL,就用一層 C++/CLI 包裝。像 32bit/64bit 橋接那樣要跨越處理程序邊界,或要讓 VBA 等其他語言使用時,COM 是選項。同一個處理程序中無法共存不同位元數的 DLL,這個需求光靠 P/Invoke 無法解決。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。