☰
Windows C++崩溃转储:MiniDumpWriteDump与兜底实践
2026/10/24 4:20:43 网站建设 项目流程

简介:面向 Windows 平台 C++ 开发者的崩溃诊断代码示例,聚焦应用程序出现未捕获异常时生成 dump 文件的实现方式,适合需要排查线上崩溃、完善调试手段的初中级开发者参考。压缩包内共 1 个文件,为单个 cpp 源码,整体约 731B,内容精简,核心围绕异常过滤器、进程与线程句柄获取、dump 路径构造以及 MiniDumpWriteDump 调用展开,便于快速对照现有工程理解 dump 生成链路。已有 1629 人学习,说明该主题在 Windows C++ 崩溃排查场景中具有持续需求。读者可从中获得一份可参考的异常捕获与 dump 写入骨架,涉及 SetUnhandledExceptionFilter 注册、_EXCEPTION_POINTERS 处理、MiniDumpNormal 标志选择,以及发布版调试信息格式、dbghelp 运行依赖等容易被忽略的注意事项,适合作为搭建崩溃转储能力的起步模板,再配合 WinDbg 或 Visual Studio 对 dump 内容做进一步分析。

1. 崩溃现场只留一行日志?先把手上的 dump 需求拆成三层

C++ 程序在 Windows 上崩溃时,dump 文件是唯一能把现场带回来的黑匣子。日志只能告诉你“访问冲突”,dump 里却有崩溃线程的调用栈、寄存器、异常记录,配好符号还能还原到具体行号。我见过太多项目在崩溃后只能靠猜,最后把问题归到“玄学”上,其实缺的就是一份能分析的 dump。这份资源围绕 C++ 生成 dump 文件这件事,适合三类人:做 Windows 桌面客户端的、写后台服务的、维护 C++ 插件的。它不解决业务逻辑,只解决崩溃发生后“现场还在不在”的问题。下面按异常链路、进程内捕获、进程外兜底、避坑排查、分析验证的顺序拆开,每一步都给可抄的代码和参数。

2. 先搞清 Windows 的异常链路:MiniDump 到底在哪一层接住崩溃

2.1 从 CPU 异常到进程终止:异常链路上的四个接住点

Windows 上的 C++ 崩溃,大多数最终表现为一个“异常”。访问违例是0xC0000005,除零是0xC0000094,C++ 异常没被 catch 会走到terminate,再往下可能是abort或fastfail。这些路径最后都会经过系统的一层层处理。理解这条链路,才能知道MiniDumpWriteDump应该挂在哪。

第一层是 CPU 异常和内存管理器抛出的异常。线程执行到非法地址,CPU 触发异常,系统把控制权交给内核的异常分发器。第二层是用户态的异常处理链:结构化异常处理(SEH)的__try/__except、C++ 的try/catch、向量化异常处理(VEH)都在这一层。第三层是“没人接”的时候,系统调用进程的未处理异常过滤器,也就是SetUnhandledExceptionFilter注册的回调。第四层是过滤器也不处理,系统默认调试器、Windows 错误报告(WER)和 AeDebug 介入,最后终止进程。

进程内生成 dump,最常用的挂点是第三层。SetUnhandledExceptionFilter注册的回调能拿到EXCEPTION_POINTERS*,里面有异常记录和线程上下文。把它传给MiniDumpWriteDump,就能把崩溃瞬间的线程栈和寄存器写进文件。这个挂点的问题也很明显:它只在“没人接”的时候生效。如果代码里某个catch (...)把异常吞了,或者某个第三方库自己装了过滤器覆盖掉你的,dump 就不会生成。所以实际项目里,我一般会把SetUnhandledExceptionFilter作为主入口,再根据情况补 VEH 和进程外兜底。

VEH 用AddVectoredExceptionHandler注册,它比 SEH 更早被调用,而且对所有异常都触发,包括已经被处理的异常。用它抓 dump 要加过滤条件,不然正常流程里的异常也会写文件。常见做法是只对“严重异常码”写 dump,比如访问违例、栈溢出、非法指令,并且用一个原子标志防止重入。VEH 的好处是能赶在catch之前看到异常,坏处是它运行在异常发生的线程上,堆可能已经损坏,写文件失败的概率比未处理异常过滤器高。我的选择是:主用未处理异常过滤器,VEH 只作为补充,抓那些被吞掉的严重异常。

