简介madExcept 5 FS 是一套专为 Delphi 开发者设计的异常处理组件源码版用于捕获程序运行中的各类异常、生成堆栈跟踪信息并提供错误报告能够与 Delphi IDE 无缝集成适合需要快速定位 Bug、提升软件稳定性的中高级开发场景。压缩包共 77.07MB包含 3509 个文件其中有 pas 源码、dfm 窗体、dpk/dproj 工程文件等可供直接阅读修改的开发素材也有 dcu 编译单元、bpl 运行包、lib 静态库以及大量 h/hpp 头文件等编译产物兼顾源码学习与实际使用并附有安装与测试方法说明文档。目前已有 424 人学习下载。借助 FullSource 特性使用者能深入剖析异常捕获、堆栈解析与错误报告生成等内部实现也可根据项目需求自定义组件行为丰富的平台支持文件有助于适配不同 Delphi 版本对想研究异常处理机制或扩展组件功能的开发者是一部实用的参考。1. madExcept 5 FS先于崩溃发生的那一次拦截这几年在群里帮人看闪退报告见得最多的不是“弹了错误框”而是“程序无声无息就没了”。日志停在最后一条业务操作Windows 事件查看器里只有一句 Access violation地址是0x0041AB2连哪个函数都没写。madExcept 5 FS 就是冲这个痛点去的。FS 在 madExcept 体系里指 First Chance 异常处理不等程序走到未处理异常、被系统强制终止的 Second Chance 阶段而是在异常对象刚抛出的那一刻先拦一道把调用栈、寄存器、线程现场全部拍下来再根据你的策略决定是吞掉、记录还是放行。适合那些线上闪退不可复现、客户端反复被卸载、DLL 边界出问题又背黑锅的 Delphi/CBuilder 项目。这一篇直接讲怎么把它接进工程、怎么把报告变成能定位问题的证据链。2. 为什么 FS 意味着“第一机会”异常处理链路与选型2.1 First Chance 与 Second Chance同一场事故的两个时间点Windows 的异常分发机制分两轮。第一轮叫 First Chance异常抛出后系统先让进程内的顶层异常处理器Top-Level Exception Handler过目这时候调用栈还是完整的、局部变量还没被清理、异常源地还没被栈展开破坏是拿现场证据的黄金窗口。如果没有任何处理器决定拦截系统进入第二轮 Second Chance按默认规则终止进程这时再挂接的捕获逻辑拿到的栈已经部分失效甚至只剩一个地址。madExcept 5 FS 做的事就是在第一轮里插入自己的处理器。它先把异常链上的上下文全部记录成结构化报告然后问你要不要把这个异常“消化”掉。这个机制的价值在于传统 try-except 只能抓到当前线程当前代码块里的异常一旦异常跨过模块边界、被某个框架层吞了或者发生在后台线程业务层根本感知不到FS 模式是从异常发生的源头直接接住不依赖你在每一处业务代码里手动包保护层。FS这个后缀对应的就是这一整套 First Chance 采集与分发能力而不是某个独立 IDE 插件。理解这一点很关键你装进工程的是一个异常采集框架配置的灵活性核心在回调函数的请求与放行逻辑上。2.2 什么项目值得上 madExcept 5 FS三条选型判据不是所有项目都需要把崩溃报告做成产品级基础设施。团队小、发布频率低、用户量小到崩溃日志可以靠用户口述的项目直接套个 showmessage 也能撑。如果你正在做以下三类项目之一madExcept 5 FS 就值得投进去第一类是面向大量终端用户的工具软件或业务客户端用户环境五花八门缺 DLL、显卡驱动冲突、输入法注入都可能触发崩溃复现只能靠现场报告。第二类是含多线程、插件化或依赖第三方 DLL 的系统崩溃线程栈必须追溯到模块边界。第三类是对质量和响应速度有要求的项目领导要的是“这周修复率提升了多少”而不是“我们还在找复现步骤”。从我接触的项目看还有一个隐性的判断条件项目是否有持续集成环境。madExcept 5 FS 给出的报告质量取决于符号文件与构建产物的对应关系临时编译一次就想靠它定位效果会打五折这点后面第 4 章会展开。2.3 把 madExcept 5 挂进工程的最小路径常见做法是通过 IDE 的组件安装机制把 madExcept 编译进设计环境里然后在工程文件中手动引入两个运行时单元。最小改动量下你的工程文件末尾会变成这个样子program KClient; uses Vcl.Forms, madExcept in ..\madExcept\madExcept.pas, madExceptHook in ..\madExcept\madExceptHook.pas, KMainForm in KMainForm.pas {MainForm}; {$R *.res} begin Application.Initialize; Application.CreateForm(TKMainForm, MainForm); Application.Run; end.madExcept单元提供异常报告生成与接口定义madExceptHook负责在初始化阶段把 FS 处理器注册进系统异常分发链。把这两个单元放在 uses 列表靠前的位置是为了保证它们的初始化代码先于你的业务单元执行否则在异常处理器注册完成之前发生的崩溃依然会漏网。工程文件改完后重新编译并运行一次主动制造一个除零异常试试。默认配置下madExcept 5 会弹出它自带的崩溃报告对话框里面有完整的调用栈和异常类型。若弹不出来先确认这两个单元确实被编进dcu输出目录再确认 IDE 里没启用“忽略特定异常类型”的过滤设置。这一步跑通之后我们再谈 FS 模式的自定义回调。3. 配置 FS 模式从默认弹窗到自定义处理回调3.1 用全局 Hook 接管 First Chance 异常madExcept 5 默认行为是拦截未处理异常并显示报告对话框但这不适合所有场景发布版客户端不该把技术报告直接怼在用户脸上而且有些异常是“可控但需要被记录”的比如文件被占用、网络瞬断、配置项格式错误。FS 模式给了你一个注册全局回调的入口让每个异常在第一时间先经过你的代码。常见做法是调用RegisterExceptionHandler它的声明如下function RegisterExceptionHandler( Handler: TExceptionHandler; ThreadMode: TThreadMode; Event: TExceptionEvent): Integer;回调函数的实现听起来抽象落地下来一般长这个样子function MyFSHandler(const exceptIntf: IMEException; out handled: Boolean): Boolean; var report: TStringList; begin // First Chance 阶段handled 输出参数决定异常是否被吞掉 if exceptIntf.ExceptionClass EInOutError then handled : True // 文件写入类异常记录但不崩 else handled : False; // 其他异常照常抛出进入 Second Chance report : TStringList.Create; try report.Text : exceptIntf.BugReport; report.SaveToFile(D:\logs\crash_ FormatDateTime(yyyymmdd_hhnnss, Now) .txt); finally report.Free; end; Result : True; end;代码里的handled是最容易理解错的参数。设为True表示这个异常已经被你处理了系统不会再向上抛程序继续往下跑设为False表示你只是“看了一眼”并记录了现场异常继续正常传播流程。Result表示是否接管默认的 madExcept 报告界面——返回True时你自己写了文件就要关掉它的默认弹窗否则用户会看到两层输出。注册这回调的最佳位置是工程文件begin块的最前面或者主窗体OnCreate里只注册一次initialization RegisterExceptionHandler(MyFSHandler, stDontSync, epMainThread); end.stDontSync表示回调不主动做线程同步适合只做内存操作和文件写入的轻量逻辑若你在回调里要访问 VCL 控件或调用需要消息循环的代码改成stSync会更稳代价是可能阻塞当前线程一小段时间。epMainThread限定只在主线程上触发回调后台线程崩溃则会进入默认流程对全局记录需求更强的项目我一般会注册两个回调主线程一个工作线程一个分别处理。3.2 上下文信息让崩溃报告自带现场崩溃报告如果只有调用栈你只能知道“在哪个函数崩了”不知道“用户当时在做什么”。madExcept 的接口里暴露了一些上下文属性但更可靠的业务上下文还得靠自己在回调里补充。我一般会在回调里读取一个全局对象的状态把它拼进报告文件function MyFSHandler(const exceptIntf: IMEException; out handled: Boolean): Boolean; var lines: TStringList; ctx: TKContextInfo; begin ctx : TKContextInfo.GetInstance; lines : TStringList.Create; try lines.Add(UserID ctx.UserID); lines.Add(CurrentForm ctx.CurrentFormName); lines.Add(NetworkState ctx.NetworkState); lines.Add(LastAction ctx.LastActionLog); lines.Add(--- bug report start ---); lines.Add(exceptIntf.BugReport); lines.SaveToFile(ctx.LogDir fs_ FormatDateTime(yyyymmdd_hhnnss, Now) .log); finally lines.Free; end; handled : False; // 只记录不吞异常 Result : True; end;把业务上下文放在LastActionLog里是个好习惯。很多闪退是特定操作序列触发的比如“上传图片后立即切换账套”如果你有操作埋点报告里就能直接看到最后一步动作。这个变量每执行一个关键操作就覆盖写入一次不用记全量日志避免文件体积失控。3.3 报告内容与文件落盘的参数取舍FS 回调里最忌讳的事情是“什么都往报告里塞”。调用栈、线程列表、模块列表、CPU 寄存器、系统信息全部打进去一份报告能到几百 KB高频崩溃时磁盘和网络都会被拖死。我的经验是分两个级别一种是把BugReport完整落盘保留所有细节仅限开发版或内测版开启另一种是发布版只记录异常类型、异常地址、模块名、调用栈前十帧和业务上下文。后者的信息量足够定位 80% 的崩溃文件体积控制在 10 KB 以内上传快、干扰少。落盘目录要选用户有写权限的路径不要硬编码D:\logs否则用户以普通权限运行时报告根本写不进去。用GetEnvironmentVariable(LOCALAPPDATA)拼一个KClient\CrashReports目录是可移植且安全的选择。目录不存在就创建创建失败则退回到临时目录这层容错必须做因为异常回调本身就是程序处于不稳定状态不要再让文件系统问题掩盖真正要排查的事故。4. 根据崩溃报告定位问题栈回溯、线程现场与符号文件4.1 读懂 BugReport 的关键字段madExcept 5 生成的报告结构大致固定我一般在排障时只盯着几个栏位看表格字段含义定位价值Exception Class异常类型如 EAccessViolation判断是逻辑问题还是系统级内存问题Exception Message异常附带信息经常直接写着越界下标或文件名Exception Address崩溃指令所在的模块偏移结合符号表换算成函数名Call Stack从崩溃点向外的调用链解决“哪条路径调到这里”的问题Threads所有线程的栈快照排查多线程协作导致的崩溃Modules加载的模块列表及版本排查 DLL 冲突、补丁不一致报告里最骗人的是Exception Message为空的 Access Violation。地址直接指向某个系统 DLL 内部看起来像第三方库的锅但顺着Call Stack看前两条栈帧往往会回到你自己的消息处理函数。栈回溯是理解崩溃路径的主线索而不是只看落点地址。4.2 符号表与版本映射让地址变函数名崩溃日志里如果只有00401234这种裸地址对现代项目来说基本等于废纸。madExcept 要把地址解析成函数名依赖编译时生成的调试符号。Delphi/CBuilder 的链接器选项里一般提供 map 文件的开关把详细模式打开后编译器会输出完整的映射关系表madExcept 在崩溃时查找该表即可还原函数名。工程交付时记得把每次构建的 map 文件或调试符号与 exe 一并归档。这里的规矩是报告里的模块版本号和构建号必须能对应到唯一一份符号文件。我习惯在工程里加一个构建版本常量并定义到资源或全局变量里const BUILD_ID 20250607_1300_trunk_4a3f2c;回调里把这个BUILD_ID写进报告头部归档符号文件时文件名也带同一个BUILD_ID。线上反馈回来看报告里的构建号找符号文件几秒钟就能对上。4.3 一个真实定位流程的三步走第一步先把报告里的Exception Address和Call Stack前三条记录抄出来。第二步用报告里的BUILD_ID找到对应的 map 文件把裸地址换算成函数名这一步 madExcept 报告里通常已经替你做了——前提是发布构建时 map 文件没有被剥离。第三步打开源码定位到那个函数重点看这几点是否有对空对象解引用、是否有无符号整数比较、是否有回调函数在跨模块传递时被错误约定调用。碰到Call Stack本身就残废的报告比如只在崩溃点有一条记录后面全是Unknown大概率是优化编译把栈帧给省略了或者你的 hook 触发在异常链已经展开之后。这时候不要硬解地址转而看Modules栏位确认崩溃模块是否带调试版本号再检查编译器优化选项。报告里那份Threads列表也别忽略。很多“偶尔崩一次”的现场主线程栈是正常的反而是某个工作线程停在了释放对象的代码上。如果团队里多线程代码量大且频繁 new/free 对象FS 报告里带出线程栈会省掉你挂调试器复现一下午的时间。5. 避坑madExcept 5 FS 集成中常见的五个翻车现场5.1 现象一DLL 崩溃只出现“外部地址”现象主程序用 madExcept加载的一个业务 DLL 里崩了报告里只显示Unknown module和一个裸地址无法换算函数名。原因DLL 工程没有编译 madExcept 相关单元主程序拿不到 DLL 的符号表另外系统异常分发经过模块边界时DLL 里的栈帧格式不会被主程序的 hook 完整解析。解决给 DLL 工程也加上 madExcept 相关单元并开启同样的符号输出配置。发布时确认 DLL 的 map 文件与 DLL 本体同版本归档。若 DLL 是第三方且无符号文件则在报告里至少记录模块名与版本号反向联系厂商时也算有据可查。5.2 现象二FS 回调里写文件导致递归崩溃现象异常发生后回调函数里写日志日志文件被占用或无权限写文件操作又抛出异常madExcept 再次进回调进入无限递归程序彻底卡死。原因回调函数本身没有做异常隔离。FS 回调运行在系统的异常分发链上任何一环再抛异常都会重新进入分发入口。解决回调函数整体包一层裸 try-except任何异常都吞掉确保回调自己永远不抛文件写入前先判断目录可写写不进去就放弃绝不让日志行为本身变成新的事故源头function MyFSHandler(const exceptIntf: IMEException; out handled: Boolean): Boolean; begin try // 原有逻辑 except // 记录回调失败次数不再做任何操作 end; handled : False; Result : True; end;5.3 现象三优化编译后栈回溯对不上行号现象用户现场报告的调用栈能列出函数名但点开源码行号指向的位置和实际崩溃逻辑明显不对。原因编译器开了内联和帧指针省略优化某些栈帧被优化合并madExcept 依据静态符号表换算出的行号是近似值不是精确落点。解决定位问题时把函数名当作线索而非最终答案重点看崩溃点所在函数整体逻辑。对怀疑有问题的单元单独关闭优化重新编译做一次定向对比。这不是 madExcept 的 bug是符号还原的固有精度问题理解为它给的是“案发附近区域”而不是“精确坐标”就能少走弯路。5.4 现象四报告发送阻塞主线程二次锁死现象加了 FS 回调后崩溃现场偶尔变成“卡死不动”任务管理器显示进程无响应。原因回调里直接发了 HTTP 请求把崩溃报告传给服务端。崩溃发生时主线程状态本身不稳定同步网络请求一直等超时界面消息循环被阻塞用户感知为假死。解决回调内不要做任何网络操作只负责落盘。程序下次正常启动时扫描崩溃报告目录用后台线程统一上传上传成功再删文件。这个模式也叫本地落地加延迟上报是客户端崩溃采集的常见做法。5.5 现象五打包时机不对报告里全是“Unknown”现象测试机上报的报告完全正常发布给用户后全变成Unknown function和裸地址。原因发布包使用 Release 配置编译且关闭了 map 文件输出madExcept 运行时没有任何符号可查。解决发布配置里也保留 Detailed map 文件生成只是不给最终用户分发而已——直接把 map 文件归档到构建服务器内部存储。同时确认构建机上没有开启“生成轻量调试信息”之类的减配选项。这个问题在快速迭代的小团队里几乎每个月都能踩到一次属于典型的因为“发布要精简”而误删关键排障数据。6. 进阶把崩溃报告变成持续可用的工程质量数据6.1 用版本号与构建 Mark 给报告打标早期接入 madExcept 时我见过一个项目报告文件存了几千个命名全是crash_20250501_230011.log解析时根本分不清版本。后来我们做了一件事把构建号、分支名、编译时间全部塞进报告文件名和文件头排序和归因一下就清楚了。function BuildReportFileName: string; var tag: string; begin tag : Format(%s_%s_%s, [BUILD_ID, APP_VERSION, COMPILE_TIME]); Result : GetEnvironmentVariable(LOCALAPPDATA) \KClient\CrashReports\ tag _ FormatDateTime(yyyymmdd_hhnnss, Now) .txt; end;发布后每天扫一次报告目录按BUILD_ID分组统计崩溃次数哪一次构建增量引入的崩溃增量立刻显现。别小看这步它把“收到一份崩溃日志”变成“一周崩了 47 次其中 39 次在新版本”的决策信息。6.2 本地落盘加延迟上传的可靠发送模式报告最终是要汇总到服务端的。常见做法是写一个后台线程每次启动时遍历崩溃报告目录文件逐条上传成功后移动到 backup 目录或直接删除。上传接口要幂等同一条报告重复提交时服务端按报告唯一 ID 去重。我在最近一个模拟项目X里给上传线程加了一个简单限制单次最多处理 20 份报告处理完退出线程避免程序启动瞬间被大量历史报告占满网络。崩溃报告是有时效性的优先传当天的历史存量排后。线程里每传完一条就Sleep(300)给主线程留资源这在低配用户机上尤其重要。还有一个习惯值得分享每季度把报告里的Exception Class做一次分布统计。曾经有个客户端项目翻来覆去都是同一个数组越界问题开发组修了一版上线后统计看该类异常环比降了 90%那个数据比任何测试报告都有说服力。崩溃报告系统不是修一次就完事的它的价值随时间线性累积前提是你把构建号、符号文件、报告归档这套动作养成肌肉记忆。这条路我走下来最大的教训只有一个——hook 要接得早符号要存得住报告不要当场发。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?