简介:madExcept 5 FS 是一套面向 Delphi 开发者的异常捕获与调试增强组件,完整名称即 madExcept 5 FullSource,适合需要深入定位运行时错误、构建稳定桌面应用的中高级开发人员。资源包为 rar 压缩格式,整体约 77.07MB,共含 3509 个文件,包括 delphi 工程文件(pas、dpr、dpk)、编译产物(dcu、bpl、dcp、obj)、头文件(h、hpp)以及 IDE 集成配置(dproj、bdsproj)等,便于在完整源码基础上学习、修改与重新编译。描述中还给出“madexcept5源码版安装及测试方法.txt”,可辅助快速接入现有工程。属于 FullSource 源码版,核心价值在于可完整查看异常处理、堆栈跟踪、错误报告与 IDE 菜单集成等内部实现,并针对特定项目自定义细节。目前已有 424 人学习浏览,适合希望在 Delphi 调试链路中引入系统化异常报告机制的开发者参考。
1. madExcept 5 FS 是什么:Delphi 崩溃闪退后的黑匣子,把「用户说闪退」变成「第几行出错」
做 Delphi 桌面开发,最头疼的售后场景就是用户丢过来一句「双击就闪退」,你连异常类都看不到。madExcept 5 FS 就是专门治这个的异常捕获与崩溃报告库,它不靠你每个函数写 try/except,而是从系统异常分发的源头把崩溃现场完整抓下来——异常类、调用堆栈、寄存器、线程状态、模块列表,直接打包成可读报告。带完整源码的 FS 版还能让你把实现吃透,按自己项目的口径定制报告内容。适合独立开发者、维护多年老项目的团队,以及被闪退问题反复折腾、想把排查成本一次降下来的人。
2. 异常捕获机制:为什么 try/except 救不了闪退,而 hook 出来的报告能救命
2.1 Delphi 原生 try/except 的四个盲区
很多 Delphier 的第一反应是「把所有代码包进 try/except 不就行了」。真这么干过的人都知道,这不叫兜底,叫给自己挖坑。第一个盲区是覆盖范围:try/except 只能保护你显式包裹的代码块,而真正让程序挂掉的地方,往往是某个事件回调、第三方库内部、或者后台线程的 execute 体里,这些位置你根本包不进去。
第二个盲区藏在异常类型里。Delphi 的异常体系处理的是 EAbort、EAccessViolation 这类派生类,但像栈溢出、纯虚拟调用、内存页错误这类系统级异常,有时候根本不走 Delphi 的异常分发路线,直接触发操作系统的未处理异常出口,程序当场终止,你的 except 块连执行机会都没有。
第三个盲区是线程。Delphi 的老版本里,工作线程抛出的异常如果没在线程内部捕获,通常会直接终结线程,主线程完全无感知。你看到的现象是「功能偶尔失效,但不崩溃」,这种问题用 try/except 去排查,等于大海捞针。
第四个盲区最现实:就算你捕获到了异常,默认的异常消息也就一句 "Access violation at address ...",没有调用堆栈,没有参数上下文,没有哪个模块、哪一行代码。对定位 bug 来说,这就相当于只告诉你「车坏了」,不告诉你哪个零件、哪个工况下坏的。madExcept 解决的正是这四个盲区的交集:不依赖你包裹、能接住系统级异常、能跟上线程、还能把现场完整记录下来。
2.2 madExcept 的 Hook 原理:抢先接管未处理异常
madExcept 的核心思路不是「加一层 try」,而是「换掉进程级的异常总出口」。它在初始化阶段做两件事:一件事是安装进程级的未处理异常过滤器,让任何线程抛出且没有被捕获的异常,都先拐到它自己手里;另一件事是包装 VCL 内部 TApplication 的异常处理方法,把 Delphi 自己认为「已处理」的异常也拉进来过一遍记录。
这个链路的优先级很关键。系统在决定一个未处理异常是否终结进程之前,会先询问进程级过滤器;madExcept 就是利用这个时机,在终结动作发生前接管现场。接管后它会做几步操作:先挂起进程内其他所有线程,避免它们在收集信息的过程中改变内存和寄存器,破坏现场;然后读取当前线程的上下文(寄存器值、栈指针)、遍历调用栈、枚举当前进程加载的所有模块;最后按你配置的格式生成一份快照报告。
我后来翻了源码才发现,遍历调用栈这块它没有完全依赖系统 API,而是自己实现了栈回溯逻辑。这样做的好处是能绕开某些模块对栈回溯的干扰,代价是它对编译优化级别敏感——这也是后面避坑章里要说的「开优化后行号对不上」问题的根源。理解了这个机制,你就明白为什么它敢说「不需要手动包 try/except」:它不是在业务层兜底,而是在系统层兜底。
2.3 一份崩溃快照里到底有什么
下面这份清单是报告的核心信息项,看懂它们,你才知道一次崩溃真正留下了哪些线索。
| 信息项 | 内容说明 | 排查价值 |
|---|---|---|
| 异常类与消息 | 如 EAccessViolation、EOutOfMemory 及原始消息文本 | 第一眼判断崩溃性质 |
| 调用堆栈(Call Stack) | 按调用顺序列出当前线程函数地址链 | 定位崩溃点在哪个函数、哪一行 |
| 线程状态 | 每个线程的栈范围、运行状态、最后错误码 | 排查多线程死锁或线程间踩内存 |
| 模块列表 | 当前进程加载的 exe/dll 基地址、版本、路径 | 揪出旧版 DLL、第三方注入模块 |
| CPU 寄存器 | 崩溃时刻各寄存器值及原始栈内容 | 底层分析指针越界、虚表调用 |
| 系统环境 | OS 版本、内存总量、CPU 核心数 | 判断是否属于特定环境复现 |
这份清单就是调试时的黑匣子。报告拿到手,大部分闪退问题不必再靠用户「你帮我回忆一下刚才点了什么」来还原,后面的章节会手把手带你从报告反推出源码行号。
3. 集成到正式项目:Patch 注入、编译开关、报告策略三步走
3.1 安装 FS 源码包:编译 .dpk 并注册到 IDE
FS 版是完整源码包,不能像网上的二进制版本那样直接放一个 bpl 进 IDE 就能用,得自己编译一遍。常见做法是把源码目录解压后,在 IDE 里打开对应版本的 .dpk 工程,右键 Build,然后 Install。注意两点:一是把源码根目录加进 IDE 的 Library Path,否则后续项目编译时找不到 madExcept 相关的 pas 文件;二是 32 位和 64 位目标平台需要分别编译和安装对应的运行时包,只装了 32 位包,切到 64 位编译会直接报找不到 dcp。
安装完成后,IDE 菜单里会出现 MadExcept Tools 这一项。如果看不到,多半是包安装成功但 IDE 没有刷新菜单缓存,重启一次 IDE 通常就好了。到这里,库已被 IDE 认识,但还没进到你的目标工程里。
3.2 Patch 注入:.dpr 被动的手脚与它为什么要排第一
把 madExcept 装进某个工程的常规动作是执行菜单里的 Patch Project for madExcept。它做的事情非常直接:修改你的 .dpr 源文件,把 madExcept 单元插到程序体 uses 子句的第一位。下面这个对比很直观。
Patch 之前:
program DemoAgent; uses Vcl.Forms, MainForm in 'MainForm.pas'; {$R *.res} begin Application.Initialize; Application.MainFormOnTaskbar := True; Application.CreateForm(TMainForm, MainForm); Application.Run; end.Patch 之后:
program DemoAgent; uses madExcept, Vcl.Forms, MainForm in 'MainForm.pas'; {$R *.res} begin Application.Initialize; Application.MainFormOnTaskbar := True; Application.CreateForm(TMainForm, MainForm); Application.Run; end.这里有一个必须理解的细节:Delphi 单元的初始化段是按 uses 顺序执行的,madExcept 被放到第一位,意味着它的初始化代码最先执行,异常挂钩在 Forms 创建之前就已经装好。如果你手贱把它挪到后面,窗口创建过程中抛出的异常就会漏出 hook 覆盖范围,Patch 的意义就废了一半。所以我一般 Patch 完之后不会再去动 uses 的顺序,源码级调试时也尽量不手工增删这一行。
Patch 的同时,工具还会在工程目录生成或更新一份 madExcept 的配置文件(工程同名 .cfg 或独立的 .madExcept 配置),工程编译时配置随程序走。此时编译一次,你的程序就已经具备崩溃快照能力,只是还没有任何报告策略。
3.3 双套报告策略:开发期弹窗调试,交付期静默收集
Patch 之后第一件事不是急着部署,而是把报告策略分成两套来配。开发期我习惯让异常弹出 madExcept 自带的报告窗口,里面能直接看调用栈、模块列表,甚至能复制报告文本贴到工单里;交付期则改成静默模式:崩溃时后台生成报告文件并通过网络上报,尽量不打扰用户。
下面的配置项是核心,不同场景参数不同。
| 配置项 | 开发期建议 | 交付期建议 |
|---|---|---|
| Handle exceptions | 勾选 | 勾选 |
| Report on | 仅崩溃线程 | 全部线程 |
| Create report | Human readable 文本 | Binary(.madRep 体积更小) |
| 报告窗口交互 | 允许弹窗 + 调试信息 | 静默自动发送 |
| 断点行为 | 开启 Break in debugger | 关闭 |
交付期的静默上报一般通过事件钩子实现。madExcept 在报告生成后会回调你在程序里注册的事件,常用的是 OnSendReport 这类回调。下面这段是我常用的 HTTP 上报写法:
procedure TMainForm.HandleMadReport( const exceptIntf: IMEException; var handled: Boolean); var payload: string; begin // 把报告关键字段拼成一个字符串 payload := exceptIntf.ExceptionClass + '|' + exceptIntf.ExceptionMessage + '|' + exceptIntf.StackDetails; // 异步发送,不阻塞当前界面 TThread.CreateAnonymousThread( procedure var client: TNetHTTPClient; // System.Net.HttpClient begin client := TNetHTTPClient.Create(nil); try client.Post('https://crash.example.com/collect', payload); finally client.Free; end; end ).Start; // 置 True 表示由我们自己处理,madExcept 不再弹默认窗口 handled := True; end;逻辑说明:IMEException 接口封装了本次异常的完整信息,ExceptionClass 拿到异常类名,ExceptionMessage 拿到原始消息,StackDetails 拿到格式化后的调用栈文本。这里把上报放到匿名线程里,避免网络请求卡住报告窗口。handled 置 True 很关键,否则用户还是会看到弹窗,静默就名存实亡。
参数说明:Post 的目标地址建议用内网或自家域名,不要在 payload 里塞敏感数据,报告文本可能含有内存片段。邮件上报我也试过,但办公网络环境经常限制非标准端口,HTTP 上报在大多数网络下反而更顺。到这里,崩溃报告从生成到送达的链路已经跑通,下一步是学会读懂报告。
4. 读懂崩溃报告:从 .madRep 到具体源码行号的定位链路
4.1 报告文件在哪、怎么命名、怎么打开
默认情况下,报告会写到可执行文件同目录下的 madExceptReports 子文件夹,文件名包含程序名和崩溃时间,一眼能看出事故发生的先后顺序。人类可读模式生成的是文本文件,直接记事本打开就能看;二进制模式生成的是 .madRep 文件,需要工具打开。
打开 .madRep 有现成路径:在 IDE 里直接用菜单打开报告文件,就能看到解析好的调用栈。如果你没有 IDE 环境,也可以用 fs 包里自带的独立查看器打开,它会自动读取报告里的模块列表和堆栈帧。这个查看器虽然界面朴素,但胜在能直接展示符号化之后的调用链,比对着十六进制地址猜要靠谱得多。
4.2 开启 Detailed Map:把地址变成函数名和行号
报告里如果全是 "Unknown function",十有八九是工程没开详细映射文件。MadExcept 要拿到准确的函数名和源码行号,依赖编译器生成的 map 文件。在工程选项里打开详细映射,并把生成的 map 文件保留在和 exe 一起的交付目录里:
Project Options -> Delphi Compiler -> Compiler -> Map file: Detailed这里有几个关键点要讲清楚。第一,map 文件必须和出问题的那一次 exe 构建严格对应,哪怕改了一行代码重新编译,旧 map 配新 exe 解析出来的行号也是错的。第二,开启优化后,函数可能被内联、指令被重排,行号会偏移,排查时要以异常地址所在函数为锚点,而不是死抠行号。第三,有些发布流程会顺手删掉 map 文件,交付前记得把 map 文件留档,否则用户那边崩溃了,你这边连符号都解析不了,等于白装。
4.3 一场虚拟崩溃的完整定位演示
下面随手模拟一份报告的核心片段,演示定位思路。
Exception class: EAccessViolation Message: Access violation at address 00404567 in module 'DemoAgent.exe'. Read of address 00000014. Call Stack: 00404567 +0 DemoAgent.exe Unit1.pas:137 TMainForm.ProcessTxn 00404012 +1 DemoAgent.exe Unit1.pas:102 TMainForm.Button1Click 00408099 +2 DemoAgent.exe Vcl.Forms.pas:… TApplication.Run阅读顺序是自底向上。最底层是 TApplication.Run,说明异常发生在正常的消息循环里;上一层是 Button1Click,说明事件入口在这里;最顶层是 ProcessTxn,且地址加偏移是 +0,这就是真正崩溃的栈帧。行号 137 指向 Unit1.pas 里的具体代码行,打开源码直接看那一行附近,十有八九是访问了某个未初始化的对象成员,读地址 00000014 这种偏移量也印证了「空指针加字段偏移」的典型特征。
定位逻辑就是一套主干四问:哪个模块崩溃(排除第三方 DLL)、哪个函数(缩小范围)、哪一行(锁定代码)、读的还是写的(判断空指针还是野指针方向)。把四问跑完,闪退基本就破案了。
4.4 批量回收报告:SMTP 与 HTTP 两条上报路线
小规模用户群用邮件上报就够,在配置面板里填写 SMTP 参数即可。但我个人的经验是,邮件上报被反垃圾策略拦截的概率不低,而且邮件正文无法结构化。更可控的做法是走 HTTP 上报,把报告 POST 到自己维护的收集服务,服务端按程序版本、异常类、崩溃地址做聚合统计,能直观看到哪个版本、哪个模块的崩溃占比最高。
HTTP 上报的服务端其实不复杂,一个能接收 POST 的接口就行,收到的 payload 存文件或入库。我一般会在 payload 里额外带上程序版本号和构建批次,这样后续按版本对比崩溃率时才有数据可查。一旦链路通了,后面每个版本发布都能拿到真实的崩溃数据,而不是靠用户在群里反映。
5. 避坑指南:集成 madExcept 遇到的 5 个典型翻车现场
5.1 现象:加载 .dpk 编译时报找不到 dcp 或某些单元
原因:FS 源码包解压后没有把源码根目录加进 IDE 的 Library Path,后续包编译和工程编译都找不到 madExcept 相关 pas;或者在 64 位平台下没有先编译 64 位版本的运行时包,直接编译 x64 目标必然失败。
解决:在 IDE 的 Library Path 里加入源码根目录,按包依赖顺序先编译基础包再编译主包,32 位和 64 位分别 Build 一次并 Install。编译报错时重点关注第一个错误,多数是路径问题,不是代码问题。
5.2 现象:Patch 之后程序启动即异常退出,或被杀毒软件弹窗拦截
原因:madExcept 需要修改进程级异常分发链并安装多处挂钩,这类行为在部分带主动防御的安全软件眼里和恶意软件特征重叠。个别带反调试、带壳的程序也会和 hook 冲突。
解决:先把程序目录加入安全软件白名单验证是不是误杀;把报告策略改成静默模式,减少交互行为;仍冲突的,在配置里尝试降低挂钩的侵入级别。如果还不行,就得评估这个版本的工具链是否适合当前发布形态,必要时退回到在关键路径手动包 try 的方案。这类兼容问题没有银弹,只能逐个环境排。
5.3 现象:报告里全是 Unknown function,没有函数名和行号
原因:最常见的两个,一是工程没开详细 map 文件,二是编译后把 map 删了或没随包交付。开优化也会让部分内联函数对不上符号名。
解决:在工程选项里打开 Detailed map file,发布时把 map 留档并和 exe 同版本号归档。解析报告时确保打开的是同一构建批次的 map。开了详细 map 之后仍然个别帧显示 Unknown,通常是系统 DLL 的栈帧,不是程序代码段,不用太纠结。
5.4 现象:64 位 Release 版偶发崩溃,但 madExceptReports 目录里一个报告都没有
原因:64 位下栈回溯要比 32 位难,异常如果发生在非托管回调或线程池线程里,且该线程的异常被某些框架层静默吞掉,就轮不到进程级处理器;另外如果报告生成线程本身再次崩溃,也会导致报告丢失。
解决:配置里把报告对象覆盖到全部线程,打开线程级异常捕获;把报告格式改成二进制减小体积,降低生成过程中的二次崩溃概率;同时保留 madTrace 日志帮助复现。排查这类问题要沉住气,先把概率调大,再谈解决。
5.5 现象:同一个崩溃反复弹窗,用户被骚扰到直接卸载
原因:没有配置重复报告抑制,每次异常都触发一次弹窗,用户在一个死循环里被迫「协助排查」。
解决:交付版本把弹窗交互关掉,走静默上报;上报失败也不要再次弹窗,让 handled 保持 True。上报动作做异步队列,失败后的重试逻辑控制在用户无感范围内。用户不是测试工,别把排查成本转嫁给他们。
6. 进阶用法:把 madExcept 当测试探针,主动制造事故
6.1 用 raise 验证整条报告链路
集成完不验证等于没集成。我一般会在程序里留一个隐藏指令,主动抛一个异常,确认报告能生成、能上报、能在服务端收到。验证用的代码简单直接:
procedure TMainForm.TriggerTestCrash; begin raise Exception.Create('madExcept pipeline test'); end;抛完这次异常,去 madExceptReports 目录确认报告文件出现,再确认上报服务端收到了 payload,整条链路才算闭环。这个验证动作每次发版前都要跑一遍,别嫌麻烦——真出事故时才发现链路是断的,比事故本身更尴尬。
6.2 接入 CI 冒烟测试
工具链里最有价值的位置其实是自动化压测。把程序塞进 CI 冒烟流程,跑核心用例,任何崩溃都会吐出 .madRep,CI 把报告文件作为构建产物归档,按构建号归类,后面排查直接取对应构建的报告。
# 把崩溃报告按构建号归档,供后续回溯 $src = ".\bin\madExceptReports" $dst = ".\artifacts\crashes\build-$env:BUILD_NUMBER" if (Test-Path $src) { New-Item -ItemType Directory -Force -Path $dst | Out-Null Copy-Item "$src\*.madRep" $dst -Force }这段脚本逻辑不复杂:检测报告目录是否存在,存在就把所有 .madRep 按构建号归档。配合定时跑回归用例,崩溃趋势一目了然。很多偶发崩溃在开发者本机一辈子复现不出来,放 CI 里跑一晚上就现原形了。
6.3 用 madTrace 在关键路径留后悔药
崩溃报告是事故后的黑匣子,但黑匣子只有事故后才读。FS 包里的 madTrace 单元可以在关键业务路径上主动留痕迹,崩溃时最近的日志会被一起写进报告,相当于给排查留了后悔药。比如支付流程这种高风险路径,进去之前留一行:
uses madTrace; procedure TMainForm.StartPayFlow; begin madTrace('进入支付流程,订单号=%s', [OrderNo]); // ...业务代码 end;这行日志成本极低,但在崩溃排查时能告诉你是走到了哪一步才炸的。从那以后,我每次打包交付前都会强制走一遍「Patch + Detailed map + 静默上报」三件套,再主动触发一次测试崩溃验证链路,确认服务端收到报告才发布。madExcept 装好不难,难的是把从崩溃发生到报告送达的整条链路跑通,链路上每一个环节都验证过,后续排查才是顺风局。希望帮到你。
本文还有配套的精品资源,点击获取