要在 C# 中使用原生 DLL,C++/CLI 包裝是有力選項的理由 - 與 P/Invoke 的比較整理
· 更新日期: · 小村 豪 · C++/CLI, C#, Windows 開發, 原生整合
更新紀錄(僅初版,2026年03月07日 發布)
- 初次發布
引用本文(DOI: 10.5281/zenodo.21616237)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
小村 豪(2026)。〈要在 C# 中使用原生 DLL,C++/CLI 包裝是有力選項的理由 - 與 P/Invoke 的比較整理〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616237 https://staging.comcomponent.com/zh-TW/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/
- DOI(最新版本)
- 10.5281/zenodo.21616237
- DOI(此版本)
- 10.5281/zenodo.21616238
想從 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
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
Windows 應用程式的工作列通知區常駐與 Toast 通知 —— NotifyIcon 的陷阱與 AppNotification 的選型
本文整理將業務用 Windows 應用程式常駐於工作列通知區,並透過 Toast 通知告知使用者的實作要點。內容涵蓋 NotifyIcon 的正確用法與「關閉後仍留在通知區」的設計、檔案總管重新啟動時的重新註冊、三種 Toast API(Windows App SDK Ap...
VB6 應用程式能用到什麼時候 ── 執行環境的支援現況與務實的 .NET 遷移做法
VB6 應用程式究竟能用到什麼時候?本文整理 VB6 執行環境的支援政策(Windows 11 也在支援範圍內)與 IDE 支援早已終止這種不對稱現況,並以實務指南的形式,說明全面重寫、自動轉換、階段性遷移的判斷表、遷移前的資產盤點、VB6 與 .NET 的不相容之處,以及...
業務應用程式的日本年號・國定假日・結算日處理 ── 抗改元設計與 JapaneseCalendar・營業日計算實務
報表要顯示「令和8年」、營業日計算要排除國定假日、20日結算次月底付款──日本業務應用程式特有的日期處理,背後藏著「日後會變動的規格」:改元、國定假日的法規修訂、月底的進位。本文整理以 JapaneseCalendar 顯示年號、抗改元的設計方式,國定假日不可寫死在程式碼中...
防止 Windows 應用程式重複啟動 ── 具名 Mutex 與重複執行時的視窗前置
整理業務用 Windows 應用程式的常見需求「不讓同一個應用程式啟動兩次」,如何用具名 Mutex 實作。涵蓋 Global\ 與 Local\ 命名空間差異在 RDP 環境中的陷阱、擁有執行緒限制與 AbandonedMutexException、考量 SetForeg...
委外・委託開發 Windows 應用程式前該整理的事項
在委外・委託開發 Windows 應用程式之前,整理既有軟體改版、設備整合、COM/ActiveX、發布與更新、維護等應留意的重點。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
32 位元 / 64 位元互通
整理 32 位元 / 64 位元互通、原生邊界與相關 Windows 設計判斷的主題頁面。
常見問題
整理諮詢這個主題時常見的問題。
- 什麼時候該用 C++/CLI 而不是 P/Invoke?
- 對方若是像 Win32 API 那種以 extern "C" 公開的直白 C 介面,用 P/Invoke 最直接。但若對方是完整的 C++ 函式庫,有類別、複雜的所有權、std::wstring、std::vector、例外與回呼,在中間插一層薄的 C++/CLI 包裝會比較好維護。判斷基準是「相對於原生 DLL 的複雜度,翻譯層擺哪裡最自然」:簡單就 P/Invoke,複雜就 C++/CLI。
- C++/CLI 包裝有什麼好處?
- C++/CLI 端可以直接 #include 原生標頭,把 std::wstring、std::vector 當 C++ 型別接住,再按需要轉成 .NET 型別。給 C# 看到的只有 string、List<T>、IDisposable 和 .NET 例外,IntPtr 與釋放函式都不會露出來。此外還能統一例外與錯誤碼的轉換、把 C++ ABI 的變動對 C# 隱藏,也適合先薄薄包一層需要的 API、逐步擴大的階段式遷移。
- 用 P/Invoke 呼叫 C++ 類別會遇到什麼問題?
- P/Invoke 能直接對上的只是 DLL 的匯出函式,因此最後仍得有一層把 C++ 壓成 C 形式函式的轉換,等於已經在寫包裝。C# 端會冒出 IntPtr、MarshalAs、手動緩衝區、委派生命週期管理、錯誤碼解析等細節,還要面對可變長資料怎麼回傳、誰釋放字串緩衝等問題。原本以為選了 P/Invoke,實際上是在設計一套 C 相容 API。
- 什麼情況不該選 C++/CLI?
- 有幾種情況。對方本來就公開了乾淨的 C API 時,P/Invoke 比較直接;需要跨平台時不能用,因為 C++/CLI 是 Windows 限定;邊界本來就小、型別單純時,多一個包裝 DLL 的成本反而更大;對 AOT 或部署限制很嚴格時,也要先看整體要求再決定。