要在 C# 中使用原生 DLL,C++/CLI 包裝是有力選項的理由 - 與 P/Invoke 的比較整理
· 小村 豪 · C++/CLI, C#, Windows 開發, 原生整合
想從 C# 使用 Windows 既有資產或既有 DLL 的需求相當常見。 對方若是像 Win32 API 那種直白的 C 介面,用 P/Invoke 就夠了。
但實務上遇到的多半是更有個性的 DLL。
有 C++ 類別、有自家的所有權流派、例外會往外丟,也常常直接出現 std::wstring、std::vector。
這時若單靠 P/Invoke 硬撐,邊界多半會越做越辛苦。
本文整理的是:這時若在中間插一層薄薄的 C++/CLI 包裝,會讓哪些事情變輕鬆。 這不是在講 P/Invoke 不好。 而是 P/Invoke 夠用的情境,與 C++/CLI 才發揮價值的情境不同。
1. 先下結論(一句話)
- 對方是 C 的函式群,P/Invoke 最直接
- 對方是 C++ 函式庫,在中間插一層 C++/CLI 包裝比較好維護
- 尤其當 類別、所有權、字串、陣列、例外、回呼 都牽涉進來時,別把重擔丟給 C# 這一端
換句話說,別把原生 DLL 的細節直接帶進 C#。 原生的細節由 C++ 這一端吃下來,只把面向 .NET 的那一層整好。 這種分工做好後,程式碼跟除錯都會輕鬆很多。
2. 可以只用 P/Invoke 的情境
先講重要的事:如果 P/Invoke 能解決,那它最簡單。 沒必要硬把 C++/CLI 搬進來。
P/Invoke 適合下列情境:
- 以
extern "C"公開的扁平函式 API - 參數/回傳值是整數、指標、簡單結構等
- 字串規格清楚、緩衝區的責任也單純
- 資源管理像
Create/Destroy那樣明確 - 在 C# 端可以直接寫
SafeHandle或StructLayout
到這個程度,在 C# 宣告後就可以用。 感覺就像呼叫 Windows API,實作也容易讀。
3. P/Invoke 會突然變辛苦的邊界
問題在於:對方不是「普通的 C API」時。 空氣從這邊開始變。
3.1. 對象開始是 C++ 類別時
原生 DLL 若以 C++ 類別為核心設計,從 C# 真正想看到的是方法,但 P/Invoke 能直接對上的只是 DLL 的匯出函式。 也就是說,最後某處仍得有一層 把 C++ 壓成 C 形式函式 的轉換。
走到這裡,其實就已經在「寫包裝」了。
既然如此,與其在 C# 端長出一堆 IntPtr 與釋放函式,不如 把包裝往 C++ 那一側擺,比較自然。
3.2. 所有權與生命週期難以看清時
C++ 中常出現:
- 是呼叫端釋放嗎
- 回傳的指標是借用嗎
- 參數是
const&還是所有權轉移 - 內部有快取、對生命週期有假設
這些情境用 C# 的 IntPtr 表達,初期能動,但之後回頭看就會很吃力。
「這個指標,到底誰什麼時候刪?」一旦出現,邊界就會模糊起來。
3.3. std::wstring、std::vector、回呼、例外出現時
到這裡,P/Invoke 就進入「寫得出來、但寫得不舒服」的區段。
- 想把
std::wstring在 C# 直接表達 - 想回傳
std::vector<T> - 想以回呼接收原生處理的進度
- 失敗時會丟出 C++ 例外
這些元素一多,C# 端就會冒出 MarshalAs、手動緩衝區、固定長度陣列、委派生命週期管理、錯誤碼解析等等。
努力寫當然寫得出來。 但 努力的地方並不是重點,就很痛苦。 本來想做的是業務邏輯與 UI,不是在邊界上做格鬥技。
3.4. 不想把 C++ 的細節漏到 C# 時
原生 DLL 側的 API 並不總是剛好對著 C# 的口味。
例如原生側可能是:
- 要把多個方法呼叫組成一次處理
- 錯誤用回傳值 + out 參數回報
- 對初始化順序有假設
- 執行緒安全有限制
但給 C# 看的往往是更直觀的 API。 作為這層的翻譯,C++/CLI 相當合適。
4. 插入一層 C++/CLI 包裝的架構
架構很單純:
flowchart LR
Cs[C# 應用] -->|.NET 風格的 API| Wrapper[C++/CLI 包裝 DLL]
Wrapper -->|直接使用原生標頭與型別| Native[原生 C++ DLL]
讓 C# 看到的只是 .NET 風格的 API,並把:
- 字串轉換
- 陣列 / vector 轉換
- 例外轉換
- 所有權整理
- 錯誤碼的語義
- 必要時的執行緒界面或回呼吸收
全封在 C++/CLI 這一層內。
重點是 別把 C++/CLI 專案做太大。 它的角色就是「翻譯」與「整形」。 一旦開始寫業務邏輯,那層就變主角了。
5. 用 C++/CLI 能輕鬆什麼
5.1. C++ 型別可以直接當 C++ 用
這點很大。
C++/CLI 端可以直接 #include 原生標頭,原封不動用 C++ 的型別。
也就是說,C# 不必硬「重現 C++ 的世界」。
std::wstring、std::vector 先當 C++ 型別接住,再按需要改裝送到 .NET 就好。
5.2. 可以把 API 整形成 .NET 風
給 C# 看到的是熟悉的:
stringbyte[]List<T>IDisposable- 例外
這些差異看似細微,但會明顯改變使用者端的負擔。 尤其在團隊開發中,不熟原生側的成員也比較能放心使用。
5.3. 例外與錯誤的責任比較好整理
原生側混著例外與錯誤碼時,C# 直接接會很難處理。 在 C++/CLI 那一層統一整理:
- 把例外轉成 .NET 例外
- 把錯誤碼轉成有意義的例外或結果型別
- 補上日誌需要的 context
就能讓呼叫端乾淨許多。 在邊界上一次翻譯成「有意義的失敗」,後面會輕鬆非常多。
5.4. ABI 的動盪可以對 C# 隱藏
C++ 的類別、方法 ABI 並不像 C 函式那樣簡單。 一旦 C# 要直接接觸,就會碰上匯出函式與 marshalling 的各種細節。
插一層 C++/CLI,就能 把 C++ 的事情鎖在 C++ 那一側,對 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。
6.3. 用 C++/CLI 包裝就可以這樣寫
在 C++/CLI 端接住原生細節,把 API 整形給 C#:
// 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;
};
// 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)
{
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 或部署限制很嚴格
- 要先看整體要求。
判斷基準是:「相對於原生 DLL 的複雜度,翻譯層擺哪裡最自然?」 簡單就 P/Invoke,複雜就 C++/CLI,大致這樣切。
8. 總結
從 C# 使用原生 DLL,P/Invoke 仍是主流做法。 不過前提是 對方以 C API 的姿態出現。
若原生側是一個完整的 C++ 函式庫,
與其在 C# 端堆 IntPtr 與 marshalling 屬性硬撐,做一層薄的 C++/CLI 包裝往往更能保持邊界乾淨。
尤其在下列情境下 C++/CLI 仍是實際的選項:
- 類別型 API
- 所有權假設
std::wstring、std::vector- 例外轉換
- 回呼
- 階段式遷移
做這層包裝雖然沒什麼酷炫感, 但「在邊界上把東西整好」這件事,對後續的維護有長期的正面影響。 想同時活用 Windows 既有資產與 .NET 時,C++/CLI 依然很有用。
9. 參考資料
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - 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
- Performance considerations for interop (C++) - Microsoft Learn
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
序列通訊應用的陷阱 - 先釐清 1 byte 單位、逾時、流控、重連、USB 轉換、UI 凍結
從設備整合與儀器控制的實作現場出發,整理序列通訊應用最容易踩到的陷阱。把訊息邊界、逾時語意、流控線設定、single writer、session 重連與 hex dump 日誌一一拆開,幫助讀者把「偶爾才壞」的 byte 序列處理改造成可預測且容易調查的結構。
Windows Forms、WPF、WinUI 該選哪個 - 新規開發、既有資產、發佈、UI 表現的判斷表
從既有資產的規模、畫面是表單中心還是表現力中心、現代 Windows UI 是否為產品要件、發佈與運營怎麼跑這四個觀點,整理 WinForms、WPF、WinUI 該選哪個的判斷表,並提醒只想用 Windows App SDK 不必全面遷移到 WinUI。
使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性
整理在同一機器內以共享記憶體交換大型資料時的陷阱與設計要點。把 control plane 和 data plane 分離、縮小並行模型、用固定寬度整數和標頭設計 ABI、以 offset 取代指標、明示 commit protocol、為當機復原放入 generation...
將 .NET Framework 遷移到 .NET 之前該確認的事 - 在著手前就決定勝負的實戰檢核表
整理將 .NET Framework 業務應用程式遷移到現代 .NET 之前必須先盤點的論點。涵蓋著地版本、Windows 專用前提的取捨、不再支援的 API、共用函式庫切法、第三方部件、運營與 CI/CD,幫助在著手前釐清範圍並降低遷移風險。
用 Native AOT 把 C# 做成原生 DLL 的方法 - 用 UnmanagedCallersOnly 從 C/C++ 呼叫
從現有 C/C++ 應用程式以 in-process 方式呼叫 C# 邏輯時,本文示範以 Native AOT 將類別庫發佈為原生 DLL,並用 UnmanagedCallersOnly 公開 cdecl 進入點。透過 handle、錯誤碼與扁平 C ABI 設計交界面,整...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
32 位元 / 64 位元互通
整理 32 位元 / 64 位元互通、原生邊界與相關 Windows 設計判斷的主題頁面。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。