
做自研引擎做到一定阶段Crash minidumps 这种工具链模块迟早会被提上日程。我见过不少团队功能开发排期排得满满当当崩溃一律本地用调试器看等包发给测试、发给用户之后问题就变得很被动不知道对方机器上发生了什么也不知道崩溃时引擎执行到了哪一步。这篇文章是“Building a Simple Engine”系列里关于 Tooling 的一篇讲的是我给自己那套简单自研引擎搭的崩溃转储工具链——从异常发生的瞬间把现场留下来再把这堆二进制数据变成一条可读的调用栈。适合引擎主循环已经能跑起来、正准备补工具链的人。不会讲云上报和数据分析平台就讲 Windows 上怎么把崩溃现场可靠地留下来以及怎么用最低成本把它变成有效的调试信息。1. Crash minidumps 在自研引擎工具链中的位置它不是调试器的替代品而是调试器的接力棒1.1 自研引擎为什么必须有一套自己的崩溃转储方案商业引擎自带一整套崩溃上报生态比如 Unreal 的 CrashReportClient崩溃后新起一个进程来收集日志和转储文件再决定是交付给 Crash Reporter 还是本地留存。自研引擎没有这些现成能力但你面对的问题其实和商业引擎一模一样崩溃发生在测试机、发生在玩家电脑、发生在凌晨跑批任务的构建机上你的 Visual Studio 调试器挂不上去日志正好停在崩溃前最后一行后面的执行轨迹只能靠猜。更麻烦的是很多典型问题恰恰是本机复现不出来的。内存越界把堆写坏了逻辑上可能跑几十秒后才在另一个地方触发访问违规显卡驱动版本差异导致渲染线程崩溃或者某个平台相关 API 返回了预期外的错误。这类问题如果没有崩溃现场排查成本会非常高。崩溃转储文件的价值就在于它在异常发生的那一瞬间把进程地址空间、调用栈、寄存器、加载模块和异常信息打包成一个标准格式的文件。你拿着这个文件可以在一台和你环境完全不同的机器上重建崩溃现场就像现场勘察拍的照片。所以我的判断是崩溃转储不是调试器的替代品而是调试器的接力棒。在你能用调试器的时候直接调试当然最高效但当你够不到那台机器、够不到那个进程的时候转储文件就是唯一的接力棒。1.2 商业引擎背后的通用工作流Unreal 的崩溃后台我读过一段时间它值得参考的不是具体代码而是那条清晰的分工链路。游戏进程捕获到异常后本身不承担复杂的分析工作它只负责把当前进程相关的信息快速写到磁盘随后由另一个独立的 CrashReportClient 进程去读取这些文件负责收集日志、附加 GPU 信息、展示错误对话框、甚至把文件上传到崩溃收集服务。这种“写转储”和“处理上报”分离的设计是因为崩溃现场通常不可信堆可能已经损坏、栈可能已经溢出、内存分配可能失败继续在崩溃进程里做复杂操作只会增加二次崩溃的风险。自研引擎不一定需要单独做一个 CrashReportClient但至少该借鉴它的设计原则崩溃发生时能写多少写多少绝不贪多后续的符号化、上传、展示全部放到下一次启动或独立工具里做。后面我会讲到进程内写 dump 的那几行代码看起来简单但要保证它在异常环境下也能跑通必须把操作收敛到最小的集合。1.3 先明确你要从崩溃转储里拿到什么很多第一次搭崩溃转储的人会陷入一个误区以为把 MiniDumpWriteDump 调用起来生成一个 .dmp 文件就完事了。打开文件后才发现里面只有一条地址序列没有任何函数名根本没法看。这是因为发布版本默认没有调试符号转储解析需要 PDB需要转储文件里的模块 GUID 和某个缓存目录里的 PDB 精确匹配。所以团队里最好先达成这样一个共识崩溃转储的价值等于“转储文件 对应版本的符号文件 可复现的符号化流程”三者之和缺一环转储就基本是废的。我后面会用一整节讲符号和版本管理先用这个目标来倒推工具链设计崩溃发生时快速写盘构建时把 PDB 按版本妥善留存分析时用工具把盘里的转储还原成函数级调用栈。这套链路不复杂但每一步都有细节。2. 崩溃拦截点到底设在哪儿异常过滤器、启动器与 C 未处理异常2.1 Windows 异常上报路径Windows 上一次访问违规Access Violation会触发结构化异常处理SEH机制。系统按线程栈向上查找是否有对应的__try/__except处理器如果整条链都没有人捕获异常最终会落到系统默认的 UnhandledExceptionFilter。这个过滤器做的事情很多显示“应用程序错误”对话框、把事件写入 Windows 事件日志、然后结束进程。我们想介入的位置就是这最后一段。SetUnhandledExceptionFilter这个 API 允许你安装一个回调函数让系统在你指定的函数里先处理异常再决定进程的最终命运。这也是 MiniDumpWriteDump 最标准的挂载点。需要注意这个过滤器只在“异常没有被用户代码捕获并继续传播”的情况下才会被调用。如果你的代码里已经用__try/__except或 C 的 try/catch 把异常吞掉了那过滤器自然不会被触发。2.2 C 项目里哪些异常场景不会走到过滤器C 异常和 SEH 是两个不同层面但最终都会通向进程终止路径。未捕获的 C 异常会调用std::terminate默认行为是弹一个终止对话框并结束进程这个路径不保证会调用你设置的 UnhandledExceptionFilter。另一个容易被遗漏的是纯虚函数调用、动态初始化抛异常等场景。所以如果引擎主线程入口是main或WinMain比较稳妥的做法是在最外层包一层__try/__except直接捕获所有 SEH 异常。我在自己引擎里的做法是双保险第一层是SetUnhandledExceptionFilter安装全局过滤器覆盖那些在系统默认路径上触发的异常第二层是在WinMain里用__try/__except把主循环包起来如果异常没被系统过滤器截获也能在主线程栈上立刻生成转储。渲染线程、工作线程同样需要单独包一层。你可以把每个线程的执行入口都当成一个独立的“主函数”来看不能指望全局过滤器兜住所有线程上的所有异常因为某些线程异常可能直接导致进程终止或者挂起。2.3 用最简单代码先跑通一条崩溃链路下面这段代码是我在自研引擎里最早期用过的版本非常朴素但已经能完成“崩溃发生后把现场写成 dmp 文件”这个核心目标。新建一个 CrashReporter 模块代码量不多安装时机放在引擎初始化最早阶段// CrashReporter.h #pragma once namespace eng { void InstallCrashHandler(const wchar_t* reportDir); }// CrashReporter.cpp #include CrashReporter.h #include windows.h #include dbghelp.h #include cstdio #pragma comment(lib, dbghelp.lib) static wchar_t g_reportDir[MAX_PATH]; static bool g_isInCrash false; static void WriteMinidump(EXCEPTION_POINTERS* ep) { // 防止同一个进程里崩溃处理自身再次触发崩溃形成递归 if (g_isInCrash) return; g_isInCrash true; SYSTEMTIME st; GetLocalTime(st); wchar_t path[MAX_PATH]; wsprintfW(path, L%s\\crash_%04d%02d%02d_%02d%02d%02d_%d.dmp, g_reportDir, st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, GetCurrentProcessId()); HANDLE hFile CreateFileW(path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile INVALID_HANDLE_VALUE) return; MINIDUMP_EXCEPTION_INFORMATION mei{}; mei.ThreadId GetCurrentThreadId(); mei.ExceptionPointers ep; mei.ClientPointers TRUE; MINIDUMP_TYPE dumpType static_castMINIDUMP_TYPE( MiniDumpNormal | MiniDumpWithDataSegs | MiniDumpWithIndirectlyReferencedMemory | MiniDumpScanMemory); BOOL ok MiniDumpWriteDump( GetCurrentProcess(), GetCurrentProcessId(), hFile, dumpType, ep ? mei : nullptr, nullptr, nullptr); CloseHandle(hFile); // ok 为 FALSE 时这里暂时不做额外处理 // 因为崩溃现场本身可能已经不具备可靠分配内存或写日志的能力。 } static LONG WINAPI CrashFilter(EXCEPTION_POINTERS* ep) { WriteMinidump(ep); // 无论是否写成功都走系统默认的终止流程避免让“半死”的进程继续运行 return EXCEPTION_EXECUTE_HANDLER; } void eng::InstallCrashHandler(const wchar_t* reportDir) { wcsncpy_s(g_reportDir, reportDir, MAX_PATH); SetUnhandledExceptionFilter(CrashFilter); }代码里最容易被忽略的是MiniDumpWithIndirectlyReferencedMemory和MiniDumpScanMemory。前者会把栈上指针间接引用的内存块也尽量抓进转储这对看局部变量对应的对象内容很有帮助后者会增加扫描开销但能在崩溃现场保留更多可能相关的堆内容。自研引擎早期不需要追求极致小巧转储文件大一点没关系完整度优先。我建议从MiniDumpNormal开始再加这两个选项等文件体积大到影响同步或上传时再按实际需求裁剪。2.4 栈溢出这种特殊崩溃为什么过滤器会失灵这里有个需要单独处理的坑栈溢出Stack Overflow发生时当前线程的栈空间已经非常紧张。你安装的 UnhandledExceptionFilter 也需要在栈上执行函数调用的很可能会因为栈空间不足而再次抛出栈溢出异常导致过滤器根本运行不到 MiniDumpWriteDump 那一步。Windows 提供了向量化异常处理AddVectoredExceptionHandler它优先于 SEH、无需分配栈空间即可执行但对于栈溢出仍然不能完全保证能生成有效转储。更可靠的做法是预留一个独立的备用栈在向量化异常处理函数里检测到异常代码是STATUS_STACK_OVERFLOW时切换到备用栈去执行转储。这引入了不少额外复杂度所以我的建议是如果引擎早期阶段栈溢出不频繁可以先检测到栈溢出后在日志里写一句别指望转储文件总是生成成功等这个模块稳定了再引入备用栈方案。后面我会专门讲实际踩过的坑。3. 转储文件落地之后符号、版本与堆栈还原的完整闭环3.1 拿到一个 .dmp第一件事不是开 WinDbg放下“转储文件生成了就万事大吉”的最快方式就是实际去分析一个 Release 版本的 dmp。你用 WinDbg 打开输入!analyze -v看到一排地址和一个 FAILURE_BUCKET_ID但找不到任何函数名这时候才会意识到符号文件的重要性。Windows 可执行文件和 DLL 被加载时加载器会记录模块的 GUID、时间戳、以及一系列调试目录信息。转储文件里也记录了这些信息。符号文件PDB内部带有对应的 GUID只有两者完全匹配调试器才能把地址解析成函数名。也就是说发布出去的游戏包可以没有 PDB但你的存档服务器上必须保留一份与这个游戏包完全对应的 PDB。版本不同、build 时间不同、哪怕只是改了编译选项PDB 都会发生变化转储文件就解析不出来。这是我在实际项目里最常看到团队踩坑的地方辛辛苦苦收了一堆 dmp结果发现发布版本对应的 PDB 被构建机自动清理了等于白收。3.2 用 cdb/WinDbg 跑一次手动符号化分析 Windows minidump 的通用工具是 WinDbg 或者更轻量的命令行调试器 cdb。如果只是自己手动看崩溃点一条命令就够cdb -z crash_20250617_120314.dmp -y srv*D:\symbols*https://msdl.microsoft.com/download/symbols;D:\MyEngine\symbols -c !analyze -v; q参数-z表示打开转储而不是启动进程-y指定符号搜索路径-c传入启动后要执行的调试命令。!analyze -v是自动分析命令它会解析异常类型、崩溃模块、调用栈并尽量给出一个可读的“故障桶标识”。一个能正确匹配符号的调用栈核心部分通常长这样STACK_TEXT: 00 000000f2bff5a 00007ff64c1b9f14 Engine!game::World::Tick0x114 01 000000f2bff5b 00007ff64c1ba2d0 Engine!game::GameLoop::Update0x1c 02 000000f2bff5c 00007ff64c1ba455 Engine!engine::Runner::Run0x21 03 000000f2bff5d 00007ff64c1ba733 Application!main0x123看到这类信息你才能从“某地址访问违规”升级为“World::Tick 里某个函数写坏了内存”。这个过程看起来很直接但盲区也不少inline 函数会被优化掉栈帧指针省略会导致回溯不准尾调用会让调用栈变得比实际更短。这些属于正常现象不是转储失效。3.3 自研引擎如何低成本搭建符号库不做商业工具的情况下最省钱的符号管理方案是利用微软提供的 symstore 工具。它可以从构建产物里提取 PDB按照符号名称和 GUID 归档到一个服务器目录同时生成索引文件。后续调试器只需要配置符号路径指向那个目录就能自动按需拉取对应版本。symstore 的使用非常简单symstore add /f output\*.pdb /s D:\MySymbols /t MyEngine /v 1.2.3 /c release build但我个人更推荐一种对自研项目来说更透明的做法把符号目录和版本目录强绑定比如\\buildserver\symbols\1.2.3\Game.pdb \\buildserver\symbols\1.2.4\Game.pdb构建脚本在打包时把 PDB 复制到以版本号命名的目录下同时在游戏启动配置里把当前版本号写进日志或 crash 文本信息。这样分析转储时只需要知道发布版本号就能直接到对应目录拿 PDB。symstore 的优势是自动索引和按需下载缺点是团队要理解它的工作方式自研引擎早期团队小透明目录往往更好排查问题。3.4 进阶DIA SDK 与自动化解析如果你需要把崩溃分析集成到自动流程里比如每天晚上自动批量解析新上报的 dmp把调用栈输出到网页后台那就绕不开 DIA SDK。微软的“调试接口访问” SDK 可以读取 PDB 中的符号和调试信息也能从转储文件和 PE 文件里提取模块信息。配合StackWalk64或新版StackWalkEx可以自己实现栈回溯引擎。这个方向实现成本不低但它能解决一个实际问题团队成员不用每个人都装 WinDbg、学调试命令后台就可以给出一个缩略版调用栈。我的建议是先把 cdb 手动流程跑通把 PDB 版本管理做好再考虑 DIA SDK。自动解析是锦上添花不是第一个要解决的痛点。手动流程的稳定性决定了后面自动化能不能成立。4. 把引擎状态和崩溃堆栈一起存下来上下文附加与进程外上报4.1 最朴素可靠的做法崩溃现场开一个同名 .txt转储文件记录的是进程底层的快照但它不会告诉你“当前处于哪个关卡”“玩家的血条是多少”“上一帧渲染命令提交到哪一步”。这些高层状态对同一类崩溃的定位非常有价值。最简单的方式是在 CrashFilter 里把引擎已经维护的日志尾部、当前场景名、最后执行的帧号、渲染设备信息等写到一个同名记事本文件里和 dmp 文件放同一目录static void WriteContextFile(const wchar_t* dumpPath) { wchar_t contextPath[MAX_PATH]; wsprintfW(contextPath, L%s.txt, dumpPath); HANDLE hFile CreateFileW(contextPath, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile INVALID_HANDLE_VALUE) return; // 这里去拿引擎全局状态比如日志循环缓冲区的最后 16KB const char* text GetEngineCrashContext(); DWORD bytesWritten 0; WriteFile(hFile, text, (DWORD)strlen(text), bytesWritten, nullptr); CloseHandle(hFile); }这种做法最大的优点是可控。日志循环缓冲是引擎本来就有的崩溃时只做一次内存拷贝和文件写入不需要额外网络请求不需要解析复杂结构。对自研引擎早期阶段来说这个 txt 文件很多时候比 dmp 文件还管用因为一眼就能看到最后几行日志和关键状态。4.2 想把上下文写进 dmp使用 MINIDUMP_USER_STREAM如果希望上下文信息跟随转储文件走不额外产生散落文件可以用 MiniDumpWriteDump 的UserStreamParam参数在调用时附加自定义数据块。它的基本原理是构造一个MINIDUMP_USER_STREAM数组每个元素包含一个 GUID 类型标识和缓冲区指针再放到MINIDUMP_USER_STREAM_INFORMATION里传给MiniDumpWriteDump。MINIDUMP_USER_STREAM userStream{}; MINIDUMP_USER_STREAM_INFORMATION userStreamInfo{}; char contextBuffer[8192]; FillEngineContext(contextBuffer, sizeof(contextBuffer)); userStream.Type 0x4d475354; // 自定义 GUID按项目自己约定 userStream.Buffer contextBuffer; userStream.BufferSize (ULONG)strlen(contextBuffer); userStreamInfo.UserStreamCount 1; userStreamInfo.UserStreamArray userStream;这些自定义流会作为一个独立 section 存在 dmp 文件里分析时可以用minidump.h里的接口枚举读取WinDbg 里也有命令读取未知流。好处是转储文件自包含坏处是你要自己实现一个读写格式而且很多现成工具默认不会展示这些自定义流。我的取舍是关键层级状态用 txt 文件底层寄存器、模块信息和原始调用栈用 dmp。两样都保留后期如果需要再合并。4.3 进程内写转储的局限堆被破坏、内存耗尽、栈被写爆兜底方案本身也可能遇到极端场景。堆被严重破坏时MiniDumpWriteDump 需要枚举进程虚拟内存、加载模块列表这些操作依赖系统堆和进程内部状态内存耗尽时写转储需要分配的文件缓冲、内部数据结构可能申请不到栈空间不足就是前面提过的栈溢出问题。所以我并不认同一味追求在崩溃进程里写一个“完美”的转储文件而是强调快速落盘、内容尽量完整但绝不纠缠。如果MiniDumpWriteDump返回失败至少确保日志文件写成功这就是崩溃现场的最后底线。更复杂一点的方案是进程外上报。游戏进程崩溃后不是自己继续干活而是往一个环境变量或命令参数里写入“转储文件路径”随后拉起另一个独立的小程序去收集文件、解析符号、上传。Unreal 的 CrashReportClient 就是这个思路。对小引擎团队来说第一版可以先不做进程外上报但一定要避免在崩溃处理里写大量业务逻辑因为那会显著增加二次崩溃概率。4.4 收集 GPU、驱动以及渲染设备丢失这类“半个崩溃”游戏引擎里有一类崩溃特别常见显卡驱动超时、设备丢失、导致 D3D 设备相关调用链崩溃。比如搜索引擎崩溃原因时经常看到类似 “unreal engine is exiting due to d3d device being lost” 的信息。这类问题往往不只是代码 bug还和显卡型号、驱动版本、显存占用、具体渲染命令的执行顺序有关。崩溃转储能记录到调用栈但如果缺少 GPU 信息和驱动版本分析起来往往差一口气。所以我在引擎全局配置里会记录一份“运行环境摘要”渲染 API 版本、显卡名称、驱动版本、显存大小、分辨率、垂直同步开关、画面质量档位。崩溃时用最快的字符串格式化写到上下文文件里。这样看到一份类似“NVIDIA 驱动版本 551.864K分辨率DLSS开启”的报告时排查方向会比只看调用栈清晰得多。另一个小技巧是如果引擎维护了命令列表提交队列可以在崩溃上下文里记录“最后提交的命令列表序号”“最后完成的帧号”配合 GPU 崩溃转储工具能定位得更远。5. 从崩溃发生到分析报告我在实际项目里反复踩过的坑5.1 转储文件写出来是 0 字节这是最常遇到、也最让人困惑的问题。代码看起来完全正确MiniDumpWriteDump 也返回了 TRUE但磁盘上的文件就是 0 字节。排查到最后发现CreateFileW 用的FILE_ATTRIBUTE_NORMAL没问题问题出在 crash 报告目录的权限或者目录本身不存在。尤其是用户以普通权限运行游戏时往 Program Files 安装目录里写文件可能被 UAC 拦截打开句柄失败但代码又没检查 CreateFileW 的返回值MiniDumpWriteDump 拿到一个 INVALID_HANDLE_VALUE 却没有立即报错。解决方式很直接创建报告目录时逐级 CreateDirectory写文件前检查句柄有效性并把“写转储失败”这一件事记录到系统事件日志里。另外目录路径尽量不要带空格或特殊字符避免后续自动化脚本解析时出现问题。5.2 CrashHandler 自己崩了二次崩溃保护崩溃处理代码里有静态变量修改、字符串格式化、文件写入任何一个环节再次触发访问违规系统都会再次进入 UnhandledExceptionFilter形成死循环。我在代码里用一个static bool g_isInCrash做保护但第一次写代码时把这个检查放在日志输出之后而不是函数入口的最前面结果依然崩了一次。正确做法是进入崩溃处理函数的入口立刻检查并置位标志。崩溃现场不可信任任何额外的函数调用、内存分配都可能让状态更糟所以保护标志必须最早执行。5.3 PDB 存下来但符号对不上时间戳和 GUID 的教训有一次构建机保留了一份 PDB但 WinDbg 加载后仍然显示*** WARNING: Unable to verify timestamp for ...。检查后发现构建机备份 PDB 时把文件名改了文件虽然还在但和转储文件里的模块 GUID 对不上。PDB 和可执行文件的匹配关系不是靠文件名而是靠文件内部记录的调试信息包括 GUID 和时间戳。所以请务必原样保留发布版本当时的 PDB不要做任何后处理、改名、重新编译。版本管理目录里最好记录每个版本的 exe/dll 哈希分析报错时可以快速确认是不是拿错了文件。另一个相关坑是增量编译即使代码没有改动如果把已经发布过的项目重新编译一次PDB 也有可能会变。对自研引擎来说每次打包 Release 时把 PDB 连同版本号一起归档是成本最低、收益最明显的动作。5.4 把“用户机器上的现场”和“本机复现”分开看拿到一个崩溃报告后很容易犯的错误是试图在开发机上直接复现。很多崩溃依赖特定的执行路径、输入序列、驱动环境复现不出来不代表不存在。转储文件的意义在于它帮你把“现场”带回来了你可以先静态分析看异常地址落在哪几个函数里看当前线程栈、看寄存器值、看崩溃附近的内存内容形成假设后再决定是否值得搭环境复现。把这两步分开会节省大量时间。5.5 重要的辅助信息崩溃发生时引擎处于哪个生命周期阶段我后来在上下文信息里加了一个很简单的字段引擎生命周期阶段。值包括Launcher,ConfigLoad,AssetLoad,WorldInit,GameLoop,RenderThread等。这个字段往往能第一时间缩小问题范围。比如大量崩溃都发生在AssetLoad阶段那么问题重心大概率在资源加载模块而不是游戏玩法代码。这个信息在 dmp 里看不出来需要引擎在代码里显式维护一个全局状态机崩溃时把它写进上下文文件。5.6 小引擎团队的分步验收清单如果你正准备给自己的引擎加崩溃转储可以参考下面的顺序逐项验收基础链路手动抛一个访问违规确认 dmp 文件能生成且非 0 字节。符号链路用录制的 PDB 分析 dmp确认能解析出函数名。版本链路换一个 Release 版本确认符号匹配、版本号正确。上下文链路确认日志尾部、场景名、GPU 信息等能写入上下文文件。特殊崩溃临时打开资源耗尽或栈溢出场景观察崩溃处理是否稳定。发布链路模拟用户目录权限受限的情况确认不会发生二次崩溃。这套清单测完之后才算建立起一个真正可用的崩溃转储工具链。我个人现在的习惯是引擎新功能上线前先把 crash handler 装好再把崩溃上下文里该有的状态字段补上。工具链这东西前期多花半天后期省出来的排查时间会是好几轮。尤其是当你发出一个版本后连续几天收到同一类转储文件按版本号和符号目录一套就能还原调用栈的时候你会觉得当初搭这套链路非常值当。