C++调用C# DLL完整指南:基于C++/CLI桥接的跨语言互操作实践
2026/9/2 2:18:56 网站建设 项目流程

简介:面向需要在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接口调用不需要中,接口封送开销跨语言通用、需要语言无关的接口
承载CLR(CLR Hosting)C++手动加载.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: gcroot<CSharpLib::Calculator^> _impl; }; }

这里有个关键的细节:NativeCalculator是原生类,但它内部要持有C#对象引用,直接声明CSharpLib::Calculator^ _impl是做不到的,因为原生类不能直接含托管句柄成员。正确做法是用gcroot<T>这个模板包装类,它专门用于在原生类里保存托管引用,析构时自动释放。

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) { array<int>^ 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::vector<int> 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, Version=1.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调用托管方法的单次开销在微秒级别,实测下来影响主要来自封送复杂参数,纯数值传递确实很接近原生函数的性能。如果你发现性能不符合预期,优先检查参数类型是否用了过于复杂的封送配置,而不是急着推翻方案。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询