还有一类崩溃走不到异常过滤器:TerminateProcess直接杀进程、fastfail立即终止、堆管理器检测到损坏后直接FailFast。这些情况下进程内代码没有机会执行,只能靠进程外转储。后面第 4 章会讲 WER LocalDumps 和 AeDebug 的兜底。先把进程内这条路走通,再谈兜底。

2.2 MINIDUMP_TYPE 选型:全内存 dump 什么时候值得

MiniDumpWriteDump的第四个参数是MINIDUMP_TYPE,它决定了 dump 里装什么。很多人直接传MiniDumpNormal,结果打开 dump 只有线程栈,看不到堆上的对象,排查内存越界时抓瞎;也有人一上来就MiniDumpWithFullMemory,一个客户端进程写出几个 GB 的文件,上传和存储都难受。选型要看排查目标和落地成本。

转储类型主要内容典型体积适用场景
MiniDumpNormal线程栈、模块列表、异常信息几十 KB 到几百 KB只看调用栈,快速定位崩溃函数
MiniDumpWithThreadInfo增加线程时间、优先级等信息略大于 Normal多线程死锁、线程状态分析
MiniDumpWithIndirectlyReferencedMemory栈上指针间接引用的内存几 MB 到几十 MB需要看局部变量指向的对象
MiniDumpWithHandleData进程句柄表增加几 MB句柄泄漏、无效句柄导致的崩溃
MiniDumpWithUnloadedModules已卸载模块信息很小崩溃栈里出现已卸载 DLL 的情况
MiniDumpWithFullMemory整个进程用户态内存几百 MB 到数 GB复杂内存破坏、需要离线复现现场

我的默认组合是:MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo | MiniDumpWithHandleData | MiniDumpWithUnloadedModules。这个组合在客户端项目里通常能把文件控制在几十 MB 以内,又能看到栈上指针指向的关键对象。服务端如果内存本来就大,或者崩溃涉及大块缓冲区,再单独开MiniDumpWithFullMemory,并且加开关和限流,不是每次崩溃都全量写。

还有一个容易被忽略的参数是MiniDumpWithFullMemoryInfo,它记录虚拟内存区域信息,分析堆破坏时有用,但会增加文件体积。另一个是MiniDumpWithProcessThreadData,拿到进程和线程的完整环境块,某些调试器命令依赖它。不要一次性全开,先按“能不能定位到函数和对象”这个标准选,不够再逐项加。

提示:MINIDUMP_TYPE是位掩码,多个值用按位或组合。不要用加法,虽然结果一样,但按位或更能表达意图。

2.3 DbgHelp 的边界:进程内写 dump 能做什么、不能做什么

MiniDumpWriteDump来自Dbghelp.dll,调用前要包含Dbghelp.h并链接Dbghelp.lib。它的函数签名大致是:传入目标进程句柄、进程 ID、文件句柄、转储类型、异常信息指针、用户流指针、回调指针。进程内调用时,目标进程就是自己,进程句柄可以用GetCurrentProcess(),进程 ID 用GetCurrentProcessId()。文件句柄必须是有写权限的真实文件句柄,不能用内存映射或者管道代替。

进程内写 dump 有几个硬边界。第一,它运行在已经出问题的进程里,堆可能损坏,栈可能快溢出,调用链越复杂越容易二次崩溃。所以写 dump 的代码要尽量短,不要在回调里做字符串拼接、日志上报、网络请求。第二,MiniDumpWriteDump内部会挂起其他线程,如果某个线程正持有加载器锁或者堆锁,可能造成死锁。崩溃现场本来就可能是锁竞争导致的,这种死锁概率不低。第三,某些异常发生在内核回调或者加载器锁内,进程内写文件会失败,GetLastError可能返回ERROR_DLL_INIT_FAILED之类的错误。这些情况只能靠进程外转储。

还有一个常见误解:MiniDumpWriteDump能捕获所有崩溃。它捕获的是调用时刻的进程状态,如果异常发生在它被调用之前,而进程已经进入终止流程,它不一定还能执行。未处理异常过滤器返回EXCEPTION_EXECUTE_HANDLER之后,系统会继续终止进程,所以写 dump 要在返回之前完成。如果写 dump 耗时太长,系统可能等不及,特别是服务被 SCM 监控的时候。实际项目里要给写盘加超时保护,或者把 dump 写到本地快速磁盘,不要直接写网络盘。

3. 把捕获模块落到代码里:一个可复用的 C++ 崩溃转储类

3.1 入口安装:SetUnhandledExceptionFilter 与 VEH 的分工

