简介:面向在 Windows 平台进行 C++ 开发、希望快速定位程序崩溃原因的开发者,这份小型示例围绕 MiniDumpWriteDump 展开,演示未捕获异常场景下生成 dump 文件的基本做法。压缩包仅 731B,包含 1 个 cpp 源文件,核心代码集中呈现在工程中引入 dbghelp 接口、注册 SetUnhandledExceptionFilter 异常过滤器,以及通过异常指针、线程标识和进程句柄写出转储文件的流程。异常过滤器会在程序遇到未捕获异常时被调用,并可按需扩展日志记录与清理逻辑;读者还可理解普通转储与完整内存转储等标志的取舍,掌握发布版本保留调试信息、配置链接库、处理 dbghelp 动态库依赖等注意点,并将示例迁移到自己的 Windows C++ 项目中搭建基础崩溃采集与故障排查链路。目前已有 1629 人学习,适合作为调试入门和问题定位的参考样例,也可配合 WinDbg 或 Visual Studio 继续分析 dump 内容中的堆栈与线程状态。
1. 崩溃后的现场只剩一句“软件闪了一下”,怎么办
C++ 程序在 Windows 上崩溃,最消耗人的往往不是崩溃本身,而是崩完之后什么现场都没留下。dump 文件解决的正是这件事——它在进程还有一口气的时候,把线程栈、模块列表、异常记录冻成一块能离线翻看的现场。业务日志能告诉你最后一条打印是什么,dump 能告诉你那一瞬间每个线程卡在哪一行汇编、哪个模块的哪个偏移上出的错。区别就像看完病历和看完尸检报告。
这篇讲的是一套已经跑在生产环境里的做法:异常怎么从系统分发到我们手里、dump 类型怎么选、写入模块怎么封装、符号怎么归档、拿到文件之后怎么用几条命令定位。适合两类人——线上有正在发生但一直复现不了的崩溃,或者刚给项目接上崩溃采集、发现生成的文件老打不开。
2. 异常分发链路:从 KiUserExceptionDispatcher 到 MiniDumpWriteDump 的落点
2.1 未处理异常是怎么一步步落到我们手上的
用户态的崩溃路径比很多人想的要长。CPU 触发异常后先进内核,内核把异常转回用户态交给 ntdll 的KiUserExceptionDispatcher,它开始遍历当前线程 TEB 上挂着的 SEH 链(ExceptionList),逐个调用注册的处理器。如果整条链上没有一个处理器声明自己处理了,系统会先把栈展开到进程级别,然后调用UnhandledExceptionFilter,再往下才是 WER 的默认流程——弹那个“程序已停止工作”的框然后杀进程。
SetUnhandledExceptionFilter挂的就是这一环。它在整个链路里属于倒数第二道闸门,只要前面没人接住,一定会经过它。这是进程内采集 dump 最主要、也最廉价的位置。
但这里有两个必须知道的边界。第一,进程被调试器附着时,异常是先发给调试器的,我们挂的过滤器根本不会执行——这也是为什么很多人本地 F5 跑一切正常,打包出去才发现 dump 机制根本没生效。第二,CRT 自己也会在某些路径上改这个过滤器,最典型的是abort:在 Release 下abort走_invoke_watson,直接进TerminateProcess一类的快速终止路径,不经过 SEH 也不经过我们的过滤器。
第二点直接决定了工程里不能只挂一个SetUnhandledExceptionFilter。C++ 未捕获异常的标准结局是std::terminate,默认实现是abort,所以我们必须在set_terminate里自己写 dump;纯虚函数调用、CRT 参数校验失败同理,各自有各自的入口,要分别接管。只挂一个过滤器,等于只覆盖了三分之一的崩溃类型。
还有一个更硬的边界:/GS栈检查失败、CFG 控制流保护失败、堆管理器自己检测到的损坏,走的是RaiseFailFastException。这类崩溃直接在内核侧终止,压根不回到用户态的 SEH 链,SetUnhandledExceptionFilter收不到,set_terminate也收不到。这块要靠进程外的 WER 机制兜,后面单独说。
2.2 MINIDUMP_TYPE 怎么选:Normal 还是 WithFullMemory
MiniDumpWriteDump的第四个参数决定了文件里装什么。默认什么标志都不加就是MiniDumpNormal,只包含线程栈、模块列表和少量必要内存,几 MB 以内。标志可以按位或,加得越多文件越大,但能看的东西也越多。
| 标志 | 额外包含 | 体积增量 | 什么时候需要 |
|---|---|---|---|
MiniDumpNormal | 线程栈、模块列表、异常上下文 | 基线,几十 KB 到几 MB | 默认,栈回溯够用 |
MiniDumpWithDataSegs | 各模块的数据段(全局变量) | 小 | 需要看全局状态或单例 |
MiniDumpWithHandleData | 进程句柄表 | 小 | 句柄误关闭、句柄泄漏 |
MiniDumpWithThreadInfo | 线程 TEB 的时间与状态信息 | 小 | 判断线程是否卡在等待 |
MiniDumpWithUnloadedModules | 已卸载模块的记录 | 小 | 卸载后仍被调用的悬垂函数指针 |
MiniDumpWithIndirectlyReferencedMemory | 栈上指针指向的内存 | 中 | 要看某个对象的内容 |
MiniDumpWithPrivateReadWriteMemory | 全部私有读写内存 | 大 | 内存结构相关的疑难崩溃 |
MiniDumpWithFullMemory | 全量内存 | 与进程内存同量级 | 死锁、复杂内存破坏,线上慎用 |
线上我一般固定在MiniDumpNormal | MiniDumpWithDataSegs | MiniDumpWithHandleData | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules这一组。加MiniDumpWithHandleData是因为句柄类问题排查时没有它基本靠猜;加MiniDumpWithUnloadedModules是因为插件式架构里“模块卸载了但回调表没清”这种崩溃很常见,没有模块卸载记录,栈回溯会显示一坨未知地址。
MiniDumpWithFullMemory只在特定内存破坏问题临时打开,而且要配合采样率——一个吃 2 GB 内存的客户端,每次崩溃写 2 GB 到磁盘,客户机的磁盘和网络都受不了。这是典型的“调试需要”和“现场承受能力”之间的取舍。
2.3 MiniDumpWriteDump 的七个参数逐个拆
这个函数的签名不长,但每个参数都有讲究:
BOOL WINAPI MiniDumpWriteDump( HANDLE hProcess, // 目标进程句柄 DWORD ProcessId, // 目标进程 PID HANDLE hFile, // 已打开的、带写权限的文件句柄 MINIDUMP_TYPE DumpType, // 上面那组标志 PMINIDUMP_EXCEPTION_INFORMATION ExceptionParam, // 异常现场,可为 nullptr PMINIDUMP_USER_STREAM_INFORMATION UserStreamParam,// 自定义数据流,一般不用 PMINIDUMP_CALLBACK_INFORMATION CallbackParam // 回调,可用来过滤内存或看进度 );hProcess和ProcessId要对应同一个进程。进程内调用直接传GetCurrentProcess()和GetCurrentProcessId();跨进程调用则需要先用OpenProcess拿到带PROCESS_QUERY_INFORMATION | PROCESS_VM_READ权限的句柄,权限不够时OpenProcess会直接失败,GetLastError返回 5。
hFile必须是我们自己CreateFile打开的句柄,不能用CreateFile之后忘了关。这里有个容易翻车的细节:如果目标是写在网络路径或者受限目录,CreateFileW返回INVALID_HANDLE_VALUE,但很多人写完没检查就直接调MiniDumpWriteDump,最后得到一个大小为 0 的文件,还以为是转储函数的问题。
ExceptionParam指向一个MINIDUMP_EXCEPTION_INFORMATION结构,三个字段:
MINIDUMP_EXCEPTION_INFORMATION mei = {}; mei.ThreadId = GetCurrentThreadId(); // 出异常的那个线程 ID mei.ExceptionPointers = ep; // 系统传进过滤器的 EXCEPTION_POINTERS* mei.ClientPointers = FALSE; // 指针属于哪个地址空间ThreadId必须是真正抛异常的那个线程,不是写 dump 的线程——如果约定用别的线程去写 dump,这个值要从崩溃线程那边传过来,写错会导致分析器把错误的栈当成异常栈。ClientPointers的语义是“异常信息结构位于调用者的地址空间还是被转储进程的地址空间”,进程内调用两者重合,微软自己的示例用的是FALSE;真正做成进程外转储时这个标志要按实际指针归属来设,设错的典型症状是文件能生成,但!analyze -v拿不到异常上下文,只能看到一堆普通线程栈。
剩下两个参数在绝大多数工程里传nullptr就行。CallbackParam唯一值得用的场景是配合MiniDumpWithFullMemory做内存过滤,把明显的图片、音视频大块内存排除掉,能砍掉一大半体积。
3. 从过滤器到写入文件:把 dump 生成写成可复用模块
3.1 进程内写还是进程外写,先做个取舍
进程内写的意思是崩溃进程自己调MiniDumpWriteDump写自己。好处是简单、没有额外进程、异常指针天然就在手里。坏处也很实在:写 dump 这件事本身要用堆、要打开文件、要走文件系统,而崩溃时刻这些资源未必是干净的。堆已经损坏、持有锁的线程正好是崩溃线程、栈已经溢出,这几种情况下进程内转储的失败率并不低。
进程外写是另起一个小进程,通过命名管道或者共享内存拿到崩溃通知,然后调MiniDumpWriteDump去转储目标进程。它绕开了目标进程已经被污染的堆和锁状态,成功率明显更高。代价是要处理跨进程的异常信息传递——异常指针本身指向的是目标进程的地址空间,得先ReadProcessMemory读出来或者干脆只传 ThreadId 让分析器自己找。这套东西工作量不小,通常用在崩溃率已经影响口碑、必须拿到高保真现场的产品上。
实际落地时我的做法是分两层:进程内先兜底写一份 Normal 级别的,保证“至少有”;对于 FailFast 那一类连过滤器都进不去的崩溃,靠系统 WER 的 LocalDumps 配置补一份。两层叠加之后,绝大多数崩溃现场都能留下来。
3.2 一份可以直接抄的 DumpWriter 实现
头文件只暴露两个方法:安装和手动触发。
// DumpWriter.h #pragma once #include <windows.h> #include <string> class DumpWriter { public: // dumpDir 传绝对路径;Install 里会把目录建好,崩溃时不再做这件事 static bool Install(const std::wstring& dumpDir); // 手动写一次;ep 为 nullptr 时没有异常上下文,但仍然有线程栈 static bool Write(EXCEPTION_POINTERS* ep, const wchar_t* reason); private: static LONG WINAPI HandleUnhandled(EXCEPTION_POINTERS* ep); static bool BuildPath(wchar_t* buf, size_t cch, const wchar_t* reason); static std::wstring s_dir; static bool s_installed; };实现里的关键点集中在三处:多入口接管、防重入、路径预构造。
// DumpWriter.cpp #include "DumpWriter.h" #include <dbghelp.h> #include <process.h> #include <strsafe.h> #include <exception> #include <cstdlib> #pragma comment(lib, "dbghelp.lib") std::wstring DumpWriter::s_dir; bool DumpWriter::s_installed = false; static thread_local bool g_inDump = false; // 防重入,按线程隔离 bool DumpWriter::Install(const std::wstring& dumpDir) { if (s_installed) return true; s_dir = dumpDir; CreateDirectoryW(s_dir.c_str(), nullptr); // 目录必须提前建好 ::SetUnhandledExceptionFilter(&DumpWriter::HandleUnhandled); // C++ 未捕获异常:默认 terminate -> abort -> fast fail,收不到 SEH,必须自己接管 std::set_terminate([] { DumpWriter::Write(nullptr, L"terminate"); ::TerminateProcess(::GetCurrentProcess(), 3); }); // 纯虚函数调用 _set_purecall_handler([] { DumpWriter::Write(nullptr, L"purecall"); ::TerminateProcess(::GetCurrentProcess(), 4); }); // CRT 参数校验失败(格式串不匹配、非法参数等) _set_invalid_parameter_handler([](const wchar_t*, const wchar_t*, const wchar_t*, unsigned int, uintptr_t) { DumpWriter::Write(nullptr, L"invalid_parameter"); ::TerminateProcess(::GetCurrentProcess(), 5); }); s_installed = true; return true; } LONG WINAPI DumpWriter::HandleUnhandled(EXCEPTION_POINTERS* ep) { Write(ep, L"unhandled"); // 返回这个值让系统直接结束进程,不再弹系统错误对话框 return EXCEPTION_EXECUTE_HANDLER; } bool DumpWriter::Write(EXCEPTION_POINTERS* ep, const wchar_t* reason) { if (g_inDump) return false; // 写的过程中又崩了,直接放弃 g_inDump = true; wchar_t path[MAX_PATH] = {0}; if (!BuildPath(path, MAX_PATH, reason)) { g_inDump = false; return false; } HANDLE hFile = ::CreateFileW(path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile == INVALID_HANDLE_VALUE) { g_inDump = false; return false; } MINIDUMP_EXCEPTION_INFORMATION mei = {}; MINIDUMP_EXCEPTION_INFORMATION* pMei = nullptr; if (ep != nullptr) { mei.ThreadId = ::GetCurrentThreadId(); mei.ExceptionPointers = ep; mei.ClientPointers = FALSE; // 进程内,异常信息在本进程空间 pMei = &mei; } MINIDUMP_TYPE type = static_cast<MINIDUMP_TYPE>( MiniDumpNormal | MiniDumpWithDataSegs | MiniDumpWithHandleData | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules); BOOL ok = ::MiniDumpWriteDump(::GetCurrentProcess(), ::GetCurrentProcessId(), hFile, type, pMei, nullptr, nullptr); ::CloseHandle(hFile); g_inDump = false; return ok != FALSE; }逻辑上要说明三件事。第一,Write里所有字符串都是栈上的定长缓冲区加StringCchPrintfW,全程不碰std::wstring的拼接——std::wstring拼接会分配堆内存,堆已经坏了的时候这一步就可能再崩一次,把整个流程拖进递归。第二,g_inDump用thread_local而不是全局标志,是因为真正需要防的是同一个线程在处理这次崩溃的过程中再次进入,用全局标志反而会挡住其他线程本来可以成功的转储。第三,HandleUnhandled返回EXCEPTION_EXECUTE_HANDLER,进程会在这里结束,不会再走 WER 默认流程弹框——这个选择适合客户端产品,但不适合那些希望系统托管的服务,服务场景可以直接把这个返回值改成EXCEPTION_CONTINUE_SEARCH。
文件名生成这一块单独说:
bool DumpWriter::BuildPath(wchar_t* buf, size_t cch, const wchar_t* reason) { SYSTEMTIME st; ::GetLocalTime(&st); wchar_t exe[MAX_PATH] = {0}; ::GetModuleFileNameW(nullptr, exe, MAX_PATH); const wchar_t* name = wcsrchr(exe, L'\\'); name = name ? name + 1 : exe; HRESULT hr = StringCchPrintfW(buf, cch, L"%s\\%s_%u_%04d%02d%02d_%02d%02d%02d_%s.dmp", s_dir.c_str(), name, ::GetCurrentProcessId(), st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, reason ? reason : L"unknown"); return SUCCEEDED(hr); }进程名、PID、本地时间、触发原因四段拼起来。PID 和时间戳都要留——同一台机器上多实例跑的时候,光有时间戳分不清是哪个进程;光有 PID 又会在重启后被复用。reason字段是后续批量筛选的关键,terminate、purecall、unhandled分开存目录或者分开命名,统计崩溃类型时一眼就能看出比例。
要注意GetLocalTime而不是GetSystemTime。跨时区部署时统一用 UTC 更好,但排查时人脑是按本地时间对日志的,这个取舍自己定,关键是整个产品线统一,别一半 UTC 一半本地时间。
3.3 编译选项、DbgHelp 版本和目录策略
这三样和代码本身一样重要,缺一样都会让 dump 白生成。
编译选项上,Release 配置必须同时打开/Zi(生成调试信息)和链接器的/DEBUG(把 PDB 路径和调试信息写进 PE)。很多人以为 Release 不带调试信息是“优化”,实际结果是 PDB 都没有,dump 打开之后栈上全是模块名加偏移,等于白忙。/Zi对性能的影响主要来自编译器不能再省略帧指针,如果确实对性能敏感,可以用/Oy省帧指针同时保留/Zi,代价是某些栈回溯会缺帧——这是要自己权衡的,别默认两头都要。
DbgHelp 的版本问题更隐蔽。系统目录下那份dbghelp.dll通常是随系统带的旧版本,MiniDumpWithThreadInfo、MiniDumpWithUnloadedModules这类较新的标志在旧版本上会被静默忽略或者直接返回失败。做法是从 Windows SDK 的调试工具目录里拷一份较新的dbghelp.dll放到 exe 同级目录。dbghelp不在系统的 KnownDLLs 列表里,同目录优先加载是生效的。拷的时候注意位数——x64 进程只能加载 x64 的dbghelp.dll,混用会直接加载失败,表现是MiniDumpWriteDump返回FALSE且GetLastError是一个看不懂的错误码。
目录策略上,客户端不要往Program Files下写,普通用户权限不够。%LOCALAPPDATA%\<产品名>\dumps是客户端最省事的位置,不需要额外权限,卸载时也容易清。服务端进程如果跑在系统账户下,工作目录是C:\Windows\System32,任何相对路径都会写到那儿去,一定要用GetModuleFileNameW拿 exe 路径再往上推,或者直接固定到%ProgramData%下。
清理策略按两个维度做:保留最近 N 个(比如 50 个),同时限制目录总大小(比如 500 MB)。只按数量清,遇到大 dump 会把磁盘吃满;只按大小清,崩溃密集时可能只剩下最近几个,反而丢了早期样本。两个条件都满足才删,用FindFirstFileW遍历一次按修改时间排序就够了,不需要额外的数据库。
4. 避坑与常见问题:五条踩过的现场记录
4.1 dump 生成了但打开全是地址,符号加载不上
现象:文件大小正常,WinDbg 打开能进来,但!analyze -v报一堆Unable to load image,栈上全是module+0x1a2b3c。
原因:要么根本没有 PDB,要么 PDB 和 exe 不是同一次构建产物。最常见的场景是发布包里只放了 exe,PDB 留在构建机上,等要分析的时候构建机已经重新编译过一轮,PDB 被覆盖了。PDB 的匹配靠的是编译时写进 PE 的 GUID 加 Age,不是文件名也不是时间戳,版本差一点就对不上。
解决:发布流水线上把 PDB 按版本号归档,目录结构用产品\版本号\模块名.pdb,和安装包一起进制品库。分析时把符号路径指到对应版本目录,.reload /f之后看lm输出里每个模块后面标注的路径是不是归档路径,不是就说明没匹配上。这一条是整个链路里最容易省掉、也最容易在出事那天后悔的一步。
4.2 本地 F5 跑不生成,双击 exe 能生成
现象:开发机上调试运行,程序崩了之后什么都没留下;同样的代码直接双击 exe 跑,dump 正常落盘。
原因:调试器附着时接管了异常分发,第一次机会异常就断在调试器里,第二次机会也先交给调试器,UnhandledExceptionFilter根本轮不到执行。
解决:验证 dump 机制必须用“开始执行(不调试)”。另外在 VS 的异常设置里,把 Win32 异常的勾去掉再调试,否则很多“看起来会崩”的地方直接被调试器接住,会让人误以为代码没问题。这条其实是个方法论问题——任何和崩溃采集相关的验证,都要在无调试器条件下做一遍,调试器下的一切结论都不算数。
4.3 崩溃后进程卡死,dump 一直不落盘
现象:程序不退出,任务管理器里 CPU 接近 0,内存不涨,dump 目录里没有文件,也没有新文件在增长。
原因:异常处理器里调用了不安全的函数。最常见的是在过滤器里打业务日志——日志库内部有锁,而崩溃发生时那个锁正好被某个已经挂掉的线程持有;其次是拼接std::wstring触发堆分配,而堆本身已经损坏。
解决:过滤器路径上只允许做三件事——拼路径(用栈上缓冲区)、CreateFileW、MiniDumpWriteDump。日志、内存分配、任何带锁的第三方调用全部禁止。这条要写进代码规范,因为新人接手时第一反应往往是“顺手打个日志方便排查”,而这恰恰是让整个机制失效的最短路径。
4.4 /GS 检查失败、堆损坏类崩溃一个 dump 都没有
现象:崩溃类型统计里,0xC0000602这种代码完全没有对应的 dump 文件,只能从系统的 WER 报告里看到一条记录。
原因:这类崩溃走RaiseFailFastException,在内核侧直接终止进程,不回到用户态的 SEH 链,SetUnhandledExceptionFilter和set_terminate全部收不到。典型的触发者包括/GS栈缓冲区检查失败、CFG 违规、堆管理器检测到的RtlReportFatalFailure。
解决:靠系统级配置兜底。在注册表HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps下建一个项,配置DumpFolder(保存目录)、DumpType(0 表示 minidump,1 表示 full,2 表示自定义)、DumpCount(保留个数)。这是 WER 自己的转储机制,不受进程内状态影响。部署时通过安装包的安装脚本写进去,卸载时清掉,不要指望用户手动配。这条和
本文还有配套的精品资源,点击获取