
1. 写在前面为什么要用C#写COM组件再让Qt去调1.1 跨语言调用的几种选择什么时候该上COM跨语言调用这件事做上位机、做桌面软件的朋友早晚都会碰上。最常见的一个场景主程序是C#写的界面方便、报表方便、跟数据库打交道方便但手头又有一堆C库或者是某个子系统本身就是基于Qt开发的两边必须互通数据、互相触发功能。这时候摆在面前的路大致有四条P/Invoke、C/CLI、COM、进程间通讯。P/Invoke适合那种功能简单、参数单一的C接口DLL比如调用一个Win32 APIC/CLI虽然桥接能力很强但要求C这侧本身用Visual C来编译还引入了托管混合模式维护起来头大进程间通讯像管道、共享内存能把两个程序解耦但要自己处理协议、同步稍微复杂一点就失控。COM是这几个方案里最老牌、也最“正式”的一种它是二进制层面的标准C#能创建COM组件C/Qt能调用COM组件而且注册之后任何支持COM的语言都能用不限于某一对组合。我这篇要记录的就是其中一条最典型的路线C#写一个COM组件用VS2008编译注册然后Qt 4.6.4写个简单程序通过ActiveQt的QAxObject调用它。整个工程不复杂但涉及的知识点很碎注册、位数、接口定义、调用语法任何一个环节出问题都会让人卡半天。1.2 这篇内容适合谁环境版本怎么看如果你手头正好是VS2008加Qt 4.6.4的环境那这篇可以当一份完整操作手册用。就算你现在用的是VS2019、VS2022加Qt 5.15或者Qt 6核心流程依然成立因为COM的基础规范这十几年基本没变过C#侧还是那套ComVisible、Guid、ClassInterfaceQt侧也还是QAxObject走IDispatch动态调用。这套东西特别适合下面几类人老项目维护者需要在C#系统和Qt系统之间做数据交换。做工控上位机的工程师PLC、视觉检测、运动控制卡各用各的语言经常要做胶水层。学生或者刚入行的人想搞明白Windows下托管代码和非托管代码到底是怎么互相调用的。我用的环境是VS2008对应的.NET Framework 3.5Qt版本是4.6.4后者是很典型的VS2008编译产物。这里提前说一句Qt的预编译库和编译器版本必须匹配否则就算COM注册没问题你Qt程序自己就编译不过。后面我会专门讲这个坑。2. 整体方案设计一条调用链是怎么串起来的2.1 COM组件到底是啥为什么它能当“翻译”往简单了说COM就是一套二进制接口规范。所有COM对象都必须实现IUnknown支持自动化调用的还得实现IDispatch。客户端不需要知道对象是用什么语言写的也不需要链接对象的实现代码它只要拿到一个接口指针按接口定义的方法去调用就行。打个比方COM组件就像一个标准插座接口就是插座上的孔位。C#做出来的插座只要规格符合国家标准C的插头插上去就能通电Qt的插头也一样。你不用关心这个插座内部是硅胶还是陶瓷里面怎么接线的都无所谓。COM把“接口的定义”和“接口的实现”彻底分离了所以跨语言才可能成立。C#侧要做的事就是把自己包装成这个标准插座。.NET Framework专门提供了COM Interop机制公共语言运行时CLR会自动生成一个叫“COM可调用包装器”CCW的东西把.NET对象包装成COM对象。类上标了ComVisible(true)CLR就知道这个类型要暴露给COM然后把接口方法、属性都转成COM能理解的形态。2.2 用C#实现COM组件的优与劣好多人一听到“写COM组件”第一反应是用C写ATL或者MFC。那当然可以但我个人更倾向在合适的场景下用C#写尤其当组件本身业务逻辑复杂的时候。C#写COM组件的好处非常明显一是开发效率高字符串处理、集合操作、异常处理都有现成的东西不用像C那样跟引用计数、BSTR内存管理死磕二是不容易把进程搞崩溃托管代码有GC兜底不会因为忘记释放接口泄露内存当然前提是你别在非托管侧手动乱AddRef三是改起来快业务逻辑变了重新编译注册就行。代价也有目标机器必须装对应版本的.NET Framework否则CCW起不来组件启动第一次加载CLR首次调用会明显慢一些还有跨进程、跨位数部署的时候比原生COM组件麻烦。但在同一个工控机上的进程内调用场景里这些代价基本可以接受。2.3 一次完整调用的链路拆解我这次测试的组件功能很简单就是一个计算器提供了加法、减法和一个返回版本号的方法。整个调用链路拆开是五段C#代码编译成托管程序集MyMathLib.dll。通过RegAsm.exe注册把CLSID、ProgId、类型库信息写进注册表。RegAsm同时生成类型库MyMathLib.tlb里面描述了接口和方法签名。Qt程序里new一个QAxObject(MyMathLib.Calc)按ProgId从注册表找到CLSID再创建COM实例。调用dynamicCall(Add(int,int), 3, 5)通过IDispatch的Invoke方法转发到C#方法返回值再通过COM的VARIANT传回Qt。这里有个容易被带偏的点很多人以为Qt调用COM必须有类型库文件.tlb参与编译其实QAxObject走的是运行时动态绑定只要有注册表信息不依赖tlb也能调用。tlb更多是给C早期绑定、给VB脚本看的。当然我这里还是会生成tlb因为可以用OLE-COM Object Viewer检查接口定义排查问题的时候非常好用。整个方案选型的核心逻辑是组件无UI、业务逻辑独立、调用频率不算高适合进程内COMC#负责实现和注册Qt负责动态调用两边都不需要额外引入重量级的通信框架。3. C#创建COM组件的完整步骤3.1 新建类库项目先搞定三个关键属性打开VS2008新建一个C#类库项目我给它起名叫MyMathLib。项目建好后有几个地方要提前设置否则后面注册、调用都要走弯路。第一个是项目属性里的Target Framework确认选的是.NET Framework 3.5而不是.NET Framework 3.5 Client Profile。Client Profile缺少System.Web等组件虽然我们这个简单组件用不上但以后扩展就知道疼了。第二个是AssemblyInfo.cs文件。VS2008生成的默认AssemblyInfo里有一行是[assembly: ComVisible(false)]这表示程序集里的所有类型默认对COM不可见。要么把它改成[assembly: ComVisible(true)]要么在每个类上单独标[ComVisible(true)]。我习惯在程序集级别设为true类级别再做细粒度控制。第三个是GUID。COM接口、COM类都需要有唯一的GUIDVS2008的菜单“工具”里有个“创建GUID”点击后选择Registry Format复制出来就是标准格式。一定要记住接口的Guid和类的Guid不能一样。我自己第一次写的时候偷懒复制粘贴结果注册后接口和类的标识冲突Qt按ProgId创建对象时报错排查了半天。3.2 用显式接口而不是自动接口C#类暴露给COM有两种常见做法一种是不定义接口直接在类上标ComVisible用ClassInterfaceAttribute设置成AutoDual或None另一种是先定义接口再让类继承接口。我强烈建议用第二种也就是“显式接口”。原因是AutoDual会自动把.NET类的所有公共成员映射到COM接口虽然省事但会带上很多.NET框架自带的成员比如ToString、Equals、GetHashCodeCOM调用方就会看到一个“臃肿”的接口。更重要的是AutoDual生成的接口没有版本稳定性代码一改接口就变CLSID不变但方法表全乱了老客户端很容易翻车。我测试用的接口和实现代码如下using System; using System.Runtime.InteropServices; namespace MyMathLib { [ComVisible(true)] [Guid(7A5E1F20-9C6A-4D2B-B7A3-6C8F15EAA001)] public interface ICalc { [DispId(1)] int Add(int a, int b); [DispId(2)] int Subtract(int a, int b); [DispId(3)] string GetVersion(); } [ComVisible(true)] [Guid(7A5E1F20-9C6A-4D2B-B7A3-6C8F15EAA002)] [ProgId(MyMathLib.Calc)] [ClassInterface(ClassInterfaceType.None)] public class Calc : ICalc { public int Add(int a, int b) { return a b; } public int Subtract(int a, int b) { return a - b; } public string GetVersion() { return 1.0.0; } } }注意关键点[ClassInterface(ClassInterfaceType.None)]表示不自动生成类接口只暴露我们自己定义的ICalc。[DispId]是给IDispatch调度用的标识Qt的dynamicCall按照方法名来找DispId虽然不写也能工作写上更规范。[ProgId(MyMathLib.Calc)]定义了外部调用时的字符串标识。3.3 强命名给自己的组件盖个章C#的COM组件建议做强命名。原理很简单CCW加载.NET程序集时CLR要按程序集完全限定名去全局程序集缓存GAC或者本地目录里匹配程序集。如果没有强命名程序集身份就只靠文件名撑着很容易被同名DLL顶掉注册时RegAsm的/codebase参数虽然能豁免GAC部署但正式场合依然不推荐完全裸奔。在VS2008里操作很傻瓜项目属性 - 签名勾选“为程序集签名”下拉框里选“新建”输入一个密钥文件名字比如MyMathLib.snk。VS会自动调用sn工具生成密钥对。如果你习惯命令行也可以用SDK命令提示符执行sn -k MyMathLib.snk然后把生成的文件加进项目再在AssemblyInfo.cs里加一行[assembly: AssemblyKeyFile(MyMathLib.snk)]其实通过项目属性签名选项卡操作VS2008会替你把AssemblyKeyFile维护好不用手写。强命名做完编译出来dll的公开KeyToken就有了注册时CLR能找到唯一身份。3.4 手动regasm注册的完整命令和参数解释VS2008项目属性里有个“Register for COM interop”的勾选项勾上以后每次编译自动帮忙注册。但我不建议依赖它原因有三一是VS2008的自动注册走的是它自己的注册逻辑有时候注册到WOW6432Node还是正常节点跟你实际Qt程序的位数对不上二是自动注册只在开发机上有效部署到目标机器还是要手动折腾一遍三是自动注册出问题的时候缺少中间状态不利于排查。所以我推荐手动注册命令分三步。先打开“Visual Studio 2008 Command Prompt”切换到dll所在目录然后执行RegAsm。注意路径里的Framework目录要和Qt程序的位数匹配32位Qt程序用32位CLR注册cd D:\MyProjects\MyMathLib\bin\Release C:\Windows\Microsoft.NET\Framework\v2.0.50727\RegAsm.exe MyMathLib.dll /tlb:MyMathLib.tlb /codebase64位Qt程序用64位CLR注册cd D:\MyProjects\MyMathLib\bin\Release C:\Windows\Microsoft.NET\Framework64\v2.0.50727\RegAsm.exe MyMathLib.dll /tlb:MyMathLib.tlb /codebase参数解释一下。/tlb:MyMathLib.tlb表示同时生成类型库文件方便后续检查。/codebase是关键它允许RegAsm把DLL的物理路径直接记到注册表里而不是要求你把程序集安装到GAC。有了/codebase即便程序集没有强命名也能注册代价是注册表里多了一条“代码库”路径Windows 2000之后对这个参数有策略限制本地开发用得最多。如果注册错了想撤销先执行同一路径下的C:\Windows\Microsoft.NET\Framework\v2.0.50727\RegAsm.exe MyMathLib.dll /unregister我在实际测试中习惯这样先编译Release再在命令行里手动注册。因为Debug版带着调试符号虽然也能用但发布到别的机器上麻烦。3.5 注册后怎么确认成功注册完不要急着写Qt代码先确认注册表里真的有东西。打开regedit重点看两个位置。第一个是HKEY_CLASSES_ROOT\MyMathLib.Calc能查到子项CLSID说明ProgId已经生效。第二个是HKEY_CLASSES_ROOT\CLSID{7A5E1F20-9C6A-4D2B-B7A3-6C8F15EAA002}\InprocServer32正常注册后InprocServer32子键的默认值应该是mscoree.dll这说明CLR会接管这个COM对象的创建。如果这里显示的路径乱七八糟说明注册的CLR路径不对。还有一个工具叫OLE-COM Object ViewerVS2008自带了也可以单独下载。打开后在“All Objects”里找到Calc类右键可以查看接口结构能看到ICalc的Add、Subtract、GetVersion方法。这相当于从COM视角确认了C#代码里定义的东西真的露出来了能到这个程度C#侧就稳了。我一般还把生成的MyMathLib.tlb复制到Qt工程目录下虽然不参与编译但留着当接口文档。4. Qt侧调用COM组件的代码与配置4.1 确认ActiveQt模块可用的两个小动作Qt 4.6.4提供了一个叫ActiveQt的模块它分两半QAxContainer负责跟第三方COM对象通信QAxServer用来写ActiveX控件。我们只用到前者对应Qt工程里要加的模块名是axcontainer。这里必须先确认一件事你手头的Qt 4.6.4编译时到底有没有把ActiveQt模块编进去。很多网上下载的预编译包尤其是不完整的绿色版ActiveQt经常被漏掉。检查方法有两个一是看Qt安装目录的include文件夹下有没有QtAxContainer目录这个目录下应该有qaxobject.h、qaxbase.h等头文件二是看lib文件夹下有没有QtAdv4.lib这类库文件Qt4的debug版本叫QtAdv4d.librelease版本叫QtAdv4.lib。如果这两个都没找到说明这个Qt包不含ActiveQt要么重新下载完整版要么自己用源码编译一次Qt。自己编Qt的流程比较长但是只要走通一次后面版本迁移就省事了。4.2 新建Qt工程并配置.pro文件我用Qt Creator或者VS2008里的Qt插件都可以建工程。为了保持环境干净我这里直接手动写一个.pro文件用qmake生成VS工程或者直接用命令行编译都行。我的工程名是TestCom目录结构很简单只有一个main.cpp和一个.pro文件。.pro内容如下QT core gui axcontainer greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET TestCom TEMPLATE app SOURCES main.cpp第一行QT axcontainer就是把ActiveQt的QAxContainer模块加进来qmake会自己处理include目录和lib依赖。第二行是为了让工程在Qt 5下也能编译QT4.6.4里这条判断不生效到了Qt5才需要widgets模块属于顺手加的保险。如果你用VS2008加Qt插件建工程记得在项目属性的“Qt Modules”里勾上ActiveQt container。4.3 用QAxObject调用COM组件核心代码逐行讲解调用COM组件最核心的类是QAxObject它属于动态调用方式也就是通过IDispatch接口找方法、传参数、收返回值。下面是我测试用的main.cpp几乎每一行都有讲究#include QApplication #include QAxObject #include QDebug int main(int argc, char *argv[]) { QApplication app(argc, argv); QAxObject *calc new QAxObject(MyMathLib.Calc, 0); if (calc-isNull()) { qDebug() Create COM object failed; return -1; } QVariant sum calc-dynamicCall(Add(int,int), 3, 5); qDebug() 3 5 sum.toInt(); QVariant diff calc-dynamicCall(Subtract(int,int), 10, 4); qDebug() 10 - 4 diff.toInt(); QVariant version calc-dynamicCall(GetVersion()); qDebug() Version: version.toString(); delete calc; return 0; }第一行QAxObject的构造函数参数是ProgId也就是C#代码里[ProgId(MyMathLib.Calc)]定义的那个字符串。QAxObject拿到ProgId后会去注册表解析成CLSID然后CoCreateInstance创建对象。如果ProgId不存在或者注册信息错误对象创建失败isNull返回true。dynamicCall的第一个参数是方法签名格式是“方法名(参数类型列表)”。这里的参数类型名必须和COM的VARIANT类型对得上比如int就是整数四字节QString会被转成BSTR。后面的可变参数按顺序对应方法参数3和5会自动转成QVariant。dynamicCall(GetVersion())这里一定要带空括号表示无参方法。如果不带QAxObject会尝试用默认参数调用但C#方法并没有默认参数容易出问题。返回值包在QVariant里整型用toInt()取字符串用toString()取。还有另一个写法用setControl加CLSID字符串QAxObject *calc new QAxObject(0); calc-setControl({7A5E1F20-9C6A-4D2B-B7A3-6C8F15EAA002});效果一样。但日常更推荐ProgId因为字符串好记代码里一目了然。4.4 运行效果和额外说明这个Qt程序如果一切顺利运行后控制台输出应该是3 5 8 10 - 4 6 Version: 1.0.0这里有个小细节因为是控制台程序qDebug的输出会打到控制台如果你建的是Qt Widgets空窗口程序输出会在“应用程序输出”面板里。为了让逻辑足够简单我这里没有建界面整个工序就是调用、输出、退出。有人会问如果C#方法返回一个复杂类型比如结构体或者数组该怎么处理QAxObject支持COM里的SafeArrayC#返回object[]或者数组时QAxObject可以用QVariantList接收但建议能拆就拆尽量用基本类型组合省得在VARIANT转换上花时间。我后面改造真实项目时C#侧都优先暴露细粒度方法这样Qt侧调用最轻松。5. 我踩过的坑常见问题与排查实录5.1 regasm提示没有权限或加载失败RegAsm报“没有权限”是最容易处理的把命令行提示符右键用管理员身份运行就行。真正头疼的是报“未能加载程序集”或者“无法读取程序集”一般是三种原因造成的。第一种是路径错误或者文件名写错切换目录时最好用绝对路径别信相对路径。第二种是dll确实不是.NET程序集把C的dll拿给RegAsm注册那自然加载不了。第三种是dll依赖的其他库缺失比如C#项目引用了第三方dll但没有一起复制过来RegAsm要加载元数据时找不到引用就会失败。解决方法是把依赖输送到同一目录或者用/libpath参数指定搜索路径。还有一次我遇到过很奇怪的现象RegAsm显示注册成功但注册表里就是找不到记录。最后发现命令提示符是32位还是64位的问题64位系统里64位和32位RegAsm注册的视图不一样找不到是因为我用regedit打开的是另一个视图。这个我在5.3节单独说。5.2 Qt一直说创建不了COM控件QAxObject创建失败isNull返回trueQt还经常在调试输出里打印一句“QAxObject::setControl: unable to create control”。排查这个问题的顺序特别固定。先确认ProgId是不是写错了最好去注册表里搜一下MyMathLib.Calc看CLSID子项存不存在。再确认注册表里InprocServer32的默认值是不是mscoree.dll不是的话说明CCW没接管。然后确认目标机器上装了对应版本的.NET FrameworkVS2008编译的程序集至少需要.NET 3.5Win10/11某些版本默认不带.NET 3.5需要到控制面板功能里勾选启用。还有一个非常隐蔽的问题CLSID重复。我前文说过用同一个Guid复制粘贴如果一个进程同时注册了多个组件CLSID冲突后Qt用ProgId解析出来的对象可能完全不是你想要的那个而且代码层面不报错。遇到莫名其妙的数据异常先查Guid。5.3 64位系统下注册表重定向导致的诡异现象这个坑在x64系统上几乎人人都要踩一次。Windows为了让32位程序在64位系统上正常运行做了注册表重定向32位进程读HKEY_CLASSES_ROOT\CLSID时实际会先映射到HKLM\SOFTWARE\WOW6432Node\Classes\CLSID64位进程读的是正常节点。用32位的RegAsm.exe注册写入的是WOW6432Node64位Qt程序去读正常节点自然找不到组件。反过来64位RegAsm写入正常节点32位Qt程序去读WOW6432Node也找不到。这就是为什么我一直强调注册要用与Qt程序位数配套的RegAsm。排查时可以开两个regedit视图直接在地址栏输HKLM\SOFTWARE\WOW6432Node\CLSID和HKLM\SOFTWARE\CLSID看看组件到底注册在哪一边。如果两边都乱就直接unregister再用正确位数重来一遍。5.4 字符串返回乱码、方法返回空值C#返回stringCOM侧对应的是BSTRQAxObject拿到BSTR后会自动转成QString正常情况不会乱码。真乱码的时候先确认C#侧返回字符串是Unicode还是被错误编码过比如从外部文件读取的GBK文本没转码就直接返回QString默认UTF-16跟BSTR天然匹配问题多半出在C#源头上。方法返回空值更常发生在参数类型不匹配的场景。dynamicCall的第一个参数写了“Add(int,int)”但传入的参数是double或者qint64VARIANT类型转换失败时QVariant就是空的toInt()返回0看起来像是方法本身返回0。我遇到过的是C#方法签名里参数是longQt侧写成int调用返回值一直不对。后来把dynamicCall改成“Add(long,long)”一切正常。所以调用前先确认两端签名尤其是长整型和整型的差异。5.5 Qt库与编译器版本不匹配这个坑最隐形最后说一个环境层面的隐形坑Qt库本身是用什么编译器编出来的你的Qt工程就必须用什么编译器来编。QT4.6.4时代官方预编译包分成VS2008版、MinGW版等如果你用MinGW版Qt却在VS2008里写工程链接时会报一堆LNK2019、LNK2001错误信息里经常出现“unresolved external symbol”一开始很容易误以为是COM注册问题。我当时的做法是重新下载了VS2008版本的Qt预编译包然后配置好VS2008的Qt路径问题才消失。如果你要用VS2010、VS2015也一样必须找到对应编译器版本的Qt构建。没有现成包就自己编译Qt源码这也是很多老工程师电脑里常备Qt源码的原因。另外在VS2008中使用Qt还需要Qt VS插件的支持。插件配置好之后新建工程时可以选择Qt ApplicationVS里调试Qt代码也比较方便。如果只用Qt Creator那就用Qt Creator自带的编译套件同样要保证套件的编译器与Qt库一致。最后再给想复现这套流程的朋友一个建议不要因为只是简单测试就跳过强命名、跳过tlb生成、跳过显式接口。这些步骤在demo阶段确实能省但真实项目里维护起来全是债。我后来把C# COM组件的这套做法固化成了团队模板新项目直接套模板省掉了一半的踩坑时间。整体跑通一次之后你会发现C#和Qt之间其实也就隔着一层薄薄的COM协议并没有想象中那么神秘。