C# DLL 給 C++ 使用 — 技術方案與 LeYuECat 實作筆記
起因(2026-04-23) :.NET Framework 4.8 WPF 半導體設備控制軟體要把 C# 的 motion/IO 邏輯出貨給 C++ 客戶用。 落地(2026-04-30):完成
LeYu.ECat.Cpp.Wrapper(內部代號 LeYuECat),把LeYuEQ.Plugin.Motion.Delta.EtherCAT包成 C++ 客戶可以#include <LeYuECat.h>直接用的 OO API。本文先比方案,再寫實作。
核心結論
C# DLL 是 Managed Assembly,C++ 無法直接 LoadLibrary 呼叫。必須透過一層「橋」把 Managed code 暴露成 C ABI(標準 C 呼叫慣例)。
對 .NET Framework 4.8 的既有 C# 程式碼來說,C++/CLI Wrapper 是現實上唯一無痛、官方支援、可長期維護的選項。後面有實作細節。
四大方案比較
| 方案 | 適用 .NET 版本 | 原理 | 部署複雜度 | 效能 | 推薦場景 |
|---|---|---|---|---|---|
| C++/CLI Wrapper | Framework 4.8 ✅ / .NET 5+ ✅ | /clr 模式同時吃 managed + native | 中(多一顆 DLL) | CLR 啟動成本 | 工控 / 設備軟體、既有 .NET Framework 生態 |
Native AOT + UnmanagedCallersOnly | .NET 7+ only ❌ Framework | AOT 編譯成純 native DLL | 低(單一 DLL) | 最佳(無 CLR) | 新專案、工具鏈、UE5 整合 |
| DNNE | .NET 5+ only ❌ Framework | 自動 產生 native shim + 啟動 CLR | 高(3 件套) | 中 | 不想 AOT 又想避開 C++/CLI |
| DllExport(社群) | Framework 4.8 ✅ | IL 後處理注入 export | 低(單一 DLL) | 中 | Framework 4.8 不想寫 C++/CLI |
方案細節
方案 1:C++/CLI 橋接層(LeYuECat 實際採用)
寫一個 C++/CLI 專案(/clr 模式)同時吃兩邊:
- 對內:用
#using <Foo.dll>直接 reference C# DLL,寫 managed code 呼叫 C# 物件 - 對外:暴露標準 C ABI(
extern "C" __declspec(dllexport))給 native C++ 用
優點
- 最成熟、Visual Studio 原生支援
- 複雜型別(class、string、陣列)用起來最自然
- 支援 .NET Framework 4.8
- C# 一邊可以正常持有 GC 物件、丟例外,跨界的轉換集中在這一層
缺點
- 綁死 Windows + .NET
- 需要 CLR 載入成本
- 多了一層 DLL(CppCli 中介)
方案 2:.NET Native AOT + UnmanagedCallersOnly
.NET 7+ 的官方解法。把 C# 方法標上 [UnmanagedCallersOnly],用 Native AOT 編譯成真正的原生 DLL,C++ 可以直接 DllImport / LoadLibrary。
[UnmanagedCallersOnly(EntryPoint = "Add",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int Add(int a, int b) => a + b;
優點
- 編譯出來是純 native DLL,不需要 CLR
- 啟動快、可給任何 C++ 程式用
缺點
- 需要 .NET 7/8+,.NET Framework 4.8 不支援
- 跨邊界 API 必須是 C ABI(不能用
string、List<T>、例外、GC 物件) - 要把 C# 邏輯整個搬到新 runtime
方案 3:DNNE
AaronRobinsonMSFT/DNNE 會根據 C# 裡的 [UnmanagedCallersOnly] 自動產生一顆 native shim DLL(後綴 NE.dll),C++ call 這顆 shim,shim 內部啟動 .NET runtime 去跑 Managed code。
優點
- 不用寫 C++/CLI、不用 AOT
- 產生標準 C header + import lib
缺點
- 要部署 shim DLL + managed DLL +
.runtimeconfig.json三件套 - 需要現代 .NET(不吃 Framework 4.8)
方案 4:DllExport(社群方案)
UnmanagedExports (Robert Giesecke) 或 3F/DllExport。靠 IL 後處理,把 C# method 注入成 DLL export。
[DllExport("Count", CallingConvention = CallingConvention.StdCall)]
public static int Count(IntPtr stringPtr) { ... }
優點
- .NET Framework 原生支援
- C++ 直接
LoadLibrary就能用 - 單一 DLL
缺點
- 靠第三方 post-build hack
- 不是官方支援
- 有些靜態分析工具會抱怨
- 維護風險
決策流程圖
你的 C# DLL 跑在哪?
├─ .NET Framework 4.8(目前主力)
│ ├─ 要給既有 C++ 工程用 → C++/CLI Wrapper ⭐
│ └─ 只想 export 幾個函式 → DllExport
│
└─ .NET 7/8+(新專案)
├─ 可重寫、要最佳效能 → Native AOT + UnmanagedCallersOnly ⭐
└─ 不想 AOT → DNNE
LeYuECat 實作案例
LeYu Delta EtherCAT 既有的 C# plugin(LeYuEQ.Plugin.Motion.Delta.EtherCAT,net48)需要出貨給 C++ 客戶,以下是這次走 C++/CLI 路線的實際結果。
三層架構
Customer C++ ──#include <LeYuECat.h>──▶ extern "C" __stdcall flat ABI
│
▼
LeYu.ECat.CppCli.dll (C++/CLI /clr, x64, net48)
│ gcroot<Object^>
▼
LeYu.ECat.Managed.dll (C# net48, Tomlyn)
│ P/Invoke
▼
EtherCAT_DLL_x64.dll (Delta runtime)
每層的責任切得很清楚:
LeYuECat.h(單一 header) — 客戶面對的全部介面。內含extern "C"的ECat_*函式宣告、OO 包裝(LeYu::EtherCATService/LeYu::Axis/ …)、把 Delta error code map 成英文字串的inlineswitch、以及繼承std::runtime_error的EtherCATException。客戶端只需要這一個 .h 檔。CppCli/(thin/clrshim) —Exports.cpp實作每一個ECat_*入口,HandleTable.{h,cpp}把不透明void*對應到 managed 物件,Logging.cpp處理 native callback 轉 managed delegate。沒有業務邏輯。Managed/(C# net48) — 真正的邏輯都在這。原則上 逐字 copy 自上游 plugin 專案,header 註解寫「Do not diverge without syncing back」,避免本地 fork 化。
為什麼挑 C++/CLI
- 主程式是 .NET Framework 4.8,方案 2 / 3(AOT、DNNE)直接出局
- 方案 4(DllExport)不能優雅地處理「C# 是 OO 而且要把多個 service / axis 物件交給 C++ 持有」,每呼叫一次都得自己手刻 handle 表,還是被迫寫 C++/CLI 等級的東西,那不如直接寫
- C# 端有大量例外、字串、
Task<T>、List<T>— C++/CLI 的 try/catch 跨界 +msclr::interop是最少摩擦的處理方式
Build 設定(最小可行配置)
vcxproj 三件套:
<ConfigurationType>DynamicLibrary</ConfigurationType>
<PlatformToolset>v143</PlatformToolset> <!-- 或 v145,要對應 VS 版本 -->
<CLRSupport>true</CLRSupport>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
VS Installer 必須勾的個別元件:
- Desktop development with C++(workload)
- .NET Framework 4.8 targeting pack
- C++/CLI support for v143(或 v145)build tools ← 這個常常忘記裝,沒裝會直接 build error
只走 x64。AnyCPU / Win32 都會炸(Delta 的 EtherCAT_DLL_x64.dll 是 64-bit only)。
出貨 layout(5 顆 DLL + 1 顆 header)
dist/
bin/
LeYu.ECat.CppCli.dll ← /clr 橋
LeYu.ECat.CppCli.lib ← 客戶 link 用 import lib
LeYu.ECat.Managed.dll ← C# 邏輯
EtherCAT_DLL_x64.dll ← Delta SDK
Tomlyn.dll ← managed 依賴
include/
LeYuECat.h
redist/
VC_redist.x64.exe
客戶端 #include <LeYuECat.h> + link LeYu.ECat.CppCli.lib,runtime 把 bin/* 全部丟到 .exe 旁邊就完工。不需要 GAC、不需要 .runtimeconfig.json、不需要任何註冊步驟。
跨邊界鐵律 — 與 LeYuECat 實作對照
不能直接跨過 C ABI 的東西
- C#
string/List<T>/Dictionary/ 任何 GC 物件 - C# exception
- C++
std::string/std::vector/ C++ class / C++ exception
Pattern 1:用不透明 handle + gcroot 表來代理 GC 物件
C++ 端拿到的是 void*(其實就是遞增整數),實際的 managed 物件由 C++/CLI 端維護的 map 持有:
// HandleTable.cpp(節錄)
struct Entry { gcroot<Object^>* root; };
static std::unordered_map<void*, Entry> g_table;
static std::mutex g_mutex;
static uintptr_t g_nextHandle = 1;
void* RegisterObject(Object^ obj) {
std::lock_guard<std::mutex> lock(g_mutex);
void* handle = reinterpret_cast<void*>(g_nextHandle++);
g_table[handle] = { new gcroot<Object^>(obj) };
return handle;
}
Object^ ResolveObject(void* handle) {
std::lock_guard<std::mutex> lock(g_mutex);
auto it = g_table.find(handle);
return it == g_table.end() ? nullptr : (Object^)(*(it->second.root));
}
void UnregisterObject(void* handle) {
std::lock_guard<std::mutex> lock(g_mutex);
auto it = g_table.find(handle);
if (it == g_table.end()) return;
delete it->second.root; // 釋放 gcroot,GC 才能回收 managed 物件
g_table.erase(it);
}
關鍵點:
gcroot<Object^>不能直接當 STL map 的 value,因為它是 managed-aware 型別 — 必須用new配在 heap 上、map 存指標- handle 用「遞增整數 reinterpret_cast 成 void*」就夠了,不需要真正的指標。不可以直接給客戶 managed 物件位址
- 整張表用 mutex 保護;
Resolve不會延長物件壽命,是gcroot在背後 keep-alive
Pattern 2:handle 為 key 的 side tables
純 managed 物件無法乾淨地攜帶「我被 create 時拿到的 config 路徑」「我已經被顯式 shutdown 過了」這種 C++ 端關心的狀態。LeYuECat 的解法是在 CppCli 層另外開幾張 unordered_map<void*, T>:
// Exports.cpp(節錄)
static std::unordered_map<void*, std::string> g_configPaths;
static std::unordered_map<void*, std::vector<void*>> g_serviceAxisHandles;
static std::unordered_set<void*> g_shutDownServices;
g_configPaths— 每個 service handle 對應自己的 TOML 路徑,Initialize時用這個路徑呼叫InitializeAsync(string)的 overload。早期版本是「複製 TOML 到 managed assembly 旁邊的固定檔名」,雙卡情境下會 raceg_serviceAxisHandles— serviceInitialize後一次預註冊所有 axis handle,後續GetAxis(i)直接從 cache 拿。沒這層的話高頻 polling 每秒都在長 HandleTableg_shutDownServices— 客戶顯式Shutdown()後 destructor 又跑一次Shutdown()是常見寫法,沒這個 flag 會把全域 refcount 推到負數
Pattern 3:跨 N instance 的 process-wide 資源 refcount
Delta 的 _ECAT_Master_Open / _ECAT_Master_Close 是 process 全域只能呼叫一次,但 wrapper 允許 N 個 service instance(每張 EtherCAT 卡一個)。解法是 static refcount:
public static class EtherCATMasterLifetime
{
private static int _refCount;
private static ushort _cachedExistCard;
private static readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1);
// 測試 seam — 預設指向真實 DLL,測試把它換成 lambda
public static MasterOpenFunc OpenFunc = CEtherCAT_DLL.CS_ECAT_Master_Open;
public static MasterCloseFunc CloseFunc = CEtherCAT_DLL.CS_ECAT_Master_Close;
public static ushort AcquireFirstOpen(out ushort existCard)
{
_gate.Wait();
try {
int newCount = Interlocked.Increment(ref _refCount);
if (newCount == 1) {
existCard = 0;
ushort ret = OpenFunc(ref existCard); // 0→1 transition:真的呼叫
_cachedExistCard = existCard;
return ret;
}
existCard = _cachedExistCard; // 之後都 skip
return 0;
}
finally { _gate.Release(); }
}
public static ushort ReleaseLastClose() { /* 對稱:1→0 才真的 Close,<0 clamp 並 warn */ }
}
這是這個專案最容易爆的地方,幾個 lessons:
- gate 用
SemaphoreSlim,不用lock— 上層的 service 是 async(InitializeAsync/ShutdownAsync),同樣的 gate 也要能裝WaitAsync(雖然這版用Wait()),語意一致 existCard要 cache — 0→1 那一次 Delta 回傳「找到幾張卡」,後續 acquire 不能再叫一次(會被當第二次 init),要回傳 cache- 負數 refcount 一定會發生 — 客戶程式雙重 shutdown、catch 例外時順便 Destroy …。clamp 回 0 並 log warning 比 throw 安全
- 測試 seam 一定要留 —
OpenFunc/CloseFunc設成 public static delegate,預設指向真實 P/Invoke。測試只要在 setUp 把它們換成 lambda,就能完整驗證 refcount 語意而不需要硬體:
EtherCATMasterLifetime.OpenFunc = (ref ushort existCard) => {
Interlocked.Increment(ref _openCallCount);
existCard = 1;
return 0;
};
// 然後跑 8 個並行 AcquireFirstOpen,斷言 _openCallCount == 1
Pattern 4:native callback ↔ managed delegate
客戶想自帶 logger 時,要把一個 __stdcall 的 C 函式指標餵進 managed code。靠 Marshal.GetDelegateForFunctionPointer:
// Logging.cpp
int32_t __stdcall ECat_SetLogCallback(ECat_LogCallback cb)
{
if (cb == nullptr) {
LeYu::CppCli::ActiveLogger::Current = nullptr; // 還原預設 FileLogger
return 0;
}
IntPtr fp(reinterpret_cast<void*>(cb));
auto del = safe_cast<LeYu::ECat::Logging::CallbackLogger::LogCallbackDelegate^>(
Marshal::GetDelegateForFunctionPointer(
fp,
LeYu::ECat::Logging::CallbackLogger::LogCallbackDelegate::typeid));
LeYu::CppCli::ActiveLogger::Current =
gcnew LeYu::ECat::Logging::CallbackLogger(del);
return 0;
}
C# 端的 delegate 型別必須跟 C 簽章對齊到位元組:
// CallbackLogger.cs
public delegate void LogCallbackDelegate(int level, string category, string message);
// LeYuECat.h
typedef void (__stdcall *ECat_LogCallback)(int32_t level, const char* category, const char* message);
注意點:
- C# delegate 雖然寫
string,但 P/Invoke marshaller 在「從 managed 呼叫 native」這個方向會自動把stringmarshal 成LPStr(ANSI char*),跟 C 端的const char*對得上 - 客戶 callback 丟例外一定要在 managed 端 try/catch 吃掉,例外穿越 C ABI 是 UB
Pattern 5:每一個 entry point 都是「try → 轉 error code」
managed exception 不能穿過 C ABI。所有 ECat_* 都長這樣:
int32_t __stdcall ECat_Service_Initialize(void* handle, int32_t* outSuccess)
{
if (handle == nullptr || outSuccess == nullptr) return ERR_PARAMETER;
try {
auto svc = ResolveService(handle);
if (svc == nullptr) return ERR_PARAMETER;
bool ok = svc->InitializeAsync(...)->Result;
*outSuccess = ok ? 1 : 0;
return 0;
}
catch (LeYu::ECat::Core::HardwareException^ hex) {
return (int32_t)hex->ErrorCode; // 業務例外 → 真實 error code
}
catch (System::Exception^ ex) {
LeYu::CppCli::LogManagedException(ex);
return ERR_NOT_SUPPORT; // 其他例外 → 通用 fallback
}
}
HardwareException 帶著原始 Delta error code,可以原樣回給 C++ 端;其餘例外吃掉 + log,回傳 0xF009(ERR_ECAT_NOT_SUPPORT)。客戶 OO 包裝層收到非 0 就丟 EtherCATException,把 code map 成英文訊息 — 整條鏈是 C# exception → error code → C++ exception,C ABI 那一段絕對是純 int32_t。
Pattern 6:字串與陣列 marshalling
字串:
// C++ → C#
System::String^ s = msclr::interop::marshal_as<System::String^>(configPath);
// C# → C++ output buffer(呼叫端配置)
static void CopyStringToBuffer(System::String^ src, char* dst, int32_t dstLen) {
if (dst == nullptr || dstLen <= 0) return;
if (src == nullptr) { dst[0] = '\0'; return; }
std::string s = msclr::interop::marshal_as<std::string>(src);
strncpy(dst, s.c_str(), (size_t)(dstLen - 1));
dst[dstLen - 1] = '\0';
}
陣列:永遠是 T* outBuffer + int32_t bufferCount + int32_t* outActualCount,由 managed 端 copy 進去:
static void CopyDoubleArray(array<double>^ src, double* dst,
int32_t bufferCount, int32_t* outActualCount)
{
int32_t len = (src != nullptr) ? src->Length : 0;
if (outActualCount != nullptr) *outActualCount = len;
if (dst == nullptr || bufferCount <= 0 || src == nullptr) return;
int32_t copyCount = (len < bufferCount) ? len : bufferCount;
for (int32_t i = 0; i < copyCount; i++) dst[i] = src[i];
}
不要把 managed 陣列 pin 起來丟給 C++ 長期持有 — pinning 會卡死 GC 壓縮,而且 C++ 端不知道何時可以放開。Copy 出去最直觀。
Pattern 7:客戶端 OO 包裝是純 inline header
LeYuECat.h 裡的 LeYu::Axis / LeYu::EtherCATService 完全 inline、零成本:
class EtherCATService {
public:
explicit EtherCATService(const std::string& path) : handle_(nullptr) {
detail::Check(::ECat_Service_CreateFromConfig(path.c_str(), &handle_));
}
~EtherCATService() {
if (handle_) { try { ::ECat_Service_Destroy(handle_); } catch(...) {} }
}
EtherCATService(const EtherCATService&) = delete; // 不可 copy
EtherCATService(EtherCATService&& o) noexcept // 可 move
: handle_(o.handle_) { o.handle_ = nullptr; }
bool Initialize() {
int32_t ok = 0;
detail::Check(::ECat_Service_Initialize(handle_, &ok));
return ok != 0;
}
Axis GetAxis(int32_t i) {
void* ah = nullptr;
detail::Check(::ECat_Service_GetAxis(handle_, i, &ah));
return Axis(ah);
}
private:
void* handle_;
};
幾個原則:
- RAII:destructor 一定 swallow exception,避免在棧展開時再丟一次
- Service 是 unique 所有權:
= deletecopy、保留 move - Axis 是 value type:只持有
void*,所有權在 service,destructor 不做事 — 服務銷毀時一併失效 - 所有
extern "C"呼叫都過detail::Check(int32_t):非 0 就 throwEtherCATException並把 code map 成英文字串 - 錯誤碼對照表 inline 在 header 裡:客戶不需要查文件,IDE 跳轉就看得到
避雷清單
- 不要讓 GC 物件的生命週期跨越邊界 — 用 handle table +
gcroot<Object^>代理 - 不要讓 exception 穿透 C ABI — 一定要在 managed/CLI 層 try/catch 轉 error code
- 不要在 native 端長期持有 managed pointer — 用
GCHandle.Alloc(..., Pinned)或 handle table 管理