C++调用C# DLL实战:C++/CLI、COM与AOT三种跨运行时方案

发布时间:2026/10/15 12:06:06
C++调用C# DLL实战:C++/CLI、COM与AOT三种跨运行时方案 简介本资源面向需要在C工程中复用C#类库的开发者聚焦跨语言互操作这一常见难题提供一套可在Visual Studio 2010环境下直接运行的调用示例。内容围绕C#端导出可供非托管代码调用的方法、C端通过P/Invoke与GetProcAddress动态获取函数地址并完成调用展开涵盖调用约定设置、DLL引用配置及项目编译要点适合具备一定C与.NET基础、希望打通两种语言边界的进阶学习者。压缩包共42个文件约3.34MB包含cpp与h源码、dll与lib库文件、sln与vcxproj工程配置、pdb与ilk调试符号以及docx说明文档和编译中间产物完整保留了从源码到可执行文件的工程结构。目前已有1100余人学习下载。读者可据此获得一套可复现的跨语言调用范例理解P/Invoke机制与运行时函数定位思路并掌握多项目协同编译的排错方法。1. 为什么要在 C 里调用 C# 的 DLL一次跨运行时的真实需求手上有个 C 写的图像处理主程序算法管线跑得挺稳但客户突然要求接入一套用 C# 写的业务规则引擎——对方只给了一个编译好的 DLL没有源码也不打算重写。这种场景在工控、医疗影像、金融终端里并不少见C 负责性能敏感的计算C# 负责快速迭代的业务逻辑两边各自演化多年谁也不想推倒重来。问题就摆在面前C 怎么调用 C# 编译出来的 DLL这里有个容易被忽略的前提C# 编译产物是托管程序集IL 中间语言 元数据运行在 CLR 上C 原生代码跑在操作系统线程里两者内存模型、异常机制、字符串编码全都不一样。直接LoadLibrary加载 C# DLL 是行不通的因为它的 PE 头里没有可供原生代码直接调用的导出函数表。所以“C 调用 C# DLL”本质上不是一次简单的动态链接而是要在两个运行时之间架一座桥。这座桥怎么搭、搭多宽、承重多少决定了你后面是顺风顺水还是天天救火。下面把我实际趟过的几条路拆开讲包括每种的适用边界和参数细节。2. 三条主流技术路线C/CLI、COM 可见、AOT 导出2.1 C/CLI 包装层最直接但绑定编译器C/CLI 是微软给 Visual Studio 提供的一套语言扩展允许在同一个源文件里混写原生 C 和托管 C# 类型。它的核心价值在于你可以写一个中间层 DLL对外暴露纯原生 C 风格导出函数对内直接#using那个 C# DLL像调用普通 .NET 类一样 new 对象、调方法。调用方 C 程序完全感知不到 CLR 的存在。先看 C# 侧被调用的类假设编译为BusinessRules.dll// BusinessRules.cs —— 编译为 BusinessRules.dll namespace BusinessRules { public class RuleEngine { // 简单方法传入两个整数返回计算结果 public int Evaluate(int inputA, int inputB) { // 实际业务逻辑可能更复杂这里仅作演示 return inputA * 10 inputB; } // 字符串处理方法用于演示编码转换 public string FormatResult(string tag, int value) { return $[{tag}] result{value}; } } }然后是 C/CLI 包装层编译为BridgeWrapper.dll// BridgeWrapper.cpp —— 编译为原生导出 DLL #include string #include msclr/marshal_cppstd.h // 用于 string 与 String^ 互转 #using BusinessRules.dll // 引用 C# 程序集 using namespace System; using namespace BusinessRules; // 对外暴露的纯 C 导出函数 extern C __declspec(dllexport) int __stdcall Bridge_Evaluate(int a, int b) { // gcnew 在托管堆上创建 C# 对象 RuleEngine^ engine gcnew RuleEngine(); return engine-Evaluate(a, b); } extern C __declspec(dllexport) int __stdcall Bridge_FormatResult(const char* tag, int value, char* outBuf, int bufSize) { RuleEngine^ engine gcnew RuleEngine(); String^ managedTag gcnew String(tag); // 原生 char* 转托管 String String^ result engine-FormatResult(managedTag, value); // 托管 String 转回原生 char*注意编码为 UTF-8 msclr::interop::marshal_context ctx; const char* nativeResult ctx.marshal_asconst char*(result); strncpy_s(outBuf, bufSize, nativeResult, _TRUNCATE); return 0; }逻辑说明#using指令让编译器读取 C# 程序集的元数据gcnew是托管对象的创建方式^是指向托管对象的句柄。marshal_as负责在原生字符串和托管String之间做编码转换默认按当前代码页处理如果 C# 侧返回的是中文务必确认两边编码一致否则就是一堆问号。参数bufSize由调用方传入防止缓冲区溢出这是跨语言调用里最容易被忽视的安全边界。这条路线的优点是类型安全、调试方便Visual Studio 里能直接单步进 C# 代码。代价是包装层必须用 MSVC 编译且项目要开启/clr选项生成的 DLL 依赖 .NET Framework 或 .NET Core 的运行时。如果主程序是 MinGW 或跨平台构建的这条路直接堵死。2.2 COM 可见组件跨编译器但注册表依赖重如果 C 侧不是 MSVC或者你希望调用方完全不用关心 .NET 的存在可以把 C# 类做成 COM 可见组件。C# 通过[ComVisible(true)]和[Guid]特性暴露接口编译时勾选“为 COM 互操作注册”然后用regasm注册到系统。C 侧用#import导入类型库或者直接用CoCreateInstance创建对象。C# 侧改造using System.Runtime.InteropServices; namespace BusinessRules { [ComVisible(true)] [Guid(A1B2C3D4-E5F6-7890-ABCD-EF1234567890)] // 唯一 GUID [InterfaceType(ComInterfaceType.InterfaceIsIDispatch)] public interface IRuleEngine { int Evaluate(int inputA, int inputB); string FormatResult(string tag, int value); } [ComVisible(true)] [Guid(B2C3D4E5-F6A7-8901-BCDE-F12345678901)] [ClassInterface(ClassInterfaceType.None)] public class RuleEngine : IRuleEngine { public int Evaluate(int inputA, int inputB) inputA * 10 inputB; public string FormatResult(string tag, int value) $[{tag}] result{value}; } }C 侧调用#import BusinessRules.tlb no_namespace named_guids // 导入类型库 int CallViaCOM(int a, int b) { CoInitialize(NULL); // 初始化 COM 库线程模型需匹配 IRuleEnginePtr engine(__uuidof(RuleEngine)); // 智能指针创建实例 int ret engine-Evaluate(a, b); CoUninitialize(); return ret; }参数说明CoInitialize的线程模型要和 C# 侧标记的[ComVisible]行为匹配默认是 STA如果 C 主线程是 MTA需要改成CoInitializeEx(NULL, COINIT_MULTITHREADED)。#import会生成.tlh和.tli两个头文件智能指针IRuleEnginePtr自动管理引用计数。这条路最大的坑在部署目标机器必须注册 COM 组件regasm需要管理员权限而且 .NET 版本升级后 GUID 可能失效运维成本不低。2.3 .NET AOT 导出无运行时依赖的新选择.NET 7 之后Native AOT 编译可以把 C# 代码直接编译成原生机器码并且支持[UnmanagedCallersOnly]特性导出 C 风格函数。这种方式生成的 DLL 不依赖 CLR调用方就像调用普通原生 DLL 一样。代价是 AOT 对反射、动态加载有限制且需要 .NET 7 SDK。C# 侧using System.Runtime.InteropServices; public static class AotExports { [UnmanagedCallersOnly(EntryPoint Aot_Evaluate)] public static int Evaluate(int a, int b) { return a * 10 b; } [UnmanagedCallersOnly(EntryPoint Aot_FormatResult)] public static unsafe int FormatResult(byte* tagPtr, int value, byte* outBuf, int bufSize) { string tag Marshal.PtrToStringUTF8((IntPtr)tagPtr) ?? ; string result $[{tag}] result{value}; byte[] bytes System.Text.Encoding.UTF8.GetBytes(result); int copyLen Math.Min(bytes.Length, bufSize - 1); Marshal.Copy(bytes, 0, (IntPtr)outBuf, copyLen); outBuf[copyLen] 0; // 手动补 null 终止符 return copyLen; } }发布命令dotnet publish -r win-x64 -c Release /p:PublishAottrue /p:NativeLibShared生成的BusinessRules.dll就是原生 DLLC 侧用LoadLibraryGetProcAddress即可调用。这条路适合新项目但如果你手上的 C# DLL 是第三方给的、无法重新编译那就只能回到前两种方案。3. 从零搭建 C/CLI 桥接环境、编译与第一个可运行 Demo3.1 环境准备与项目配置先确认工具链Visual Studio 2022安装时勾选“使用 C 的桌面开发”和“.NET 桌面开发”两个工作负载。C/CLI 支持在 .NET Framework 和 .NET Core 上有所不同如果 C# DLL 是 .NET Framework 4.x 编译的包装层项目也要选对应的框架版本如果是 .NET 6/8需要 VS 2022 17.4 以上并安装 C/CLI 支持组件。新建项目时选“CLR 类库(.NET Framework)”或“CLR 空项目”不要选“Windows 桌面向导”。项目属性里几个关键项配置属性 → 常规 → 公共语言运行时支持设为“/clr”C/C → 代码生成 → 运行时库设为“/MD”或“/MDd”不能选“/MT”因为托管代码需要动态 CRT。链接器 → 高级 → CLR 映像类型保持默认的“纯 IL”或按需改成“混合”。把 C# 编译好的BusinessRules.dll放到包装层项目目录下在 C/CLI 源文件里用#using BusinessRules.dll引用。如果 C# DLL 有依赖的其他程序集也要一并放好否则运行时会在加载阶段报FileNotFoundException。3.2 导出函数的参数设计与内存管理跨语言调用最怕的就是内存谁分配、谁释放没约定清楚。我一般遵循三条规则第一所有导出函数用extern C防止 C 名称修饰导致GetProcAddress找不到符号第二字符串参数用const char*传入、char*传出加缓冲区大小绝不返回托管堆上的指针第三复杂结构体序列化成 JSON 或二进制块传递避免两边对不齐。看一个带结构体传递的例子。C# 侧定义public class PointData { public double X { get; set; } public double Y { get; set; } public string Label { get; set; } }C/CLI 包装层导出extern C __declspec(dllexport) int __stdcall Bridge_ProcessPoints(const char* jsonInput, char* jsonOutput, int outSize) { // 用 Newtonsoft.Json 或 System.Text.Json 反序列化 // 这里简化演示实际项目建议用 System.Text.Json String^ input gcnew String(jsonInput); // ... 调用 C# 侧处理逻辑 ... String^ output {\status\:\ok\}; msclr::interop::marshal_context ctx; const char* nativeOut ctx.marshal_asconst char*(output); strncpy_s(jsonOutput, outSize, nativeOut, _TRUNCATE); return 0; }参数jsonInput由调用方保证以 null 结尾jsonOutput和outSize构成输出缓冲区函数内部用_TRUNCATE防止溢出。返回值 0 表示成功非零为错误码具体错误信息可以再导出一个Bridge_GetLastError函数来获取。这种设计虽然多了一次序列化开销但胜在两边解耦C# 侧改字段不影响 C 侧的编译。3.3 调用方 C 程序的加载与错误处理调用方不需要任何 .NET 知识按普通 DLL 加载即可#include windows.h #include iostream typedef int(__stdcall* EvaluateFunc)(int, int); typedef int(__stdcall* FormatFunc)(const char*, int, char*, int); int main() { HMODULE h LoadLibraryA(BridgeWrapper.dll); if (!h) { std::cerr LoadLibrary failed: GetLastError() std::endl; return -1; } auto eval (EvaluateFunc)GetProcAddress(h, Bridge_Evaluate); auto fmt (FormatFunc)GetProcAddress(h, Bridge_FormatResult); if (!eval || !fmt) { std::cerr GetProcAddress failed std::endl; FreeLibrary(h); return -1; } int ret eval(3, 7); std::cout Evaluate result: ret std::endl; char buf[256] {0}; fmt(test, ret, buf, sizeof(buf)); std::cout Format result: buf std::endl; FreeLibrary(h); return 0; }注意LoadLibraryA用的是 ANSI 版本如果路径含中文可能失败换成LoadLibraryW并传宽字符串更稳妥。GetProcAddress返回空指针时用GetLastError看错误码常见的是 127找不到指定过程说明导出名不对用dumpbin /exports BridgeWrapper.dll确认实际导出符号。FreeLibrary之前确保所有托管对象已经释放否则 CLR 可能在卸载时卡住。4. 避坑与排查跨运行时调用最常见的五类翻车4.1 现象调用后程序直接崩溃无异常信息原因C/CLI 包装层里抛出的托管异常没有被捕获穿透到原生代码导致std::terminate。托管异常和原生异常是两套机制try/catch(...)在原生侧接不住System::Exception。解决在包装层每个导出函数内部用try { ... } catch (System::Exception^ ex) { ... }兜住所有托管异常转换成错误码返回。错误信息可以通过Marshal::StringToHGlobalAnsi(ex-Message)转成原生字符串再拷贝到调用方提供的缓冲区。4.2 现象字符串返回乱码或截断原因C# 的string是 UTF-16C 的char*通常是 UTF-8 或 ANSImarshal_as默认按当前系统代码页转换中文环境是 GBK跨机器就乱。解决统一用 UTF-8。C# 侧返回byte[]而不是string或者包装层用System::Text::Encoding::UTF8-GetBytes()手动转换。调用方拿到char*后按 UTF-8 解析。如果必须用marshal_as在项目属性里把字符集设为“使用 Unicode 字符集”并确认marshal_context的生命周期覆盖整个使用过程。4.3 现象Release 模式下正常Debug 模式下报BadImageFormatException原因C/CLI 项目在 Debug 下默认生成 32 位代码而 C# DLL 编译为 AnyCPU 或 64 位位数不匹配。解决把 C/CLI 项目的平台目标改成 x64和 C# DLL 保持一致。如果 C# DLL 是 AnyCPU在 64 位系统上默认以 64 位运行包装层也必须 64 位。用corflags工具查看 C# DLL 的实际位数别猜。4.4 现象#using报错“无法打开元数据文件”原因C# DLL 路径不对或者该 DLL 依赖的其他程序集不在搜索路径里。#using是编译期指令它需要读取程序集元数据缺一个依赖就报错。解决把 C# DLL 及其所有依赖放到包装层项目目录下或者在项目属性 → C/C → 常规 → 附加包含目录里加上 DLL 所在目录。如果 C# DLL 是 NuGet 包里的用packages目录下的实际路径。编译通过后运行时还需要这些 DLL 在可执行文件同目录或 PATH 里别编译过了就以为万事大吉。4.5 现象多线程调用时随机崩溃或数据错乱原因CLR 的托管对象不是线程安全的多个原生线程同时通过包装层调用同一个 C# 对象实例内部状态被并发修改。解决要么在 C/CLI 包装层加锁用System::Threading::Monitor或原生std::mutex要么每次调用都gcnew一个新对象避免共享状态。如果 C# 侧本身设计为无状态那问题可能出在marshal_context上——它不是线程安全的每个线程要用自己的实例。我一般会在包装层维护一个thread_local的上下文对象省去反复创建的开销。5. 进阶技巧用函数指针表替代逐个 GetProcAddress当导出函数超过十个逐个GetProcAddress写起来又臭又长还容易拼错名字。我习惯在包装层导出一个结构体里面放所有函数指针调用方一次获取整张表。这样新增函数时只改结构体定义调用方代码不用动。包装层// 定义函数指针类型 typedef int(__stdcall* EvalFunc)(int, int); typedef int(__stdcall* FmtFunc)(const char*, int, char*, int); // 导出结构体 struct BridgeApi { EvalFunc Evaluate; FmtFunc FormatResult; }; // 全局实例 static BridgeApi g_api { Bridge_Evaluate, Bridge_FormatResult }; extern C __declspec(dllexport) const BridgeApi* __stdcall Bridge_GetApi() { return g_api; }调用方typedef const BridgeApi* (__stdcall* GetApiFunc)(); auto getApi (GetApiFunc)GetProcAddress(h, Bridge_GetApi); const BridgeApi* api getApi(); int ret api-Evaluate(3, 7);这种做法的好处是版本管理清晰结构体末尾追加新函数指针老调用方不受影响如果要做 ABI 兼容可以在结构体开头加一个int version字段调用方先检查版本号再决定用哪些函数。另一个技巧是把错误信息也做成回调C# 侧发生异常时包装层调用一个由 C 侧注册的日志回调函数把托管异常堆栈传出来省得两边日志对不上。验证方法上我一般会写一个最小化的冒烟测试C 侧调用一个只返回常量的 C# 方法确认链路通了再逐步加参数、加字符串、加多线程。每加一层就压测一轮别等全部写完再联调那时候出问题定位范围太大。从那以后我每次搭跨语言桥接都强制先跑通“空调用”再写业务逻辑这个习惯帮我省了至少三次通宵排查。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询