上周又接到一个电话,客户说软件"双击图标,鼠标转两圈就没了",语气里带着点见怪不怪的无奈——毕竟这已经是这个月第三次了。我这边本机跑了几十遍都稳,偏偏对方那台机器上闪一下就没。这类问题几乎是每个做 Windows + Qt 桌面开发的人都会撞上的墙:程序不给任何提示,不留任何痕迹,Windows 下 Qt 软件闪退的检测方法如果一开始就没设计好,后面就只能靠猜。这篇就把我这些年惯用的一套排查链路完整摊开讲:从退出码、日志、异常码到 minidump 转储,再到堆栈还原和高频场景对照。适合已经能写 Qt 界面但一遇到闪退就束手无策的中级开发者,也适合要把软件交付到各种脏环境里的同学——不管你用的是 5.14、5.15.2 还是更新的版本,思路都一样。
1. 闪退查不动,是因为崩溃现场在 Windows 上本来就被吃掉了
1.1 三层信息是怎么一层层丢掉的
先想清楚一件事:一个进程死掉,本来应该留下三样东西——退出码、崩溃时的调用栈、崩溃前最后几行日志。Windows 上这三样默认全都不给你。
第一层是退出码。用资源管理器双击启动的程序,Windows 根本不把退出码展示给任何人,进程结束了就是结束了。你在 cmd 里直接用MyApp.exe跑,ERRORLEVEL才会告诉你真相。
第二层是调用栈。MSVC 编译的 Release 程序遇到未处理异常,默认行为是弹一个"程序已停止工作"的对话框,然后自行结束;如果这个对话框在某些环境里被禁用(比如组策略、或者你在代码里调过SetErrorMode),那连弹窗都没有。Qt 官方在 Windows 上的预编译包是用 MSVC 或 MinGW 构建的,前者走这套 WER 流程,后者直接用异常终止,差别很大。
第三层是日志。很多人的qDebug()输出在 Release 下全部进了OutputDebugString,而没有调试器附加时,这些字符串就是扔进了黑洞。看着代码里到处都有qDebug(),实际上一条都没留下来。
这三层一丢,你剩下的只有"它闪了一下"这个观察。所以检测方法的第一步不是找 bug,是先把现场抢回来。
1.2 先把"崩了"和"退了"分开:退出码是第一手证据
我习惯的第一步永远是在命令行里跑:
cd /d D:\build\MyApp\release MyApp.exe echo exit code = %ERRORLEVEL%注意必须用cd /d切到目录再执行,不要用start MyApp.exe,那样不会等进程结束。如果想在脚本里等,用:
start /wait "" MyApp.exe echo exit code = %ERRORLEVEL%拿到的数字最可能是这几种:
| 十进制 | 十六进制 | 含义 | 大致方向 |
|---|---|---|---|
| -1073741819 | 0xC0000005 | 访问冲突 | 野指针、越界、栈被踩 |
| -1073741795 | 0xC000001D | 非法指令 | 指令集不匹配、DLL 版本错配 |
| -1073741571 | 0xC00000FD | 栈溢出 | 递归失控、大局部数组 |
| -1073740791 | 0xC0000409 | 快速失败(fail fast) | 堆检测、CRT 安全检查、abort |
| -1073741515 | 0xC0000135 | DLL 未找到 | 缺 Qt5Core.dll / 缺 VC 运行库 |
| -1073741510 | 0xC000013A | 被 Ctrl+C 结束 | 误杀,一般不用管 |
| -1073740940 | 0xC0000374 | 堆损坏 | 内存写越界、double free |
| 3 | 0x00000003 | abort | 断言失败、qFatal、未捕获 C++ 异常后 abort |
| 0 | 0x00000000 | 正常退出 | 那就是逻辑退出,不是崩溃 |
这张表我自己打印出来贴在显示器边上好几年了。0xC0000135和0xC000013A经常被误判成"闪退",其实一个是缺库,一个是被人手动结束了。能在 main 之前就死的,一定是 DLL 问题——因为那时你的日志代码还没跑到。
提示:如果连退出码都拿不到就要考虑权限问题,某些环境下
%ERRORLEVEL%会被前一条命令覆盖,建议单独建一个 bat 只做这一件事。
2. 用 qInstallMessageHandler 在程序里立一道日志墙
2.1 处理器装在 main 的哪一行决定了你能看到什么
Qt 提供了qInstallMessageHandler,作用是把qDebug、qWarning、qCritical、qFatal的输出全部重定向到你自己写的函数里。这个安装动作必须放在QApplication构造之前,原因很实在:如果崩在 QApplication 初始化阶段(比如平台插件加载失败),装在后面的处理器根本没机会注册。
#include <QApplication> #include <QDateTime> #include <QFile> #include <QMutex> #include <cstdio> static QFile g_logFile; static QMutex g_logMutex; void myMessageHandler(QtMsgType type, const QMessageLogContext &ctx, const QString &msg) { const char *level = "DEBUG"; switch (type) { case QtDebugMsg: level = "DEBUG"; break; case QtInfoMsg: level = "INFO "; break; case QtWarningMsg: level = "WARN "; break; case QtCriticalMsg: level = "ERROR"; break; case QtFatalMsg: level = "FATAL"; break; } QString line = QString("[%1][%2][%3:%4] %5") .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss.zzz")) .arg(level) .arg(ctx.file ? ctx.file : "?") .arg(ctx.line) .arg(msg); QMutexLocker locker(&g_logMutex); if (g_logFile.isOpen()) { g_logFile.write(line.toUtf8()); g_logFile.write("\n"); g_logFile.flush(); // 关键 fflush(nullptr); // 关键中的关键 } fprintf(stderr, "%s\n", line.toLocal8Bit().constData()); } int main(int argc, char *argv[]) { g_logFile.setFileName(QCoreApplication::applicationDirPath() + "/app.log"); g_logFile.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text); qInstallMessageHandler(myMessageHandler); qSetMessagePattern("[%{time yyyy-MM-dd hh:mm:ss.zzz}][%{type}] %{message}"); QApplication app(argc, argv); ... }ctx.file和ctx.line在 Release 下默认是空的,需要在 .pro 里加:
DEFINES += QT_MESSAGELOGCONTEXTCMake 项目对应的是在target_compile_definitions里加QT_MESSAGELOGCONTEXT。不加这一行,日志里全是"?",出了事只能靠猜是哪一句打出来的,等于白干。
2.2 日志落盘:为什么 flush 写了两次还是不够
上面那段代码里g_logFile.flush()和fflush(nullptr)都写了,很多人会觉得多余。实际情况是:QFile::flush()只保证 Qt 的缓冲区刷到操作系统的文件缓存里,QFile 内部有QFileDevice的缓冲;而fflush(nullptr)是把 C 运行库所有打开的流的缓冲一并刷掉,包括可能被 stdout 重定向产生的缓冲。程序崩溃时,操作系统的文件缓存通常还是落盘的(因为进程结束时句柄被关闭),但如果你的日志是崩在其他线程、或者进程被强制终止(fail fast 场景),最后一次 flush 有没有做就是天壤之别。
另外,不要为了性能把日志缓冲开大。我见过一个项目为了让日志不拖慢界面,把 QFile 的缓冲设成 1MB,结果所有崩溃日志都是整块整块丢的。日志的价值在崩溃时兑现,不是在高频写入时省那点 IO。
写入加锁这件事也有讲究。Qt 的 message handler 本身在同线程内是顺序调用的,但多线程里多个线程同时qWarning是常态,不加锁就会写出互相穿插的乱码行,复盘时根本读不通。用QMutex是最省事的做法;如果日志量真的很大,可以改成"加锁只保护入队,落盘交给单独的写线程",但那样崩溃时队列里的内容又会丢——这就是典型的取舍,我个人的选择是:宁可慢一点,也要保证原子性。
2.3 一个运行标记,专治"启动那一下就没了"
有些闪退发生在 QApplication 构造过程中,日志还没来得及打开就已经结束了;还有些发生在极早期的插件加载阶段。这时候用一个最土的办法反而最有效:
// 程序启动第一行:写入运行标记 const QString flag = QCoreApplication::applicationDirPath() + "/running.flag"; { QFile f(flag); f.open(QIODevice::WriteOnly | QIODevice::Truncate); f.write(QDateTime::currentDateTime().toString(Qt::ISODate).toUtf8()); f.close(); } // 正常退出前(main 末尾)删除 QFile::remove(flag);下次启动时先检查这个文件是否存在:存在,说明上一次是非正常结束;再配合文件里记录的时间戳,就能知道上次扛了多久。
这个技巧解决的就是那类"运行十分钟才崩"和"启动五秒就崩"分不清的问题。日志目录里如果有running.flag残留,那日志文件的最后一行就非常有价值——它是崩溃前的遗言。
3. 异常码和 minidump:让崩溃留下一个可以复现的现场
3.1 SetUnhandledExceptionFilter 为什么比 try/catch 更靠得住
很多人第一反应是用try { ... } catch (...) { ... }包住 main。这个做法能兜住的只有 C++ 异常,兜不住:
- 空指针解引用(这是 SEH 异常,不是 C++ 异常)
- 栈溢出
- 第三方 C 库里的崩溃(OpenCV、Halcon、串口驱动回调)
- 堆损坏触发的 fail fast(这类是直接终止,任何 handler 都拦不住)
Windows 上真正能兜住这些的是结构化异常处理(SEH),入口就是SetUnhandledExceptionFilter。它会在系统默认的"程序已停止工作"流程之前被调用,给你一次写现场的机会。
#include <windows.h> #include <dbghelp.h> #pragma comment(lib, "dbghelp.lib") static LONG WINAPI myUnhandledFilter(EXCEPTION_POINTERS *ep) { QString dir = QCoreApplication::applicationDirPath() + "/crash"; QDir().mkpath(dir); QString path = QString("%1/crash_%2_pid%3.dmp") .arg(dir) .arg(QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss")) .arg(GetCurrentProcessId()); HANDLE h = CreateFileW(reinterpret_cast<const wchar_t *>(path.utf16()), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (h != INVALID_HANDLE_VALUE) { MINIDUMP_EXCEPTION_INFORMATION info; info.ThreadId = GetCurrentThreadId(); info.ExceptionPointers = ep; info.ClientPointers = FALSE; MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), h, static_cast<MINIDUMP_TYPE>(MiniDumpWithDataSegs | MiniDumpWithHandleData | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules), &info, nullptr, nullptr); CloseHandle(h); } return EXCEPTION_EXECUTE_HANDLER; }注册放在 main 最开始:
SetUnhandledExceptionFilter(myUnhandledFilter);MiniDumpWithFullMemory会把整个进程内存写下来,文件可能几百 MB 到几个 GB,线上环境不建议;MiniDumpWithDataSegs | MiniDumpWithThreadInfo这个组合一般 1~20MB,够定位到函数和变量,是性价比最高的档位。真要查堆内容再加MiniDumpWithFullMemory。
有一个坑必须提醒:Qt 自己的崩溃处理和这个过滤链会打架。Qt 在 Windows 上注册了自己的异常处理用于输出qFatal信息,如果你同时用了崩溃上报 SDK,注册顺序反了就会导致 dump 只有一半。我的习惯是放在QApplication构造之前注册,并且不在别的库回调里二次注册。
3.2 不写一行代码的兜底方案:WER LocalDumps
如果客户的机器上跑的是已经发布的版本、你没法重新编译,还有一个几乎没人知道但极其好用的办法——打开 Windows 自带的本地转储功能,让系统在你程序崩溃时自动把 dump 写到指定目录。只需要在客户机上导入一个注册表文件:
Windows Registry Editor Version 5.00 [HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe] "DumpFolder"=hex(2):44,00,3a,00,5c,00,63,00,72,00,61,00,73,00,68,00,00,00 "DumpCount"=dword:0000000a "DumpType"=dword:00000002DumpFolder是 REG_EXPAND_SZ 类型的路径,上面那串十六进制对应D:\crash,你也可以直接手填字符串。DumpType=2表示完整转储,1表示 mini,0表示自定义。DumpCount控制保留多少个。
这条路子的好处是它抓得比你代码里更早:在SetUnhandledExceptionFilter之前、在 DLL 加载失败那一刻,系统就能生成 dump。缺platforms/qwindows.dll导致的启动闪退,靠这个能直接看到是哪一条加载失败。
改注册表需要管理员权限,交付时可以让现场的 IT 人员配合,排查完再删掉这个键就行。
3.3 顺带看一眼 Windows 事件查看器
有些现场你连 dump 都拿不到,比如客户只愿意给你一句"就是闪退"。这时让他们打开事件查看器,查看"Windows 日志 → 应用程序",筛选来源为"应用程序错误",能看到这样的记录:
- 事件 ID 1000:记录故障应用程序名、模块名、异常代码、偏移量
- 事件 ID 1001:Windows 错误报告,附带 bucket 信息
异常代码那一栏就是前面表里的0xC0000005这类值,而"故障模块名称"经常能直接把嫌疑人点出来——比如写的是nvwgf2umx.dll,那就是显卡驱动的问题,跟你代码一点关系都没有。这一条信息有时候比 dump 还快。
4. 有了 dump 之后,怎么把它读回源代码的那一行
4.1 Release 也必须留 PDB,这不是可选项
dump 只记录地址,没有符号表就只能看到一串Qt5Core.dll+0x1a2b3c,什么都推不出来。所以在发布流程里必须带上:
QMAKE_CXXFLAGS_RELEASE += /Zi QMAKE_LFLAGS_RELEASE += /DEBUG /OPT:REF /OPT:ICFCMake 里对应:
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} /Zi") set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF")/Zi生成的是 PDB,不会让 exe 变大也不会明显变慢,只是多一个文件。归档 PDB 的地址要和 exe 的版本一一对应,我一般按版本号_构建哈希_日期建目录:
archive/ 1.8.3_7f2a9c1_20240912/ MyApp.exe MyApp.pdb Qt5Core.dll Qt5Core.pdbQt 的官方 PDB 在安装包的Debug info组件里,如果没装过,就得从对应版本的源码自己编译一次,这一步很痛苦但值得。至少把 Qt 自己的 DLL 名字和版本记清楚,很多时候光看栈里 Qt 函数的调用顺序就能猜到大方向。
4.2 WinDbg 里我固定敲的那几条命令
用 WinDbg(现在叫 WinDbg Preview 也行)打开 dump 之后,我基本按这个顺序来:
.sympath+ srv*D:\symbols*https://msdl.microsoft.com/download/symbols .sympath+ D:\archive\1.8.3_7f2a9c1_20240912 .reload /f !analyze -v .ecxr kb!analyze -v会给出它认为的异常类型和可能的责任模块,但它对 Qt 程序经常判断偏,因为它不理解 Qt 的对象模型。真正有价值的是.ecxr(切到异常记录对应的上下文)加kb(带参数打印栈)。看到调用链里出现你自己的函数名,就成功了一半。
如果是堆损坏,还需要加一步:
!heap -p -a 000001a2b3c4d5e0它能告诉你这个地址属于哪个堆块、是分配还是已释放,0xC0000374那类问题基本都靠它破案。
另外,Visual Studio 也能直接打开 dump 文件,而且对 C++ 栈的显示更友好,调试体验比 WinDbg 好,只是需要你有对应的 pdb 和源码。我个人习惯是:先 VS 打开看栈,栈里有可疑的 Qt 内部帧再去 WinDbg 配合!analyze深挖。
4.3 从栈帧的形态判断是哪一类问题
看多了会有直觉,几个典型形态:
- 栈底是
KiUserExceptionDispatcher→ 往上第一帧是你自己的函数 → 基本确定是那一行解引用了空指针或者野指针 - 栈里出现
QMetaObject::activate→ 说明是信号槽触发路径上崩的,重点查槽函数里的对象是否还活着 - 栈里出现
QTextDocument、QWidget::paintEvent之类的绘制函数 → 优先怀疑显卡驱动、远程桌面、多屏切换 - 栈里出现
QThreadPrivate::start→ 那是工作线程,别去查 UI 代码,重点查共享数据的锁 - 栈非常浅,只有两三层,且异常码是
0xC0000409→ 大概率是 CRT 安全检查或者abort(),往上找qFatal、Q_ASSERT
5. 高频闪退场景逐条对照,别再从零开始猜
5.1 启动就闪退:先查 platforms 插件和运行时库
这一类是最多的,特点是日志文件根本不会生成,因为崩在 QApplication 构造之前。排查顺序我固定这么走:
第一,确认platforms/qwindows.dll在不在 exe 同级目录。Qt 程序启动时必须能加载平台插件,找不到就直接 abort,抛出那句经典的 "This application failed to start because no Qt platform plugin could be initialized"。用windeployqt MyApp.exe --release一条命令就能把依赖铺全,比手动 copy 可靠得多。
第二,确认 VC 运行库。用dumpbin /dependents MyApp.exe看它依赖哪些VCRUNTIME140.dll、MSVCP140.dll,客户机上没装对应的可再发行组件包就会0xC0000135。要么让客户装,要么把这些 DLL 一起打包进安装目录。
第三,用 Process Monitor 过滤Process Name is MyApp.exe和Result is NAME NOT FOUND,它能精确告诉你哪个路径下缺哪个文件。这个工具我每次排查启动闪退都会开,比人肉数文件快十倍。
第四,检查 Qt 版本与插件是否混用。见过最多的奇葩问题是开发机上装了 5.14 和 5.15.2 两套 Qt,PATH里先找到的 DLL 和插件目录里的不是一套,运行时版本检查失败,直接退出。环境变量QT_DEBUG_PLUGINS=1打开后会打印插件加载的详细过程,路径冲突一眼可见。
5.2 运行一段时间才崩:跨线程和对象生命周期
如果程序能跑起来、也能正常显示,跑几分钟几十分钟才崩,那基本可以排除部署问题,落在代码上。最常见的就是这几类:
跨线程直接操作 UI。只有主线程能碰 QWidget,工作线程里ui->label->setText(...)在某些机器上跑一天都不崩,在某些机器上一压测就死。正确做法是发信号或者用QMetaObject::invokeMethod(obj, "slot", Qt::QueuedConnection, ...)。这类问题在 dump 里的特征非常明显:栈里同时出现QThreadPrivate和QWidget相关的帧。
lambda 里捕获了裸this。这是最隐蔽的一类。比如一个对话框用QTimer::singleShot(5000, this, [this]{ ... }),用户在两秒内把对话框关掉了,五秒后 lambda 执行,this已经是野指针。改成QPointer<QObject> guard(this)然后在 lambda 里判空,或者给singleShot传 context 对象,能省掉大量这类崩溃。
deleteLater和delete混用。一个对象被delete之后,事件队列里还有它的事件,事件派发时访问已释放内存。Qt 的对象树机制在这种情况下会连着父对象一起删,反而更容易出现"删了两次"。
自定义容器忘记加锁。QHash、QList在多线程并发读写时会引发堆损坏,报出来的异常码是0xC0000374而不是0xC0000005。看到堆损坏,第一件事就是去搜static的容器和全局变量。
5.3 第三方库带来的闪退:串口、视觉库、数据库驱动
Qt 的模块化设计有个副作用:模块加载失败和模块内部崩溃的表象很像,但排查路径完全不同。
比如unknown module(s) in Qt: serialport这个报错,是 .pro 里写了QT += serialport但当前套件没装这个模块,编译期就炸,属于最简单的。真正麻烦的是编译通过了、运行时QSerialPort打开串口触发驱动层异常,直接闪退。这种崩溃的 dump 栈会一路走到系统 DLL(SetupAPI、serenum),看起来跟 Qt 完全无关。遇到这种情况我的做法是:先把串口通信隔离到一个独立进程,通过本地 socket 通信,进程崩了主界面还活着,重启就行。
视觉库(Halcon、OpenCV)的崩溃同理,尤其是摄像头采集回调里抛出的异常如果不接住,会直接把整个进程带走。数据库驱动qsqlite.dll、qsqlmysql.dll缺失的话同样是启动期0xC0000135,用QT_DEBUG_PLUGINS=1能看到具体是哪一类插件没加载成功。
6. 把检测手段工程化:让下一个闪退自己把证据交出来
6.1 环形日志缓冲:崩溃那一刻把最后 2000 行吐出来
一直往磁盘写日志有两个坏处:IO 开销大、日志文件无限膨胀。工业现场的机器常年不关机,日志能涨到几十个 GB,反而拖慢了主流程。我现在用的方案是内存环形缓冲加崩溃时批量落盘:
- 内存里维护一个固定容量(比如 5000 条)的
QVector<QString>当作环,用writeIndex循环覆盖 - 正常运行时完全不碰磁盘
- 触发崩溃处理器时,先把环里最后 2000 条一次性写到
crash_时间戳.log - 同时写一份 dump
这个方案的好处是崩溃日志和 dump 时间戳一致,复盘时能对着看:日志里最后一句是"开始解析文件 xxx",dump 里停在解析函数,因果关系一目了然。实现上比想象中简单,关键是环的写入要加锁,落盘时要把环冻结(用一个volatile bool标记)避免线程继续往里写。
6.2 版本、构建、符号三件套缺一不可
这是个流程问题,不是技术问题,但翻车率极高。我在每个项目的启动日志第一行强制打印这些:
qInfo() << "版本" << APP_VERSION << "构建时间" << __DATE__ << __TIME__ << "Qt" << QT_VERSION_STR << "编译器" << QMAKE_CXX_COMPILER << "系统" << QSysInfo::prettyProductName() << "位数" << QSysInfo::currentCpuArchitecture();出问题时第一句就问客户要这份信息,能省掉一半的来回沟通成本。最怕的是给了一个 dump,结果客户装的版本和你手上的源码差了三个提交,符号全对不上,白折腾一下午。
配合 Git 提交哈希一起打进去更好:
GIT_HASH = $$system(git rev-parse --short HEAD) DEFINES += GIT_HASH=\\\"$$GIT_HASH\\\"这样任何一个 dump 都能追溯到唯一的源码版本。
6.3 人为复现:别等它自己崩
最后说一个反直觉的经验:等崩溃自然发生是最慢的路。我通常会在测试阶段主动制造压力,让它尽快暴露:
- 界面层面反复快速开关同一个窗口、反复切页,几十次就能暴露生命周期问题
- 用脚本模拟高频点击(
QTest::mouseClick或者系统的自动化工具),暴露重入问题 - 内存层面用 Application Verifier 打开堆检查,越界写一跑就报,不用等到线上
- 显卡相关的问题直接用
QT_OPENGL=software切软件渲染对比,能切换说明是驱动问题 - 长时间挂机跑 8 小时以上,专治那些靠时间累积才出现的句柄泄漏型闪退
Application Verifier 这个工具值得单独提一句,它对堆的检查远比默认严格,能抓到很多"平时不崩、上线才崩"的越界写。代价是运行速度慢几倍,只适合在测试机上开。
我个人在实际操作里的体会是,闪退排查这件事,八成的时间花在"让问题可观测"上,两成才是真正找 bug。日志、退出码、dump 这三样东西只要有一个到位,绝大多数闪退都能在两小时内定位到函数级;三样都没有,就只能在代码里漫无目的地加qDebug,运气不好一周都出不来。所以与其事后救火,不如一开始就花半天时间把 message handler 和异常过滤器搭好——这半天,迟早会替你省回来。