ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++调用C# DLL的完整方案:用C++/CLI桥接实现互操作

2026/9/3 20:03:24 拓冰建站 浏览量
C++调用C# DLL的完整方案:用C++/CLI桥接实现互操作 简介面向需要实现跨语言集成的开发者以C调用C# DLL为主线的实例工程完整展示C#将业务逻辑封装为托管DLL后由C应用加载调用的完整链路覆盖C#封装与C调用的关键环节适合作为后续项目的基础骨架。压缩包共22个文件、约1.09MB包含头文件、源文件、DLL动态库以及工程配置文件也带有界面资源文件可直接使用Visual Studio打开并对照编译理解不同文件在互操作场景中的分工。该资源已有1117人学习尤其适合刚接触C与C#互操作的初中级开发者作为快速上手的起步模板。通过这套代码可梳理DLL导出、接口声明和调用约定也能参考原生C调用托管代码时的工程组织方式包内附带exe、dll等产物还可直接运行验证调用效果减少环境配置与从零探索的成本。1. 项目背景为什么会有“C调用C# DLL”这种需求1.1 一个真实项目的起点我接到过一个老项目的改造需求底层采集模块是 C 写的跑在 Windows 服务里上层新做的数据分析模块是 C# 的已经封装成 DLL并且测试得很完善。现在最省事的办法就是让老 C 模块直接去调用这个 C# DLL而不是把 C# 逻辑用 C 重写一遍。这个需求很典型工控上位机、桌面软件插件、量化交易选股系统里经常遇到。很多人第一反应是C 直接LoadLibrary然后GetProcAddress拿函数指针不就完事了吗如果对方是普通 C/C DLL确实这么干。但换成 C# 生成的 DLL这套方法立刻失效。C# 代码编译出来的是托管程序集不是 PE 格式导出的原生函数C 进程就算把 DLL 加载进来也拿不到GetProcAddress需要的导出函数表。换句话说C 和 C# 之间隔着一堵墙这堵墙叫 CLR。1.2 托管与非托管边界为什么不能直接调用要理解这个问题先分清两个概念C# DLL 运行在 .NET 运行时CLR之上代码里的对象、类、垃圾回收、异常机制都归 CLR 管这叫托管代码C 默认编译出来的是直接跑在操作系统上的原生代码这叫非托管代码。两者之间没有共享的对象模型也没有统一的函数调用约定。C# 用DllImport调用 C DLL本质上是让托管代码主动跨出去反过来C 想调 C# DLL必须得有一个“中间翻译”把边界两边对接上光靠 C 编译器自己做不到。所以这篇要演示的就是最常用的解决方案用一个 C/CLI 项目做桥接 DLL。C/CLI 是微软在 Visual C 里提供的托管扩展允许在一个程序集里同时写托管代码和非托管代码。它既能被 C# 项目引用也能被 C 项目引用天然适合当翻译。下面我会把一个完整的可运行例子拆开讲包括 C# 类库怎么写、桥接项目怎么配置、原生 C 怎么调用以及我踩过的坑。2. 方案选型三条互操作路线怎么选2.1 三种常见方案的优缺点对比在动手之前先想清楚选哪条路。C 调用 C# 的方案大致有三种我列个表对比一下。方案实现方式优点缺点适合场景C/CLI 桥接新建 CLR 类库项目引用 C# 程序集向 C 暴露托管类类型转换灵活性能好开发效率高仅限 Windows/MSVC 环境桥接项目配置稍复杂老 C 工程需要复用 C# 逻辑COM 互操作C# 类库设置 COM 可见注册后由 C 通过 COM 接口调用标准化接口可跨进程/语言注册麻烦部署要处理 regasm调试链路长需要跨语言、跨进程稳定交互C 接口封装C# 侧用 DllExport 类库导出 C 风格函数C 侧 LoadLibrary 调用调用方看起来像原生 C DLL侵入性小需要额外依赖库复杂类型传递麻烦无法启用 /clr 的纯原生工程三条路我都试过实际项目里用得最多的是 C/CLI 桥接。因为大多数场景是两个模块在同一台 Windows 机器上甚至同一个进程内没必要绕一大圈去注册 COM 或者额外封装 C API。2.2 为什么我选了 C/CLI 桥接C/CLI 最大的优势是它允许你在桥接层里写类似 C 的代码但同时又可以直接操作 C# 对象。比如我需要传一个std::vectordouble给 C# 方法C/CLI 里可以很方便地把std::vector转成托管数组arraydouble^需要把一个 C# 的Func委托转成 C 函数指针也有现成语法。这种灵活性是纯 COM 或 C 接口封装给不了的。另一个重要原因是部署。C/CLI 编译出来的桥接 DLL 和普通 DLL 一样直接放进程序目录就能用只要目标机器装了对应版本的 .NET不用注册任何组件。COM 方案里如果忘了跑一遍regasm调用方就会提示“没有注册类”这种问题在客户现场排查起来非常痛苦。C 接口封装虽然干净但 C# 侧需要引入第三方封装库维护起来又多一个依赖。所以下面所有实操我都基于 C/CLI 来写。3. 完整实战C# 类库 - C/CLI 桥接 - 原生 C 调用3.1 第一步C# 侧类库设计与编译先写一个简单的 C# 类库包含一个计算器类后面用 C 调它。这个类里放了三种典型方法简单的整数加法、处理数组的方法、返回字符串的方法这样能把值类型、数组、字符串的场景都覆盖到。using System; using System.Collections.Generic; namespace CSharpLib { public class Calculator { public int Add(int a, int b) { return a b; } public double Average(double[] values) { if (values null || values.Length 0) throw new ArgumentException(数组不能为空); double sum 0; foreach (double v in values) { sum v; } return sum / values.Length; } public string FormatResult(string prefix, double value) { return ${prefix}: {value:F2}; } } }编译时注意两点。第一目标框架和后面 C/CLI 项目的目标框架尽量保持一致我用的是 .NET Framework 4.7.2如果你用的是 .NET Core/.NET 5C/CLI 项目的目标框架也要对应选好否则会出现运行时加载失败。第二平台目标先在 C# 侧保持“Any CPU”等桥接项目和调用方项目都定好平台后再统一检查有没有不一致。编译成功后会生成CSharpLib.dll这个 DLL 是给桥接项目引用的。3.2 第二步创建 C/CLI 桥接项目打开 Visual Studio新建项目选择“CLR 类库”。项目名字我起的NativeBridge。建好之后右键项目 - 引用 - 添加引用把刚才编译出的CSharpLib.dll加进去。如果你的 C# 项目在同一个解决方案里可以直接添加“项目引用”这样调试起来更方便。然后是关键的配置项。选中桥接项目打开属性页找到“配置属性 - 常规 - 公共语言运行时支持”确保是“公共语言运行时支持 (/clr)”。默认的 CLR 类库模板已经开了但如果是后来改成桥接的旧项目经常会漏掉这一步。同时确认目标框架和 C# 项目一致。下面是最核心的桥接类代码#pragma once #include vector using namespace System; using namespace CSharpLib; namespace NativeBridge { public ref class CalculatorBridge { private: Calculator^ _calc; public: CalculatorBridge() { _calc gcnew Calculator(); } int Add(int a, int b) { return _calc-Add(a, b); } // 直接接收托管数组 double Average(arraydouble^ values) { return _calc-Average(values); } // 接收 C 侧常用的 std::vectordouble double AverageVector(const std::vectordouble values) { arraydouble^ arr gcnew arraydouble((int)values.size()); for (size_t i 0; i values.size(); i) { arr[(int)i] values[i]; } return _calc-Average(arr); } // 从 C 风格字符串构造 System::String^ System::String^ FormatResult(const char* prefix, double value) { System::String^ managedPrefix gcnew System::String(prefix); return _calc-FormatResult(managedPrefix, value); } }; }注意ref class是 C/CLI 的关键字它声明的是一个托管类。Calculator^里的^叫做句柄可以理解成托管世界里的指针和 C# 里的引用类似。如果你在桥接类里看到的^就代表这是一个托管对象引用C/CLI 编译器会替你做引用计数和垃圾回收不需要你手动delete。编译桥接项目会生成NativeBridge.dll。这个 DLL 里既持有 C# 程序集的引用又暴露了托管类CalculatorBridge可以被本机任意支持/clr的 C 项目使用。3.3 第三步原生 C 工程里调用调用方工程可能是一个 MFC 程序、一个控制台程序或者一个通过LoadLibrary加载插件的宿主程序。这里我用一个控制台程序演示。新建一个 C 控制台项目后把项目属性里的“公共语言运行时支持”设置为/clr。这一步极其重要不开启的话代码里用不了gcnew、ref class这些语法。在调用方代码里最直接的方式是通过#using引入桥接 DLL然后在main函数中创建桥接对象。#include iostream #include vector #using NativeBridge.dll using namespace NativeBridge; int main() { CalculatorBridge^ bridge gcnew CalculatorBridge(); int sum bridge-Add(3, 4); std::cout Add(3, 4) sum std::endl; std::vectordouble data { 1.0, 2.0, 3.0, 4.0 }; double avg bridge-AverageVector(data); std::cout Average avg std::endl; System::String^ result bridge-FormatResult(avg, avg); System::Console::WriteLine(result); return 0; }#using NativeBridge.dll和 C# 里的using不同它是给 C/CLI 编译器看的指令相当于告诉编译器“我要用到这个程序集里的类型”。如果用 Visual Studio 的项目引用功能添加了引用这行也可以不写但写上对不熟悉项目引用的人更直观。运行前把CSharpLib.dll、NativeBridge.dll都复制到 exe 所在目录。如果只复制了NativeBridge.dll运行时会报“找不到文件”或者“未能加载文件或程序集”底下再跟一串System.IO.FileNotFoundException的提示。原因就是桥接 DLL 里面还引用着 C# DLLCLR 在加载桥接 DLL 时会顺着引用链把 C# 程序集一起找出来。所以这类项目发布时一定要带上全部托管依赖不能只带一层。4. 跨边界参数传递与异常处理细节4.1 字符串、数组、结构体的传递方式值类型参数最简单int、double、bool在 C# 和 C 两边都是直接映射桥接层不需要做任何转换。数组就需要特别处理了因为 C# 的数组是托管类型它和 C 的std::array、std::vector没有直接对应关系。我的经验是在桥接层暴露多个重载让调用方根据自己手里的数据类型选最方便的那个。比如上面例子里的Average接收托管数组AverageVector接收std::vectordouble后者内部自己转换调用者很舒服。字符串是最容易出问题的。C 侧可能是char*、wchar_t*、std::string、std::wstringC# 侧是System::String^。在 32 位程序里gcnew System::String(const char*)能转换 ANSI 字符串但如果你传的是 UTF-8 编码转换后中文会乱码。想要稳妥一点用System::Runtime::InteropServices::Marshal类做显式转换指定正确的编码格式。在项目里处理中文字符串时我一般建议调用方统一使用宽字符std::wstring然后在桥接层用gcnew String(wstr.c_str())转成System::String^这样中文和数字都能对齐。结构体跨边界是另一个大坑。如果只是几个简单字段可以在 C/CLI 桥接层里定义一个托管结构体把字段类型设成int、double这种然后和 C# 的类做字段映射。如果结构体里有嵌套数组或可变长度字段就不要硬传了老老实实在桥接层把结构体拆成若干基础类型参数或者序列化成 JSON 字符串再传。为了少踩坑跨边界的数据结构应该保持扁平。4.2 在桥接层统一处理托管异常C# 方法可能会抛异常比如Average里对空数组抛了ArgumentException。如果这个异常直接穿过 C/CLI 桥接层抛到非托管 C 调用方程序大概率直接崩溃而且崩溃信息很难看有时候只给你一个AccessViolationException。解决办法是在桥接层的每个公开方法里包一层try-catch把托管异常转成错误码或者把错误信息写出来。下面这段是对AverageVector的异常处理增强double AverageVectorSafe(const std::vectordouble values, bool success) { try { success true; return AverageVector(values); } catch (System::Exception^ ex) { success false; System::Console::WriteLine(ex-ToString()); return 0.0; } }这样 C 调用方可以通过success判断是否成功而不必面对一个飞出来的异常。我通常在桥接层定义一个统一入口把异常日志写到 Windows 事件日志或本地日志文件里比在客户端弹一个.NET Runtime错误框要友好得多。记住一个原则托管异常不应该跨出非托管边界桥接层是最后一道防线。5. 常见问题排查与避坑指南5.1 高频错误对照表下面列几个我实际遇到的报错以及对应的解决思路。报错信息常见原因解决办法System.BadImageFormatException进程位数和 DLL 位数不匹配统一 exe、桥接 DLL、C# DLL 的平台目标都设为 x64 或都设为 x86FileNotFoundException或Could not load file or assembly缺少 C# 依赖或者版本不一致用进程监视工具看加载路径把缺的 DLL 拷贝到 exe 目录The application has failed to start because its side-by-side configuration is incorrectVC 运行库版本不一致安装对应的 Visual C Redistributable或调整桥接项目的运行库设置OSError: [WinError 1114] 动态链接库初始化例程失败初始化时加载 .NET 失败常见于目标框架不匹配确认 C/CLI 项目和 C# 项目的目标框架一致Windows 上安装对应 .NET 版本AccessViolationException托管异常越界或者字符串/数组跨边界传递时内存被释放在桥接层捕获所有托管异常避免直接 throw 到原生代码5.2 排查工具与调试经验遇到问题不要急着用网上下载的“DLL修复工具”先搞清楚是哪一个 DLL 缺失、为什么缺失。我习惯先用dumpbin /dependents NativeBridge.dll查看依赖再打开“程序集绑定日志查看器”Fusion Log Viewer看 CLR 加载程序集的路径。如果找不到日志信息可以用 Process Monitor 加一个进程名过滤看 exe 启动时尝试读取了哪些 DLL 路径缺的就是环境变量 PATH 或 exe 目录里没放的那个。调试 C/CLI 桥接项目时还有一个小技巧把 C# 工程和桥接工程放进同一个解决方案启用“本机代码调试”和“托管代码调试”这样在 C 调用方里打断点后可以单步进入 C# 代码。我第一次操作时不知道要去“调试 - 选项 - 调试 - 常规 - 使用托管兼容模式”里调整结果一直看不到 C# 断点命中。实测下来只要两边都是 Debug 编译断点行为很稳定定位异常非常方便。另外不要忽略 Release 和 Debug 的差异。C/CLI 项目在 Debug 下能跑通不代表 Release 下没问题。我曾经遇到一次 Release 下桥接 DLL 初始化失败最后发现是一个静态变量的初始化顺序问题只有优化后才会触发。所以在发布前务必用 Release 配置完整跑一遍调用链而不是只在 VS 内置的调试宿主里测试。这次做完之后我又移植了一次把 C# DLL 换成了 .NET 6桥接项目的目标框架也同步改成 .NET 6整体流程差别不大。唯一要提醒的是老版本 C/CLI 对较新的 .NET 版本支持有限如果项目比较旧尽量先把 Visual Studio 和 MSVC 工具集升级到新版本。跨语言调用这种事工具版本越新踩坑越少。本文还有配套的精品资源点击获取