ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++调用C# DLL完整指南:基于C++/CLI桥接的跨语言互操作实践

C++调用C# DLL完整指南:基于C++/CLI桥接的跨语言互操作实践 简介面向需要在C项目中集成C#程序集功能的开发者这是一份演示C调用C#封装DLL的跨语言集成示例。资源以C#封装动态库、C侧完成加载与调用为主线包含完整解决方案与工程源码并附带了可运行的exe和DLL产物便于对照验证调用链是否打通。压缩包共22个文件以h/cpp源文件、vcproj/sln工程文件、dll/exe运行文件为主另有txt说明和调试记录文件整体约1.09MB结构紧凑且容易定位关键模块。已有1117人学习该资源适合刚接触C/CLI或COM互操作、需要快速搭建混合语言调用流程的初中级开发者参考可从示例工程中直接提取调用模板和封装思路。 如果你的工作机里还跑着十年前写的C桌面程序而新需求是让它去调用一套C#写好的算法库你会怎么办把C#逻辑重写成C那相当于把一套已经稳定运行几年的代码推倒重来怎么看都不划算。我遇到这个场景的时候第一反应就是让C直接加载C#编译出来的DLL把跨语言调用这件事本身解决掉。C调用C# DLL本质上就是让非托管代码跨过托管边界去调用托管代码。这条路上有很多方案真正顺着走一遍之后会发现最难的不是调通那一下而是类型的封送、内存的归属、运行时的匹配这些细节。这篇文章我会用一个完整可跑的样例把C通过C/CLI桥接方式调用C# DLL的整套流程讲清楚包括中间踩过的几个坑和排查思路给正在做类似技术验证的同行一份能直接参考的记录。1. 动手前先盘清路线C调C#不止一条路先说选型。很多人一看到C调C#就问能不能直接把C# DLL做成COM组件或者干脆说用C/CLI。这两种都可行但适用场景完全不同。我这次的需求是C主程序完全保留原生编译不开启/clr不引入托管入口点同时要调用C#类库里的算法而且不希望为了通信做进程拆分。基于这个约束我对比了常见的四种路线。方案跨语言方式C侧是否需要托管支持开发成本运行时开销适用场景C/CLI桥接DLL桥接层编译为托管原生混合程序集不需要调用方保持纯原生低极低同进程直接调用Windows平台、C#库以.NET Framework为主COM互操作C#类库注册为COM组件C通过COM接口调用不需要中中接口封送开销跨语言通用、需要语言无关的接口承载CLRCLR HostingC手动加载.NET运行时用托管API调用不需要高低需要运行时动态决策、不能依赖编译期引用进程间通信C#做成独立服务或子进程不需要中高序列化进程切换主程序需要隔离性或跨机器调用我最终选的是C/CLI桥接。原因是这个方案在四者中开发量最小C调用方完全不需要知道托管的存在桥接层把C#对象包装成原生类来用连参数类型转换也不用手工做太多。COM互操作需要给C#类写Guid、配注册表托管对象生命周期管理还容易出问题CLR Hosting则要自己搞定程序集加载、AppDomain管理、反射调用这一大套东西工作量直接上一个量级。至于进程间通信性能损耗大还要处理序列化和进程崩溃隔离不适合我这套紧耦合调用的场景。2. 搭一个最小可跑通的三层结构这部分的思路是C#类库只做业务实现C/CLI做一层很薄的包装把托管对象暴露成C能直接用的原生类纯C程序只依赖这个包装类。下面我把三层代码和项目配置都列出来方便直接照着建工程跑一遍。2.1 第一层C#类库设计C#类库其实没什么特殊设计就是把你要对外暴露的功能做成public类和方法。唯一注意点是方法参数和返回值尽量用基础类型比如double、int、string、数组不要依赖自定义复杂类型否则后面桥接层转换会很痛苦。这是一个非常基础但影响深远的约束。using System; namespace CSharpLib { public class Calculator { public double Add(double a, double b) { return a b; } public string GetVersion() { return 1.0.0; } public int[] GetSquareArray(int count) { var result new int[count]; for (int i 0; i count; i) { result[i] i * i; } return result; } } }编译目标我建议选.NET Framework 4.6.1以上。原因后面会讲到C/CLI对.NET Framework的兼容性最好如果你把C#库做成.NET 6桥接项目就要额外配置成netcore模式复杂度瞬间上来了。2.2 第二层C/CLI桥接项目新建一个C类库项目比如叫CppBridge然后在项目属性里把公共语言运行时支持设置为公共语言运行时支持 (/clr)。这一步是桥接层能引用托管程序集的前提。接着在解决方案引用里添加CSharpLib项目引用。项目的核心有两个文件一个头文件暴露原生类接口一个cpp实现包装逻辑。Bridge.h#pragma once #ifdef BRIDGE_EXPORTS #define BRIDGE_API __declspec(dllexport) #else #define BRIDGE_API __declspec(dllimport) #endif #include vcclr.h #using CSharpLib.dll namespace CppBridge { class NativeCalculator { public: BRIDGE_API NativeCalculator(); BRIDGE_API ~NativeCalculator(); BRIDGE_API double Add(double a, double b); BRIDGE_API void GetVersion(char* outBuffer, int bufferSize); BRIDGE_API void GetSquareArray(int count, int* output); private: gcrootCSharpLib::Calculator^ _impl; }; }这里有个关键的细节NativeCalculator是原生类但它内部要持有C#对象引用直接声明CSharpLib::Calculator^ _impl是做不到的因为原生类不能直接含托管句柄成员。正确做法是用gcrootT这个模板包装类它专门用于在原生类里保存托管引用析构时自动释放。Bridge.cpp#include Bridge.h #include stdexcept #include cstring using namespace System; using namespace System::Runtime::InteropServices; namespace CppBridge { NativeCalculator::NativeCalculator() { _impl gcnew CSharpLib::Calculator(); } NativeCalculator::~NativeCalculator() { // gcroot会自动释放托管引用这里无需额外操作 } double NativeCalculator::Add(double a, double b) { try { return _impl-Add(a, b); } catch (System::Exception^ ex) { System::Console::Error-WriteLine(ex-ToString()); throw std::runtime_error(C# method threw exception); } } void NativeCalculator::GetVersion(char* outBuffer, int bufferSize) { System::String^ versionStr _impl-GetVersion(); const char* ansi (const char*)Marshal::StringToHGlobalAnsi(versionStr).ToPointer(); strncpy_s(outBuffer, bufferSize, ansi, bufferSize - 1); Marshal::FreeHGlobal(IntPtr((void*)ansi)); } void NativeCalculator::GetSquareArray(int count, int* output) { arrayint^ managedArr _impl-GetSquareArray(count); for (int i 0; i count; i) { output[i] managedArr[i]; } } }这段代码里Marshal::StringToHGlobalAnsi是把C#的System::String转成非托管内存里的ANSI字符串用完马上调用Marshal::FreeHGlobal释放这是跨语言字符串处理的标准动作。如果你只转不释放每次调用就泄漏一块内存在长驻服务里很快就会把内存撑爆。我习惯上不让桥接层直接返回const char*而是让调用方传入缓冲区把内存归属权明确留给调用方这样后续维护会省心很多。编译之后CppBridge项目会产出CppBridge.dll和CppBridge.lib。DLL同时包含托管代码和原生代码所以叫“混合模式程序集”这也是这个方案的核心载体。2.3 第三层纯C调用方调用方就是一个普通的C控制台程序完全不用开启/clr。这样主程序继续以纯原生应用的方式编译、部署跟以前没有任何区别。#include iostream #include vector #include Bridge.h int main() { CppBridge::NativeCalculator calc; double r calc.Add(3.14, 2.86); std::cout Add(3.14, 2.86) r std::endl; char version[32] { 0 }; calc.GetVersion(version, sizeof(version)); std::cout Version: version std::endl; const int count 10; std::vectorint data(count, 0); calc.GetSquareArray(count, data.data()); for (int i 0; i count; i) { std::cout data[i] ; } std::cout std::endl; return 0; }调用方项目需要配置三处附加包含目录指向Bridge.h所在目录附加库目录指向CppBridge.lib所在目录附加依赖项里写上CppBridge.lib。生成事件里把CppBridge.dll和CSharpLib.dll复制到exe输出目录。这些都是常规C链接配置不展开说了。如果你不想在调用方项目里配置lib链接也可以把桥接层改成导出C语言接口配合LoadLibrary/GetProcAddress动态加载。无非是把类成员函数改写成几个extern C的函数比如void* CreateCalculator()、void DestroyCalculator(void*)然后内部用void指针承载NativeCalculator对象指针。这种方法适合做插件系统或者调用方不想暴露头文件的场景。3. 跨边界的数据转换字符串、数组、异常都是重灾区数据跨过托管边界这件事比大多数人想的要麻烦。它有明确的规则基础数值类型比如double、int可以直接传但字符串、数组、对象这些引用类型必须经过封送处理。“封送”说白了就是把托管内存里的对象转换成非托管代码能访问的形态这个过程很容易踩坑。3.1 字符串的几种处理姿势字符串是整个封送里最容易出错的地方尤其是内存释放。C#的System::String是一块托管堆上的UTF-16数据C那边如果拿const char*指针直接读等于访问一块随时可能被垃圾回收搬走的地址行为完全未定义。我踩过的错误做法是在桥接层里用Marshal::StringToHGlobalAnsi转出一个char*指针直接作为返回值丢给C调用方。表面上能跑但调用方用完这个指针后并不知道要释放它于是每次调用泄漏。更隐蔽的是如果你在别的线程里使用了它HGlobal虽然不受GC影响但它本质上还是非托管内存没有人释放就永远不还回去。所以更稳的方案是调用方传缓冲区进来像上面的GetVersion那样。桥接层负责把托管字符串拷贝进缓冲区拷贝完成后立刻释放临时分配的内存谁也不欠谁的。这对于接口设计来说也是最清晰的一种模型内存所有权属于调用方桥接层只是临时借用。实际上在C/CLI里还有第三种做法就是把System::String转成std::string作为返回值。因为std::string自己管理内存调用方不会出现悬挂或泄漏。这个方法我后来也常用但需要引入msclr命名空间并且要确保桥接层和调用方都用同一个运行时库配置不然std::string的内存管理和调用方不匹配又会埋下新坑。例子我就不展开了记住原则返回std::string比返回char*安全传缓冲区比返回指针更清晰。3.2 数组和批量数据的小技巧数组跨边界要小心一件事C#的int[]在托管堆里是连续内存看起来跟原生int数组很像但你不能直接拿指针过去用。一旦数组被GC压缩移动原生侧拿到的指针就失效了。最朴素的办法是像上面示例那样在桥接层用for循环把托管数组元素逐个拷到原生数组里简单可靠性能也在可接受范围。如果你的数据量特别大比如几十万条记录逐元素拷贝开销就不能忽略了。这时可以考虑让C#侧直接把数据写入C传入的非托管内存缓冲区。C#里可以用Marshal.Copy(byte[], int, IntPtr, int)来干这事C侧预先分配好内存再把指针传给桥接层。这样只做一次拷贝不经过逐元素封送。极端情况下还可以用fixed关键字把C#数组钉在托管堆上然后把指针传给C但这样会抑制GC压缩属于用性能换便利能不用就不用。3.3 异常一定要在桥接层截住这是我最想强调的一点。C#抛出的异常在跨越到原生侧时表现形式取决于运行时C侧不主动捕获的话轻则程序直接崩溃重则出现各种莫名奇妙的运行错误。你在C#里明明写了try/catch但异常还是可能在封送环节变成SEH异常导致紧邻的调用点直接终止进程。所以桥接层里的每个方法都应该在调用C#方法的外面包一层try/catch。捕获System::Exception后记录日志然后要么返回错误码要么抛一个原生侧能理解的std::runtime_error。我上面的示例采用了后者因为调用方用try/catch处理std::exception更符合C习惯。这里我建议日志信息写全一些包括异常类型和堆栈不然生产环境里C#那边抛错了这边只有一个模糊的错误码排查效率会很低。4. 真实项目里翻车最多的几个环节跑通Hello WOrld级别的调用只是第一步真正放到实际项目里你会碰到一堆环境相关的问题。我根据自己的实测经验把翻车率最高的几个问题列出来每一个都附上排查链路。4.1 平台位数不一致最隐蔽的崩溃原因之一这套方案里C#类库和C/CLI桥接DLL的平台必须一致但很多人会在C#那边沿用默认的AnyCPU。AnyCPU编译出来的程序集有个特性跑在x64进程里就是64位的跑在x86进程里就是32位的。看起来没毛病但C/CLI桥接DLL不支持AnyCPU它必须指定x86或x64。一旦调用方进程位数和桥接DLL不一致加载DLL时就会报出奇怪的错误。我当时遇到的现象是程序启动时偶尔正常偶尔在加载C#程序集时抛BadImageFormatException错误信息甚至会误导人以为是C#程序集损坏。排查办法是先确认所有项目的目标平台右键项目-属性-生成-平台目标以及C项目的“配置管理器”里要保证所有项目都使用同一平台。最省事的做法是全部项目统一设成x64因为这个年代还能跑C/CLI的机器基本都是64位系统。如果你还是不小心配成AnyCPU和其他平台混用可以从事件查看器里捞到真实的加载错误里面会明确说“尝试加载格式不正确的程序”或者类似信息。这个错误一出现基本就是位数匹配问题不用先怀疑代码。4.2 .NET运行时版本对不上报错名称很迷惑C/CLI桥接DLL在加载时会在进程内启动CLR。它启动的是哪个版本的运行时取决于编译桥接DLL时设定的目标框架。如果你的C#类库用了.NET Framework 4.8编译而桥接DLL目标框架是.NET Framework 4.5运行时会在运行时做绑定重定向。可如果C#类库用的依赖包要求更高版本的CLR桥接层加载时就会抛FileLoadException或者TypeLoadException。这类报错往往一眼看不出和.NET版本有什么关系比如“未能加载文件或程序集CSharpLib, Version1.0.0.0”或者“无法将类型从X转换为Y”。我排查这类问题的标准流程是先用fuslogvw.exe程序集绑定日志查看器开启程序集绑定日志再跑一次程序日志里会非常明确地告诉你程序集加载失败的具体原因和位置。绝大多数情况下问题都可以通过把桥接DLL的目标框架统一改成和C#类库一致来根治。这里特别提醒一下如果你的C#库已经迁移到.NET 8.NET Core系C/CLI桥接就需要用VS2022的/clr:netcore模式来编译项目配置里要把公共语言运行时支持改成“.NET Core和.NET 5公共语言运行时支持”。不是不行但涉及更多的配置和版本要求所以在技术选型阶段就要确认好C#库的框架版本。4.3 调试器要设成混合模式否则C#断点跑不到很多人第一次在VS里调试这个三层架构时会发现C代码断点能命中C#代码里的断点却一直显示“不会命中当前未命中断点”。这不是代码问题而是调试器的类型配置问题。C/CLI桥接DLL是混合程序集你必须在启动项目的调试设置里开启“启用本机代码调试”和“启用托管代码调试”。具体操作是右键启动项目-属性-调试-“调试器类型”把默认的“仅本机”改成“混合”或“托管和本机”。这样你就可以从C调用一路跟到C#方法里面两个世界共享一套断点。顺带一提如果你用的是附加到进程的方式也要在附加对话框里同时选上“本机代码”和“托管代码”类型缺一个都会出现同样的问题。5. 部署与长期维护的几点忠告桥接方案跑通只是第一步后续的部署和维护才是决定这个方案能不能落地的地方。我在这里总结几个用真金白银换来的经验。5.1 文件依赖C# DLL和桥接DLL必须在一起混合模式程序集和普通C DLL不一样它依赖的东西不止一个VC运行库还依赖目标.NET Framework运行时和C#类库本身的依赖项。换句话说你发布时不能只复制CppBridge.dll必须把它引用的CSharpLib.dll以及C#类库引用的所有第三方程序集如果有一起放在可执行文件目录或专门配置的探测路径下。我遇到过一个特别容易迷惑人的情况程序启动时报“找不到指定的模块”系统错误码0x8007007E而不是“找不到程序集”。当时第一反应是某个DLL缺失一查发现CppBridge.dll和CSharpLib.dll都在最后定位到问题是C#类库内部依赖的一个原生第三方库没被一起拷走。因为托管程序集加载时如果它依赖的原生模块缺失异常就会以找不到模块的形式冒出来。这类问题建议直接用Dependency Walker或者Process Explorer查看模块加载情况能少走很多弯路。5.2 别让桥接层发胖C/CLI桥接项目最忌讳的是把业务逻辑也放进去。这层代码应该保持“薄”只负责类型转换和调用转发不写任何算法和业务规则。原因有几个第一/clr编译模式下C的一些特性会受限很多标准库操作和静态对象初始化行为都会变得很微妙第二桥接层一旦复杂了编译速度直线下降代码审查也更难第三将来如果C#类库内部重构影响面就控制在这层薄包装里改动量可以很小。我给自己定的规矩是桥接层每个方法最多10行超过这个量就说明应该在C#侧做封装或者把转换逻辑下沉到C#侧。比如你想把C#返回的复杂对象转成C结构体宁可在C#类库里加一个专门返回简单数据的方法也别在桥接层里做一堆字段转换。5.3 性能上的两个小修正C调C#必然有跨过托管边界的一次开销但这个开销可以控制。第一个经验是批量调用代替逐条调用。如果你要从C#拿1000条数据一次性调用一个返回数组的方法比循环调用1000次单条取数的方法要快很多因为每次跨边界调用都有上下文切换和GC探测的开销。第二个经验是不要在循环里频繁创建C#对象。桥接层里那个gcroot对象一旦构造好就尽量长期复用频繁构造和销毁托管对象会明显增加GC压力。另外如果是高频调用且对延迟极其敏感的场景建议在真正投入生产前用Stopwatch实测一下单次调用耗时和3000次调用的累积耗时心里有数。网络上有不少文章说C/CLI调用托管方法的单次开销在微秒级别实测下来影响主要来自封送复杂参数纯数值传递确实很接近原生函数的性能。如果你发现性能不符合预期优先检查参数类型是否用了过于复杂的封送配置而不是急着推翻方案。本文还有配套的精品资源点击获取
返回列表