ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Windows下Qt软件闪退检测:退出码、日志与minidump全链路

Windows下Qt软件闪退检测:退出码、日志与minidump全链路 上周又接到一个电话客户说软件双击图标鼠标转两圈就没了语气里带着点见怪不怪的无奈——毕竟这已经是这个月第三次了。我这边本机跑了几十遍都稳偏偏对方那台机器上闪一下就没。这类问题几乎是每个做 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%拿到的数字最可能是这几种十进制十六进制含义大致方向-10737418190xC0000005访问冲突野指针、越界、栈被踩-10737417950xC000001D非法指令指令集不匹配、DLL 版本错配-10737415710xC00000FD栈溢出递归失控、大局部数组-10737407910xC0000409快速失败fail fast堆检测、CRT 安全检查、abort-10737415150xC0000135DLL 未找到缺 Qt5Core.dll / 缺 VC 运行库-10737415100xC000013A被 CtrlC 结束误杀一般不用管-10737409400xC0000374堆损坏内存写越界、double free30x00000003abort断言失败、qFatal、未捕获 C 异常后 abort00x00000000正常退出那就是逻辑退出不是崩溃这张表我自己打印出来贴在显示器边上好几年了。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_castconst 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_castMINIDUMP_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] DumpFolderhex(2):44,00,3a,00,5c,00,63,00,72,00,61,00,73,00,68,00,00,00 DumpCountdword:0000000a DumpTypedword:00000002DumpFolder是 REG_EXPAND_SZ 类型的路径上面那串十六进制对应D:\crash你也可以直接手填字符串。DumpType2表示完整转储1表示 mini0表示自定义。DumpCount控制保留多少个。这条路子的好处是它抓得比你代码里更早在SetUnhandledExceptionFilter之前、在 DLL 加载失败那一刻系统就能生成 dump。缺platforms/qwindows.dll导致的启动闪退靠这个能直接看到是哪一条加载失败。改注册表需要管理员权限交付时可以让现场的 IT 人员配合排查完再删掉这个键就行。3.3 顺带看一眼 Windows 事件查看器有些现场你连 dump 都拿不到比如客户只愿意给你一句就是闪退。这时让他们打开事件查看器查看Windows 日志 → 应用程序筛选来源为应用程序错误能看到这样的记录事件 ID 1000记录故障应用程序名、模块名、异常代码、偏移量事件 ID 1001Windows 错误报告附带 bucket 信息异常代码那一栏就是前面表里的0xC0000005这类值而故障模块名称经常能直接把嫌疑人点出来——比如写的是nvwgf2umx.dll那就是显卡驱动的问题跟你代码一点关系都没有。这一条信息有时候比 dump 还快。4. 有了 dump 之后怎么把它读回源代码的那一行4.1 Release 也必须留 PDB这不是可选项dump 只记录地址没有符号表就只能看到一串Qt5Core.dll0x1a2b3c什么都推不出来。所以在发布流程里必须带上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_ASSERT5. 高频闪退场景逐条对照别再从零开始猜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 两套 QtPATH里先找到的 DLL 和插件目录里的不是一套运行时版本检查失败直接退出。环境变量QT_DEBUG_PLUGINS1打开后会打印插件加载的详细过程路径冲突一眼可见。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已经是野指针。改成QPointerQObject 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 栈会一路走到系统 DLLSetupAPI、serenum看起来跟 Qt 完全无关。遇到这种情况我的做法是先把串口通信隔离到一个独立进程通过本地 socket 通信进程崩了主界面还活着重启就行。视觉库Halcon、OpenCV的崩溃同理尤其是摄像头采集回调里抛出的异常如果不接住会直接把整个进程带走。数据库驱动qsqlite.dll、qsqlmysql.dll缺失的话同样是启动期0xC0000135用QT_DEBUG_PLUGINS1能看到具体是哪一类插件没加载成功。6. 把检测手段工程化让下一个闪退自己把证据交出来6.1 环形日志缓冲崩溃那一刻把最后 2000 行吐出来一直往磁盘写日志有两个坏处IO 开销大、日志文件无限膨胀。工业现场的机器常年不关机日志能涨到几十个 GB反而拖慢了主流程。我现在用的方案是内存环形缓冲加崩溃时批量落盘内存里维护一个固定容量比如 5000 条的QVectorQString当作环用writeIndex循环覆盖正常运行时完全不碰磁盘触发崩溃处理器时先把环里最后 2000 条一次性写到crash_时间戳.log同时写一份 dump这个方案的好处是崩溃日志和 dump 时间戳一致复盘时能对着看日志里最后一句是开始解析文件 xxxdump 里停在解析函数因果关系一目了然。实现上比想象中简单关键是环的写入要加锁落盘时要把环冻结用一个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_OPENGLsoftware切软件渲染对比能切换说明是驱动问题长时间挂机跑 8 小时以上专治那些靠时间累积才出现的句柄泄漏型闪退Application Verifier 这个工具值得单独提一句它对堆的检查远比默认严格能抓到很多平时不崩、上线才崩的越界写。代价是运行速度慢几倍只适合在测试机上开。我个人在实际操作里的体会是闪退排查这件事八成的时间花在让问题可观测上两成才是真正找 bug。日志、退出码、dump 这三样东西只要有一个到位绝大多数闪退都能在两小时内定位到函数级三样都没有就只能在代码里漫无目的地加qDebug运气不好一周都出不来。所以与其事后救火不如一开始就花半天时间把 message handler 和异常过滤器搭好——这半天迟早会替你省回来。
返回列表