简介:这份资源面向需要在C#项目中复用QT能力的开发者,聚焦QT封装为DLL并由C#通过P/Invoke调用的完整实践,适合具备一定C++与C#基础、希望借助QT丰富UI与多媒体组件的中级开发者参考。压缩包共105个文件、约18.15MB,包含cpp与h源码、pro工程文件、lib与a导入库、dll动态库、pdb调试符号,以及C#侧的cs、csproj、sln工程文件,另有qm翻译文件与qwinwidget等窗口嵌入相关源码,覆盖从QT工程构建到C#测试调用的完整链路。已有868人学习下载。读者可据此理解C接口设计、DllImport声明、调用约定与错误处理等关键环节,并借助现成工程对照调试,减少跨语言集成中的试错成本。
1. 把 Qt 界面塞进 C# 主程序:一个 DLL 边界上的真实场景
接手一个工业上位机项目时,主框架是 C# WinForms,但客户指定要复用一套已经调好的 Qt 图表控件和串口采集逻辑。重写不现实,唯一的出路是把 Qt 那部分编译成 DLL,让 C# 通过 P/Invoke 调进来。这就是「QT封装成dll,C#调用测试例子」要解决的事:它演示了如何把 Qt 的类导出成 C 风格接口,再在 C# 侧用DllImport加载、传参、取返回值,跑通一条最小可用的跨语言调用链。适合两类人:手里有现成 Qt 模块、想嵌进 .NET 桌面程序的开发者;以及想搞清楚 DLL 导出、调用约定、字符串封送这些底层细节的人。下面按「能干什么 → 怎么跑 → 怎么改」的顺序拆。
2. 工程结构与技术选型:Qt 侧导出、C# 侧封送的两段式设计
2.1 为什么中间要垫一层 C 接口
Qt 的类有 C++ 的 name mangling、虚表、信号槽元对象系统,直接把这些符号暴露给 C# 是不现实的——C# 的DllImport只认稳定的 C 调用约定符号。所以这个例子的核心设计是两段式:Qt 侧写一个纯 C 的导出层(extern "C"+__declspec(dllexport)),内部再去 new Qt 对象、调 Qt 方法;C# 侧只面对这几个 C 函数,参数限定在int、double、char*、void*这类能安全封送的类型。
常见做法是把 Qt 对象指针当成不透明句柄(void*)返回给 C#,C# 存成IntPtr,后续每次调用都把句柄传回去。这样 C# 完全不需要知道 Qt 类的内存布局,Qt 侧也能自由改内部实现,只要 C 接口签名不变。这是跨语言封装里最稳的一种边界设计,比试图导出整个类要省心得多。
2.2 目录结构长什么样
一个能跑的最小工程大致是这样组织的,Qt 侧和 C# 侧各一个目录,产物在同一个输出目录汇合:
QtDllDemo/ ├── QtSide/ │ ├── qtdll.h # 导出函数声明,C# 侧照着它写 DllImport │ ├── qtdll.cpp # 导出函数实现,内部调 Qt 类 │ ├── widgetwrapper.h # 被包装的 Qt 类 │ ├── widgetwrapper.cpp │ └── QtSide.pro # qmake 工程文件 └── CSharpSide/ ├── Program.cs # 调用测试入口 └── CSharpSide.csprojqtdll.h是整个项目的契约文件,两边都依赖它。我一般会把它单独拷一份到 C# 工程里,注释写清楚每个参数的含义和所有权归属(谁分配、谁释放),否则过两个月自己都记不清char*返回值要不要free。
2.3 Qt 工程的关键配置
.pro文件里有两处必须改对,否则 DLL 要么导不出符号,要么 C# 加载时报「找不到指定模块」:
QT += core gui widgets TEMPLATE = lib CONFIG += dll TARGET = QtSide # 导出宏,配合头文件里的 QTDLL_EXPORT 使用 DEFINES += QTDLL_LIBRARY # 关键:确保导出的是 C 符号,不被 C++ 修饰 win32: QMAKE_CXXFLAGS += -fno-rttiTEMPLATE = lib加CONFIG += dll告诉 qmake 产出动态库而不是可执行文件。DEFINES += QTDLL_LIBRARY是给导出宏用的开关,头文件里靠它决定当前是「导出」还是「导入」。至于-fno-rtti,不是所有场景都需要,但如果你的 Qt 类里用了dynamic_cast又不想让符号表变复杂,加上它能让导出面更干净——这条属于经验项,不加也能跑,加了排查符号问题时少一层干扰。
3. 核心代码拆解:从导出函数到 C# 调用的一条完整链路
3.1 头文件:定义两边都认的契约
// qtdll.h #ifndef QTDLL_H #define QTDLL_H #ifdef QTDLL_LIBRARY #define QTDLL_API __declspec(dllexport) #else #define QTDLL_API __declspec(dllimport) #endif #ifdef __cplusplus extern "C" { #endif // 创建对象,返回不透明句柄;失败返回 NULL QTDLL_API void* QTDLL_Create(void); // 销毁对象,必须与 Create 配对调用 QTDLL_API void QTDLL_Destroy(void* handle); // 设置一个整型参数,返回 0 表示成功 QTDLL_API int QTDLL_SetValue(void* handle, int value); // 取当前计算结果 QTDLL_API int QTDLL_GetResult(void* handle); // 传入字符串,内部拷贝,调用方无需保留生命周期 QTDLL_API int QTDLL_SetName(void* handle, const char* name); #ifdef __cplusplus } #endif #endif这段头文件是整个项目的骨架。extern "C"保证函数名不被 C++ 修饰,C# 才能按名字找到入口。QTDLL_API宏在编译 DLL 时展开成dllexport,在 C# 侧(如果也引用这个头做对照)展开成dllimport。所有函数第一个参数都是void* handle,这就是前面说的不透明句柄模式。QTDLL_SetName的const char*参数约定为「内部拷贝」,这样 C# 传string时封送器分配的临时内存用完即弃,不会出现悬垂指针。
3.2 实现文件:句柄背后是真正的 Qt 对象
// qtdll.cpp #include "qtdll.h" #include "widgetwrapper.h" #include <cstring> void* QTDLL_Create(void) { // 在 DLL 堆上 new,句柄只在 DLL 内部有意义 WidgetWrapper* obj = new WidgetWrapper(); return static_cast<void*>(obj); } void QTDLL_Destroy(void* handle) { if (!handle) return; delete static_cast<WidgetWrapper*>(handle); } int QTDLL_SetValue(void* handle, int value) { if (!handle) return -1; static_cast<WidgetWrapper*>(handle)->setValue(value); return 0; } int QTDLL_GetResult(void* handle) { if (!handle) return -1; return static_cast<WidgetWrapper*>(handle)->result(); } int QTDLL_SetName(void* handle, const char* name) { if (!handle || !name) return -1; // 内部拷贝一份,避免调用方释放后悬垂 static_cast<WidgetWrapper*>(handle)->setName(QString::fromUtf8(name)); return 0; }每个导出函数都做了空指针检查,这是跨语言边界的血泪经验——C# 侧一旦传了IntPtr.Zero,没有检查就是直接崩进程,而且崩溃栈里看不到 C# 的调用点,排查起来像黑匣子。QTDLL_SetName里用QString::fromUtf8而不是fromLocal8Bit,是因为 C# 的string封送成char*时默认走 UTF-8(在 .NET Core 及以上),统一编码能避免中文乱码。WidgetWrapper内部可以随便用 Qt 的信号槽、容器,这些都不会泄漏到 C 接口外面。
3.3 C# 侧:DllImport 声明与调用
using System; using System.Runtime.InteropServices; class Program { // 与 qtdll.h 一一对应,CallingConvention 默认 Winapi 即可 [DllImport("QtSide.dll", CallingConvention = CallingConvention.Cdecl)] private static extern IntPtr QTDLL_Create(); [DllImport("QtSide.dll", CallingConvention = CallingConvention.Cdecl)] private static extern void QTDLL_Destroy(IntPtr handle); [DllImport("QtSide.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int QTDLL_SetValue(IntPtr handle, int value); [DllImport("QtSide.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int QTDLL_GetResult(IntPtr handle); [DllImport("QtSide.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] private static extern int QTDLL_SetName(IntPtr handle, string name); static void Main() { IntPtr h = QTDLL_Create(); if (h == IntPtr.Zero) { Console.WriteLine("创建失败"); return; } try { QTDLL_SetValue(h, 42); QTDLL_SetName(h, "测试名称"); Console.WriteLine($"结果: {QTDLL_GetResult(h)}"); } finally { QTDLL_Destroy(h); // 必须释放,否则 DLL 堆泄漏 } } }CallingConvention.Cdecl必须和 Qt 侧默认的调用约定一致,Windows 上 C++ 默认是__cdecl,写成StdCall会在调用时栈失衡,表现为「一调用就崩」或返回值错乱。CharSet.Ansi配合string参数,封送器会按 ANSI 编码分配内存——这里和 Qt 侧的fromUtf8要对应上,如果发现中文变问号,就把CharSet改成CharSet.Ansi并确认 Qt 侧用fromLocal8Bit,或者两边统一走 UTF-8 的byte[]手动转换。try/finally包住Destroy是硬性习惯,句柄泄漏在长时间运行的上位机里会慢慢吃光内存。
3.4 运行前必须凑齐的依赖
Qt DLL 不是自包含的,C# 进程启动时如果找不到 Qt 的运行时库,DllImport会抛DllNotFoundException或BadImageFormatException。需要把 Qt 安装目录下bin里的Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll(版本号按实际)拷到 C# 输出目录,platforms子目录里的qwindows.dll也要一并带上。32 位和 64 位必须严格一致:C# 工程设成 x64,Qt 侧就得用 64 位套件编译,混用是新手最常翻的车。
4. 避坑与排查:DLL 调用链上最容易翻车的五个点
4.1 现象:DllNotFoundException,但 DLL 明明在目录里
原因通常是依赖缺失而非主 DLL 不在。Windows 加载QtSide.dll时会连带解析它依赖的 Qt 运行时,任何一个找不到都会报「找不到指定模块」,错误信息具有误导性。解决办法是用 Dependencies 工具(或dumpbin /dependents)打开QtSide.dll,把列出的 Qt DLL 全部拷到同目录,platforms/qwindows.dll单独建子目录放。排查时先确认位数一致,再看依赖树,比反复改路径高效。
4.2 现象:调用返回乱码或直接访问冲突
多半是调用约定或字符集不匹配。CallingConvention写成默认的Winapi在 x86 上等价于StdCall,而 Qt 导出的是Cdecl,栈清理方错位就会崩。字符集方面,CharSet.Ansi与CharSet.Unicode决定封送器分配单字节还是双字节缓冲,和 Qt 侧fromUtf8/fromLocal8Bit必须成对。我的做法是字符串参数一律用byte[]加显式Encoding.UTF8.GetBytes,绕开封送器的隐式转换,虽然多写两行但行为完全可控。
4.3 现象:程序跑一段时间内存持续上涨
句柄没释放,或者QTDLL_SetName这类函数内部new了缓冲却没在Destroy里回收。跨语言场景下 GC 管不到 DLL 堆,所有Create必须有配对的Destroy。建议在 C# 侧包一个SafeHandle子类,把IntPtr托管起来,靠Dispose模式保证异常路径也能释放。另外 Qt 对象如果内部起了定时器或线程,Destroy里要先停掉再delete,否则析构期间回调打到已释放内存,崩溃点飘忽不定。
4.4 现象:Debug 能跑,Release 一调就崩
常见于结构体传参或返回值优化差异。这个例子只传基础类型所以风险低,但一旦你把接口扩展成传结构体,就要注意 C# 的[StructLayout(LayoutKind.Sequential)]和 C++ 侧字段顺序、对齐必须完全一致,bool在 C# 是 1 字节而 C++ 可能是 4 字节,用int代替bool传参最稳。Release 下编译器还可能做内联和寄存器优化,让某些未定义行为暴露出来,所以接口层尽量保持「只传 POD 类型」这条纪律。
4.5 现象:Qt 界面相关调用在 C# 线程里卡死或异常
Qt 的 GUI 对象有线程亲和性,必须在主线程创建和操作。如果 C# 在后台线程调QTDLL_Create且内部创建了QWidget,行为未定义。稳妥做法是让 DLL 内部自己保证 GUI 操作跑在 Qt 主线程(用QMetaObject::invokeMethod投递),或者干脆把导出接口限制在纯数据计算,界面部分由 C# 自己画。这个例子里WidgetWrapper如果只是数据容器就没问题,一旦涉及窗口就要重新设计线程模型。
5. 二次开发:把示例扩成可维护的跨语言模块
5.1 接口扩展的推荐姿势
示例只有五个函数,真实项目往往要传数组、回调、结构体。扩展时守住三条:参数只用 POD 和const char*;数组用「指针 + 长度」两个参数传,不要指望 C# 侧知道 C++ 的std::vector布局;回调函数用delegate加Marshal.GetFunctionPointerForDelegate转成函数指针传进去,并且要在 C# 侧用静态字段持有 delegate 引用,防止被 GC 回收导致回调时崩溃——这是最隐蔽的一个坑,现象是「回调偶尔不触发或进程直接退出」。
// 回调封送的正确写法:delegate 必须被强引用持有 [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void ProgressCallback(int percent); private static ProgressCallback _cbHolder; // 防止 GC 回收 [DllImport("QtSide.dll", CallingConvention = CallingConvention.Cdecl)] private static extern void QTDLL_SetProgressCallback(IntPtr h, ProgressCallback cb); static void Register(IntPtr h) { _cbHolder = p => Console.WriteLine($"进度 {p}%"); QTDLL_SetProgressCallback(h, _cbHolder); }UnmanagedFunctionPointer的调用约定要和 C++ 侧函数指针类型一致,_cbHolder这个静态字段看着多余,实则是保命符。Qt 侧收到函数指针后存起来,每次进度变化就调一次,C# 的 GC 完全不知道这个非托管引用,没有强引用兜底随时可能回收。
5.2 验证改造是否成功的检查清单
改完接口别急着接主程序,先用一个最小 C# 控制台把每个导出函数单独调一遍,重点看四件事:返回值是否符合约定(0 成功、负数失败)、字符串往返是否一致(传中文再取回来比对)、连续Create/Destroy一千次内存是否平稳、异常路径(传IntPtr.Zero)是否被安全拦截而不是崩溃。这四步走完,再往 WinForms 或 WPF 里嵌就只是宿主差异,风险已经收敛在 DLL 边界内。
| 检查项 | 通过标准 | 失败时先看 |
|---|---|---|
| 符号导出 | dumpbin /exports能看到全部函数名 | .pro是否漏了CONFIG += dll |
| 位数匹配 | C# 与 Qt 同为 x64 或 x86 | 工程平台设置、Qt 套件版本 |
| 字符串往返 | 中文传入取出完全一致 | CharSet与fromUtf8是否配对 |
| 句柄生命周期 | 千次创建销毁内存无增长 | Destroy是否覆盖所有异常路径 |
5.3 一个我踩过的教训
早期做类似封装时,我图省事让QTDLL_GetResult直接返回内部QString的const char*,C# 侧拿到IntPtr再转string。单次调用没问题,压力测试时偶发乱码——原因是 Qt 对象内部字符串被后续操作重建,原指针失效,而 C# 还在读那块内存。从那以后我每次设计导出接口,凡是返回字符串一律改成「调用方传缓冲 + 长度,DLL 往里拷」或者「DLL 分配、提供配套 Free 函数」,绝不让指针的生命周期跨过函数边界。这个习惯让后面几个跨语言模块的线上问题少了八成。希望帮到你。
本文还有配套的精品资源,点击获取