
做C开发久了几乎每个人都会撞上同一个尴尬场景手头是一套跑了多年的C业务系统代码不敢乱动但需求方突然丢过来一个C#写的算法库、报表引擎或者设备通讯模块要求“无缝集成”。重写成C工期不允许而且C#里的LINQ、async、丰富的库生态在原生C里硬复刻一遍纯属自找麻烦。反过来把C工程整个迁到.NET同样不现实。唯一的出路就是让C代码去调用C#类库。这件事我前前后后做过好几个方案其中最实用、最省心的一条路就是利用C/CLI写一层“胶水包装”——C/CLI工程本身可以同时操作托管对象和原生对象我们把C#类包装成原生C能识别的接口再以普通DLL的方式丢给C工程引用。整个过程不需要改业务代码不需要搞复杂的进程通信编译产物就是一个常规的DLL和头文件。这篇文章会把从C#类库编写、C/CLI包装、导出配置到原生工程调用的完整流程都过一遍适合那些被“C调C#”折磨过、或者正准备踩坑的开发者也包括只见过C#没见过托管扩展的纯原生C选手。1. 为什么要在C里调用C#类库以及三条可选的技术路线1.1 最典型的业务场景先说清楚这不是什么小众需求。我自己遇到的场景大致能归成三类第一类是历史包袱型。老系统是C写的运行了好多年数据、日志、权限体系都长在C进程里。但新需求里往往要接入一些.NET生态才有现成方案的能力典型的就是各类报表生成、图表渲染、Office文件解析。这些库用C#写起来一周搞定用C写轻则一个月重则直接劝退。第二类是算法复用型。团队里有专门的算法工程师他们的代码习惯全部是C#写的包含大量测试用例和可视化调试工具。要让C主程序直接调用这些算法最省事的不是让算法组改行学C而是让C这边想办法把C#的DLL当第三方库一样用。第三类是插件架构型。主程序提供C插件接口但实际业务逻辑由C#模块提供两者需要在同一个进程内完成调用。这种情况下不可能把插件改成独立进程去走IPC只能想办法在进程内打通两个世界。这三类场景有一个共同点你不可能等到“把C#代码完全翻译成C”的那一天必须现在就有一条低成本的调用通路。1.2 三条技术路线对比C/CLI、COM、运行时自托管在动手之前先明确一件事C和C#之间的调用没有“银弹”常见方案就三种各有各的适用范围。方案原理优点缺点适合场景C/CLI混合程序集在C/CLI工程里直接引用C#程序集用托管句柄操作C#对象对外暴露原生C类开发量小进程内调用几乎没有性能损耗类型转换可控只能跑Windows需要MSVC编译C#侧的.NET版本受C/CLI工具集限制两个模块都自己掌控追求开发效率这是本文主线COM可见方式给C#类库打上ComVisible标记注册后生成TLBC用#import导入跨语言标准C工程不需要任何托管支持注册表依赖、线程模型受限、只能暴露COM可见成员调试麻烦需要给第三方C工具链使用的交付场景.NET运行时自托管用宿主API把.NET Core运行时加载进C进程直接用函数指针调用托管方法不依赖C/CLI适合在Linux等平台调用.NET要手动处理运行时配置、委托转换代码复杂坑多跨平台或生产环境不宜引入/clr编译选项时路线比完了结论很直接如果双方代码都在自己手里开发机是Windows而且想用最小成本解决问题C/CLI就是首选。下面整个实操过程都围绕这条主线展开。2. 动手前必须搞懂的C/CLI基础知识2.1 环境要求与工程类型做C/CLI开发第一个门槛就是工具链。这东西本质上属于“C编译器对CIL的扩展支持”所以只有微软的MSVC支持像MinGW、Clang这些都不行。环境要求其实不高Windows 10以上装了Visual Studio的“使用C的桌面开发”工作负载。安装时可选项里务必勾上“适用于最新的v143生成工具的C/CLI支持”。这一步很多人忽略导致建工程时根本找不到C/CLI模板。目标框架默认是.NET Framework 4.x如果需要调用.NET 6/7/8的程序集要看VS版本是否支持对应的/clr:netcore编译选项。在Visual Studio里新建工程时搜“CLR”就能找到两个模板CLR空项目和CLR类库。咱们要的就是类库模板——它默认就开好了/clr编译选项并且会帮你生成一个预编译头后面包装代码就放在这个工程里。2.2 托管类型、跟踪句柄与gcrootC/CLI最难适应的不是语法而是心智模型。你写C的时候用的是*和表示原生指针和引用但在C/CLI里托管对象是用^称号为“跟踪句柄”来引用的。它跟原生指针最大的区别是垃圾回收器会移动对象在内存中的位置而跟踪句柄会跟着变所以不能把它当成固定的地址拿去算偏移。对应地gcnew用来创建托管对象nullptr用来置空句柄。遇到C#那边的一个类实例在C/CLI里就写成CalcLib::Calculator^ calc gcnew CalcLib::Calculator();然后调用方法用-跟C一样的写法。这些概念在上手阶段最容易搞混最常见的翻车现场就是有人试图拿C的delete去释放托管对象或者把System::String^当成std::string去操作。真正复杂的点在于要把托管对象“藏”在一个原生C类里。因为面向原生C调用方暴露接口时你显然不希望对方看到任何^和gcnew的痕迹。这里需要用到msclr::gcrootT——它是个模板包装类可以在原生C类的成员变量里安全地保存一个托管句柄。它本质上利用了C的析构函数和局部对象生命周期配合GC机制保证托管引用在使用期间不会被回收。#include msclr/gcroot.h class CalculatorBridgeImpl { private: msclr::gcrootCalcLib::Calculator^ _calc; };注意gcroot只是“引用保持”并不会像GCHandle::Alloc那样把对象钉住pinning。但对我们这种“只在调用期间使用、不把托管对象内存地址传给原生代码”的场景引用保持就足够了。2.3 /clr编译对工程配置的硬性约束/clr一旦打开项目配置会受到几个硬性约束提前知道能省掉大量排查时间运行库必须用多线程DLL/MD不能使用静态链接的/MT。原因很简单CLR本身是动态加载的混合程序集的CRT也必须匹配动态链接。工程默认值通常是对的但如果从老工程改过来就得检查。/clr与/GL全程序优化、/LTCG、以及某些编辑继续功能互斥。Visual Studio里如果开了这些选项编译到一半会报错提示禁用即可。异常处理模型会被强制改为/EHa这跟原生C默认的/EHsc行为不同。最直观的区别是/EHa下catch(...)能捕获到结构化异常代码里如果有“所有异常都吞掉”逻辑行为会发生变化。这一点在混合工程里踩过的人不少。用了/clr的工程产出的程序集是“混合模式”既包含原生代码也包含IL。加载它时进程必须能初始化CLR——这意味着即使调用方是纯原生C程序它运行的时候进程里也会多出一个.NET运行时。这些约束不会影响功能但如果不了解很多“编译不过”和“运行时异常”的问题会让人抓狂。3. 完整实操从C#类库到C可调用的包装DLL3.1 第一步编写一个标准的C#类库先造一个最简单的C#类库当样板。为了让演示贴近真实情况我故意让它既返回数值又返回字符串因为字符串跨越托管/原生边界恰恰是最大的坑点。using System; namespace CalcLib { public class Calculator { public double Add(double a, double b) { return a b; } public string GetDescription(double value) { return string.Format(当前数值: {0:F2}, value); } } }这里有个经验C#类库的TargetFramework要提前想好。如果你在C/CLI工程里引用一个目标为.NET 8.0的类库而C/CLI只支持.NET Framework的/clr模式就会出现无法解析或运行时报BadImageFormatException之类的错误。最稳妥的做法是先按.NET Framework 4.7.2/4.8编译C#类库跟C/CLI的默认目标保持一致确实要跨到现代.NET平台时再一起切到/clr:netcore。这块我在后面的版本排查部分还会单独说。3.2 第二步新建C/CLI包装工程并引用C#程序集创建一个CLR类库工程名字我用CalcBridge。建好之后主要做三件事第一配置项目属性确认通用语言运行时支持为/clr。第二在工程的引用里添加对CalcLib.dll的项目引用。这一步可以直接在VS里右键-添加引用。选中工程引用后编译时VS会按顺序先编译C#工程再编译包装工程依赖关系自动处理不用手写#using路径。第三在包装工程的头文件里通过下面的宏管理导出符号。这个宏的本质是在编译包装工程时导出在被调用方包含头文件时导入。#pragma once #ifdef BRIDGE_EXPORTS #define BRIDGE_API __declspec(dllexport) #else #define BRIDGE_API __declspec(dllimport) #endif记得在包装工程的预处理器定义里加上BRIDGE_EXPORTS。命令行里大约是/D BRIDGE_EXPORTSVS的图形界面在预处理器定义里填一行即可。3.3 第三步用原生接口类包装托管对象现在到了核心环节定义一个原生C类作为对外窗口内部用一个隐藏的实现类去持有C#对象。头文件里只暴露干净的原生接口// bridge.h #pragma once #include string #ifdef BRIDGE_EXPORTS #define BRIDGE_API __declspec(dllexport) #else #define BRIDGE_API __declspec(dllimport) #endif namespace CalcBridge { class BRIDGE_API CalculatorBridge { public: CalculatorBridge(); ~CalculatorBridge(); double Add(double a, double b); std::wstring GetDescription(double value); private: void* _impl; }; }实现文件里把细节藏起来// bridge.cpp #include bridge.h #include msclr/gcroot.h #include vcclr.h #include string #using CalcLib.dll using namespace System; using namespace System::Runtime::InteropServices; using namespace CalcLib; namespace CalcBridge { ref class ManagedHolder { public: Calculator^ calc; ManagedHolder() { calc gcnew Calculator(); } }; class CalculatorBridgeImpl { public: msclr::gcrootManagedHolder^ holder; CalculatorBridgeImpl() { holder gcnew ManagedHolder(); } double Add(double a, double b) { return holder-calc-Add(a, b); } std::wstring GetDescription(double value) { String^ managedStr holder-calc-GetDescription(value); IntPtr ptr Marshal::StringToHGlobalUni(managedStr); std::wstring result(static_castconst wchar_t*(ptr.ToPointer())); Marshal::FreeHGlobal(ptr); return result; } }; CalculatorBridge::CalculatorBridge() : _impl(new CalculatorBridgeImpl()) { } CalculatorBridge::~CalculatorBridge() { delete static_castCalculatorBridgeImpl*(_impl); _impl nullptr; } double CalculatorBridge::Add(double a, double b) { return static_castCalculatorBridgeImpl*(_impl)-Add(a, b); } std::wstring CalculatorBridge::GetDescription(double value) { return static_castCalculatorBridgeImpl*(_impl)-GetDescription(value); } }这里有几个设计点要说透ManagedHolder是C/CLI的托管ref类里面直接持有C#对象句柄。它存在的意义是让实现类内部的封装更清晰当然你也可以直接在CalculatorBridgeImpl里放gcrootCalculator^效果一样纯看个人习惯。_impl用void*而不是直接放CalculatorBridgeImpl*是为了让头文件里不需要包含任何C/CLI相关声明。这样原生调用方包含bridge.h时不会因为缺少托管编译环境而报错。这个细节决定了你的接口头文件能不能被纯C工程直接吃进去。delete static_castCalculatorBridgeImpl*(_impl)这行不需要无谓的检查因为构造失败会直接抛异常_impl不会是空指针。关于字符串转换我多说两句。Marshal::StringToHGlobalUni是把System::String^拷贝成一块非托管内存里的宽字符得到IntPtr后转成const wchar_t*构造std::wstring最后必须FreeHGlobal释放。整个流程等价于“把托管字符串拷贝出来转成C宽字符串”。如果你拿到的是System::String^想更快但稍别扭一点也可以用PtrToStringChars拿到内部字符指针但那个指针指向的都是托管堆内部用它构造std::wstring时是拷贝所以安全如果把它当普通wchar_t*长期持有就有GC移动的风险。实际项目里我无脑推荐Marshal拷贝方案语义清晰、不容易踩生命周期雷。3.4 第四步编译配置与生成产物到这里包装工程就可以编译了。编译前我习惯再做一次检查清单项目配置是Release/x64C/CLI对x64支持得很稳x86也能跑但如果你调用C#那边依赖AnyCPU注意一致。平台工具集选了当前VS版本对应的一套并且C/CLI支持组件已安装。C#类库被正确添加为“项目引用”并且它的输出目录是明确的不放到bin里而是直接走VS的依赖拷贝到包装工程的输出目录。编译成功之后CalcBridge.dll会出现在输出目录。它跟普通的CDLL不一样的地方在于它内部带了一个托管模块段。你用dumpbin /headers CalcBridge.dll看输出能看到CLR Header相关字段。此时你手里已经有一个CalcBridge.dll、一个CalcBridge.lib、一个bridge.h。后面把它们三个一起交给C调用方跟普通第三方库完全一样。如果这一步编译报错九成是上面/clr的约束没满足或者C/CLI支持组件缺失。先到这一步排查不要直接去抠代码。4. 在原生C工程中调用包装DLL4.1 需要配置三处地方现在把视角切到调用方一个完全没有启用任何托管支持的纯C工程可以是老掉牙的MFC程序也可以是现代的C20控制台程序。调用方需要做的配置跟引用普通第三方库一样三处包含目录里加入bridge.h所在路径或者直接相对路径#include ../../SharedInclude/bridge.h。链接器-输入-附加依赖项里加上CalcBridge.lib。当然也可以用#pragma comment(lib, CalcBridge.lib)写在源码里少一处配置。把CalcBridge.dll和CalcLib.dll注意C#类库的DLL也必须带复制到调用方exe所在目录。第3点是容易漏的。很多人只拷了包装DLL忘了C#原DLL运行到第一次调用时直接抛FileNotFoundException或者Could not load file or assembly。因为包装DLL里引用的C#类型元数据只在调用时才去加载加载路径默认是exe目录。4.2 调用示例与预期输出调用代码长这样#include iostream #include string #include clocale #include bridge.h #pragma comment(lib, CalcBridge.lib) int main() { std::setlocale(LC_ALL, .936); CalcBridge::CalculatorBridge calc; double sum calc.Add(1.5, 2.5); std::wcout Lsum sum std::endl; std::wstring desc calc.GetDescription(sum); std::wcout desc std::endl; return 0; }输出sum 4 当前数值: 4.00第一行是原生C算的第二行是C#那边格式化完返回的字符串由C打印的。整个过程发生在同一个进程里没有IPC、没有网络、没有临时文件。setlocale(LC_ALL, .936)是Windows下控制台显示中文的常见做法如果你的控制台代码页是UTF-8可以换成.65001。这是一个小到不能再小但真实存在的坑不设置locate时std::wcout输出中文经常会输出“无字符”或者被吞掉。4.3 调用时必须注意的几条守则根据我几次实战的经验调用方最容易犯的问题集中在下面几条第一不要在原生边界上传递大量字符串时反复走Marshal分配释放。它每次都会拷贝高频调用会有明显CPU开销。真遇到性能敏感路径更好的做法是一次性把String^转成共享内存或者缓存到包装层里让C侧读缓冲区。第二跨DLL边界返回STL类型是“高风险但通常可行”的操作。我们的接口返回std::wstring这在调用方和包装DLL都用同一个MSVC版本和CRT设置时没问题。但如果调用方工程用了不同版本的CRT比如一边/MD一边/MT字符串对象的内部内存管理就乱了。稳妥的替代方案是把接口改成返回const wchar_t*加长度或者在包装层维护一个缓冲区由DLL生命周期管理。我只是为了演示方便才用std::wstring生产系统里要记得评估这个风险。第三不要在C#侧和C侧各自保存同一份业务状态。想象一下C#的Calculator实例可能在某个时刻被GC引擎压缩移动你如果之前把它的某个字段地址取出来放进了原生类的成员里下一次调用就直接悬空。只要坚持“每次调用都穿过包装层、由托管句柄访问对象”就不会有这个麻烦。5. 常见问题与排查实录5.1 编译期的典型错误C/CLI的报错信息向来晦涩我把实际遇到的几种记录下来碰到可以直接对号入座。报错信息常见原因解决办法错误 C1189: #error: 使用/clr需要安装C/CLI支持组件VS装组件时没勾选C/CLI修改VS安装勾上对应版本的C/CLI支持后重启错误 C3699: 不能使用此间接寻址方式把String^当成String*或String使用统一用^原生指针语法在托管类型上禁用错误 C3037: 未使用/clr编译的源文件不能引用托管类型头文件或cpp没开/clr整个包装工程工程的“通用语言运行时支持”设为/clr错误 LNK2028/链接器找不到.NET模块包装工程没有正确引用C#程序集在“引用”管理器里添加“项目引用”检查#using路径warning C4793: 函数编译为本地代码某个ref class成员函数里的Lambda或模板用了非托管语法通常不是致命伤但代表这段代码跑在原生侧注意是否有GC句柄被误用编译期最常见的其实不是语法而是混用/MT和/clr。很多老C工程都习惯用静态运行时一加/clr就报编译器内部错误C1001。这个时候不要在报错里纠结直接检查项目属性 - C/C - 代码生成 - 运行库改成多线程(/MD)。5.2 运行期的链接与加载错误编译过了运行却炸这种问题比编译期更考验经验。我按频率高低排一下现象1首次调用抛System.IO.FileNotFoundException原因几乎永远是C#原DLL没复制到exe目录或者C#程序集版本与原引用不一致。对策把CalcLib.dll放进exe目录或者在exe目录下建一个CalcLib.dll同级的CalcLib.dll.config、CalcLib.pdb等全套依赖。排查时可以直接在异常前用DebugView或Event Viewer看程序集绑定日志能立刻定位到底缺哪个。现象2BadImageFormatException通常是位数不匹配。x64的C调用方加载了x86的C#程序集。检查所有工程的目标平台是否一致重点是包装工程和C#类库工程。现象3调用时卡死或死锁如果你在C#侧代码里访问了UI相关的控件或者用了[STAThread]单线程套间偏偏C调用线程是MTA线程跨套间调用会卡死。对策是确保C调用线程的套间状态与C#侧需求一致或者在C#侧用Task跑在后台线程避免套间冲突。这个坑多在涉及WinForms/WPF的类库时出现纯计算的类库不会幸灾乐祸。5.3 混合调试技巧混合模式调试是这类工程最容易产生挫败感的地方。我的建议很简单调试器类型必须选对。在Visual Studio里如果你从原生C工程启动调试调试 - 调试器类型默认是“仅限本机”这时你设在下层C#代码里的断点不会被命中。改成“混合”或者“自动”之后才能同时调试原生和托管代码。另外一个团队协作层面的建议给C/CLI包装工程本身配一个“启动外部程序”的调试配置把C#工程编译成CopyLocal的DLL放好然后直接启动编译好的原生exe。这样F5体验最好别指望每次都能从调用方工程一路步进到托管代码。5.4 .NET版本不一致导致的坑最后专门讲一个现在越来越常见、但老资料很少提的坑C#类库用了.NET 6.0或.NET 8.0而C/CLI工程还停留在.NET Framework模式。早期C/CLI只支持托管C编译到.NET Framework所以C/CLI和现代.NET不能直接混用。近几年Visual Studio支持了/clr:netcore和/clr:net8.0这样的编译目标能把C/CLI包装工程输出成面向现代.NET的混合程序集。注意这需要较新的VS版本并且一些老的C/CLI语法限制依然存在。如果你非要调的是跨平台的.NET类库另一个更保险的选择是第6节要说的运行时宿主方案那个方案完全绕开C/CLI。6. 还想更进一步两种替代方案的使用要点6.1 COM可见方式适合交付给第三方C工具链如果你的C#类库不是自己C工程内部用而是要交付给外部团队、且对方只用纯标准C那C/CLI的包装DLL可能让对方水土不服毕竟它是Microsoft-specific的。这时COM方式反而是更标准的答案。流程不复杂在C#类库的类上标记[ComVisible(true)]给接口和类加上GUID用regasm /tlb生成并注册同时在C侧用#import CalcLib.tlb生成智能指针包装。调用方代码大致长这样#import CalcLib.tlb named_guids raw_interfaces_only CoInitialize(nullptr); CalcLib::ICalculatorPtr spCalc; spCalc.CreateInstance(__uuidof(CalcLib::Calculator)); double result spCalc-Add(1.0, 2.0);COM方式的核心成本在线程模型如果你的C#类库内部依赖STA或UI线程非套间调用就会出问题而且regasm注册需要在目标机上做一次。所以一般我只在“不能要求对方编译器支持/clr”的交付场景推荐COM。6.2 .NET运行时自托管跨平台与生产环境保平安如果说C/CLI方案的软肋是MSVC锁定那运行时宿主方案就是那个没有软肋、但麻烦翻倍的选择。它的核心思路是你的C进程自己加载.NET运行时并且通过函数指针调用托管方法。以现代.NET为例大致链路是用hostfxr库初始化运行时 - 加载目标程序集 - 通过cdecl或stdcall函数指针把托管方法“变成”原生函数指针 - 调用。.NET提供的NativeAOT或者hostfxr_initialize_for_runtime_config接口是这条路的入口。好处很明显不依赖/clr可以在非Windows甚至嵌入式环境里调用.NET程序集坏处也很明显字符串编组、委托生命周期、运行时版本对齐全都要自己管代码量比C/CLI方案大一个数量级。适合那种“必须上生产Linux服务器”的硬骨头场景。结尾还想说的两句经验回到开头那个问题C到底能不能优雅地调用C#类库答案是可以而且方法不唯一。但如果问我在大多数Windows业务系统里会推荐哪个我还是会选C/CLI这条“简单封装”的路——它的开发量最小调试链最顺维护成本也可控。我个人在实际项目中踩过的最值钱的一条经验其实是把边界接口设计得“窄”一点。包装层里只暴露业务需要的几个方法不要贪心把C#类的所有公共方法都反射成原生接口。每多一个方法就多一个类型转换的测试面。另一个小技巧是输出目录里始终保留一份完整的CalcBridge.dll CalcLib.dll组合运行环境出问题第一时间用dumpbin /dependents检查依赖比盲猜快得多。希望这篇东西能帮还在“C调C#”门前徘徊的人少走几趟弯路。真到了要动手的那天记住先把工程配置调对再谈代码顺序很重要。