
简介这份资源面向需要在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封装成dllC#调用测试例子」要解决的事它演示了如何把 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_castvoid*(obj); } void QTDLL_Destroy(void* handle) { if (!handle) return; delete static_castWidgetWrapper*(handle); } int QTDLL_SetValue(void* handle, int value) { if (!handle) return -1; static_castWidgetWrapper*(handle)-setValue(value); return 0; } int QTDLL_GetResult(void* handle) { if (!handle) return -1; return static_castWidgetWrapper*(handle)-result(); } int QTDLL_SetName(void* handle, const char* name) { if (!handle || !name) return -1; // 内部拷贝一份避免调用方释放后悬垂 static_castWidgetWrapper*(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# 工程设成 x64Qt 侧就得用 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 函数」绝不让指针的生命周期跨过函数边界。这个习惯让后面几个跨语言模块的线上问题少了八成。希望帮到你。本文还有配套的精品资源点击获取