先写安装入口。核心是SetUnhandledExceptionFilter,返回旧的过滤器指针保存下来,方便以后链式调用。要不要装 VEH 取决于项目里有没有大量catch (...)。如果第三方 SDK 经常吞异常,VEH 能补上。下面这段代码把两个入口都装上,但 VEH 只对严重异常码写 dump。

#include <windows.h> #include <dbghelp.h> #include <atomic> #include <string> #pragma comment(lib, "dbghelp.lib") static std::atomic<bool> g_dumping{false}; static LPTOP_LEVEL_EXCEPTION_FILTER g_prevFilter = nullptr; static PVOID g_vehHandle = nullptr; static bool IsSevereException(DWORD code) { switch (code) { case EXCEPTION_ACCESS_VIOLATION: case EXCEPTION_STACK_OVERFLOW: case EXCEPTION_ILLEGAL_INSTRUCTION: case EXCEPTION_PRIV_INSTRUCTION: case EXCEPTION_ARRAY_BOUNDS_EXCEEDED: case EXCEPTION_INT_DIVIDE_BY_ZERO: return true; default: return false; } } static LONG CALLBACK VectoredHandler(PEXCEPTION_POINTERS ep) { if (ep && ep->ExceptionRecord && IsSevereException(ep->ExceptionRecord->ExceptionCode)) { // 仅标记,不在 VEH 里直接写大文件,避免影响异常分发 // 实际写盘交给未处理异常过滤器或独立线程 } return EXCEPTION_CONTINUE_SEARCH; } void InstallCrashHandler() { g_prevFilter = SetUnhandledExceptionFilter(UnhandledFilter); g_vehHandle = AddVectoredExceptionHandler(1, VectoredHandler); }

这段代码的关键点:SetUnhandledExceptionFilter返回旧过滤器,保存下来是为了在自定义过滤器不处理时交还给前一个。AddVectoredExceptionHandler的第一个参数传 1 表示插入到调用链最前面,传 0 表示最后。VEH 回调里不要做重活,只做判断和标记,真正写 dump 放到未处理异常过滤器里。IsSevereException只列了常见严重异常码,栈溢出单独处理,因为栈溢出时当前线程栈已经不可用,需要换栈或者用独立线程。

3.2 写盘函数:MiniDumpWriteDump 的六个参数怎么填

写盘函数是整个模块的核心。下面这个版本把路径、异常信息、转储类型、用户流都处理了,并且带防递归标志。

#include <windows.h> #include <dbghelp.h> #include <string> #include <ctime> #include <atomic> static std::atomic<bool> g_dumping{false}; static std::wstring BuildDumpPath() { wchar_t modulePath[MAX_PATH]{}; GetModuleFileNameW(nullptr, modulePath, MAX_PATH); std::wstring path(modulePath); size_t slash = path.find_last_of(L"\\/"); if (slash != std::wstring::npos) { path = path.substr(0, slash + 1); } else { path = L".\\"; } SYSTEMTIME st{}; GetLocalTime(&st); wchar_t name[160]{}; swprintf_s(name, L"crash_%04d%02d%02d_%02d%02d%02d_pid%lu.dmp", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, GetCurrentProcessId()); return path + name; } static bool WriteDump(EXCEPTION_POINTERS* ep, const std::wstring& dumpPath) { HANDLE file = CreateFileW( dumpPath.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (file == INVALID_HANDLE_VALUE) { return false; } MINIDUMP_EXCEPTION_INFORMATION mei{}; mei.ThreadId = GetCurrentThreadId(); mei.ExceptionPointers = ep; mei.ClientPointers = FALSE; // 进程内写,传 FALSE MINIDUMP_TYPE type = static_cast<MINIDUMP_TYPE>( MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo | MiniDumpWithHandleData | MiniDumpWithUnloadedModules); BOOL ok = MiniDumpWriteDump( GetCurrentProcess(), GetCurrentProcessId(), file, type, &mei, nullptr, nullptr); DWORD lastError = ok ? ERROR_SUCCESS : GetLastError(); CloseHandle(file); if (!ok) { // 这里只记录错误码,不要做复杂格式化或网络上报 OutputDebugStringW(L"MiniDumpWriteDump failed\n"); } return ok != FALSE; }

