恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C++/CLI桥接C#与C++:三层架构混编工程实战
首页
资讯中心
/
C++/CLI桥接C#与C++:三层架构混编工程实战
C++/CLI桥接C#与C++:三层架构混编工程实战
发布时间:2026/9/20 15:30:46
简介面向需要在C与C#之间搭建互操作桥接的开发者这份工程实例以C/CLICLR作为中间层系统演示了从C原生类封装、托管包装类生成到C#项目引用与调用的完整流程。资源包共包含117个文件压缩包大小约17.53MB文件类型涵盖C/CLI中间层工程文件、C源码、C#调用工程文件以及编译生成的动态库、调试文件、可执行文件等目录结构清晰便于对照学习跨语言调用的代码组织。工程源码完整保留可在本地直接编译运行并借助调试文件进行断点跟踪。目前已有159人浏览学习。通过本实例可以逐一掌握gcroot包装、托管类与非托管类映射、#pragma managed指令的用法并重点理解异常处理、数据类型转换、编译平台配置等互操作中的易错环节。对于有一定C或C#基础、希望实现两种语言混编的中高级开发者而言这是一份可直接参考或套用的完整工程模板。 很多项目跑到最后都会撞上同一堵墙核心算法和底层采集是用 C 写的业务逻辑和界面却是 C# 的项目两边必须互相调用。我第一次做这种混编需求时也想偷懒用 C# 重写一遍 C 算法结果被现实教育了——几万行老代码、各种第三方原生库、实时采集线程哪是说重写就能重写的。后来老老实实走了一条微软官方早就铺好的路用 C/CLICLR做中间层把 C# 类库桥接给 C 调用。这条路我完整走过一遍踩过的坑比坑还多今天把整个工程实例拆开讲清楚代码可以直接抄配置可以直接用重点是让后来的人少熬几个夜。我要分享的是三层结构C# 类库负责业务实现C/CLI 桥接 DLL 做翻译官纯 C 程序负责调用。你不用把 C# 代码改成 C也不用搞 COM、更不用开进程间通信所有类型转换都集中在桥接层完成业务代码两边都不污染。这个方案适合谁适合手里有存量 C 代码、又需要引入 C# 组件或 SDK 的开发者也适合第一次接触混编、想在工程里验证 C/CLI 可行性的朋友。1. 为什么 C 和 C# 之间必须有一座桥1.1 两种语言天生不在一个世界里C 默认编译成原生机器码自己管理内存对象活在原生堆上C# 编译成 IL由 CLR 在托管堆上分配对象内存由垃圾回收器统一管。这两套运行时各有各的规矩直接互相调用对方的对象是不可能的——你甚至没法在 C 代码里写一个new 一个C#对象因为编译器根本不知道 CLR 的存在。有人会问那 C# 不也提供了 P/Invoke可以直接调 C 的 DLL 吗没错C# 调 C 可以用 DllImport但那解决的是“C 函数暴露成 C 接口”的问题。反过来C 要调 C# 的类、事件、泛型、异步方法P/Invoke 完全使不上劲因为 C# 那头没有一个对应的 C 接口可以给你导入。COM 也能互通但你得给 C# 类写 COM 注册、处理 GUID、接口定义维护成本非常高一个项目里 COM 和 CLR 混在一起调试那酸爽谁试谁知道。说到底就是两个运行时之间缺一个翻译层。微软给这个翻译层起了个很直白的名字C/CLI也常写成 C/CLR。它原本叫 Managed C是 C 在微软平台上的一种方言编译出来的程序集既包含原生代码又包含托管代码可以在同一个 DLL 里同时操作原生对象和托管对象。1.2 桥接层到底解决了什么问题C/CLI 最大的价值就是它允许你在一个源文件里同时写两套语法用^声明托管句柄用*声明原生指针用gcnew创建托管对象用new创建原生对象托管类用ref class声明原生类用class声明。两边对象的转换、方法的调用、事件和回调的转发全都由这层代码完成。这其实就是设计模式里的桥接模式思想C 是抽象C# 是实现桥接层把变化隔离开。业务逻辑如果变了只改 C# 类库调用方式变了只改 C 侧调用代码。桥接层只做翻译不写业务。我在实际项目里踩过一个反面教材图省事把一些计算逻辑直接写在桥接层后来要复用 C# 计算结果桥接层反而成了逻辑黑洞改一处要重新编三个项目。桥接层薄系统才好维护。2. 工程搭建VS 解决方案与项目结构2.1 三层结构怎么划分这一节直接给出工程骨架。在 Visual Studio 里建一个解决方案里面放三个项目项目语言输出类型作用CsLibraryC#类库业务实现、算法封装、事件定义BridgeLibraryC/CLIDLL引用 C# 类库把托管类型包装成原生类供 C 调用NativeAppC控制台/桌面程序原生 C 入口调用桥接层导出的原生类为什么要单独建一个 BridgeLibrary 而不是在 NativeApp 里直接开/clr因为纯 C 项目里一旦开启/clr编译整个项目的代码都要受 CLR 约束很多第三方 C 库根本没法用/clr编译会产生一堆链接错误。更难受的是你项目里所有的原生代码都会被扯进托管生态哪天想剥离出来就晚了。桥接层独立成 DLL 之后原生程序只需要链接一个普通导入库程序本身不开/clr对原有 C 工程几乎是零侵入。2.2 关键配置/clr 与平台一致性BridgeLibrary 项目属性里必须设置“公共语言运行时支持”为“公共语言运行时支持 (/clr)”。这个选项在“配置属性 - 常规”下面。要注意的是C/CLI 项目和 C# 类库有一个不太直观的坑平台目标必须一致。C# 项目默认是 AnyCPU在 64 位操作系统上跑的时候JIT 会把它编译成 64 位而 C/CLI 的 DLL 是混合程序集平台必须明确指定。如果 C/CLI 桥接层选 x64C# 那边还是 AnyCPU运行时经常会出现“试图加载格式不正确的程序”这个经典错误。我的做法是解决方案里所有项目包括 C# 和桥接层平台都统一设成 x64 或 x86绝不在一个混合方案里混用。如果你建的 C# 项目只有 AnyCPU可以打开“配置管理器”手动添加 x64 平台。还有 Visual Studio 的 C 工程默认开启/RTC1运行时错误检查这个选项和/clr是不兼容的。遇到编译错误 C4793 或链接报错去“配置属性 - C/C - 代码生成 - 基本运行时检查”把它设成“默认值”即可。我当时第一次编译 BridgeLibrary被这个选项折腾了大半天后来凡是建 C/CLI 项目第一步就关掉它。CMake 用户也可以配本质就是加/clr编译选项比如target_compile_options(BridgeLibrary PRIVATE /clr)。但我个人建议混编工程直接用 VS 解决方案管理因为 C/CLI 项目涉及引用、部署、调试VS 的可视化配置会省心很多。你要是平时用 vscode 写 C建议这个混合工程回到 VS 里编译调试vscode 对 C/CLI 的 IntelliSense 和调试支持非常弱。3. 桥接层实现从 C# 类到 C 类的完整过程3.1 C# 端一个带事件的计算器类先用一个经典的计算器类做演示这个类包含一个普通方法、一个返回字符串的方法、一个事件基本覆盖了日常混编场景里的三种调用形态。namespace CsLibrary { public delegate void ResultHandler(double value); public class Calculator { public event ResultHandler? OnResult; public double Add(double a, double b) { double result a b; OnResult?.Invoke(result); return result; } public string GetVersion() { return 1.0.0; } } }为什么这个类要特意加一个事件因为在真实项目里C# 库往往会通过事件上报进度、扫码枪触发、心跳通知等异步消息C 侧如果只能调方法不能收事件那等于被砍了一条腿。事件转发是桥接层里最容易被轻视、也最容易写崩的环节后面我会专门讲。3.2 C/CLI 桥接用 gcroot 包住托管对象桥接层是重头戏。这里的关键是你最终想暴露给原生 C 的是一个普普通通的 C 类而不是托管类。因为原生 C 项目不开/clr代码里不能出现^和gcnew。那桥接层怎么在内部保存托管对象答案是gcrootT。gcrootT是微软提供的一个模板类它内部封装了一个GCHandle让原生类可以安全地持有一个托管对象的引用。你可以把它理解成一根“锚链”把托管对象钉在原生对象上垃圾回收器不会随便回收它。演示代码如下。头文件CalculatorBridge.h#pragma once #include vcclr.h #include string #ifdef BRIDGE_EXPORTS #define BRIDGE_API __declspec(dllexport) #else #define BRIDGE_API __declspec(dllimport) #endif class CalculatorBridge { public: CalculatorBridge(); ~CalculatorBridge(); double Add(double a, double b); std::string GetVersion(); private: gcrootCsLibrary::Calculator^ _calc; };实现文件CalculatorBridge.cpp#include CalculatorBridge.h #using CsLibrary.dll CalculatorBridge::CalculatorBridge() { _calc gcnew CsLibrary::Calculator(); } CalculatorBridge::~CalculatorBridge() { // gcroot 析构时会自动释放托管句柄这里不需要手动 delete } double CalculatorBridge::Add(double a, double b) { return _calc-Add(a, b); } std::string CalculatorBridge::GetVersion() { System::String^ version _calc-GetVersion(); return std::string( (const char*)System::Runtime::InteropServices::Marshal::StringToHGlobalAnsi(version).ToPointer() ); }这里有三个需要重点解释的细节。第一#using CsLibrary.dll是 C/CLI 引用托管程序集的方式相当于 C# 里的“添加引用”。在实现文件里写#using只是兜底更规范的做法是在 VS 项目引用里添加对 CsLibrary 的引用VS 会替你处理程序集路径。第二在 C/CLI 项目里你可以直接用CsLibrary::Calculator^这样的托管句柄不用写gcnew的时候加命名空间限定。但要注意gcroot模板的头文件是vcclr.h很多刚入坑的人漏了这个头文件编译报“gcroot 未定义”还一脸懵。第三字符串返回是必须处理的。C# 的string是 UTF-16 的托管字符串C 的std::string是字节串桥接层必须做编组转换。上面代码用了Marshal::StringToHGlobalAnsi转成 ANSI 字节串再拷贝到std::string用完之后要记得Marshal::FreeHGlobal释放非托管内存上面示例为了简洁没写实际工程里必须补上否则每次调用都泄漏一块非托管内存。3.3 事件与回调怎么传到 C要让 C 侧收到 C# 事件最合理的办法是把事件转成原生回调函数指针。C/CLI 里可以用Marshal::GetFunctionPointerForDelegate把一个托管委托转成函数指针转发给原生代码。但这里面有一个大坑委托对象如果不保持引用垃圾回收器会随时回收它回收之后再调用函数指针程序直接崩溃而且崩溃现场非常诡异往往是几百毫秒之后在完全不相干的地方崩掉。所以桥接层的做法是聚合一个托管委托把它和原生回调绑定起来。// 托管回调转发类 public delegate void NativeCallback(double value); public ref class CallbackWrapper { private: CsLibrary::ResultHandler^ _csHandler; NativeCallback^ _nativeCallback; public: CallbackWrapper(NativeCallback^ callback) { _nativeCallback callback; _csHandler gcnew CsLibrary::ResultHandler(this, CallbackWrapper::FireEvent); } void FireEvent(double value) { _nativeCallback(value); } CsLibrary::ResultHandler^ GetHandler() { return _csHandler; } };然后在CalculatorBridge里持有一个gcrootCallbackWrapper^的成员在构造函数里创建它并把原生函数指针保存到原生成员变量里。这样委托链始终有引用不会被 GC 回收C 侧拿到的函数指针也永远是有效的。这个方法我强烈建议直接背下来它是我在多个工程里验证过最稳的事件桥接方案。4. 原生 C 侧接入与验证4.1 在纯 C 程序里调用桥接类桥接层完成后原生 C 程序的使用方式非常简单它看不到任何 C# 的影子就是一个普通 C 类调用。注意 NativeApp 项目不需要开启/clr。#include iostream #include CalculatorBridge.h int main() { CalculatorBridge bridge; double sum bridge.Add(1.2, 3.4); std::cout sum sum std::endl; std::cout version bridge.GetVersion() std::endl; return 0; }在项目配置上需要告诉 NativeApp 去哪找桥接 DLL 的头文件和导入库.lib。一个是“C/C - 常规 - 附加包含目录”把 BridgeLibrary 的头文件目录加进去另一个是“链接器 - 常规 - 附加库目录”和“链接器 - 输入 - 附加依赖项”把 BridgeLibrary 的.lib加进去。运行时把 BridgeLibrary.dll 和 CsLibrary.dll 复制到 NativeApp 的输出目录我一般通过“生成事件 - 后期生成事件”写一条copy命令搞定省得手动丢文件。这一步之所以顺畅正是因为桥接层对外暴露的是原生导出类。如果你的桥接层走托管类暴露原生项目就必须开/clr才能用那前面说的低侵入优势就全没了。4.2 验证要点从编译到跑通工程搭建完第一件事不是写业务而是验证整条链路能不能通。我的验证顺序是这样的先编译 CsLibrary确认 C# 类库能正常生成 DLL。再编译 BridgeLibrary确认#using能找到 CsLibrary确认编译没有/clr相关报错。最后编译 NativeApp确认链接器能找到导入库运行起来没有“找不到 DLL”的弹窗。运行程序先测普通方法返回再测事件回调有没有触发。只要你第二步里的 BridgeLibrary 能编过基本就成功了一大半。混编工程最怕的不是代码写不对而是编译链断掉所以一定要按这个顺序逐层排查。4.3 调试技巧两个调试器同时工作调试这种混编工程有个非常实用的技巧把 NativeApp 设为启动项目然后在 VS 里同时打开 BridgeLibrary.cpp 和 CsLibrary.cs分别在各层代码里打断点。只要项目属性里“调试 - 调试器类型”设置为“混合”VS 会同时启用原生调试器和托管调试器C、C/CLI、C# 代码之间可以无缝断点跟踪。这个功能我第一次用的时候简直惊了从 C 调用点一路步进到 C# 的 Add 方法里再步进回来整个调用链看得清清楚楚。排查混编问题这个必须会用。5. 实战中绕不开的坑这一节把我在工程实践里踩过的典型问题整理成速查表不一定覆盖所有情况但每一条都是真实 debug 出来的。常见问题根本原因解决方式编译报 C4793 错误/clr与/RTC1不兼容代码生成 - 基本运行时检查设为默认值运行时出现“试图加载格式不正确的程序”C/CLI 与 C# 平台目标不一致所有项目统一 x64 或 x86找不到 CsLibrary.dllC# 程序集没有复制到桥接层输出目录通过项目引用并设置“复制本地”或后期生成事件 copy调用委托时程序随机崩溃委托被垃圾回收器回收用 GCHandle 或类成员持有委托引用std::string 中文乱码托管字符串编组编码不对用 StringToHGlobalUTF8 或显式指定编码在非托管线程里访问托管对象线程未附加到 CLR用System::Threading::Thread或手动附加运行时有几个点要特别说明。第一关于字符串编组我上面示例用StringToHGlobalAnsi只是为了演示简单。实际项目里如果涉及中文推荐用StringToHGlobalUTF8因为 UTF-8 在 C 侧处理字符串更通用不容易踩本地代码页的坑。转换完记得用Marshal::FreeHGlobal释放我习惯用一个 RAII 封装类自动管理避免每处手动释放。第二关于非托管线程调用托管代码这个坑最隐蔽。如果你在 C 侧自己开了一个std::thread在回调里直接调桥接层方法首次调用时 CLR 可能还没有在当前线程上初始化程序会直接崩溃。解决办法是尽量所有线程都从托管侧启动或者在线程入口处用System::Threading::Thread::CurrentThread触发 CLR 初始化。最稳妥的做法所有桥接调用都发生在同一个主线程上或者把任务通过队列投递到主线程执行这样能规避一整套线程附加问题。第三不要在桥接层写业务逻辑。这算是我个人最有体会的一条经验。桥接层最有价值的形态是“薄”只做类型转换和调用转发。一旦你开始把算法判断、日志拼接、数据缓存写进桥接层后面 C# 侧改了逻辑你得重新编译两个项目而且 C/CLI 代码的调试体验永远比不上纯 C# 或纯 C自己坑自己。我后来新项目的桥接层代码量压缩到全部工程代码的 15% 以内每个方法基本就是“取参数 - 调 C# - 转类型 - 返回”四行非常舒服。C/CLI 这条桥虽然在微软的官方推荐列表里有点“冷门”但在 Windows 平台混编方案里它能做到的事情其他方案很难取代。你在 Native C 的项目里加一个桥接 DLL就像给两个不同语言的团队配了一个专职翻译两边各干各的翻译管好翻译这才是干净的架构。我个人现在做这类需求已经形成固定路径了C# 只写实现C 只写场景桥接层只写转换十年内这套思路我觉得都不过时。本文还有配套的精品资源点击获取