從 C# 呼叫原生 DLL:C++/CLI 包裝層 vs P/Invoke
· 更新日期: · Go Komura · C++/CLI, C#, Windows 開發, 原生整合
更新紀錄(1 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
- 已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.21616238)
- 初次發布
引用本文(DOI: 10.5281/zenodo.21616237)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
Go Komura(2026)。〈從 C# 呼叫原生 DLL:C++/CLI 包裝層 vs P/Invoke〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616237 https://comcomponent.com/zh-TW/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/
- DOI(最新版本)
- 10.5281/zenodo.21616237
- DOI(此版本)
- 10.5281/zenodo.22297067
想從 C# 使用 Windows 的既有資產或既有 DLL,這樣的需求相當常見。 如果對方是像 Win32 API 那樣直接了當的 C 介面,P/Invoke 就夠了。
不過,實務上碰到的是更有脾氣的 DLL。
裡面有 C++ 類別,有自己一套所有權的規矩,例外也會往外拋,std::wstring 和 std::vector 更是家常便飯。
在這裡只靠 P/Invoke 硬撐,邊界通常會愈做愈辛苦。
本文要寫的是:這種時候 用 C++/CLI 插一層薄的包裝層 會讓哪些事情變輕鬆。 這不是在說 P/Invoke 不好,重點是 P/Invoke 就夠用的場合,和 C++/CLI 才派得上用場的場合並不一樣。
另外,本文出現的程式碼片段,已經整理成可以建置的完整範例(原生 C++ 函式庫、C API 橋接層、C++/CLI 包裝層,以及 P/Invoke 版與 C++/CLI 版的 C# 呼叫端程式碼)公開在 GitHub 上。
cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)
目標讀者與前提
本文寫給這樣的開發者:已經從 C# 呼叫過原生 DLL,也寫得出 DllImport 宣告,但一碰到對方是 C++ 類別庫就卡住,不知道下一步該怎麼走。沒碰過 C++/CLI 也沒關係。反過來說,如果還沒寫過 P/Invoke,先讀「在 C# 中安全呼叫 Win32 API ── P/Invoke 實務指南」會比較快。
假設的環境是 Windows 加上 Visual Studio 2022,可以把 C++/CLI 包裝層(.vcxproj)和 C# 專案放在同一個方案裡。目標可以是 .NET Framework,也可以是 .NET 8 等版本,但 .NET 這一側有它特有的限制,整理在第 7 章。
先掌握的用語
| 用語 | 意思 |
|---|---|
| P/Invoke(Platform Invoke) | 用 C# 的 DllImport / LibraryImport 屬性(attribute)宣告原生 DLL 的匯出函式,直接呼叫它的機制 |
| 封送處理(marshalling) | 在邊界上,把 .NET 的型別(string、陣列等)與原生的表示法(wchar_t*、原始指標等)互相轉換 |
| ABI(Application Binary Interface) | 呼叫慣例、引數的傳遞方式、結構在記憶體中的排列、名稱修飾等,讓編譯完成的二進位檔彼此接得起來的約定。C 的函式約定單純又穩定,但 C++ 的類別在名稱修飾與 vtable 的排列上取決於編譯器,C# 無法直接依賴(5.4) |
SafeHandle |
包住原生控制代碼的 .NET 抽象類別。為了避免控制代碼漏放,以及「使用中被釋放」的事故,用它取代裸著帶來帶去的 IntPtr(6.2) |
StructLayout |
讓 C# 結構在記憶體中的排列對齊原生側的屬性。用途包括以 LayoutKind.Sequential 依宣告順序排列、以 CharSet 指定字串的處理方式等(6.2) |
marshal_as |
C++/CLI 提供的轉換輔助工具。像 marshal_as<std::wstring>(managedString) 這樣,在 .NET 的型別與原生的型別之間互相轉換。使用時要引入 msclr/marshal_cppstd.h 等標頭(6.3) |
| 混合組件 | 同時含有原生機器碼指令與 MSIL 的 DLL。C++/CLI 包裝層就是這種東西(第 7 章) |
目錄
- 先下結論(一句話)
- P/Invoke 就夠用的場合
- P/Invoke 突然變辛苦的邊界
- 插一層 C++/CLI 包裝層的架構
- 用 C++/CLI 會輕鬆在哪裡
- 程式碼片段
- 即使如此也不該選 C++/CLI 的場合
- 總結
- 參考資料
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 21 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
1. 先下結論(一句話)
- 對方是 C 的函式群,就用 P/Invoke 最直接了當
- 對方是 C++ 函式庫,插一層 C++/CLI 包裝層會比較好維護
- 特別是牽涉到 類別、所有權、字串、陣列、例外、回呼 時,別讓 C# 這一端硬撐
說到底,就是 別把原生 DLL 的內情直接帶進 C#。 原生的內情由 C++ 這一側接住,只把要給 .NET 看的那一面整理好。 這樣分工做得好,程式碼和偵錯都會平順許多。
flowchart TB
accTitle: 依對方 DLL 的形態決定的取捨
accDescr: 以分支呈現本文的結論,也就是對方是 C 的函式群時 P/Invoke 最直接了當,是 C++ 函式庫時插一層 C++/CLI 包裝層比較好維護的圖。
q{"對方的 DLL 是哪一種"}
q -->|"C 的函式群"| pi["P/Invoke 最直接了當"]
q -->|"C++ 函式庫"| cli["插一層 C++/CLI 包裝層"]
cli -.-> note["原生的內情由 C++ 這一側接住"]
圖 1: 取捨的結論。對方是 C 的函式群就用 P/Invoke,是 C++ 函式庫就用 C++/CLI 包裝層。
2. P/Invoke 就夠用的場合
如果 P/Invoke 就能解決,那是最簡單的。沒必要硬把 C++/CLI 搬進來。
P/Invoke 適合的,例如是這樣的場合。
- 以
extern "C"公開,是扁平的函式 API - 引數與傳回值用整數、指標、單純的結構就夠
- 字串的約定明確,緩衝區的職責也單純
- 資源管理像
Create/Destroy那樣清楚 - C# 這一側可以直接了當地寫出
SafeHandle或StructLayout
整齊到這個程度,在 C# 這一側宣告後直接用就行了,感覺接近呼叫 Windows API,實作也比較好讀。
flowchart TB
accTitle: 判斷 P/Invoke 就夠用的條件
accDescr: 呈現只要湊齊扁平的 C API、單純的引數與傳回值、明確的字串與資源約定這些條件,在 C# 這一側宣告後直接用就行的圖。
c1["extern C 的扁平函式 API"] --> ok["P/Invoke 就夠用"]
c2["引數、傳回值單純"] --> ok
c3["字串、資源的約定明確"] --> ok
ok --> use["在 C# 這一側宣告後直接用"]
圖 2: 對方的 API 整齊到這個程度,就沒必要硬把 C++/CLI 搬進來。
3. P/Invoke 突然變辛苦的邊界
問題出在對方不只是「單純的 C API」的時候。 從這裡開始,氣氛急轉直下。
3.1. 開始要面對 C++ 類別時
原生 DLL 若以 C++ 類別為核心設計,其實真正想呼叫的是類別的方法,但 P/Invoke 能直接面對的只有 DLL 的匯出函式。 也就是說,最後還是得在某處放一層 把它壓成 C 形式函式 的東西。
到了這一步,做的事情幾乎就是「寫包裝層」。
既然如此,與其在 C# 這一側長出一堆 IntPtr 和釋放函式,把包裝層收攏到 C++ 這一側更自然。
flowchart TB
accTitle: 面對 C++ 類別卻選 P/Invoke 的結果
accDescr: 呈現就算想呼叫 C++ 類別的方法,P/Invoke 能面對的也只有 DLL 的匯出函式,因此需要一層壓成 C 形式的東西,做的事情實質上就是在寫包裝層的流程的圖。
want["想呼叫 C++ 類別的方法"] --> limit["能呼叫的只有匯出函式"]
limit --> bridge["需要一層壓成 C 形式函式"]
bridge --> fact["做的事情幾乎就是包裝層"]
fact -.-> better["那就收攏到 C++ 這一側更自然"]
圖 3: 就算想用 P/Invoke 硬撐,面對 C++ 類別,最後還是得在某處寫包裝層。
3.2. 所有權與生命週期管理看不清楚時
在 C++ 裡,
- 是呼叫端負責釋放嗎
- 傳回的指標是借來的嗎
- 是
const&,還是所有權轉移 - 內部有沒有快取,對生命週期有沒有前提
這類事情是家常便飯。
把這些拿 C# 的 IntPtr 來表達,一開始能動,之後回頭讀會相當吃力。
一旦開始出現「這個指標,到底誰在什麼時候刪掉」的問題,邊界馬上就混濁了。
flowchart TB
accTitle: 用 IntPtr 表達所有權前提時的混濁
accDescr: 呈現把誰負責釋放、是借來的還是所有權轉移、生命週期有沒有前提這些 C++ 這一側的事情,用 C# 的 IntPtr 來表達後,之後回頭讀會吃力,邊界也會混濁的圖。
q1["誰負責釋放"] --> ptr["用 C# 的 IntPtr 表達"]
q2["是借來的還是所有權轉移"] --> ptr
q3["生命週期有沒有前提"] --> ptr
ptr --> bad["一開始能動之後讀不懂"]
bad --> muddy["邊界馬上混濁"]
圖 4: 把所有權與生命週期的前提用 IntPtr 帶來帶去,就會因為「誰在什麼時候刪掉」的問題讓邊界混濁。
3.3. std::wstring、std::vector、回呼、例外開始出現時
從這一帶開始,P/Invoke 進入「不是寫不出來,但寫起來不舒服」的區域。
- 想把
std::wstring原樣在 C# 表達出來 - 想傳回
std::vector<T> - 想用回呼接收原生處理的進度
- 失敗時會拋出 C++ 例外
這類要素一多,C# 這一側就會冒出 MarshalAs、手動緩衝區、固定長度陣列、委派的生命週期管理、錯誤碼的解讀等等。
當然,肯下工夫還是寫得出來。 只是 下工夫的地方並不是本質,這才是難受的地方。 本來想做的是業務邏輯或 UI,而不是在邊界上比拚格鬥技。
flowchart TB
accTitle: C++ 要素增加與 C# 這一側負擔的累積
accDescr: 呈現想傳回 wstring 或 vector、想用回呼接收進度、會拋出 C++ 例外這些要素愈多,C# 這一側的 MarshalAs、手動緩衝區、委派生命週期管理就愈堆愈高的圖。
e1["想傳回 wstring 或 vector"] --> pile["C# 這一側的程式碼愈堆愈多"]
e2["想用回呼接收進度"] --> pile
e3["失敗時拋出 C++ 例外"] --> pile
pile --> load["MarshalAs、手動緩衝區"]
pile --> load2["委派生命週期管理、錯誤解讀"]
圖 5: C++ 風格的要素每多一個,C# 這一側邊界程式碼的負擔就多疊一層。
3.4. 不想把 C++ 的內情漏到 C# 時
原生 DLL 這一側的 API,未必原樣就適合給 C# 用。
例如原生這一側就算是這樣設計的:
- 把好幾次方法呼叫組合成一次處理
- 錯誤用傳回值加 out 引數回報
- 初始化順序有前提
- 執行緒安全上有限制
也常常會想給 C# 這一側看更直接了當的 API。 作為在這裡做轉換的一層,C++/CLI 相當好用。
flowchart TB
accTitle: 轉換原生內情後給 C# 看的一層
accDescr: 呈現由 C++/CLI 的轉換層接住初始化順序、執行緒安全限制等原生 API 的設計內情,再給 C# 這一側看更直接了當的 API 的職責劃分的圖。
nat["原生 API 的設計內情"] -.-> ex["初始化順序、執行緒限制等"]
nat --> conv["C++/CLI 的轉換層"]
conv --> api["給 C# 看直接了當的 API"]
圖 6: 不把原生這一側的設計內情直接對著 C#,而是插一層 C++/CLI 當作轉換的層。
4. 插一層 C++/CLI 包裝層的架構
架構本身很單純。
flowchart LR
Cs[C# 應用程式] -->|給 .NET 用的 API| Wrapper[C++/CLI 包裝層 DLL]
Wrapper -->|直接處理原生的標頭與型別| Native[原生 C++ DLL]
圖 7: 在 C# 應用程式與原生 C++ DLL 之間,插一層 C++/CLI 包裝層 DLL 的架構。
讓 C# 看得到的只有 .NET 風格的 API,並把
- 字串轉換
- 陣列與 vector 的轉換
- 例外的轉換
- 所有權的釐清
- 錯誤碼的解讀
- 必要時吸收執行緒邊界與回呼
都關進 C++/CLI 這一側。
重要的是 別把 C++/CLI 專案本身做得太大。 它的職責始終只是「翻譯」和「整理」。 一旦連業務邏輯都開始放進去,這一層反而變成主角了。
flowchart TB
accTitle: 關進 C++/CLI 包裝層的工作
accDescr: 呈現把字串與陣列的轉換、例外與錯誤碼的轉換、所有權的釐清這些翻譯與整理的工作關進 C++/CLI 這一側,而不放業務邏輯的職責界線的圖。
w["C++/CLI 包裝層的職責"] --> t1["字串、陣列的轉換"]
w --> t2["例外、錯誤碼的轉換"]
w --> t3["所有權的釐清"]
w -.-> warn["不放業務邏輯"]
圖 8: 包裝層的職責限定在「翻譯」和「整理」,別做得太大很重要。
5. 用 C++/CLI 會輕鬆在哪裡
5.1. C++ 的型別可以就當 C++ 的型別處理
這一點相當關鍵。
在 C++/CLI 這一側可以 #include 原生的標頭,直接使用 C++ 的型別。
也就是說,C# 這一側不必硬去「重現 C++ 的世界」。
std::wstring 也好,std::vector 也好,先當成 C++ 的型別接住,再依需要的形態交給 .NET 這一側就好。
flowchart TB
accTitle: 先接住 C++ 型別再交給 .NET 的流程
accDescr: 呈現引入原生的標頭,把 wstring 與 vector 當成 C++ 的型別接住,轉換成需要的形態後再交給 .NET 這一側,因此 C# 這一側不必重現 C++ 世界的圖。
nt["原生的 wstring 或 vector"] --> recv["在 C++/CLI 這一側當 C++ 型別接住"]
recv --> conv["轉換成需要的形態"]
conv --> net["交給 .NET 這一側"]
net -.-> nofake["C# 不必重現 C++ 的世界"]
圖 9: C++ 的型別先當成 C++ 的型別接住,轉換之後再交給 .NET 就好。
5.2. 可以把 API 整理成 .NET 風格
給 C# 這一側,可以端出
stringbyte[]List<T>IDisposable- 例外
這些看慣了的 API 形式。
這個差別看起來不起眼,卻大幅改變了使用端的負擔。 特別是團隊開發時,不熟原生內情的成員也比較容易上手,這點很有用。
5.3. 例外與錯誤的職責比較好釐清
原生這一側混著例外與錯誤碼時,C# 這一側原樣接住會很難處理。 在 C++/CLI 這一側先統一整理一次,
- 把例外轉換成 .NET 的例外
- 把錯誤碼轉換成有意義的例外或結果型別
- 補上日誌需要的脈絡
這些都做得到。
在邊界上先翻譯成一次「有意義的失敗」,呼叫端就會清爽很多。
flowchart TB
accTitle: 例外與錯誤碼在邊界上的翻譯
accDescr: 呈現把原生這一側混雜的例外與錯誤碼在 C++/CLI 這一側統一整理一次,例外轉成 .NET 的例外,錯誤碼轉成有意義的例外或結果型別,並補上日誌需要的脈絡的圖。
mixed["原生的例外與錯誤碼"] --> tr["在 C++/CLI 這一側統一整理"]
tr --> e1["例外轉成 .NET 的例外"]
tr --> e2["錯誤碼轉成有意義的形態"]
tr -.-> log["補上日誌需要的脈絡"]
圖 10: 在邊界上先翻譯成一次「有意義的失敗」,C# 的呼叫端就會清爽起來。
5.4. 可以把 ABI 的浮動對 C# 藏起來
C++ 的類別與方法,不像 C 的函式那樣有單純的 ABI。 一旦 C# 開始直接認識這些內情,匯出函式與封送處理的細節就會浮上檯面。
插一層 C++/CLI 包裝層,就能 把 C++ 的內情關在 C++ 這一側,只給 C# 看穩定的那一面。 這種分離在函式庫更新時也派得上用場。
flowchart TB
accTitle: 用包裝層擋下 ABI 浮動的結構
accDescr: 呈現 C++ 的類別與方法不像 C 的函式那樣有單純的 ABI,因此把這些內情關在 C++ 這一側,只給 C# 看穩定的那一面,在函式庫更新時這種分離也派得上用場的圖。
abi["C++ 類別的 ABI 並不單純"] --> hide["C++ 的內情關在 C++ 這一側"]
hide --> stable["只給 C# 看穩定的那一面"]
stable -.-> update["函式庫更新時也派得上用場的分離"]
圖 11: 不讓封送處理與匯出函式的細節浮上檯面,只給 C# 看穩定的那一面。
5.5. 階段式遷移比較好做
把既有的原生 DLL 一口氣全部重做太沉重。 用 C++/CLI 包裝層的話,可以先把需要的 API 薄薄包一層,從 C# 這一側新的畫面或新的工作流程開始用起,這種階段式的遷移比較好做。
想一邊活用 Windows 的既有資產,一邊把周邊集中到 .NET 的場合,這種做法相當合用。
6. 程式碼片段
這裡不放「原樣就能跑的完整範例」,只放足以看出邊界樣貌的片段。
6.1. 原生 DLL 這一側的 API 樣貌
// NativeLib.hpp
#pragma once
#include <string>
#include <vector>
namespace NativeLib
{
struct AnalyzeOptions
{
int threshold;
std::wstring modelPath;
};
struct AnalyzeResult
{
bool ok;
std::wstring message;
std::vector<int> scores;
};
class Analyzer
{
public:
explicit Analyzer(const std::wstring& licensePath);
AnalyzeResult Analyze(const std::wstring& imagePath, const AnalyzeOptions& options);
};
}
這個 API 就原生 C++ 來說很普通。 但要從 C# 直接碰它,相當費工夫。
6.2. 想用 P/Invoke 做的話會變成這樣
首先,為了從 C# 直接呼叫,得在某處把它壓成 C 形式的函式。 例如就得另外準備這樣的橋接函式。
// 壓成 C API 的橋接層樣貌
extern "C"
{
__declspec(dllexport) void* Analyzer_Create(const wchar_t* licensePath);
__declspec(dllexport) void Analyzer_Destroy(void* handle);
__declspec(dllexport) int Analyzer_Analyze(
void* handle,
const wchar_t* imagePath,
const AnalyzeOptionsNative* options,
AnalyzeResultNative* result);
}
C# 這一側大概也會長這樣。
internal sealed class SafeAnalyzerHandle : SafeHandle
{
private SafeAnalyzerHandle() : base(IntPtr.Zero, ownsHandle: true) { }
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
NativeMethods.Analyzer_Destroy(handle);
return true;
}
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct AnalyzeOptionsNative
{
public int Threshold;
public IntPtr ModelPath;
}
internal static class NativeMethods
{
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern SafeAnalyzerHandle Analyzer_Create(string licensePath);
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern void Analyzer_Destroy(IntPtr handle);
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern int Analyzer_Analyze(
SafeAnalyzerHandle handle,
string imagePath,
ref AnalyzeOptionsNative options,
out AnalyzeResultNative result);
}
如果到這裡就結束還好,但實際上還會多出
- 可變長度的資料要怎麼傳回
- 字串緩衝區由誰釋放
- 錯誤細節要放在哪裡
- 回呼的生命週期要怎麼守住
這些議題。
也就是說,本來以為只是選了 P/Invoke,實際上多半已經開始在設計一套 C 相容 API。
flowchart TB
accTitle: P/Invoke 方案實質上變成 C 相容 API 設計的流程
accDescr: 呈現就算打算用 P/Invoke 直接呼叫,還是得另外準備 C 橋接函式,在 C# 這一側寫 SafeHandle 與 StructLayout,再多出可變長度、釋放、回呼等議題,實質上已經開始設計 C 相容 API 的流程的圖。
start["打算用 P/Invoke 直接呼叫"] --> bridge["另外準備 C 橋接函式"]
bridge --> decl["寫 SafeHandle 或 StructLayout"]
decl --> more["可變長度、釋放、回呼的議題"]
more --> real["實質上開始設計 C 相容 API"]
圖 12: 本來以為「只是選了 P/Invoke」,回過神來多半是在設計 C 相容 API。
這些議題收攏到 C++/CLI 之後,會替換成下面這樣。它們不會像魔法一樣消失,準確的說法是 移到 C++ 這一側能自然寫出來的形態。
| P/Invoke 會冒出的議題 | P/Invoke 這一側典型的處理 | 在 C++/CLI 這一側會變成什麼 |
|---|---|---|
| 可變長度的資料要怎麼傳回 | 準備「查詢所需大小的函式」與「填滿緩衝區的函式」兩段式,由 C# 這一側配置緩衝區 | 直接接住原生傳回的 std::vector,改裝成 List<int> 或陣列再傳回(6.3) |
| 字串緩衝區由誰釋放 | 在 C API 加上釋放用的函式,並遵守 C# 這一側一定要呼叫的約定 | std::wstring 的生命週期在原生這一側就結束,給 C# 的只是新建一個 String^ 傳回(6.3) |
| 錯誤細節要放在哪裡 | 除了傳回值的錯誤碼,還要準備取出細節的函式,或以 out 引數傳出的結構 | 用 try / catch 接住原生的例外,轉換成有意義的 .NET 例外再重新拋出(6.3) |
| 回呼的生命週期要怎麼守住 | 為了不讓委派被 GC 回收,用欄位等方式持續保住參考 | 把回呼的註冊與取消註冊關進 C++ 這一側,只給 C# 看事件或委派 |
| 控制代碼的所有權要怎麼表達 | 衍生 SafeHandle,從 ReleaseHandle 呼叫釋放函式 |
在包裝層的解構函式/完成項裡 delete 原生的物件(6.3) |
6.3. 用 C++/CLI 包裝層可以這樣寫
在 C++/CLI 這一側接住原生的內情,把要給 C# 看的 API 整理好。
// AnalyzerWrapper.h
#pragma once
#include "NativeLib.hpp"
using namespace System;
using namespace System::Collections::Generic;
public ref class AnalysisOptions
{
public:
property int Threshold;
property String^ ModelPath;
};
public ref class AnalysisResult
{
public:
property bool Ok;
property String^ Message;
property List<int>^ Scores;
};
public ref class AnalyzerWrapper : IDisposable
{
public:
AnalyzerWrapper(String^ licensePath);
~AnalyzerWrapper();
!AnalyzerWrapper();
AnalysisResult^ Analyze(String^ imagePath, AnalysisOptions^ options);
private:
NativeLib::Analyzer* _native;
};
這裡 C++/CLI 特有的是 ~AnalyzerWrapper() 和 !AnalyzerWrapper() 這兩個。兩個看起來都像 C++ 的解構函式,但職責上對應到 .NET 的 Dispose 模式。
| C++/CLI 的寫法 | 編譯器產生的東西 | 從 C# 看到的行為 |
|---|---|---|
~AnalyzerWrapper()(解構函式) |
實作 IDisposable 的 Dispose() |
離開 using 時,或呼叫 Dispose() 時執行 |
!AnalyzerWrapper()(完成項) |
覆寫 Object::Finalize 的 Finalize() |
GC 回收時執行。何時執行並不確定 |
標準做法是 把原生資源的釋放寫在完成項裡,再由解構函式呼叫它。下面的實作中,~AnalyzerWrapper() 只是呼叫 this->!AnalyzerWrapper() 就是這個意思;這樣寫的話,就算 C# 這一側忘了呼叫 Dispose(),最後 GC 也會替你收拾。解構函式被呼叫時,GC::SuppressFinalize 會抑制完成項執行,所以不會變成重複釋放。
另外,Dispose()、Finalize()、Dispose(bool) 由編譯器產生,所以不用自己在 C++/CLI 這一側寫。反過來說,也不能從 C++/CLI 的程式碼直接呼叫 Dispose(),而是用 delete 運算子呼叫解構函式。這組對照關係整理在 How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn 的「Destructors and finalizers」。
flowchart TB
accTitle: 解構函式與完成項的分工
accDescr: 呈現從 C# 的 using 或 Dispose 會呼叫解構函式,再經由完成項釋放原生資源,忘了呼叫 Dispose 時由 GC 呼叫完成項最後收拾,並由 SuppressFinalize 防止重複釋放的圖。
us["C# 的 using 或 Dispose"] --> dtor["解構函式(相當於 Dispose)"]
forget["忘了呼叫 Dispose"] -.-> gc["GC 回收時的完成項"]
dtor --> fin["呼叫完成項"]
fin --> del["delete 原生資源"]
gc -.-> del
dtor -.-> sup["用 SuppressFinalize 防止重複釋放"]
圖 13: 釋放寫在完成項,再由解構函式呼叫它的標準做法。就算忘了 Dispose,最後 GC 也會收拾。
// AnalyzerWrapper.cpp
#include "AnalyzerWrapper.h"
#include <msclr/marshal_cppstd.h>
using msclr::interop::marshal_as;
AnalyzerWrapper::AnalyzerWrapper(String^ licensePath)
{
_native = new NativeLib::Analyzer(marshal_as<std::wstring>(licensePath));
}
AnalyzerWrapper::~AnalyzerWrapper()
{
this->!AnalyzerWrapper();
}
AnalyzerWrapper::!AnalyzerWrapper()
{
delete _native;
_native = nullptr;
}
AnalysisResult^ AnalyzerWrapper::Analyze(String^ imagePath, AnalysisOptions^ options)
{
// 若在處置之後被呼叫,就在進入原生之前於此擋下。
// 解構函式(= Dispose)會把 _native 設成 nullptr,
// 少了這道檢查就會經由 null 指標進入原生,
// 得到的不是 .NET 的例外,而是存取違規讓整個處理程序當掉。
// 從 C# 這一側來看,「Dispose 之後再碰就拋出 ObjectDisposedException」
// 才是預期的行為,所有使用 _native 的方法都需要這道檢查
if (_native == nullptr)
{
throw gcnew ObjectDisposedException("AnalyzerWrapper");
}
NativeLib::AnalyzeOptions nativeOptions{};
nativeOptions.threshold = options->Threshold;
nativeOptions.modelPath = marshal_as<std::wstring>(options->ModelPath);
try
{
auto nativeResult = _native->Analyze(
marshal_as<std::wstring>(imagePath),
nativeOptions);
auto managed = gcnew AnalysisResult();
managed->Ok = nativeResult.ok;
managed->Message = gcnew String(nativeResult.message.c_str());
managed->Scores = gcnew List<int>();
for (int score : nativeResult.scores)
{
managed->Scores->Add(score);
}
return managed;
}
catch (const std::exception& ex)
{
throw gcnew InvalidOperationException(gcnew String(ex.what()));
}
}
C# 這一側就相當直接了當。
using var analyzer = new AnalyzerWrapper(@"C:\license.dat");
var result = analyzer.Analyze(
@"C:\input.png",
new AnalysisOptions
{
Threshold = 80,
ModelPath = @"C:\model.bin"
});
if (!result.Ok)
{
Console.WriteLine(result.Message);
}
C# 看得到的只有 string、List<int> 和 IDisposable。
IntPtr、釋放函式、原生字串緩衝區的細節都看不到。
這一點很關鍵。
7. 即使如此也不該選 C++/CLI 的場合
當然,C++/CLI 不是萬能的。 也有不該選它的場合。
- 對方一開始就公開了乾淨的 C API
- 這種情況 P/Invoke 比較直接了當。
- 需要跨平台
- C++/CLI 以 Windows 為前提。
- 邊界面很小,型別也單純
- 多一個包裝層 DLL 的成本反而更大。
- 對 AOT 或散發限制看得相當嚴格
- 先看過整體架構的需求比較好。
只有最後的「AOT 或散發限制」比較抽象,這裡把實際會發生作用的限制列出來。這一塊若還帶著 .NET Framework 時代的感覺,很容易踩到。
| 限制 | 內容 | 實務上的影響 |
|---|---|---|
| OS | 以 .NET(.NET Core 系)為目標的 C++/CLI 只支援 Windows | 若計畫要在 Linux 容器或 macOS 上跑,在這一步就選不了 |
| Native AOT | Native AOT 的不支援清單裡明列了 C++/CLI。連帶地,Assembly.LoadFile 這類動態載入、System.Reflection.Emit、Windows 內建的 COM 也都不能用 |
與用 PublishAot 產生單一原生二進位檔的方針無法並存 |
| 輸出形式 | 以 .NET 為目標時,不能做成 exe,只能是 DLL。也不能以 .NET Standard 為目標 |
進入點放在 C# 這一側的 exe,C++/CLI 則以 DLL 的形式被參考 |
| 專案格式 | 使用的是 .vcxproj,而不是 SDK 樣式的 csproj。一個專案也無法同時以多個 .NET 版本為目標 |
若同時需要 .NET Framework 版與 .NET 版,就把專案檔分開 |
| 執行階段相依性 | 指定 /clr 會連帶啟用 /MD,因此需要 MSVC 執行階段的 DLL。以 .NET 為目標時,還要把 ijwhost.dll 放進輸出目錄 |
若前提是 XCOPY 散發或單一檔案發行,要先確認清楚 |
| CPU 架構 | 混合組件含有原生的機器碼指令,因此無法像 C# 的 AnyCPU 那樣用一個二進位檔涵蓋所有架構 | 按 x86 / x64 等目標分別建置再散發 |
| 載入方式 | .NET 7 以後一律載入到預設的 AssemblyLoadContext。.NET 6 以前,若最初是從原生這一側被呼叫,有時會載入到另一個 AssemblyLoadContext |
在每個外掛各自使用不同載入內容的架構下,要先確認行為 |
另外,C++/CLI 專案能以 .NET(.NET Core 系)為目標,是 Visual Studio 2019 以後的事。若手上只有更舊的環境,就得先以 .NET Framework 為前提來考慮。
也就是說,判斷基準是「相對於原生 DLL 的複雜度,在哪裡翻譯最自然」。 單純就 P/Invoke,複雜就 C++/CLI。 這樣分辨大致上都行得通。
flowchart TB
accTitle: 不該選 C++/CLI 的場合
accDescr: 呈現已經有乾淨的 C API、需要跨平台、邊界很小且型別單純、對 AOT 與散發限制看得嚴格這四種場合下,不該選 C++/CLI 包裝層的圖。
n1["已經有 C API"] --> no["不選 C++/CLI"]
n2["需要跨平台支援"] --> no
n3["邊界小且型別單純"] --> no
n4["AOT、散發限制嚴格"] --> no
no -.-> judge["在哪裡翻譯最自然"]
圖 14: C++/CLI 不是萬能的。若符合這四點,先考慮 P/Invoke 或重新檢視架構。
8. 總結
從 C# 使用原生 DLL 的方法裡,P/Invoke 至今仍是最正統的做法。 只不過,那是 對方作為 C API 夠直接了當時 的情況。
如果原生這一側是以 C++ 函式庫的形式設計的,
與其在 C# 這一側排一堆 IntPtr 與封送處理的屬性硬撐,用 C++/CLI 做一層薄的包裝層,往往更能把邊界維持乾淨。
特別是牽涉到
- 以類別為基礎的 API
- 所有權的前提
std::wstring或std::vector- 例外轉換
- 回呼
- 階段式的遷移
這些時候,C++/CLI 是相當務實的選項。
要做的事情並不華麗。 但「在哪裡把邊界整理好」這種事,之後在維護上會確實顯現出來。 想同時活用 Windows 的既有資產與 .NET 時,C++/CLI 到現在仍然很好用。
9. 參考資料
- 本文的完整範例程式碼(原生 C++ 函式庫、C++/CLI 包裝層、C# 呼叫端) - komurasoft-blog-samples (GitHub)
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - Microsoft Learn
- How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn
- /clr (Common Language Runtime compilation) - Microsoft Learn
- Native AOT deployment overview - Microsoft Learn
- Using C++ Interop (Implicit PInvoke) - Microsoft Learn
- Platform Invoke (P/Invoke) - Microsoft Learn
- Overview of Marshaling in C++/CLI - Microsoft Learn
- marshal_as - Microsoft Learn
- Interop (C++) 的效能考量 - Microsoft Learn
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
引數為什麼會壞掉 ── Windows 命令列引數的規則
在 Windows 上並不存在引數的陣列,傳給 CreateProcess 的是一條字串,分割由接收端負責。本文說明 CommandLineToArgvW・CRT・.NET 的分割規則,以及 .NET 的 ArgumentList 與 C++ 中正確的組裝方式。
父處理程序消失之後還剩下什麼 —— 用 Job Object 圈養子處理程序
為什麼強制結束 UI 之後,SDK 的輔助處理程序仍然殘留,一直佔著攝影機或 COM 連接埠?本文從量測應用的角度,說明如何用 Job Object 把處理程序樹變成一個單位,並借助 KillOnJobClose 與完成埠來設計子處理程序的壽命。
具名管道實務 ── 從設計到安全,看懂 Windows 行程間通訊的標準做法
以實務角度說明 Windows 行程間通訊的標準做法——具名管道。依據一手資料整理位元組模式與訊息模式的取捨、同時接受多個用戶端的伺服器結構、ACL 與模擬的安全設計,以及 .NET 的具名管道串流。
在 C#・PowerShell 中使用 WMI/CIM ── 硬體資訊取得・處理程序監控・遠端查詢的實務指南
取得 PC 序號、監控磁碟可用空間、偵測處理程序啟動,這些定番需求的標準答案就是 WMI/CIM。本文解說 Get-CimInstance 等 CIM Cmdlet 的用法、從舊版 Get-WmiObject 的遷移方式、C# 的 System.Management 與 C...
在 Windows 應用程式中處理 USB 裝置的方法 ── 虛擬 COM・HID・WinUSB・專用 SDK 的選擇方式
本文比較從Windows應用程式控制設備與USB裝置的四種方式──虛擬COM埠、HID、WinUSB、廠商製SDK。並以實務角度整理是否需要安裝驅動程式、多台連接時的識別方式、如何追蹤拔插,以及效能上限。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
32 位元 / 64 位元互通
整理 32 位元 / 64 位元互通、原生邊界與相關 Windows 設計判斷的主題頁面。
常見問題
整理諮詢這個主題時常見的問題。
- P/Invoke 與 C++/CLI 包裝層該怎麼取捨?
- 對方若是以 extern "C" 公開的扁平 C 函式群,P/Invoke 最直接了當也最簡單。對方若是以 C++ 類別為核心的函式庫,又牽涉到所有權、字串、陣列、例外、回呼,用 C++/CLI 插一層薄的包裝層會比較好維護。判斷基準是「相對於原生 DLL 的複雜度,在哪裡翻譯最自然」,單純就 P/Invoke、複雜就 C++/CLI,這樣分辨大致上都行得通。
- 插一層 C++/CLI 包裝層會讓什麼變輕鬆?
- 在 C++/CLI 這一側可以 #include 原生的標頭,把 std::wstring 與 std::vector 當成 C++ 的型別直接處理,因此 C# 這一側不必重現 C++ 的世界。給 C# 看的只有 string、byte[]、List<T>、IDisposable、例外這些 .NET 風格的 API,IntPtr、釋放函式與封送處理的細節都可以藏起來。C++ 例外與錯誤碼可以在邊界上轉換成 .NET 的例外,也比較好做活用既有資產的階段式遷移。
- 只用 P/Invoke 走下去,什麼時候會變得辛苦?
- 原生 DLL 若以 C++ 類別為核心設計,P/Invoke 能直接呼叫的只有 DLL 的匯出函式,最後還是需要一層壓成 C 形式函式的東西,等於實質上開始設計 C 相容 API。再加上想傳回 std::wstring 或 std::vector、想用回呼接收進度、會拋出 C++ 例外這些要素,C# 這一側的 MarshalAs、手動緩衝區、委派的生命週期管理就會愈堆愈高。把所有權與生命週期的前提用 IntPtr 來表達,之後回頭讀會相當吃力。
- 有不該選 C++/CLI 的情況嗎?
- 有。對方一開始就公開了乾淨的 C API 時,P/Invoke 比較直接了當。另外 C++/CLI 以 Windows 為前提,需要跨平台時就不能用。邊界面小、型別也單純時,多一個包裝層 DLL 的成本反而更大;對 AOT 或散發限制看得嚴格時,也最好先確認整體架構的需求。