
1. 先把“崩溃”这件事拆开看做桌面端开发这些年最怕的不是功能做不出来而是软件在客户现场毫无征兆地消失——窗口一闪进程没了任务管理器里干干净净用户只会说一句“你这软件有毛病”然后你对着日志文件里最后一行正常输出发呆。Qt 崩溃信息的抓取本质上就是解决这个“死无对证”的问题让程序在倒下的那一瞬间留下足够多的现场证据让我们事后能把它拉起来复盘。这篇内容就是围绕 Qt 崩溃信息怎么抓、抓到什么程度、怎么还原成能读懂的调用栈来展开从 Windows 到 Linux从进程内回调到外部收集把能落地的做法一次讲透。不管你是刚开始用 Qt 写第一个上位机的小白还是已经带过几个商用项目的老手这套东西基本可以直接抄过去用。先明确一个前提没有任何一种方案能抓住 100% 的崩溃。这句话听起来有点丧气但它决定了后面所有设计取舍。一次崩溃从宏观上可以分成四类抓取手段完全不同。第一类是系统级异常Windows 上叫 SEH 结构化异常典型成员是访问违例0xC0000005、栈溢出0xC00000FD、整数除零、非法指令。Linux 上对应的是 SIGSEGV、SIGFPE、SIGILL、SIGBUS、SIGABRT。这类崩溃的共性是CPU 在硬件层面发现不对劲主动把控制权交给操作系统操作系统再回调进程注册的处理函数。只要回调函数写得对这类崩溃能拿到非常完整的现场包括出错指令地址、寄存器快照、整条调用栈。第二类是C 层的异常没被接住。抛出来的异常一路往上穿穿到 main 之外还没有 catch标准库就会调用 std::terminate进而调用 abort()。最终在系统层面仍然会落到 SIGABRT 或者相应的终止流程上所以 Catch 住 terminate 是个很划算的补充点。第三类是主动退出比如 assert 失败、野指针检查库发现堆损坏后强行 abort、或者代码里某处调了 exit()。这类“崩溃”严格说不算异常系统回调往往不会被触发得靠 atexit 或者 terminate handler 兜着。第四类是进程被外部干掉或者是资源耗尽被杀比如内存爆掉被系统回收。这类情况进程内根本来不及反应只能依赖外部守护进程或系统转储机制。真正让人头疼的其实不是“抓不到异常”而是“抓到了但读不懂”。我见过太多团队辛辛苦苦集成了 dump 生成结果用户回传上来一个几十兆的文件打开一看栈顶全是Qt5Core.dll0x2a3f1c这种地址没有任何函数名和行号。这种 dump 的利用率极低等于白抓。所以我们要在标题里强调“读这一篇就够了”——不只是教你写那几行回调代码更要教你让这份现场在事后能被翻译成人话。1.1 抓取的核心不是日志是内存快照很多人的第一反应是崩溃了就把日志多打一点不就行了于是到处插 qDebug甚至想在崩溃回调里再打印一遍调用栈。这条路我实际走过结论是日志能告诉你程序走到哪儿但不能告诉你为什么走到这儿就死了。设想一个多线程场景主线程在刷新 UI工作线程在解析数据一个共享的 QList 被两个线程同时读写某一刻工作线程读到的是主线程写了一半的状态指针成了垃圾值一解引用就炸。这时候日志里最后一行可能是工作线程三秒前打的一句“开始解析”你完全不知道崩溃瞬间另一个线程在干什么。而一份内存快照里包含了所有线程的栈、寄存器和部分堆数据你可以同时看到主线程卡在哪个函数、工作线程卡在哪个函数、谁持有哪把锁。这种横向对比能力才是 dump 真正不可替代的地方。另一个关键点是地址到符号的映射关系。程序每次编译函数的机器码位置都会变同一个void Parser::run()在今天的版本里可能是 0x0040A120改两行代码就变成 0x0040A380。所以 dump 里的裸地址必须配合编译时生成的符号文件才能还原。Windows 上是 pdbLinux 上是带调试信息的可执行文件或分离出来的 debug 文件。这就引出一条铁律发布版本必须归档好对应的符号文件并且和版本号严格一一对应。我见过一个项目第一年发布的包里忘了留 pdb第二年想查老版本崩溃只能对着汇编硬啃那个痛苦程度不值得任何人体验。1.2 三层兜底的分工一套靠谱的崩溃信息体系我习惯分成三层来设计每层职责清晰互不越界。第一层是阻止程序带着错误状态继续跑。典型的做法是接住 std::set_terminate在未捕获异常时先记录再终止如果是 Qt 应用还要考虑 Qt 自己的一些断言输出。这一层不生成 dump只负责在可控范围内留下文本线索。第二层是系统异常回调也就是微软平台上那个 SetUnhandledExceptionFilter或者类 Unix 平台上的 sigaction。它负责生成 dump 文件或者 core 式的自定义转储是整个体系的核心。第三层是外部收集与还原发生在崩溃之后一个独立的进程扫描待处理的 dump 目录做符号化、去重、打包、上传。这一层绝对不能放在崩溃进程内原因下一节详细说。三层各司其职最大的好处是崩溃现场的逻辑极其简单出错概率低。崩溃处理函数写得越复杂二次崩溃的概率越高我吃过这个亏——在回调里用了 Qt 的字符串拼接结果因为堆已经被破坏拼接过程自己又崩了一次最终文件只写了 0 字节。2. 整体方案设计为什么要把重活挪到崩溃之后方案设计阶段有一个必须想清楚的问题崩溃处理函数里到底能做什么微软的官方文档对这个函数有非常克制的描述大意是它运行在一个高度不确定的上下文里堆可能被破坏、锁可能被持有、其他线程可能还在跑。这意味着你在里面调用的每一个函数都得掂量一下它在异常情况下的行为。2.1 崩溃回调的安全边界先说结论我在崩溃回调里只允许做这几件事把已经预先打开好的文件句柄拿出来写少量字节、调用系统提供的转储 API、然后结束进程。其他一律不做。为什么强调“预先打开”因为在崩溃回调里调 CreateFile 或者 fopen内部要走堆分配和文件系统调用如果崩溃原因恰好是堆损坏这一步就会挂掉。我的做法是程序启动时就按需创建好文件句柄用完了就重新开一个保持随时有一个可写的空文件句柄待命。文件名可以程序启动时用进程 ID 和时间戳拼好避免崩溃时再去算。为什么不做符号化符号解析要读 pdb 或调试文件要分配大量内存要调用 dbghelp 里一堆复杂接口运行时间可能几十毫秒到几百毫秒。而崩溃进程此刻处于一个“随时可能彻底死透”的状态你多用一点资源它就少一点活下来的机会。正确做法是把 dump 原样落盘符号化放到后面由健康进程完成。还有一个常被忽略的点多线程同时崩溃。如果工作线程和主线程在同一瞬间都触发了异常两个线程会同时进入回调同时往同一个文件写。Windows 的转储 API 内部会做一定串行化但为了保险我会用一个原子标志位做互斥第一个进来的线程负责写后面的直接挂起或者走快速退出路径。2.2 目录规划与命名约定崩溃文件的管理比生成更麻烦。早期我图省事直接写到程序目录下结果在 Program Files 里程序没有写权限dump 悄无声息地失败了花了两天才发现。现在我的方案是写到用户可写目录Windows 上取QStandardPaths::AppLocalDataLocation下的crash子目录Linux 上取~/.local/share/app/crash。目录结构我习惯这么组织crash/ pending/ # 待处理 20240612_141233_pid8124.dmp 20240612_141233_pid8124.txt uploaded/ # 已回传 failed/ # 超过重试次数每个崩溃现场是一对文件dump 本体加上一个同名的小文本里面记录版本号、构建号、Qt 版本、操作系统版本、崩溃时间、进程 ID、可执行文件路径。这个 txt 的作用极大尤其在后面做聚类时不需要打开 dump 就能按版本分组。文本内容要在崩溃前就准备好每次启动写一份到内存里崩溃时直接 dump 到磁盘不要临时构造。2.3 与 Qt 事件循环、线程模型的关系Qt 应用有个特点大量逻辑跑在事件循环驱动的槽函数里主线程的栈通常不深但业务代码可能被拆得七零八落。这带来两个影响。一是主线程栈信息量有限。如果崩溃发生在主线程栈上往往只有 QApplication::exec 和几层事件分发真正执行到出问题的槽函数可能已经返回了。这时候 dump 的价值主要在于查看当时的数据状态必须配合MiniDumpWithDataSegs之类的选项才能看到全局变量。我一般会额外维护一个最近操作环形缓冲区记录最近 50 条用户动作和业务事件崩溃时一并写进 txt和栈信息互相印证。二是QThread 里的崩溃不容易被察觉。有些老代码在 QThread 里直接 throw 或者访问非法地址如果这个线程是用系统 API 创建的而不是 QThread异常回调的行为会有差异。我的建议是无论用哪种方式创建线程都在线程入口处统一确认异常处理器已经安装。Qt 的信号槽跨线程调用底层依赖事件队列队列里传递的参数如果是指针一旦指向的对象已经被销毁接收方解引用就是崩溃这类问题在 dump 里表现为栈顶停在QMetaCallEvent::placeMetaCall附近看到这个特征基本就可以往对象生命周期方向去查。3. Windows 平台实操转储文件怎么生成Windows 是 Qt 桌面开发的主战场方案也最成熟。核心就两个 APISetUnhandledExceptionFilter 负责接管未处理异常MiniDumpWriteDump 负责把内存写成文件。这两个都在 dbghelp 库里。3.1 代码骨架下面这段是我目前在用的骨架做了删减保留了最关键的逻辑。它被放在一个独立的 cpp 里只在定义了崩溃收集宏的构建中编译。// crashhandler_win.cpp #include windows.h #include dbghelp.h #include atomic #include cstdio #pragma comment(lib, dbghelp.lib) namespace { // 启动时准备好的落盘路径宽字符避免编码问题 wchar_t g_dumpPath[MAX_PATH] {0}; HANDLE g_dumpFile INVALID_HANDLE_VALUE; std::atomicbool g_inCrash{false}; // 转储类型按需调整见 3.2 节的对比 const MINIDUMP_TYPE kDumpType static_castMINIDUMP_TYPE( MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules); } LONG WINAPI QtCrashHandler(EXCEPTION_POINTERS* info) { // 只允许一个线程进来其他线程直接等待进程终结 bool expected false; if (!g_inCrash.compare_exchange_strong(expected, true)) { Sleep(1000); return EXCEPTION_EXECUTE_HANDLER; } if (g_dumpFile ! INVALID_HANDLE_VALUE) { MINIDUMP_EXCEPTION_INFORMATION mei{}; mei.ThreadId GetCurrentThreadId(); mei.ExceptionPointers info; mei.ClientPointers FALSE; MiniDumpWriteDump( GetCurrentProcess(), GetCurrentProcessId(), g_dumpFile, kDumpType, mei, nullptr, nullptr); } // 顺手写一份纯文本线索路径同名不同后缀 // 注意这里用预先格式化好的静态缓冲区不做字符串拼接 if (info info-ExceptionRecord) { // 复用启动时已打开的 txt 句柄同理此处省略 } FlushFileBuffers(g_dumpFile); CloseHandle(g_dumpFile); g_dumpFile INVALID_HANDLE_VALUE; // 交回系统走默认的终止流程 return EXCEPTION_EXECUTE_HANDLER; } void InstallCrashHandler() { // 1. 计算路径并创建文件这一步在程序正常启动时执行 // 2. 打开文件句柄保持常开 // 3. 安装过滤器 SetUnhandledExceptionFilter(QtCrashHandler); }几个细节值得单独说。ClientPointers FALSE表示异常指针属于被转储的进程自身这是绝大多数情况下的正确取值。MiniDumpWithUnloadedModules会记录已经卸载的模块对于排查“某个插件的 DLL 被卸载后还有人持有它的函数指针”这类问题特别有用代价是文件稍大一点。互斥用的compare_exchange_strong比加锁更安全因为崩溃时锁状态不可信。3.2 转储类型怎么选一份体积与信息量的对照表这是被问得最多的问题没有标准答案但有明确的取舍依据。下面这张表是我在不同项目里实测的典型值进程常驻内存按 300MB 估算。转储类型包含内容典型体积适用场景MiniDumpNormal线程栈、寄存器、线程列表、模块列表50 KB ~ 500 KB只需要看调用栈网络带宽紧张MiniDumpWithThreadInfo在 Normal 基础上加线程时间、优先级等100 KB ~ 600 KB排查死锁、线程调度问题MiniDumpWithIndirectlyReferencedMemory加上栈上指针指向的一小段内存1 MB ~ 20 MB需要看局部变量、字符串内容推荐默认MiniDumpWithDataSegs加上已加载模块的数据段全局变量2 MB ~ 30 MB需要看全局状态、单例内容MiniDumpWithFullMemory整个进程地址空间与内存占用相当极难复现的疑难问题慎用MiniDumpWithUnloadedModules加上已卸载模块信息增加几十 KB插件式架构、动态库热替换我的默认配置是IndirectlyReferencedMemory ThreadInfo UnloadedModules的组合。理由很直白调用栈解决了“在哪崩”的问题间接引用内存解决了“为什么崩”的问题比如能看到那个空指针变量的值确实是 0线程信息解决了“谁在捣乱”的问题。全内存转储虽然信息最全但一个 2GB 内存的进程会生成 2GB 的文件用户回传成本极高除非是紧急线上问题我一般不默认开。这里有个容易被忽略的坑用了MiniDumpWithIndirectlyReferencedMemory之后dump 里可能包含用户的敏感数据比如密码输入框里的明文缓冲、一截内存里恰好躺着的身份证号。这一点在做正式产品时必须考虑相关处理方式放在后面第 6 节讲。3.3 pdb 与版本号让地址变成函数名dump 生成只是第一步能不能还原出可读的栈取决于两件事有没有对应版本的符号文件以及版本号有没有记录在 dump 里。符号这块MSVC 工程默认会在构建目录生成 pdb。我的做法是每次正式发版把 exe、dll 和对应的 pdb 一起归档到版本目录用构建号命名比如build/2024.06.12_rc1/。归档时不要改名改名后 dump 工具可能匹配不上。如果是 MinGW 构建默认不产生独立符号文件需要在链接参数里加-g并且保留未 strip 的原始可执行文件作为符号来源。版本号这块很多人栽过跟头。dump 文件里记录的是模块的时间戳和映像大小分析工具用它来匹配 pdb。但如果你的构建过程不稳定同名文件在不同机器上生成的时间戳不同匹配就会失败。我现在的做法是在代码里定义一个编译期字符串常量通过构建脚本注入// version.h 由构建脚本生成 #define APP_BUILD_ID 20240612-141233-git-a1b2c3d #define APP_VERSION 2.4.1然后在启动时把这两个值写进崩溃线索文件同时也可以在崩溃回调的最开头用MiniDumpWriteDump的UserStream参数塞一个自定义数据块进去。后者稍微复杂但对后面自动聚类帮助很大因为分析工具可以直接从 dump 里读出构建号不依赖旁边的 txt 文件。3.4 几个必须知道的坑坑一dbghelp.dll 版本问题。系统目录里有 dbghelp.dll但老版本功能有限某些转储类型不支持。解决办法是把较新的 dbghelp.dll 放到程序目录一起发布。但要注意Windows 会优先加载程序目录的 DLL如果你的程序目录里放了一个和系统不兼容的版本可能导致其他功能异常。我一般只在崩溃收集模块里做延迟加载不去全局替换。坑二debug 版程序。在 IDE 里调试时异常会被调试器先接住SetUnhandledExceptionFilter 不会被触发所以本地测试崩溃收集功能时一定要脱离调试器运行也就是直接双击 exe 或者用命令行启动。坑三栈溢出抓不到。如果崩溃原因是递归太深导致栈溢出异常回调本身也可能因为栈空间耗尽而无法执行。这种情况下能抓到什么完全看运气。我的应对方式是在回调注册时额外用 CreateThread 起一个栈空间很大的备用线程来处理转储主线程只负责通知它。这块代码稍长思路是异常回调里 SetEvent 唤醒等待中的线程备用线程收到信号后调用 MiniDumpWriteDump最后让原线程挂起。坑四文件被占用。如果程序有单实例限制用户连续启动两次第二次启动的实例崩溃时可能写不进同一个文件。文件名里带上进程 ID 就能避开。4. Linux 平台实操core 与进程内回溯Linux 上的思路和 Windows 有相似之处但工具链完全不同。核心是两条路让内核帮我们吐 core 文件或者在进程内捕获信号自己写转储。前者信息最全后者可控性更好。4.1 core 文件的开关与落盘位置默认情况下很多发行版是关闭 core 生成的因为大文件会占满磁盘。临时开启用ulimit -c unlimited但这对图形界面启动的程序不生效因为它继承的是桌面会话的限制。我一般的做法是在启动脚本里显式设置或者在程序启动早期用 setrlimit 调整#include sys/resource.h void EnableCoreDump() { rlimit rl; if (getrlimit(RLIMIT_CORE, rl) ! 0) return; rl.rlim_cur rl.rlim_max; // 通常是 unlimited setrlimit(RLIMIT_CORE, rl); }光开这个还不够还得关心 core 文件落到哪儿。这由/proc/sys/kernel/core_pattern决定现代发行版大多把它指向了 systemd-coredump 或者 apportcore 不会直接出现在工作目录。cat /proc/sys/kernel/core_pattern看一眼就知道。如果看到|/usr/lib/systemd/systemd-coredump %P %u %g ...这种带竖线的形式说明 core 被管道传给了系统服务得用coredumpctl才能取出来。想把 core 落到程序自己管理的目录可以临时改 core_pattern比如写成core.%p.%e。但这是系统级配置普通用户改不了而且会影响机器上所有程序正式产品里不要这么干。所以实际项目中我更依赖进程内方案。4.2 进程内信号处理与 backtrace思路很直接给 SIGSEGV、SIGABRT、SIGFPE、SIGILL、SIGBUS 这几个信号注册处理函数在函数里用 backtrace 抓取调用栈。这段代码不复杂但安全细节比 Windows 那边更微妙。// crashhandler_unix.cpp #include execinfo.h #include signal.h #include unistd.h #include fcntl.h #include cstring namespace { int g_traceFd -1; // 启动时打开好的文件描述符 void* g_frames[64]; volatile sig_atomic_t g_inCrash 0; } void SignalHandler(int sig, siginfo_t* info, void* ctx) { if (g_inCrash) _exit(128 sig); // 二次崩溃直接走人 g_inCrash 1; const char* header \n crash \n; if (g_traceFd 0) { write(g_traceFd, header, strlen(header)); int n backtrace(g_frames, 64); // backtrace_symbols_fd 是最保险的内部不分配堆内存 backtrace_symbols_fd(g_frames, n, g_traceFd); } // 恢复默认处理让系统再走一遍以便生成 core signal(sig, SIG_DFL); raise(sig); }这里有几个必须强调的点。第一处理函数里只能用异步信号安全的函数write、open、_exit是安全的printf、malloc、std::string都不安全。backtrace_symbols_fd虽然常见严格来说它并非标准里的信号安全函数但在 glibc 实现里它只做地址到符号的转换不分配堆内存实践中被广泛使用。如果你追求极致稳妥可以只把地址数组写进文件事后用工具翻译。第二写的是原始地址不是符号名。因为程序通常编译成 PIC 位置无关代码运行时的地址需要减去模块加载基址才能对应到文件偏移。最简单的办法是在日志里同时记录/proc/self/maps的内容事后自己算偏移。第三处理完之后恢复默认处理并重新触发信号这一步是为了让系统有机会生成 core 文件。如果你直接_exitcore 就没了。这是我踩过的坑早期版本直接退出导致既没有 core 也没有完整栈白白浪费一次复现机会。4.3 用 addr2line 把地址翻译成行号拿到了地址接下来就是翻译。GCC 工具链里最好用的是 addr2line用法很朴素# -f 显示函数名-C 还原 C 符号-e 指定带调试信息的可执行文件 addr2line -f -C -e ./myapp 0x0000000000412a3c输出的格式是函数名一行、文件名加行号一行。如果压缩得很厉害可以配合-i展开内联函数。批量翻译时把一堆地址拼成一行addr2line -f -C -e ./myapp 0x412a3c 0x412b10 0x412c44如果发布时做了 strip把调试信息分离到了单独的 debug 文件那么 addr2line 要指向那个文件而不是 exe。分离命令通常是objcopy --only-keep-debug myapp myapp.debug配合strip构建脚本里顺手加上就行。发布包里带 exe归档服务器上保存 debug 文件按版本号一一对应。有一点要特别注意优化等级对行号准确度影响很大。开了 -O2 之后函数可能被内联行号可能偏移。我一般对崩溃定位要求高的模块用 -O1 或者加-fno-omit-frame-pointer牺牲一点性能换取可读的栈。这个参数还直接影响 backtrace 的完整度因为很多情况下 backtrace 依赖帧指针链。4.4 与 systemd-coredump 的配合如果目标机器用的是 systemdcore 走的是系统服务取出来用coredumpctl list # 列出所有转储 coredumpctl info PID # 看基本信息 coredumpctl dump PID -o out.core # 导出到文件然后用 gdb 加载gdb ./myapp out.core进去之后bt看栈。这条路的好处是不依赖你程序里写的任何代码程序崩溃了自然就有连自己写回调都省了。坏处是依赖系统配置而且如果用户的机器上磁盘配额小转储可能被自动清理。我的实际策略是进程内信号处理作为主力负责生成小而结构化的线索文件systemd-coredump 作为兜底在遇到疑难问题时请用户配合导出。两套并行不冲突。5. Qt 项目里的具体集成细节技术点讲完了落到 Qt 工程上还有一堆琐碎但必须处理的事。5.1 工程文件配置用 qmake 的话需要在 pro 文件里根据平台链接不同的库win32 { LIBS -ldbghelp -lpsapi DEFINES ENABLE_CRASH_HANDLER } unix:!macx { LIBS -ldl DEFINES ENABLE_CRASH_HANDLER }用 CMake 的话if(WIN32) target_link_libraries(MyApp PRIVATE dbghelp psapi) target_compile_definitions(MyApp PRIVATE ENABLE_CRASH_HANDLER) elseif(UNIX) target_link_libraries(MyApp PRIVATE dl) target_compile_definitions(MyApp PRIVATE ENABLE_CRASH_HANDLER) endif()建议把整个崩溃模块单独做成一个静态库或者独立的源文件集合用宏包起来。这样发布正式版时打开日常开发调试时可以关掉——本地调试时让 IDE 直接停在崩溃点比生成 dump 再去分析效率高得多。5.2 让日志和崩溃线索互相对齐崩溃线索是“点”日志是“线”两者必须在时间轴和标识上能对齐。我的做法是设计一个统一的日志格式每行开头是[时间戳][线程ID][级别]崩溃线索文件里也记录同样的时间戳格式和线程 ID。这样在分析时可以拿崩溃时间往前推几百毫秒看日志里发生了什么。配合 Qt 的消息处理机制很合适void QtMessageHandler(QtMsgType type, const QMessageLogContext ctx, const QString msg) { // 统一格式写到文件同时同步一份到标准错误 // 注意这里可以正常使用 Qt 的字符串因为不在崩溃回调里 QByteArray line FormatLine(type, ctx, msg); LogSink::instance().write(line); }然后在 main 里qInstallMessageHandler(QtMessageHandler)。有几个细节日志文件建议按大小滚动同时限制总数否则长期运行的程序会把磁盘写满写日志时不要每条都 flush用缓冲区加定时刷新崩溃时再强制 flush——但这一点在崩溃回调里做不到因为回调里不能用 Qt 的这些东西。所以我通常在文本线索里只写最关键的信息正常的详细日志靠缓冲区落盘。日志内容也有讲究。我习惯在日志里记录关键对象的生命周期比如某个网络连接的创建和销毁事件循环每次进入耗时超过 100ms 的操作。这类信息在排查“崩溃前程序是不是已经卡住了”的时候非常有用。5.3 多线程场景下的额外注意事项Qt 的多线程场景里崩溃往往和对象的跨线程使用有关。有几个经验点。不要在崩溃回调里调用 Qt 的任何东西。包括 QCoreApplication 的静态方法、QFile、QString。QString 看似无害但它的内部有隐式共享和原子引用计数堆被破坏后操作它就是在刀尖上跳舞。QThread 的 run 函数里的崩溃要怎么定位如果崩在 QThread 派生的 run 函数里栈是独立的一条。dump 里会包含所有线程分析时要在调试器里逐个线程切换找到那个栈顶在异常地址的线程。这就是为什么MiniDumpWithThreadInfo值得开。考虑加一个“心跳”机制。主线程平时每隔几百毫秒更新一次时间戳如果某个工作线程发现主线程超过 5 秒没更新就往日志里写一条“主线程疑似卡死”同时把当前的线程状态记录一遍。这个机制在排查界面冻结类问题时非常好用虽然它不是崩溃抓取但对同一个问题域很有帮助。信号槽跨线程传参要小心。我遇到过一次典型的崩溃一个 QObject 在工作线程里被 deleteLater同时主线程的一个定时器还在往它发信号deleteLater 执行后对象销毁信号队列里还排着一个指向它的调用事件事件循环一执行就访问了已释放内存。这种崩溃的栈看起来是随机的可能是内存分配函数内部也可能是虚函数表相关的地址必须结合日志推断对象生命周期才能定位。所以我在关键对象的析构里会打一条日志写清楚对象 ID。5.4 发布包别漏了东西这个属于低级但高频的错误。发布包需要包含可执行文件、所有依赖的 Qt 库、平台插件、以及dbghelp.dll如果目标机器比较老。符号文件和 txt 线索模板不用放进去但调试版的 pdb 或者 debug 文件要归档。打包时有一个值得注意的点不要 strip 掉所有信息。有些打包脚本为了减小体积把所有符号都去掉了结果崩溃分析时什么都看不到。折中方案是发布 exe 保持带部分符号或者至少保留导出符号表配合独立的调试文件使用。体积增加通常只有几兆相对于排查问题的成本完全可以接受。6. 崩溃信息回传、聚类与隐私生成的 dump 停留在用户机器上没有任何意义得让它回到开发这边而且要能自动消化。6.1 下次启动时上报崩溃瞬间不要尝试联网原因和前面说的一样整个进程状态不可信。我的做法是本次崩溃只落盘下一次程序正常启动时由一个后台任务扫描pending目录找到崩溃文件按照既定规则打包通常压缩成 zip体积能小一大截然后上传。上传成功就移动到uploaded失败就重试超过三次移到failed。这里要给出用户选择权。正式产品里我会在设置里放一个开关默认开启、可以关闭并且明确告诉用户上传的内容包含什么。这既是合规要求也是基本的尊重。上传的通道就用普通的 https 接口分块上传配合断点续传。文件通常几兆到几十兆用简单的分片方式就够。服务端收到后先入队异步做符号化。6.2 崩溃指纹与自动聚类如果版本发布频繁一天可能收到几百份崩溃报告。人工逐份看是不现实的必须做聚类。做法是计算一个崩溃指纹取崩溃线程的栈顶若干层函数名符号化之后的拼接成一个字符串做哈希。crash_id hash([MyParser::parseFrame, MyParser::run, QThreadPrivate::start, ...])同一个崩溃原因指纹基本相同这样就能把几百份报告压缩成十几个问题按出现次数排序优先修高频的。这个思路在崩溃收集领域很成熟自己实现也不难关键点是指纹必须基于符号化后的栈同时要在指纹里排除掉地址偏移、线程 ID、时间戳这类每次都变的部分。实际做的时候会遇到一些干扰比如栈顶可能因为优化而略有差异同一个 bug 会分裂成几个指纹。我的经验是取栈顶 5 到 8 层同时把最底层的公共框架部分去掉比如 Qt 的线程启动函数这样区分度更高。另外同一版本内的指纹才可比跨版本对比意义不大因为函数名可能变了。所以聚类时的 key 是“版本号 指纹”。6.3 敏感数据的处理前面提过dump 里可能包含用户数据。这一点在金融、医疗类应用里是硬红线必须处理。我的做法有三个层次。第一层默认不用全内存转储只用间接引用内存这样只有栈上指针指向的少量内存被包含敏感面大幅收窄。第二层在代码里维护一个敏感字段清单比如密码、证件号、密钥这些数据在使用后立即清零不要把明文长期留在堆上。第三层传输和存储加密服务端收到后设置合理的保留期限到期自动清理。如果应用对隐私要求极高还有一个终极方案只上传符号化后的栈信息不上传 dump 本体。这需要在本地做符号化然后把文本结果上传。代价是本地需要配套的工具链而且有些要查看内存的疑难问题就无法解决了。这是一个取舍根据自己的业务场景选。7. 常见问题速查表下面这张表是我这些年被问得最多的问题整理出来方便对照。现象可能原因处理方式dump 文件 0 字节回调里做太多事导致二次崩溃或文件句柄未预先打开精简回调逻辑启动时预先打开文件有 dump 但符号化失败pdb/debug 文件缺失或版本不匹配按构建号归档符号确保命名一致本地能触发发布后无 dump目录无写权限或异常被调试器拦截写到用户数据目录脱离调试器测试栈只有一两层转储类型太简单或缺帧指针使用间接引用内存选项加 -fno-omit-frame-pointer只有主线程栈转储类型未包含线程信息加 MiniDumpWithThreadInfo崩溃时间点前后日志断档日志缓冲未及时落盘关键节点强制 flush缩小缓冲上传一直失败包体过大或网络策略限制压缩后分片传输加重试与退避崩溃量突然暴涨新版本引入问题或某个环境特定按版本号和操作系统分组统计先看增量分析时栈里全是 Qt 内部函数崩溃点不在业务代码或符号偏移结合日志与最近操作记录从数据状态反推用这张表的时候有个心态上的建议不要指望一次就把崩溃体系搭完美。我自己的做法是先做最小可用版本——只需要能生成 dump、能记录版本号、能手动拿回来分析跑通之后再逐步加自动上传、聚类、隐私处理。分阶段推进每阶段都有实际价值比一开始就设计一个庞大系统然后半途而废要强得多。8. 几段踩坑心得最后聊几个具体的、文档里不会写的经验。关于测试。崩溃处理代码写完之后必须专门测。我会在代码里留一个隐藏的崩溃触发入口比如某个命令行参数进去之后分别触发访问违例、除零、未捕获异常、栈溢出四种情况逐个确认生成的 dump 能被正确打开并还原出合理的栈。这个测试要在多个操作系统版本上做尤其是 Windows 7 和老版本的 Linux 发行版因为 dbghelp 和 glibc 的行为差异比想象中大。测试做完记得把这个入口从正式版本里移除或者加密。关于符号归档。我建了一个很土但很有效的规范每次构建成功构建脚本自动把符号文件按产品名/版本号/构建号/平台的路径复制到归档目录同时写一个 json 描述文件记录可执行文件的哈希、构建时间、编译器版本。归档目录单独做备份。这么做之后任何一份用户回传的 dump 都能在几分钟内找到对应符号效率提升非常明显。不要依赖“我记得那次构建在哪台机器上”记忆是最不可靠的东西。关于先看日志再看 dump。拿到一份崩溃报告很多人的第一反应是立刻用调试器打开 dump 看栈。我的顺序是反过来的先读线索文件和日志了解用户当时在做什么、程序运行了多久、有没有异常的资源占用然后根据这些信息假设几个可能的原因最后才打开 dump 去验证假设。带着问题看栈比漫无目的地在几十个线程之间切换效率高得多。我见过同事打开 dump 之后从上到下翻一遍翻完说“看不懂”其实就是因为没有先建立假设。关于修复后的验证。崩溃修完不算完要把触发这个崩溃的场景做成一个自动化用例纳入回归测试。有些崩溃修复需要引入额外的资源管理或者生命周期调整很容易在别的地方引入新问题。没有回归用例同一个坑可能几个月后再踩一次。这一点在多人协作的项目里尤其重要。关于心态。崩溃信息抓取是一套“降低不确定性”的工程。它不能让你不写 bug但它能把排查一个偶现问题从几天缩短到几十分钟这个投入产出比在任何项目里都是划算的。我现在的习惯是新项目搭起框架的第一周就把这套东西接进去后面几乎不用再操心出了问题直接拿数据说话。