简介:这是一份面向Qt开发者的崩溃日志自动生成工具源码包,针对GUI程序在运行中突发段错误、异常终止等场景,提供核心转储与调试信息采集能力,适合具备一定C++与Qt基础、需要排查线上或测试环境崩溃问题的中高级开发者。压缩包共56个文件,约561KB,以cpp与h源码、vcxproj与pro工程文件、log与tlog日志、obj与pdb编译产物为主,另含ui界面文件、qrc资源文件及props属性配置,完整保留了QDumpTool的工程结构与构建痕迹。目前已有1501人学习下载。读者可从中获取信号捕获、堆栈跟踪、线程状态记录与日志落盘的具体实现思路,结合GDB加载核心转储定位崩溃行号,并参考内存分析与多线程信息采集的扩展方向,快速将崩溃监控能力集成到自己的Qt项目中。
1. Qt dump 工具:软件崩溃自动生成日志,到底在解决什么问题
线上跑着的 Qt 客户端,最怕的不是崩溃,是崩了之后什么都没有。用户截图发过来一句「点了下按钮就没了」,你打开事件查看器只看到一条 0xc0000005,连崩在哪个函数都不知道。Qt dump 工具要解决的就是这件事:让程序在崩溃瞬间自己把调用栈、线程状态、加载模块、寄存器现场落成一份日志文件,事后直接拿这份日志定位到源码行。它适合两类人:一类是正在用 Qt 做 Windows 桌面端、需要给现场运维留证据的开发者;另一类是已经接了崩溃上报,但发现上报上来的只有一句「程序已停止工作」、根本没法复现的团队。核心链路其实就三步——捕获异常、生成 dump、解析 dump 还原栈。下面按这条链路拆开讲,每一步都给能直接抄的代码和参数。
2. 崩溃捕获:SetUnhandledExceptionFilter 与 Qt 消息循环怎么配合
2.1 为什么 Qt 程序不能只靠 try/catch
Qt 的事件循环把用户操作、定时器、网络回调都塞进QApplication::exec()里跑,绝大多数崩溃发生在事件分发过程中,而不是你手写的某个函数调用里。访问空指针、数组越界、栈溢出这类错误在 Windows 上会触发结构化异常(SEH),它不是 C++ 异常,try/catch根本接不住。所以捕获点必须挂在进程级:SetUnhandledExceptionFilter负责接住没人处理的 SEH,std::set_terminate负责接住 C++ 异常逃逸,qInstallMessageHandler负责把崩溃前最后几条 qDebug/qWarning 也留下来。三者配合,才能拿到一份「有现场、有上下文」的日志。
2.2 最小可用的捕获代码
#include <windows.h> #include <dbghelp.h> #include <exception> #include <QCoreApplication> #include <QDateTime> #include <QDir> #pragma comment(lib, "dbghelp.lib") static LONG WINAPI CrashHandler(EXCEPTION_POINTERS* pException) { // 1. 生成 dump 文件路径,按时间戳命名,避免覆盖 QString dir = QCoreApplication::applicationDirPath() + "/crash"; QDir().mkpath(dir); QString dumpPath = dir + "/crash_" + QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss") + ".dmp"; // 2. 创建文件并写入 MiniDump HANDLE hFile = CreateFileW( reinterpret_cast<const wchar_t*>(dumpPath.utf16()), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile != INVALID_HANDLE_VALUE) { MINIDUMP_EXCEPTION_INFORMATION info; info.ThreadId = GetCurrentThreadId(); info.ExceptionPointers = pException; info.ClientPointers = FALSE; // MiniDumpWithDataSegs 保留全局变量,WithHandleData 保留句柄信息 MINIDUMP_TYPE type = static_cast<MINIDUMP_TYPE>( MiniDumpWithDataSegs | MiniDumpWithHandleData | MiniDumpWithThreadInfo | MiniDumpWithIndirectlyReferencedMemory); MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hFile, type, &info, nullptr, nullptr); CloseHandle(hFile); } // 3. 返回 EXCEPTION_EXECUTE_HANDLER 让进程正常退出,避免被系统弹窗卡住 return EXCEPTION_EXECUTE_HANDLER; } void InstallCrashHandler() { SetUnhandledExceptionFilter(CrashHandler); std::set_terminate([]() { // terminate 场景没有 EXCEPTION_POINTERS,只能记录后直接 abort qCritical("std::terminate called, no dump generated"); std::abort(); }); }逻辑说明:SetUnhandledExceptionFilter注册的回调会在系统默认崩溃处理之前被调用,此时进程还没被销毁,栈和寄存器都还在,是写 dump 的唯一窗口。MiniDumpWriteDump的第二个参数传当前进程句柄,第四个参数type决定 dump 里包含多少信息。参数说明:MiniDumpWithDataSegs会把全局变量和静态变量写进去,排查「某个全局状态被改坏」时非常关键;MiniDumpWithThreadInfo保留每个线程的栈,多线程崩溃时能看出是哪个线程先出问题;MiniDumpWithIndirectlyReferencedMemory会把指针间接引用的内存也抓一部分,代价是文件变大,一般控制在 50~200MB 之间可以接受。如果只想要最小体积,用MiniDumpNormal,但那样只能看到崩溃线程的栈,其他线程全是问号。
2.3 在 main 里挂载的时机
int main(int argc, char* argv[]) { QApplication app(argc, argv); InstallCrashHandler(); // 必须在任何业务逻辑之前 MainWindow w; w.show(); return app.exec(); }挂载点放在QApplication构造之后、窗口创建之前。太早拿不到applicationDirPath,太晚可能已经有插件或第三方库在初始化阶段崩了。常见做法是再配一个qInstallMessageHandler,把崩溃前 200 条日志写到环形缓冲区,崩溃时一并落盘,这样 dump 旁边还有一份「最后说了什么」的文本,比单纯看栈更直观。
3. 生成 dump 之后:用符号文件把地址还原成源码行
3.1 dump 里有什么、缺什么
dump 文件本质是一份进程内存快照,里面有线程栈、模块列表、异常记录,但函数名和行号不在里面。你拿 WinDbg 或 Visual Studio 打开一个没配符号的 dump,看到的是一堆Qt5Core.dll+0x1a2b3这样的偏移地址。要把偏移还原成mainwindow.cpp:87,需要两样东西:一是编译时生成的 PDB 符号文件,二是 dump 里记录的模块版本要和 PDB 严格对应。MSVC 编译时默认生成 PDB,但 Release 配置下很多人会把「生成调试信息」关掉,或者把 PDB 和 exe 分开存放后弄丢,这是解析失败最常见的原因。
3.2 配置符号路径并解析
# 假设 dump 在 D:\crash\crash_20240101_120000.dmp # PDB 在 D:\build\release\MyApp.pdb # Qt 的 PDB 从 Qt 安装目录的 bin 下找,或从官方符号服务器拉 set _NT_SYMBOL_PATH=D:\build\release;D:\Qt\5.15.2\msvc2019_64\bin;srv*D:\symbols*https://msdl.microsoft.com/download/symbols # 用 cdb 命令行解析,输出到文本 cdb -z D:\crash\crash_20240101_120000.dmp -c ".ecxr; kb; !analyze -v; q" > D:\crash\report.txt逻辑说明:_NT_SYMBOL_PATH是调试器找符号的搜索路径,分号分隔,顺序从左到右。第一段放自己程序的 PDB 目录,第二段放 Qt 的 PDB 目录,第三段是微软公共符号服务器(用于解析系统 DLL)。cdb -z表示打开 dump 文件,-c后面跟要执行的命令序列。.ecxr把上下文切到异常发生时的线程,kb打印调用栈,!analyze -v让调试器自动分析异常类型和可能原因。参数说明:如果kb输出里出现MyApp!SomeFunction+0x1a这种带模块名和函数名的行,说明符号加载成功;如果全是MyApp+0x12345,说明 PDB 没匹配上,需要检查 PDB 的 GUID 和 Age 是否和 exe 一致。用!chksym MyApp可以查看符号匹配状态。
3.3 自动化解析脚本
import subprocess import os import glob def analyze_dumps(dump_dir, symbol_path, cdb_path=r"C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\cdb.exe"): dumps = glob.glob(os.path.join(dump_dir, "*.dmp")) for dmp in dumps: report = dmp.replace(".dmp", "_report.txt") env = os.environ.copy() env["_NT_SYMBOL_PATH"] = symbol_path cmd = [cdb_path, "-z", dmp, "-c", ".ecxr; kb; !analyze -v; q"] with open(report, "w", encoding="utf-8") as f: subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT, env=env) print(f"generated: {report}") if __name__ == "__main__": analyze_dumps(r"D:\crash", r"D:\build\release;D:\Qt\5.15.2\msvc2019_64\bin")逻辑说明:脚本遍历目录下所有 dump,逐个调用 cdb 生成报告。env里注入符号路径,避免污染系统环境变量。参数说明:cdb_path按本机 Windows SDK 安装位置调整,x64 程序用 x64 调试器,x86 程序用 x86 调试器,混用会解析失败。如果 dump 数量多,可以在!analyze -v后面加-hang或-ma参数控制分析深度,但一般默认就够。生成报告后重点看STACK_TEXT段和FAILURE_BUCKET_ID,前者是调用栈,后者是微软对这类崩溃的分类标签,能快速判断是空指针、堆破坏还是栈溢出。
4. 避坑与排查:dump 生成失败、解析不出栈的 5 个真实原因
4.1 现象:crash 目录是空的,一个 dmp 都没有
原因:SetUnhandledExceptionFilter被第三方库覆盖了。很多安全软件、注入型 DLL、甚至某些 Qt 插件会在初始化时重新注册异常过滤器,把你的回调顶掉。解决:在InstallCrashHandler里先调用一次SetUnhandledExceptionFilter(CrashHandler),然后在业务启动完成后(比如主窗口show()之后)再调用一次,确保你的回调是最后注册的。另外检查CreateFileW的路径是否有写权限,Program Files 目录下普通用户没有写权限,dump 会静默失败。
4.2 现象:dump 生成了,但 WinDbg 打开提示「符号不匹配」
原因:exe 和 PDB 不是同一次编译产物。常见于 CI 流水线里 exe 打包发布、PDB 留在构建机,或者手动替换过 exe 但没更新 PDB。解决:用dumpbin /headers MyApp.exe查看 Debug Directory 里的 PDB 路径和 GUID,和 PDB 文件头里的 GUID 对比。更稳妥的做法是每次发布把 exe、PDB、dump 解析脚本打包在一起,版本号写进文件名。Qt 自身的 PDB 也要对应版本,5.15.2 的 PDB 不能解析 5.15.0 编译出来的 Qt DLL。
4.3 现象:调用栈里全是Qt5Core.dll+0x...,看不到自己的代码
原因:崩溃发生在 Qt 内部,但你的代码通过信号槽或事件回调进入了 Qt 的事件循环,栈被截断。解决:在!analyze -v之后手动执行kP打印完整栈(带参数),或者用~*k打印所有线程的栈。如果还是看不到自己的帧,检查编译时是否开了优化导致帧指针省略(FPO),Release 下建议加/Oy-关闭帧指针优化,或者用/DEBUG:FULL生成完整调试信息。另一个办法是在关键函数入口加__try/__except做局部捕获,把业务栈先记下来。
4.4 现象:dump 文件几百 MB,传输和存储扛不住
原因:MiniDumpWithIndirectlyReferencedMemory和MiniDumpWithFullMemory会把大量堆内存写进去。解决:按场景分级。开发阶段用MiniDumpWithFullMemory拿全量信息;生产环境用MiniDumpWithDataSegs | MiniDumpWithThreadInfo,体积通常能压到 20~80MB。如果还嫌大,可以在写 dump 前先判断异常码,对EXCEPTION_STACK_OVERFLOW这类明确问题只写MiniDumpNormal。另外 dump 文件本身可以用 zstd 或 7z 压缩后再上传,压缩比通常能到 5:1 以上。
4.5 现象:多线程程序崩溃,dump 里只有崩溃线程的栈
原因:MiniDumpWriteDump默认只抓当前线程的完整上下文,其他线程只有栈顶。解决:在MINIDUMP_TYPE里加上MiniDumpWithThreadInfo,它会为每个线程写入 TEB 和栈信息。如果还想要其他线程的寄存器,需要MiniDumpWithFullMemory。解析时用~*k而不是kb,前者遍历所有线程。注意:线程越多,dump 越大,写 dump 的时间也越长,如果崩溃发生在主线程且程序已经卡死,写 dump 本身可能超时,可以在MiniDumpWriteDump外面加一个看门狗线程,超时后强制结束进程。
5. 进阶:把 dump 日志接进现有日志体系与验证方法
5.1 和 filebeat / 日志采集怎么衔接
dump 是二进制,不能直接丢进 Elasticsearch 或 Loki。常见做法是分两条线:一条是崩溃时同步写一份文本摘要(异常码、崩溃线程、栈顶 10 帧、最后 200 条 qDebug),这份文本用 filebeat 采集,走现有日志管道;另一条是 dump 文件本身传到对象存储或共享目录,文本摘要里带上 dump 的路径或 URL。这样运维在 Kibana 里搜到崩溃记录时,能顺着路径拿到完整 dump 做深度分析。文本摘要的生成可以放在CrashHandler里,用StackWalk64手动走一遍栈,或者干脆在 dump 写完后调用一次 cdb 生成 report 再提取关键行。
5.2 验证 dump 方案是否真的生效
不要等线上崩了才验证。写一个测试用例,主动触发几种典型崩溃:
void TriggerNullPointer() { int* p = nullptr; *p = 42; // 触发 EXCEPTION_ACCESS_VIOLATION } void TriggerStackOverflow() { volatile char buf[1024 * 1024]; TriggerStackOverflow(); // 无限递归,触发栈溢出 } void TriggerDivideByZero() { volatile int a = 1; volatile int b = 0; volatile int c = a / b; // 触发 EXCEPTION_INT_DIVIDE_BY_ZERO }每种崩溃跑一遍,检查三件事:crash 目录是否生成 dmp、dmp 大小是否在预期范围、cdb 解析出的栈是否能定位到对应函数。参数说明:栈溢出场景下MiniDumpWriteDump可能因为栈空间不足而失败,需要在CrashHandler里用_resetstkoflw()恢复栈保护页后再写 dump。除零异常在 x64 上默认不触发 SEH,需要在代码里显式启用_controlfp或改用RaiseException(EXCEPTION_INT_DIVIDE_BY_ZERO, ...)模拟。
5.3 一个我踩过的坑:Release 下 PDB 被 strip
早期为了减小安装包体积,在 qmake 里加了QMAKE_LFLAGS_RELEASE += /DEBUG:NONE,结果 PDB 不生成,线上 dump 全是地址。后来改成/DEBUG:FULL并把 PDB 单独归档,安装包只多几 MB,但排查效率完全不是一个量级。现在我的习惯是:每次发版,exe、PDB、dump 解析脚本、符号路径配置一起打进一个版本目录,用版本号命名,谁拿到 dump 都能在五分钟内还原出源码行。这个习惯帮我省掉的返工时间,远比多存几份 PDB 占用的磁盘值钱。希望帮到你。
本文还有配套的精品资源,点击获取