参数逐个说:第一个参数是目标进程句柄,进程内写用GetCurrentProcess()。第二个是进程 ID,跟句柄对应。第三个是文件句柄,必须用CreateFileW打开,GENERIC_WRITE权限,共享模式传 0 表示独占,避免其他进程同时写。第四个是转储类型,这里选了中等粒度组合。第五个是MINIDUMP_EXCEPTION_INFORMATION指针,ThreadId用GetCurrentThreadId(),ExceptionPointers直接用过滤器拿到的指针,ClientPointers在进程内必须为FALSE,表示指针指向当前进程地址空间。第六个是用户流指针,这里传nullptr,后面第 6 章会用它写构建信息。第七个是回调指针,一般不用。

文件路径用可执行文件所在目录,加时间戳和进程 ID。不要写到当前工作目录,服务进程的工作目录可能是System32,没有写权限。也不要直接写网络盘,崩溃时网络栈状态未知,写本地磁盘成功率更高。CreateFileW的共享模式传 0 很关键,如果已经有一个 dump 正在写,第二次会失败,这正好配合防递归标志。

3.3 收尾与防递归:写日志、退出码和异常过滤器返回值

未处理异常过滤器写完 dump 后要决定返回什么。返回EXCEPTION_EXECUTE_HANDLER表示异常已处理,系统会继续终止进程;返回EXCEPTION_CONTINUE_SEARCH表示交给下一个过滤器。我的做法是写完 dump 返回EXCEPTION_EXECUTE_HANDLER,然后显式调用TerminateProcess并给一个约定好的退出码,方便守护进程识别。

