跳至主要内容

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 WrapperFramework 4.8 ✅ / .NET 5+ ✅/clr 模式同時吃 managed + native中(多一顆 DLL)CLR 啟動成本工控 / 設備軟體、既有 .NET Framework 生態
Native AOT + UnmanagedCallersOnly.NET 7+ only ❌ FrameworkAOT 編譯成純 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(不能用 stringList<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 成英文字串的 inline switch、以及繼承 std::runtime_errorEtherCATException。客戶端只需要這一個 .h 檔。
  • CppCli/(thin /clr shim)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

只走 x64AnyCPU / 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 旁邊的固定檔名」,雙卡情境下會 race
  • g_serviceAxisHandles — service Initialize 後一次預註冊所有 axis handle,後續 GetAxis(i) 直接從 cache 拿。沒這層的話高頻 polling 每秒都在長 HandleTable
  • g_shutDownServices — 客戶顯式 Shutdown() 後 destructor 又跑一次 Shutdown() 是常見寫法,沒這個 flag 會把全域 refcount 推到負數

Pattern 3:跨 N instance 的 process-wide 資源 refcount

Delta 的 _ECAT_Master_Open / _ECAT_Master_Closeprocess 全域只能呼叫一次,但 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」這個方向會自動把 string marshal 成 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 所有權= delete copy、保留 move
  • Axis 是 value type:只持有 void*,所有權在 service,destructor 不做事 — 服務銷毀時一併失效
  • 所有 extern "C" 呼叫都過 detail::Check(int32_t):非 0 就 throw EtherCATException 並把 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 管理
  • 不要忽略 CallingConvention 不一致 — 會直接 stack 爆掉,錯誤訊息超難查
  • 不要混用 x86/x64 — BadImageFormatException 99% 是這個
  • 不要把 process-singleton 的 native init 當 instance-level 處理 — 多 instance 場景必爆,要做 refcount
  • 不要把 managed 陣列 pin 起來給 C++ 長期持有 — 改成「呼叫端配 buffer,managed 端 copy」
  • 不要把測試耦合到真實硬體 — 把跨界 P/Invoke delegate 設成可注入的 static field,測試用 lambda 取代
  • 不要用會改寫 module init / metadata 的 obfuscator(如 .NET Reactor 的 NecroBit)保護給 C++/CLI 載入的 managed DLL — 會在 mixed-mode init 階段 cctor 炸 NRE → mscorlib recursive resource lookup → CLR FailFast。詳見 .NET Reactor × C++/CLI 踩雷
  • Native AOT 不是萬用:需要 C++ class、std::vector、例外自然傳遞、COM 註冊、跨 process 等場景,都不該走 Native AOT

對半導體設備控制軟體的具體建議

情境 A:既有 Motion / PLC / Vision SDK(C++)要 call C# 控制邏輯

C++/CLI Wrapper

工業場景最標準、Visual Studio 原生支援、ASE/ChipMOS FAE 除錯最直覺。LeYuECat 就是走這條,可以直接拿來當參考實作。

情境 B:TwinCore 中介層 / UE5 視覺化要 natively 呼叫

.NET 8 + Native AOT + UnmanagedCallersOnly

這部分切出獨立模組。產出純 native DLL,UE5 端整合最乾淨,也沒有 CLR 啟動延遲影響即時模擬。

情境 C:純粹想把單一 C# 函式露給 C++ 用,又不想動架構

DllExport 社群套件

最省事,但要評估長期維護風險(非官方、IL 後處理)。


參考來源

LeYuECat 內部專案

  • D:\Documents\LeYu\Workspace\EtherCAT.Cpp.Wrapper(Forgejo: Leyu/EtherCAT.Cpp.Wrapper)— 本文 case study 的實作
    • src/CppHeader/include/LeYuECat.h — 客戶面 header
    • src/CppCli/Exports.cpp / HandleTable.cpp / Logging.cpp/clr 橋實作
    • src/Managed/Lifetime/EtherCATMasterLifetime.cs — refcount 範例
    • src/Managed/Managed.Tests/LifetimeTests.cs — delegate seam 測試範例
    • samples/03_TwoCards_Parallel/main.cpp — 雙卡併行測試 lifetime

衍生踩雷紀錄

C++/CLI 橋接方案

.NET Native AOT + UnmanagedCallersOnly(現代官方方案)

DNNE(非 AOT 的現代方案)

  • AaronRobinsonMSFT/DNNE — GitHub 官方 README。說明 dnne-gen 產生 native shim、NE 後綴 binary、產出 .h/.lib、RID 設定、DnneWindowsExportsDef 等 MSBuild 屬性

DllExport(.NET Framework 4.8 社群方案)

P/Invoke 反向參考(C ABI 邊界設計)