简介这是一份面向Windows C开发者的异常捕获库改造资源针对原生CrashRpt在多线程支持不足、只能捕获先加载模块异常等痛点借助微软开源Detours技术进行深度改造显著提升崩溃捕获效率适合需要排查客户环境偶发崩溃、难以复现问题的中高级开发者。压缩包共57个文件约13.04MB包含9个dll动态库、7个头文件、3个cpp源码及obj、pdb、lib等编译产物另有vcxproj、sln工程文件与ico、rc等资源文件并附带一个可运行的演示程序方便参照接入方式。目前已有283人学习下载。通过该库可在应用中安装异常捕获模块自动生成含上下文的dump文件配合Windbg事后分析尤其适用于多线程场景下难以复现的崩溃定位demo程序则直观展示了集成与使用流程。1. 崩溃捕获这件事为什么我最后选了 CrashRpt Detours 这套组合线上程序崩了日志里只有一行“程序已停止工作”没有调用栈、没有寄存器状态、没有现场内存这种局面每个 C 工程师都遇到过。Windows 平台自带的 MiniDump 机制虽然能生成 dump但默认行为太“安静”——它不会主动上报不会附带业务上下文更不会在崩溃瞬间帮你把关键线程的栈回溯出来。我试过纯手工调SetUnhandledExceptionFilter加MiniDumpWriteDump代码不到两百行就能跑通可一旦遇到多线程竞争、DLL 卸载后的符号丢失、或者异常发生在堆破坏之后这套裸方案基本就废了。CrashRpt 这个开源库解决的就是“捕获—组装—上报”这条链路。它把未处理异常过滤、dump 生成、崩溃报告压缩打包、HTTP 上传这些环节都封装好了还带一个可选的崩溃对话框。但原版 CrashRpt 有个硬伤它依赖SetUnhandledExceptionFilter来兜底而很多异常在到达这个过滤器之前就已经把进程状态搅乱了。Detours 是微软开源的 API 挂钩库能在运行时修改函数入口指令把关键函数“劫持”到自己的处理逻辑里。把 Detours 引进来就是为了在更早的时机截住异常而不是等系统最后一道防线。这套改造后的异常捕获库适合两类人一是正在维护 Windows 桌面端 C 产品、被线上崩溃率折磨的开发者二是想研究异常捕获底层机制、需要一套可编译可调试源码的进阶学习者。它不解决“崩溃为什么发生”的业务逻辑问题但它能保证崩溃发生时你手里有一份足够完整的现场快照。下面我从工程落地角度把编译、集成、挂钩点选择、上报配置和踩坑记录拆开讲。2. 把 CrashRpt 和 Detours 编进同一个工程依赖、配置与挂钩点选择2.1 为什么不是直接用原版 CrashRpt原版 CrashRpt 的异常捕获入口是CrashRpt::Install()内部调用SetUnhandledExceptionFilter注册回调。这个机制在单线程、简单异常场景下够用但遇到以下情况就会漏异常发生在非主线程且该线程没有自己的异常处理器异常类型是EXCEPTION_BREAKPOINT或EXCEPTION_SINGLE_STEP这类调试相关异常进程已经处于“半死”状态堆锁未释放MiniDumpWriteDump内部再分配内存直接二次崩溃。Detours 的价值在于它允许我们把SetUnhandledExceptionFilter、RaiseException、甚至KiUserExceptionDispatcher这类底层函数挂钩在异常进入系统默认处理流程之前就介入。常见做法是用 Detours 挂钩SetUnhandledExceptionFilter确保无论谁后来调用它最终注册的都是我们自己的过滤器同时挂钩RaiseException在异常抛出瞬间记录线程 ID 和异常地址为后续 dump 补充上下文。2.2 编译环境与依赖准备这套库的源码包通常包含 CrashRpt 核心静态库、Detours 静态库、一个示例 MFC 或 Win32 工程以及预编译的第三方依赖zlib、libcurl 等。我一般会先确认三件事检查项要求说明Visual Studio 版本VS2015 及以上Detours 对 VS2013 支持不完整VS2019/2022 需注意工具集版本Windows SDK10.0.17763 及以上低版本 SDK 缺少部分异常结构定义字符集UnicodeCrashRpt 默认按宽字符处理路径和报告字段运行库/MT 或 /MD 保持一致Detours 静态库和主工程必须用同一运行库选项如果源码包里带了.sln直接打开后先别急着编译。右键每个项目检查“属性 → C/C → 代码生成 → 运行库”确保 CrashRpt、Detours、主程序三者一致。我见过太多人因为 Detours 用/MT、主程序用/MD链接时报LNK2005重复定义然后花半天查符号冲突。2.3 用 Detours 挂钩异常入口的代码骨架下面这段代码展示如何在main或WinMain之前完成 Detours 挂钩把SetUnhandledExceptionFilter替换成自己的版本。注意 Detours 的DetourTransactionBegin/DetourUpdateThread/DetourAttach三步必须成对出现且要在同一线程内完成。#include windows.h #include detours.h #include CrashRpt.h // 保存原始函数指针 static LPTOP_LEVEL_EXCEPTION_FILTER (WINAPI *TrueSetUnhandledExceptionFilter)( LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter) SetUnhandledExceptionFilter; // 自定义过滤器先让 CrashRpt 处理再决定是否交给系统 static LONG WINAPI MyUnhandledExceptionFilter(EXCEPTION_POINTERS* pExInfo) { // 记录异常地址和线程 ID写入 CrashRpt 的自定义属性 CrAutoInstallHelper::AddProperty(_T(ExceptionAddress), _T(%p), pExInfo-ExceptionRecord-ExceptionAddress); CrAutoInstallHelper::AddProperty(_T(ThreadId), _T(%u), GetCurrentThreadId()); // 调用 CrashRpt 的异常处理逻辑 return CrashRpt::ExceptionFilter(pExInfo); } // 挂钩安装函数 bool InstallHooks() { DetourTransactionBegin(); DetourUpdateThread(GetCurrentThread()); DetourAttach((PVOID)TrueSetUnhandledExceptionFilter, MyUnhandledExceptionFilter); LONG ret DetourTransactionCommit(); return (ret NO_ERROR); } // 挂钩卸载函数程序退出前调用 void UninstallHooks() { DetourTransactionBegin(); DetourUpdateThread(GetCurrentThread()); DetourDetach((PVOID)TrueSetUnhandledExceptionFilter, MyUnhandledExceptionFilter); DetourTransactionCommit(); }逻辑说明DetourAttach把SetUnhandledExceptionFilter的入口指令改成跳转到MyUnhandledExceptionFilter同时把原始函数地址保存到TrueSetUnhandledExceptionFilter。这样即使后续有代码调用SetUnhandledExceptionFilter注册别的过滤器实际生效的仍然是我们的版本。参数(PVOID)TrueSetUnhandledExceptionFilter是 Detours 要求的指针引用形式不能直接传函数指针。参数说明DetourTransactionBegin开始一个挂钩事务DetourUpdateThread把当前线程加入事务DetourAttach执行挂钩DetourTransactionCommit提交。如果Commit返回非NO_ERROR说明挂钩失败常见原因是目标函数已经被其他挂钩库修改过或者地址无效。2.4 集成 CrashRpt 的初始化顺序挂钩装好之后再调用 CrashRpt 的安装函数。顺序不能反先挂钩再CrashRpt::Install否则 CrashRpt 内部调用SetUnhandledExceptionFilter时会被我们的挂钩截住导致它自己的过滤器注册失败。int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow) { // 1. 先安装 Detours 挂钩 if (!InstallHooks()) { OutputDebugString(_T(Detours hook install failed.\n)); return -1; } // 2. 再安装 CrashRpt CR_INSTALL_INFO info { 0 }; info.cb sizeof(CR_INSTALL_INFO); info.pszAppName _T(MyApp); info.pszAppVersion _T(1.0.0); info.pszEmailSubject _T(MyApp Crash Report); info.pszEmailTo _T(crashexample.com); info.uMiniDumpType MiniDumpNormal | MiniDumpWithDataSegs | MiniDumpWithHandleData | MiniDumpWithThreadInfo; info.dwFlags CR_INST_ALL_POSSIBLE_HANDLERS | CR_INST_APP_RESTART | CR_INST_SEND_QUEUED_REPORTS; info.pszErrorReportSaveDir _T(.\\crash_reports); CrAutoInstallHelper crHelper(info); if (crHelper.m_nInstallStatus ! 0) { OutputDebugString(_T(CrashRpt install failed.\n)); UninstallHooks(); return -1; } // 3. 正常业务逻辑 int ret RunApplication(); // 4. 退出前卸载挂钩 UninstallHooks(); return ret; }逻辑说明CrAutoInstallHelper是 CrashRpt 提供的 RAII 封装构造时安装析构时卸载。dwFlags里CR_INST_ALL_POSSIBLE_HANDLERS让 CrashRpt 尽可能多地注册异常处理器CR_INST_APP_RESTART支持崩溃后重启CR_INST_SEND_QUEUED_REPORTS会在下次启动时尝试发送上次未发送成功的报告。参数说明uMiniDumpType决定 dump 里包含哪些内存区域。MiniDumpNormal只包含线程栈和基本模块信息体积小但信息有限加上MiniDumpWithDataSegs会包含全局变量区MiniDumpWithHandleData包含句柄表MiniDumpWithThreadInfo包含线程时间信息。如果崩溃涉及堆破坏还需要加MiniDumpWithFullMemory但 dump 体积会膨胀到几百 MB慎用。3. 崩溃报告生成与上报链路从 dump 到 HTTP 上传的完整配置3.1 dump 类型选择与体积控制CrashRpt 默认生成的 dump 是MiniDumpNormal只包含线程栈和模块列表。对于大多数空指针、数组越界、纯虚函数调用这类异常MiniDumpNormal已经足够定位到函数和行号。但如果崩溃原因是堆破坏、内存踩踏MiniDumpNormal里看不到被破坏的内存内容这时候需要升级 dump 类型。我一般按场景分三档日常线上版本MiniDumpNormal | MiniDumpWithDataSegs | MiniDumpWithThreadInfo体积通常在 210 MB灰度或内测版本再加MiniDumpWithHandleData | MiniDumpWithUnloadedModules体积 1030 MB专门排查内存问题时MiniDumpWithFullMemory体积可能超过 500 MB必须配合上传限速和本地保留策略。在CR_INSTALL_INFO里设置uMiniDumpType后CrashRpt 会在生成 dump 时按位或组合传给MiniDumpWriteDump。注意MiniDumpWithFullMemory不能和MiniDumpWithDataSegs同时用后者会被前者覆盖。3.2 自定义属性把业务上下文塞进报告光有 dump 还不够很多时候你需要知道崩溃时用户正在操作哪个模块、哪个订单号、哪个文件路径。CrashRpt 提供了CrAutoInstallHelper::AddProperty和CrashRpt::AddProperty两套接口前者在安装后即可调用后者需要在异常处理回调里调用。// 在业务逻辑中随时添加自定义属性 void OnUserAction(const CString strAction, const CString strOrderId) { CrAutoInstallHelper::AddProperty(_T(LastAction), strAction); CrAutoInstallHelper::AddProperty(_T(OrderId), strOrderId); CrAutoInstallHelper::AddProperty(_T(Timestamp), _T(%I64u), GetTickCount64()); } // 在异常过滤器里追加寄存器信息 static LONG WINAPI MyUnhandledExceptionFilter(EXCEPTION_POINTERS* pExInfo) { CONTEXT* pCtx pExInfo-ContextRecord; CrAutoInstallHelper::AddProperty(_T(EIP), _T(%p), pCtx-Eip); CrAutoInstallHelper::AddProperty(_T(ESP), _T(%p), pCtx-Esp); CrAutoInstallHelper::AddProperty(_T(EBP), _T(%p), pCtx-Ebp); return CrashRpt::ExceptionFilter(pExInfo); }逻辑说明AddProperty的第二个参数是格式化字符串第三个参数是可变参数。CrashRpt 会把这些键值对写入崩溃报告的 XML 描述文件上传时一并发送。注意AddProperty不是线程安全的如果多个线程同时调用需要自己加锁否则可能出现键值对错乱。参数说明%p用于指针%u用于无符号整数%I64u用于 64 位无符号整数。CrashRpt 内部使用_vsntprintf系列函数格式串必须与参数类型匹配否则会输出乱码或截断。3.3 HTTP 上传配置与失败重试CrashRpt 支持通过 HTTP POST 把崩溃报告上传到指定 URL。配置项在CR_INSTALL_INFO里info.pszUrl _T(http://your-server.com/crash/upload); info.pszProxy _T(); // 如有代理填写代理地址 info.pszProxyBypass _T(); info.pszEmailTo _T(crashexample.com); info.pszEmailSubject _T(Crash Report); info.dwFlags | CR_INST_SEND_QUEUED_REPORTS; info.pszErrorReportSaveDir _T(.\\crash_reports);上传失败时CrashRpt 会把报告压缩包留在pszErrorReportSaveDir目录下下次启动时如果CR_INST_SEND_QUEUED_REPORTS标志存在会自动重试发送。我一般还会在程序启动时检查该目录如果积压超过 50 个报告就弹窗提示用户手动清理避免磁盘被撑满。服务端接收端需要处理 multipart/form-data 格式CrashRpt 上传的字段包括crashrpt报告压缩包和xml描述文件。常见做法是用 Nginx 加一个上传模块或者用 Python Flask 写一个接收接口把文件按时间戳和 MD5 命名后存盘。3.4 符号服务器与事后调试dump 文件本身不包含符号信息要用 WinDbg 或 Visual Studio 分析必须配置符号路径。我一般会在构建机上把每次发布的 PDB 文件按版本号归档然后在分析机上设置_NT_SYMBOL_PATH指向归档目录和微软公共符号服务器。set _NT_SYMBOL_PATHsrv*D:\symbols*http://msdl.microsoft.com/download/symbols;D:\builds\MyApp\1.0.0\pdb分析时用 WinDbg 打开 dump执行!analyze -v自动分析再配合kb看调用栈、dv看局部变量、!heap看堆状态。如果栈回溯显示MyApp.exe0x12345这种偏移地址说明符号没加载成功检查 PDB 路径和版本是否匹配。4. 避坑与排查Detours 挂钩失效、dump 生成失败、上报超时的血泪记录4.1 挂钩后程序启动就崩报 0xC0000005现象集成 Detours 后程序一启动就访问违例异常地址在DetourAttach内部。原因Detours 挂钩需要修改目标函数入口的机器码如果目标函数位于只读内存页或者被其他安全软件保护写入会失败。另外如果DetourTransactionBegin和DetourAttach不在同一线程调用事务状态会错乱。解决确保挂钩操作在main或WinMain最开始、任何其他线程创建之前完成。如果目标函数在只读段先用VirtualProtect把页面改成PAGE_EXECUTE_READWRITE挂钩完成后再改回。我一般还会在DetourAttach前后加OutputDebugString输出返回值确认每一步都成功。4.2 dump 文件生成成功但 WinDbg 打不开现象崩溃报告目录下有.dmp文件但 WinDbg 打开时报“文件格式无效”或“符号不匹配”。原因最常见的是 dump 文件在写入过程中被截断比如磁盘空间不足、进程在MiniDumpWriteDump执行到一半时被强制终止。另一个原因是 CrashRpt 生成的 dump 被压缩包包裹解压时损坏。解决检查pszErrorReportSaveDir所在磁盘剩余空间至少保留 1 GB。如果 dump 文件大小异常小比如只有几 KB说明写入失败查看 CrashRpt 日志文件crashrpt.log里的错误码。解压时用 7-Zip 而不是 Windows 自带解压后者对大文件支持不好。4.3 上报一直超时服务端收不到数据现象崩溃对话框显示“正在发送报告”然后卡住最后提示发送失败。原因CrashRpt 默认超时时间较短如果网络状况差或服务端响应慢就会超时。另外如果程序在崩溃后网络模块已经不可用上传也会失败。解决在CR_INSTALL_INFO里没有直接设置超时的字段但可以通过修改 CrashRpt 源码里的CR_HTTP_TIMEOUT宏来调整默认是 30000 毫秒我一般改成 60000。如果崩溃后网络确实不可用依赖CR_INST_SEND_QUEUED_REPORTS在下次启动时补发。服务端要确保接收接口不限制上传大小Nginx 默认client_max_body_size是 1 MB必须调大。4.4 多线程同时崩溃导致报告丢失现象程序多个线程同时触发异常最终只生成了一份报告或者报告内容混乱。原因CrashRpt 的异常处理逻辑不是完全可重入的多个线程同时进入ExceptionFilter会竞争全局状态。解决在自定义异常过滤器里加一个原子标志确保只有一个线程执行 dump 生成和上报其他线程直接挂起或等待。常见做法是用InterlockedCompareExchange设置一个volatile LONG标志第一个进入的线程继续后续线程调用Sleep(INFINITE)挂起。4.5 发布版本崩溃调试版本正常现象Debug 编译的程序崩溃能正常捕获Release 版本要么不生成 dump要么 dump 里调用栈全是问号。原因Release 版本默认开启优化函数内联、帧指针省略/Oy会导致栈回溯困难。另外Release 版本的 PDB 文件如果没归档事后无法解析符号。解决在 Release 配置里关闭“省略帧指针”项目属性 → C/C → 优化 → 省略帧指针 → 否同时保留 PDB 文件并归档。如果用了 LTCG还要在链接器里设置“生成调试信息”为“是”否则 PDB 里没有函数地址映射。5. 进阶技巧用 Detours 挂钩RaiseException补全异常现场5.1 为什么还要挂钩RaiseExceptionSetUnhandledExceptionFilter只在异常“未被处理”时触发但很多异常在到达它之前已经被__try/__except或 C 的catch处理掉了。如果你想知道这些“被吞掉”的异常发生在哪里就需要在更早的环节挂钩。RaiseException是 Windows 里所有异常抛出的必经之路无论是throw、__debugbreak还是访问违例最终都会调用它。挂钩RaiseException后你可以在异常抛出的第一时间记录异常代码、异常地址、线程 ID甚至把调用栈抓下来存到自定义属性里。这样即使异常后来被业务代码捕获你仍然知道它发生过。5.2 挂钩RaiseException的代码实现static void (WINAPI *TrueRaiseException)( DWORD dwExceptionCode, DWORD dwExceptionFlags, DWORD nNumberOfArguments, const ULONG_PTR* lpArguments) RaiseException; static void WINAPI MyRaiseException( DWORD dwExceptionCode, DWORD dwExceptionFlags, DWORD nNumberOfArguments, const ULONG_PTR* lpArguments) { // 过滤掉调试器相关的异常避免干扰正常调试 if (dwExceptionCode ! EXCEPTION_BREAKPOINT dwExceptionCode ! EXCEPTION_SINGLE_STEP) { // 抓取当前调用栈 void* stack[32] { 0 }; USHORT frames CaptureStackBackTrace(0, 32, stack, NULL); // 把栈帧地址拼成字符串写入 CrashRpt 属性 CString strStack; for (USHORT i 0; i frames; i) { CString strFrame; strFrame.Format(_T(%p;), stack[i]); strStack strFrame; } CrAutoInstallHelper::AddProperty(_T(RaiseExceptionStack), strStack); CrAutoInstallHelper::AddProperty(_T(RaiseExceptionCode), _T(0x%08X), dwExceptionCode); } // 继续调用原始函数让异常正常传播 TrueRaiseException(dwExceptionCode, dwExceptionFlags, nNumberOfArguments, lpArguments); }逻辑说明CaptureStackBackTrace是 Windows 提供的轻量级栈回溯函数不需要符号就能拿到返回地址数组。把这些地址存进 CrashRpt 属性后事后用addr2line或 WinDbg 的ln命令批量解析就能还原出异常抛出时的调用路径。注意RaiseException会被频繁调用属性字符串不能太长我一般限制在 32 帧以内。参数说明dwExceptionCode是异常代码比如0xC0000005表示访问违例0xE06D7363表示 C 异常。dwExceptionFlags里EXCEPTION_NONCONTINUABLE表示异常不可继续。lpArguments对 C 异常来说指向_EXCEPTION_RECORD结构里面包含异常对象信息。5.3 验证挂钩是否生效挂钩装好后写一个简单的测试函数主动抛异常然后检查 CrashRpt 报告里有没有RaiseExceptionStack字段。void TestRaiseExceptionHook() { __try { RaiseException(0x12345678, 0, 0, NULL); } __except (EXCEPTION_EXECUTE_HANDLER) { // 异常被捕获但挂钩应该已经记录了现场 } }运行后打开crash_reports目录下最新报告的 XML 文件搜索RaiseExceptionStack如果能看到一串地址说明挂钩生效。如果字段为空检查DetourAttach的返回值以及MyRaiseException是否被编译器内联或优化掉——挂钩函数必须加WINAPI和__declspec(noinline)否则 Detours 找不到正确的函数入口。5.4 我踩过的一个符号解析坑有一次线上崩溃dump 里RaiseExceptionStack记录的地址全是0x00007FF...开头但用 WinDbg 解析时提示“地址不在任何模块内”。查了半天发现这些地址是 JIT 编译生成的代码不在任何 PE 模块的地址范围内。后来我在挂钩函数里加了VirtualQuery调用把每个栈帧地址对应的内存区域基址和大小也记录下来才定位到是某个脚本引擎动态生成的代码段出了问题。从那以后我每次集成这套库都会在测试阶段强制走一遍“主动抛异常 → 检查报告字段 → 用 WinDbg 解析栈帧”的流程确认挂钩和符号链路都正常再发版本。希望这套组合拳能帮你少走点弯路。本文还有配套的精品资源点击获取