static LONG WINAPI UnhandledFilter(EXCEPTION_POINTERS* ep) { if (g_dumping.exchange(true)) { // 已经在写 dump,直接交给系统,避免递归 return EXCEPTION_EXECUTE_HANDLER; } std::wstring dumpPath = BuildDumpPath(); bool ok = WriteDump(ep, dumpPath); // 记录结果到本地小日志,不要做复杂操作 if (!ok) { OutputDebugStringW(L"dump write failed in unhandled filter\n"); } g_dumping.store(false); // 显式终止,退出码可自定义,方便外部守护进程判断 TerminateProcess(GetCurrentProcess(), 0xC0000005); return EXCEPTION_EXECUTE_HANDLER; }

g_dumping.exchange(true)是防递归的第一道闸。如果写 dump 过程中又触发异常,第二次进入过滤器时exchange返回true,直接返回,不再写文件。这里用std::atomic<bool>而不是普通bool,因为崩溃可能发生在任意线程,过滤器的调用也可能并发。写完以后store(false)其实在终止流程里意义不大,但保留是为了应对“过滤器返回后进程没立刻死”的边缘情况。

TerminateProcess的退出码用0xC0000005是示意,实际项目可以定义自己的退出码,比如0xE0000001,守护进程看到这个码就知道是崩溃而不是正常退出。注意TerminateProcess不会执行析构、不会刷新缓冲区,所以不要在它之前依赖 C++ 对象的析构写日志。需要记录的崩溃摘要要么在写 dump 前用WriteFile直接写,要么放到 dump 的注释流里。

3.4 初始化时机:DllMain、静态构造和线程安全

安装崩溃处理器的时机很关键。不要在DllMain里调用SetUnhandledExceptionFilter或者加载Dbghelp.dll,DllMain持有加载器锁,里面做这些事容易死锁。也不要在全局对象的静态构造函数里安装,因为静态构造顺序不确定,可能在其他全局对象初始化崩溃时还没装上。

常见做法是提供一个显式的InstallCrashHandler(),在main或WinMain的第一行调用,越早越好。如果是 DLL 插件,在插件的初始化导出函数里调用,不要在DllMain的DLL_PROCESS_ATTACH里调用。如果项目使用#pragma init_seg控制初始化顺序,可以把安装代码放到靠前的段,但仍然不建议在静态构造里做文件操作。

线程安全方面,安装函数本身不需要加锁,但写 dump 的标志必须是原子的。多线程同时崩溃时,第一个线程拿到g_dumping标志开始写,其他线程直接终止,不会生成多份 dump。这比每个线程都写一份更可控,也避免磁盘被刷满。如果确实需要每个崩溃线程都留现场,可以用线程局部标志,但要配合限流,不然一个进程反复崩溃会生成大量文件。

注意:MiniDumpWriteDump内部会加载Dbghelp.dll并可能加载符号文件。如果崩溃发生在加载器锁内,这个调用可能卡住。生产环境可以把Dbghelp.dll放在应用目录,使用应用本地副本,减少系统目录版本差异带来的不确定性。

4. 进程内接不住的那几种崩:进程外兜底与系统级转储

4.1 WER LocalDumps:注册表三件套与作用范围

有些崩溃进程内根本没机会执行代码,比如启动阶段崩溃、堆管理器FailFast、TerminateProcess直接杀进程。这时候要靠 Windows 错误报告的本地转储。它通过注册表配置,系统在进程崩溃时自动写 dump,不需要应用代码参与。

配置项在HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps下,主要三个值:DumpFolder指定输出目录,DumpType指定转储类型,DumpCount指定保留数量。下面是一组示例命令,路径用虚构目录,实际按项目部署调整。

reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpFolder /t REG_EXPAND_SZ /d "C:\CrashDumps\MyApp" /f reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpType /t REG_DWORD /d 2 /f reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpCount /t REG_DWORD /d 10 /f

DumpFolder用REG_EXPAND_SZ,可以写环境变量,比如%LOCALAPPDATA%\CrashDumps。DumpType的取值:0 表示自定义转储类型,1 表示迷你转储,2 表示完整转储。完整转储体积大,但能拿到全部用户态内存,适合内存破坏类问题。DumpCount控制同一目录下保留多少个 dump,超出后旧的会被清理,避免磁盘写满。

这个配置的作用范围是整个机器,所有进程崩溃都会按这个规则写。如果只想针对某个应用,可以在LocalDumps下建一个以可执行文件名命名的子键,比如LocalDumps\MyApp.exe,在里面配置同样的值。子键方式更精准,不会影响其他程序。修改注册表需要管理员权限,部署时可以用安装程序写入,或者用组策略下发。

WER LocalDumps 的局限是它依赖 WER 服务,某些精简系统或者服务账户环境下 WER 可能被禁用。另外,它写 dump 的时机在进程终止流程里,如果崩溃导致系统级资源耗尽,写盘也可能失败。所以它适合做兜底,不适合替代进程内捕获。实际项目里两层都开,进程内 dump 带异常上下文和用户流,WER dump 保证启动即崩也有文件。

4.2 AeDebug 与守护进程:启动即崩和服务场景的兜底

AeDebug 是另一个系统级机制,它在进程崩溃时调用一个调试器附加到进程。注册表位置在HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AeDebug,Debugger值指定命令行,Auto值控制是否自动附加。下面是一条示例,调试器路径用虚构路径,%ld会被系统替换成进程 ID。

reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AeDebug" /v Debugger /t REG_SZ /d "\"C:\Tools\dump_tool.exe\" -p %ld -e %ld -g" /f reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AeDebug" /v Auto /t REG_SZ /d 1 /f

Auto设为 1 表示自动附加,不弹对话框。命令行里的-p是进程 ID,-e是事件句柄,-g表示忽略初始断点直接继续。这个工具需要自己实现或者用现成的调试器包装,核心是在附加后写 dump 再退出。AeDebug 的优点是能在进程刚崩溃、还没完全终止时介入,适合启动阶段崩溃。缺点是全局生效,配置不当会让所有崩溃程序都走调试器,影响用户体验。

服务场景更常用守护进程。守护进程定时检查目标进程是否存活,发现退出码是约定的崩溃码,就调用MiniDumpWriteDump对目标进程写 dump。进程外写 dump 时,目标进程句柄需要PROCESS_QUERY_INFORMATION | PROCESS_VM_READ权限,MINIDUMP_EXCEPTION_INFORMATION的ClientPointers要设为TRUE,因为异常指针指向目标进程地址空间。守护进程写 dump 的好处是即使目标进程堆损坏、加载器锁死,只要内核还没回收进程对象,就能抓到现场。坏处是需要额外的进程和权限配置,部署复杂度高。

// 进程外写 dump 的关键参数差异 MINIDUMP_EXCEPTION_INFORMATION mei{}; mei.ThreadId = crashThreadId; // 从目标进程枚举或事件获取 mei.ExceptionPointers = remoteEp; // 目标进程地址空间中的指针 mei.ClientPointers = TRUE; // 跨进程必须为 TRUE MiniDumpWriteDump( hTargetProcess, // OpenProcess 得到的句柄 targetPid, hDumpFile, type, &mei, nullptr, nullptr);

这段代码和进程内版本的差别就在ClientPointers和目标进程句柄。OpenProcess需要足够权限,服务账户下可能受会话隔离限制。如果目标进程是保护进程或者跨会话,普通权限拿不到句柄,需要驱动或者系统级工具。大多数业务进程用守护进程就够了。

4.3 符号与版本元数据:让 dump 从“能打开”到“能定位”

dump 生成只是第一步,能不能定位到源码行,取决于符号文件。C++ 项目在 Windows 上一般用 PDB 符号,编译时每个模块生成一个 PDB,里面有函数、行号、局部变量信息。dump 里记录的是模块的加载地址、时间戳和 PDB 签名,调试器根据这些信息去找匹配的 PDB。如果 PDB 丢了或者版本不对,栈里只能看到模块加偏移,看不到函数名和行号。

我的习惯是:每次构建都把 PDB 归档,按版本号、构建号、提交号分目录保存。发布包里可以不带 PDB,但构建服务器必须留。排查时把符号路径指向归档目录,调试器会自动匹配。符号路径用分号分隔多个目录,可以包含本地缓存目录和符号服务器。设置环境变量_NT_SYMBOL_PATH是一种方式,调试器里用.sympath命令也行。

.sympath C:\Symbols\MyApp\1.2.3;C:\Symbols\Cache .reload

.sympath设置符号搜索路径,.reload强制重新加载模块符号。如果 PDB 匹配,lm命令会显示模块的符号状态为pdb symbols;如果不匹配,会显示export symbols或者deferred。分析前先看lm输出,确认关键模块的符号状态,不要急着看栈,栈里全是问号的时候先解决符号问题。

版本元数据也很重要。dump 里要能看出是哪个版本、哪个渠道、哪个构建号。进程内写 dump 时可以通过用户流把构建信息写进去,第 6 章会给代码。没有用户流的话,至少把版本号写进 dump 文件名,或者在同目录写一个.txt记录构建信息。分析时先看版本,再去对应符号目录找 PDB,不要拿错版本的符号硬套,栈会错位得离谱。

4.4 手工复现时的抓取姿势:附加、命令行与权限

开发和测试阶段,经常需要手工抓 dump。最直接的方式是用调试器附加到进程,复现崩溃后让调试器保存 dump。命令行下可以用调试器的-c参数执行命令,比如附加后自动执行.dump /ma再退出。这种方式适合复现步骤明确、进程还在运行的情况。

另一种方式是用系统自带的转储功能。任务管理器右键进程可以创建转储文件,但只能抓运行中的进程,抓不到已经崩溃退出的。资源监视器、性能监视器也有类似功能,操作路径长,不适合频繁使用。命令行工具更适合脚本化,比如写一个批处理,启动目标程序、等待、抓取、结束。注意不要依赖外部下载链接,工具路径用内部部署的固定目录。

权限方面,抓其他用户的进程或者服务进程,需要管理员权限,并且可能要调整进程的保护级别。跨会话抓取更麻烦,会话 0 的服务进程和用户会话之间有隔离,普通管理员权限不一定够。测试阶段尽量在同一个会话里复现,减少权限干扰。生产环境的手工抓取要走运维流程,不要在现场随意附加调试器,附加本身会挂起目标进程,可能影响业务。

5. 避坑与排查:dump 生成了却打不开、栈是空的、进程卡死

5.1 现象:dump 文件 0 字节或只有几十 KB

现象很直观:崩溃目录里出现了.dmp文件,大小是 0,或者只有几十 KB,用调试器打开提示文件损坏、没有有效数据。原因通常有三类。第一类是CreateFileW成功但MiniDumpWriteDump失败,文件被创建了但没写入内容,代码里没有检查返回值,看起来像生成了 dump。第二类是磁盘空间不足或者目录没有写权限,CreateFileW可能成功创建空文件,但后续写入失败。第三类是转储类型选得太小,MiniDumpNormal在简单崩溃下确实只有几十 KB,这不是失败,是内容少。

解决方式:在MiniDumpWriteDump返回后立刻检查GetLastError,把错误码写进本地日志或者调试输出。文件大小要在写完后用GetFileSizeEx检查,小于预期阈值就标记为可疑。目录权限要在安装时验证,服务进程用LocalSystem运行时,可执行文件目录可能没有写权限,改用%ProgramData%下的子目录。转储类型按第 2 章的表选,如果只需要调用栈,几十 KB 是正常的,不要误判。

5.2 现象:调试器提示符号不匹配、栈是问号

打开 dump 后,栈里全是MyApp+0x1234这样的模块加偏移,没有函数名和行号,调试器提示符号不匹配或者找不到 PDB。原因一般是 PDB 和二进制不是同一次构建,或者符号路径没配对。C++ 项目经常出现“本地编译的 exe 配了服务器上的 PDB”这种错配,时间戳和签名对不上,调试器拒绝加载。

解决方式:先看 dump 里模块的时间戳和 PDB 签名,再去找对应构建的 PDB。构建服务器按构建号归档 PDB,排查时用构建号定位目录,不要用“最新”目录。符号路径里把归档目录放在最前面,缓存目录放后面。用!lmi 模块名查看模块详细信息,确认 PDB 签名。如果 PDB 确实丢了,只能看导出符号和汇编,排查效率会低很多。血泪经验是:PDB 归档要和发布流程绑定,发版必须留符号,不然线上崩溃只能靠猜。

5.3 现象:崩溃处理函数里进程卡死,dump 写不出来

现象是进程崩溃后没有退出,也没有生成 dump,卡在崩溃处理函数里。用调试器附加能看到线程停在MiniDumpWriteDump内部,或者在等待某个锁。原因通常是崩溃发生时其他线程持有加载器锁、堆锁,MiniDumpWriteDump需要挂起线程并遍历模块,拿不到锁就死等。另一种可能是崩溃处理函数里做了日志上报、网络请求,网络栈状态异常导致阻塞。

解决方式:崩溃处理函数里只做最小动作,写 dump、写一个短错误码、终止进程。不要在过滤器里调用复杂的 C++ 对象、不要分配大块内存、不要发网络请求。MiniDumpWriteDump加超时保护比较难,它本身不支持超时参数,常见做法是把写 dump 放到独立线程,主线程等待有限时间后强制终止进程。如果独立线程也卡住,守护进程兜底。生产环境不要把“写 dump”当成绝对可靠的操作,要有“写不出来也要让进程退出”的预案。

5.4 现象:同一异常反复触发,dump 刷满磁盘

现象是崩溃目录短时间内出现大量 dump 文件,磁盘被刷满,或者进程反复崩溃重启,每次重启都生成新 dump。原因是防递归标志没生效,或者进程被守护进程反复拉起,每次拉起又遇到同一个启动崩溃。多线程同时崩溃时,如果标志不是原子的,多个线程会同时写文件。另一种情况是 VEH 对所有异常都写 dump,正常流程里的异常也被记录。

解决方式:防递归用std::atomic<bool>的exchange,不要用普通bool。写 dump 前检查同目录下最近时间段的 dump 数量,超过阈值就跳过,只记录一条日志。文件名带时间戳和进程 ID,方便清理脚本按时间删除。守护进程重启策略要加退避,连续崩溃多次后停止重启并告警,不要无限拉起。VEH 里只对严重异常码写 dump,普通 C++ 异常交给catch处理。

5.5 现象:32 位进程在 64 位系统上分析错位

现象是 32 位程序在 64 位 Windows 上崩溃,dump 用 64 位调试器打开,栈回溯错位,寄存器显示不对。原因是调试器位数要和 dump 对应,32 位进程的 dump 要用 32 位调试器分析,64 位调试器虽然能打开,但上下文结构可能不匹配。另一种情况是 WOW64 环境下模块列表包含 32 位和 64 位模块,符号路径没区分。

解决方式:确认目标进程位数,用对应位数的调试器。32 位 dump 用 32 位版本打开,64 位 dump 用 64 位版本。符号路径里 32 位和 64 位 PDB 分目录存放,不要混在一起。分析前用lm看模块列表,确认System32和SysWOW64下的模块加载情况。如果 dump 是在 64 位系统上由 WER 生成的,注意 WER 可能生成 64 位格式的转储,即使进程是 32 位,这种情况要用支持 WOW64 分析的调试器配置。

6. 把 dump 用起来:WinDbg 分析、验证闭环与一个压箱底的技巧

6.1 分析流程:从 !analyze -v 到线程栈

拿到 dump 后,第一步不是直接看栈,而是让调试器做一次自动分析。打开 dump 后执行.symfix设置公共符号路径,再执行.reload加载符号,然后跑!analyze -v。这个命令会输出异常记录、故障模块、建议的排查方向,虽然它的结论不一定百分百准确,但能快速定位到异常码和崩溃线程。

.symfix .sympath+ C:\Symbols\MyApp\1.2.3 .reload !analyze -v ~*kb

.symfix把公共符号服务器加到路径里,.sympath+追加项目自己的符号目录,.reload强制重新加载。!analyze -v输出里重点看FAULTING_IP、EXCEPTION_CODE、PROCESS_NAME、MODULE_NAME。然后~*kb打印所有线程的栈,找到崩溃线程,看它的调用链。如果栈里有业务函数,结合源码和行号看局部变量,用dv命令查看变量值,用dt命令查看对象内存布局。

多线程问题不能只看崩溃线程。用~*k看所有线程栈,找有没有线程持有锁、有没有线程在等待。死锁类 dump 要看线程状态和等待链,!threads看线程列表,!locks看临界区,!cs看关键段。内存破坏类问题用!heap -s看堆摘要,!address看内存区域。不要一上来就!analyze -v然后照着结论改代码,那个结论只是起点。

6.2 验证闭环:构造可控崩溃并检查 dump

写完捕获模块,必须验证。不要等线上崩溃才知道 dump 能不能用。构造一个可控崩溃:在一个测试程序里故意解引用空指针,运行后检查 dump 文件是否生成、大小是否合理、调试器能否打开、栈里能否看到测试函数名。

// 故意崩溃,用于验证 dump 捕获链路 #include <windows.h> void CrashForTest() { int* p = nullptr; *p = 42; // 触发访问违例 0xC0000005 } int main() { InstallCrashHandler(); CrashForTest(); return 0; }

这段代码调用安装函数后立刻触发访问违例。运行后检查可执行文件目录下是否出现crash_*.dmp。用调试器打开,执行!analyze -v,看异常码是不是0xC0000005,栈里能不能看到CrashForTest。如果栈里只有模块偏移,回去查符号路径。如果文件是 0 字节,回去查MiniDumpWriteDump返回值和错误码。这个验证用例可以放进 CI,每次构建跑一遍,确保崩溃捕获模块没有被其他库覆盖或者链接错误。

验证还要覆盖不同崩溃类型:空指针、除零、栈溢出、C++ 异常未捕获。每种类型生成 dump 后都打开看一遍。栈溢出比较特殊,进程内写 dump 可能失败,要确认进程外兜底是否生效。C++ 异常未捕获走的是terminate,不是结构化异常,如果项目里装了std::set_terminate,要在里面也触发一次 dump。验证用例不用多,每种类型一个就够,关键是每次发版前跑一遍。

6.3 一个压箱底技巧:把构建信息写进 dump 注释流

MiniDumpWriteDump的第六个参数是用户流指针,很多人传nullptr。其实可以用它往 dump 里写一段自定义注释,比如构建号、提交号、渠道、命令行。分析时不用翻目录找版本信息,直接在 dump 里看。下面这段代码用CommentStreamA写一段文本。

static bool WriteDumpWithComment(EXCEPTION_POINTERS* ep, const std::wstring& dumpPath, const char* comment) { HANDLE file = CreateFileW(dumpPath.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (file == INVALID_HANDLE_VALUE) return false; MINIDUMP_EXCEPTION_INFORMATION mei{}; mei.ThreadId = GetCurrentThreadId(); mei.ExceptionPointers = ep; mei.ClientPointers = FALSE; MINIDUMP_USER_STREAM stream{}; stream.Type = CommentStreamA; stream.BufferSize = static_cast<ULONG32>(strlen(comment) + 1); stream.Buffer = const_cast<char*>(comment); MINIDUMP_USER_STREAM_INFORMATION usi{}; usi.UserStreamCount = 1; usi.UserStreamArray = &stream; MINIDUMP_TYPE type = static_cast<MINIDUMP_TYPE>( MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo | MiniDumpWithHandleData | MiniDumpWithUnloadedModules); BOOL ok = MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file, type, &mei, &usi, nullptr); CloseHandle(file); return ok != FALSE; }

stream.Type用CommentStreamA,这是标准流类型,调试器能识别。BufferSize包含结尾的\0,Buffer指向生命周期足够长的缓冲区,不要传局部std::string的临时指针。usi.UserStreamCount是流数量,这里一个流,多个流就传数组。分析时用!dh或者直接看 dump 的注释信息,有些调试器扩展能直接打印注释流。我的习惯是把build=xxx;commit=xxx;channel=xxx写进去,排查时先看注释,再看栈,版本对不上直接换符号,不浪费时间。

从那以后我每次上线前都强制走一遍:CI 里跑一个故意崩溃的 smoke case,确认 dump 文件大小、符号路径和!analyze -v输出都正常,否则不给发版。这个习惯帮我省过很多次“线上崩溃只能猜”的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询