用 Application Verifier 搭建 Windows 异常路径测试基础设施
· 更新日期: · 小村 豪 · Windows 开发, 故障排查, 工业相机, Application Verifier, 异常路径测试, 句柄泄漏
引用本文(DOI: 10.5281/zenodo.21615438)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《用 Application Verifier 搭建 Windows 异常路径测试基础设施》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615438 https://comcomponent.com/zh-CN/blog/2026/03/11/003-application-verifier-abnormal-test-foundation-part2/
- DOI(最新版本)
- 10.5281/zenodo.21615438
- DOI(此版本)
- 10.5281/zenodo.22281943
Application Verifier 是一款在你想让 Windows 原生代码和 Win32 边界上发生的异常提前浮出水面时很有力的工具。 特别是想测试句柄异常、堆破坏、低资源时的 failure path 的场合,它能相当早地把只跑正常路径试验看不到的问题摆到台面上。
上篇 工业相机长时间运行崩溃排查 - 句柄泄漏篇 整理了这样一个案例:排查长时间运转后崩溃的控制应用,结果原因是句柄泄漏。 不过,只是强化了日志还只走完一半。真正想要的,是能事先验证 今后即使因为预料之外的程序错误发生内存泄漏、句柄泄漏、中途失败、释放遗漏,也处在“知道发生了什么”的状态。
为此用上的,就是 Application Verifier。 它是一款能对运行在 Windows 原生代码和 Win32 边界上的处理,在运行时加入检查和 fault injection 的工具。实务上特别方便的一点是,不必真的把机器的内存耗尽,就能提前制造出类似内存不足、资源不足的坏法。
下篇会在工业相机控制应用的脉络下,梳理 Application Verifier 是什么、能做什么,以及怎么把它编入异常路径测试基础设施。
目录
- 先下结论(一句话)
- 什么是 Application Verifier
- 2.1. 一句话怎么说
- 2.2. 什么场合有效
- 2.3. 好处在哪里
- 2.4. 从获取到在本机启用
- Application Verifier 能做什么
- 3.1. Basics:Handles / Heaps / Locks / Memory / TLS 等
- 3.2. Low Resource Simulation:提前制造内存不足与资源不足
- 3.3. Page Heap 与 debugger
- 3.4.
!avrf/!htrace/ 日志
- 这次为什么引入
- 4.1. 目的不只是“找出 bug”
- 4.2. 制造类似内存不足的现象
- 4.3. 确认句柄异常发生时能不能追下去
- 怎么制造类似内存不足、资源不足的现象
- 5.1. Low Resource Simulation 的思路
- 5.2. 能让什么失败
- 5.3. 实务中的使用方式
- 怎么观察句柄异常
- 6.1.
Handles检查 - 6.2. 用
!htrace看 open / close 的堆栈 - 6.3. 怎么和自研日志结合
- 6.1.
- 异常路径测试基础设施的搭建方法
- 7.1. 把运行单位收敛到 harness
- 7.2. 把测试菜单拆开
- 7.3. 要收集的东西
- 7.4. 合格条件
- 7.5. 注意事项
- 粗略的分工使用
- 总结
- 参考资料
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 17 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
1. 先下结论(一句话)
- Application Verifier 是让 Windows 的 非托管 / 原生边界 上发生的误用在运行时更容易被发现的工具
- 它方便的地方不只是“找出 bug”,还在于 能把平常不容易出现的异常路径提前制造出来
Handles可以检测 invalid handle,Heaps可以让堆破坏显性化,Low Resource Simulation可以对类似内存不足、资源不足的状况做 fault injection- 把长时间常驻的 EXE 的 leak 排查全部丢给 Application Verifier 并不合适,与
Handle Count、resource lifecycle 的自研日志结合才现实 - 在异常路径测试基础设施里,把 正常路径的 verifier run 和 fault injection run 分开跑更容易读
- 即使想测试 DLL,启用 Application Verifier 的对象也是实际驱动这个 DLL 的 测试用 EXE
简单说,Application Verifier 就是 把 Windows 的 native / Win32 周边那些“难缠的 bug”硬拽到台面上的工具。 特别是像设备控制应用这种 native SDK、P/Invoke、Win32 API 混在一起是常态的世界,它相当契合。
flowchart TB
accTitle: Application Verifier 的两项作用
accDescr: Application Verifier 具有检测原生边界的误用和把平常不容易出现的异常路径提前制造出来这两项作用,可以检测 invalid handle 和堆破坏,也可以注入类似内存不足的状况。
av["Application Verifier"] --> detect["检测原生边界的误用"]
av --> inject["提前制造不易出现的异常路径"]
detect --> d1["invalid handle 或堆破坏"]
inject --> d2["注入类似内存不足的状况"]
图1:Application Verifier 的作用由“检测误用”和“提前制造异常路径”两根支柱构成。
2. 什么是 Application Verifier
2.1. 一句话怎么说
Application Verifier 是针对 Windows user-mode 应用的 运行时检验工具。 它监控运行中应用对 OS API 的使用和资源的处理方式,既能检测可疑用法,也能故意注入失败。
和“静态分析”“单元测试”不同,它看的是 实际走过这条代码路径时会怎么坏。 所以适合把平常的功能测试看不到的 failure path 逼出来。
flowchart LR
A[测试 harness] --> B[控制应用 / SDK 包装层]
B --> C[Application Verifier]
C --> D[Win32 API / native DLL / OS 资源]
C --> E[verifier stop]
C --> F[debugger output]
C --> G[AppVerifier logs]
B --> H[自研 structured log]
图2:Application Verifier 监控由测试 harness 驱动的控制应用,把观测结果留成 verifier stop、debugger 输出和日志。
2.2. 什么场合有效
特别容易见效的是下面这些场合。
- 在调用 native DLL 或相机 SDK
- 跨越 P/Invoke 或 COM
- 直接或间接大量使用句柄、堆、锁、虚拟内存
- 普通的正常路径基本不会崩,但只在异常路径上生命周期管理像是会垮
- 比起“崩掉”,先出现的是“偶尔返回奇怪的失败”
反过来说,它 不是用来追纯托管世界里 object graph 的工具。 所以 C# 应用只要 native SDK 或 Win32 边界够厚,它照样很有效,但这并不等于“纯托管的 heap leak 靠它一个就全能看”。
flowchart TB
accTitle: 判断 Application Verifier 是否有效的场合
accDescr: 对 native DLL、P/Invoke、Win32 边界厚重的应用有效,但它不是用来追纯托管世界里 object graph 的工具,图中给出这一分界。
q{"是哪一层的问题"}
q -->|"native SDK 或 Win32 边界"| yes["Application Verifier 有效"]
q -->|"纯托管的 object graph"| no["不在适用范围〔用别的工具看〕"]
图3:是否有效的分界线在于 native / Win32 边界有多厚,它不是只看纯托管世界的工具。
2.3. 好处在哪里
实务上的好处,大致是下面 3 点。
- 能更早地拦下原生边界的误用
- invalid handle
- heap corruption
- lock misuse
- 虚拟内存 API misuse 等
- 能把只在低资源时才出现的坏法提前制造出来
- 相当于
malloc的调用偶尔失败 CreateEvent或CreateFile偶尔失败VirtualAlloc失败
- 相当于
- 和 debugger 搭配起来好追
!avrf!htrace!heap -p -a- verifier stop 的日志
设备控制应用最让人头疼的,是“异常路径上不知道发生了什么”。 Application Verifier 对减少这种“搞不懂”相当见效。
flowchart TB
accTitle: 实务上受益的三点
accDescr: 能更早地拦下原生边界的误用、能把只在低资源时才出现的坏法提前制造出来、和 debugger 搭配起来好追,图中列出这三项好处。
av["Application Verifier"] --> b1["能更早拦下误用"]
av --> b2["能提前制造坏法"]
av --> b3["用 debugger 好追"]
b3 -.-> t["avrf 和 htrace 等扩展"]
图4:实务上的好处可以归结为早期检测、异常路径的提前制造和 debugger 联动这三点。
2.4. 从获取到在本机启用
先把备齐工具这一步处理掉。这里缺了,后面的内容就全是纸上谈兵。
Application Verifier 随 Windows SDK 一起提供。 Windows 本身不自带,所以要启动 SDK 的安装程序,在功能选择界面勾上“Application Verifier”。可执行文件的名字是 appverif.exe。
使用上的前提有 3 个。
- 运行的用户是这台机器 Administrators 组的成员
- ARM64EC 不在支持范围内
- 检验对象是 非托管(原生)代码
GUI 和命令行的关系,按下面这样理解就不会迷惑。
| 在做什么 | |
|---|---|
GUI(appverif.exe) |
把目标 EXE 的名字和要启用的测试组合 写入注册表 |
命令行(appverif -enable ...) |
用命令写入完全相同的注册表设置 |
| 运行时 | 目标 EXE 启动时读取这份设置,加载 verifier 的 DLL,装上 Win32 API 的挂钩 |
也就是说 用哪一种,做的事情都一样。手工操作的第一次用 GUI,CI 和脚本用命令行,这样分工就好。
flowchart TB
accTitle: GUI 与命令行的关系
accDescr: GUI 和命令行都只是写入相同的注册表设置,目标 EXE 启动时读取这份设置加载 verifier 的 DLL,装上 Win32 API 的挂钩。
gui["GUI〔appverif.exe〕"] --> reg["把设置写入注册表"]
cli["命令行"] --> reg
reg --> boot["目标 EXE 启动时读取"]
boot --> hook["加载 verifier 的 DLL 并挂钩"]
图5:GUI 和命令行都只是在写同一份注册表设置,挂钩是在目标 EXE 启动时才装上的。
GUI 的操作流程是:在界面左侧的 Applications 栏里右键选“Add Application”添加目标 EXE,在界面右侧的 Tests 栏里勾上 Basics 等项目,然后按“Save”。解除时同样在 Applications 栏里右键选“Delete Application”,再“Save”。
由此带来两条重要的限制。
- 无法对正在运行中的进程事后启用。 挂钩是在 DLL 加载时装上的,所以顺序必然是先写设置再启动。
- 设置会一直保留到明确删除为止。 抱着“只试一次”的心态放着不管,这台机器上那个 EXE 就会一直在 verifier 下启动。
另外,检测时的日志默认以二进制格式保存在 %USERPROFILE%\AppVerifierLogs,可以用 GUI 或命令行转换成 XML 来汇总。
flowchart TB
accTitle: 启用的顺序与设置的留存
accDescr: 挂钩是在 DLL 加载时装上的,因此无法对运行中的进程事后追加,顺序必然是先写设置再启动,而且设置会一直保留到明确删除为止。
set["写入设置"] --> launch["启动目标 EXE"]
launch --> on["在 verifier 下运行"]
on --> keep["设置保留到删除为止"]
keep -.-> warn["放着不管就一直在 verifier 下启动"]
running["运行中的进程"] -.-> ng["无法事后启用"]
图6:“先设置再启动”的顺序不能打乱,设置会一直留在这台机器上直到明确删除。
3. Application Verifier 能做什么
3.1. Basics:Handles / Heaps / Locks / Memory / TLS 等
Application Verifier 的基本套装是 Basics。
实务上常用的检查都集中在这里。
| 层 | 看什么 | 在本次脉络中的用处 |
|---|---|---|
Handles |
invalid handle 的使用 | 有没有踩到已 close 或已损坏的句柄 |
Heaps |
heap corruption | 把 native SDK 边界上的缓冲区破坏和 use-after-free 逼出来 |
Leak |
DLL unload 时点上仍未释放的资源 | 短命 harness 的测试或包含 unload 的场景的确认 |
Locks / SRWLock |
锁的误用 | 确认 reconnect 与 shutdown 的竞争 |
Memory |
VirtualAlloc / MapViewOfFile 等的误用 |
确认大缓冲区或共享内存周边的异常 |
TLS |
Thread Local Storage API 的误用 | 线程边界复杂的 native 代码的保险 |
Threadpool |
threadpool API 和 worker state 的一致性 | callback 或异步处理较多时的辅助 |
要点在于,不是“崩了之后再去读就明白”,而是“可疑用法当场就拦下”。 对长时间运转型的故障,这种提前相当见效。
flowchart TB
accTitle: Basics 提前检测的思路
accDescr: 不是崩了之后再读日志去猜,而是把可疑用法当场拦下,从而把长时间运转型的故障提前暴露出来。
use["可疑的 API 用法"] --> basics["Basics 的检查群"]
basics --> stop["当场拦下"]
stop --> early["把问题提前暴露出来"]
use -.-> later["以往只能崩了之后再读"]
图7:Basics 的价值在于把“崩了之后再读”变成“当场拦下”。
3.2. Low Resource Simulation:提前制造内存不足与资源不足
实务上相当方便的就是这里。 因为 不必真的把 RAM 耗尽,也能制造出接近内存不足、资源不足的现象。
思路很简单。
- 让某个 API 调用
- 以一定概率
- 故意失败
这样就能走通平常基本走不到的 error path。
具体来说,下面这些现象会变得容易有意制造出来。
HeapAlloc或VirtualAlloc失败CreateFile失败CreateEvent失败MapViewOfFile失败SysAllocString这类 OLE/COM 系的分配失败
比起为了真的制造内存不足而折腾整台机器,这种做法好处理得多。 而且还可以 只瞄准特定 DLL 注入 fault。像设备控制应用这种自研包装层与 vendor SDK 混在一起的结构,这一点相当实用。
flowchart TB
accTitle: Low Resource Simulation 的机制
accDescr: 让某一类 API 调用以一定概率故意失败,从而有意走通平常基本走不到的 error path,还可以把对象收窄到特定 DLL。
call["API 调用"] --> judge{"命中了设定的概率?"}
judge -->|"是"| fail["故意返回失败"]
judge -->|"否"| ok["照常处理"]
fail --> path["进入平常走不到的 error path"]
path -.-> dll["也可只收窄到特定 DLL"]
图8:Low Resource Simulation 的本质,是让 API 调用以一定概率失败的 fault injection。
3.3. Page Heap 与 debugger
要观察堆破坏,Heaps 与 page heap 的组合很强。
特别是 full page heap 用 guard page,在坏掉的那一刻就容易停下来,这是它的优点。
不过,这个相当重。 与其拿它做长时间的全面扫荡,不如 收窄到接近复现的场景,在 debugger 下跑,更好用。
所以在实际使用中,按下面这样划分比较现实。
- 先用
Basics大范围打一遍 - 觉得堆可疑了,就用 full page heap
- 太重的时候降到 light page heap
- 相当于生产环境的长时间试验,以自研日志为主来看
归根结底,AppVerifier 不是万能法杖,而是按场合换刀刃的工具。
flowchart TB
accTitle: page heap 的分场合使用流程
accDescr: 先用 Basics 大范围打一遍,觉得堆可疑就用 full page heap 在坏掉的那一刻停下,太重就降到 light page heap,相当于生产环境的长时间试验以自研日志为主来看。
s1["用 Basics 大范围打一遍"] --> s2{"堆可疑吗?"}
s2 -->|"是"| s3["用 full page heap 停下来"]
s2 -->|"否"| s7["长时间试验以自研日志为主"]
s3 --> s4{"太重了吗?"}
s4 -->|"是"| s5["降到 light page heap"]
s4 -->|"否"| s6["在 debugger 下局部复现"]
图9:page heap 不是常开的工具,先用 Basics 大范围打一遍,再按场合换刀刃。
3.4. !avrf / !htrace / 日志
先说清楚一个词。前面出现过几次的 verifier stop,是 Application Verifier 判定“这种用法不对”时发出的检测事件。它不是单纯的一行日志,特点是 只要在调试器下运行,就会当场中断。stop 带有编号,会像 VERIFIER STOP 00000300 这样显示出来。stop 分为可以直接继续的和无法继续(只能结束进程)的两类。
Application Verifier 并不是发出 stop 就完事了。 因为有调试器扩展和日志,追查发生了什么会更容易。
!avrf- 查看当前的 verifier 设置,以及正在发生的 stop
!htrace- 查看句柄的 open / close / invalid reference 的堆栈
!heap -p -a- 与 page heap 配合,追查坏掉的堆块
- AppVerifier 的日志
- 可以留下 stop 发生时的日志
特别是启用 Handles 时,handle tracing 会自动启用,这一点很省事。
这样就容易事后追查“这个句柄是在哪里打开、在哪里关闭的”。
flowchart TB
accTitle: 从 verifier stop 到排查的流程
accDescr: 检测到不对的用法就会发出带编号的 verifier stop,在调试器下会当场中断,可以用 avrf 确认设置和 stop,用 htrace 确认句柄的历史。
bad["检测到不对的用法"] --> stop["verifier stop〔带编号〕"]
stop --> brk["在调试器下会中断"]
brk --> avrf["用 avrf 确认设置和 stop"]
brk --> ht["用 htrace 确认句柄历史"]
stop -.-> cont["stop 分可继续和不可继续两类"]
图10:verifier stop 不是单纯的一行日志,在调试器下会当场中断,成为排查的起点。
4. 这次为什么引入
4.1. 目的不只是“找出 bug”
这次的目的,并不只是“用 AppVerifier 找出 1 个 bug”。 说得更贴近实务一点,想确认的是下面这些。
- 将来又在别的 failure path 上发生资源泄漏时
- 日志里能不能好好留下上下文
- 结合 debugger 的信息能不能追到底
- 会不会陷入“不知道发生了什么”的状态
也就是说,我们不只是把它当作 检测器,也把它当作 观测基础设施的测试 来用。
4.2. 制造类似内存不足的现象
在平常的开发机上真的制造内存不足,相当麻烦。 而且整台机器一旦变得不稳定,测试本身就会满是噪声。
于是改用 Low Resource Simulation,转向 有意去踩那些在内存不足、资源不足时才会走到的 failure path。
这样一来,下面这类问题就更容易回答了。
CreateEvent失败时,日志里会不会留下cameraId和phase- 在初始化只做了一半之后,clean up 有没有好好执行
VirtualAlloc失败时,重试会不会把状态搞坏- 保存路径的
CreateFile失败时,句柄有没有回收
要强调的是,制造异常本身不是目的,异常发生时坏法能读懂才是目的。
flowchart TB
accTitle: 用 fault injection 检验观测基础设施
accDescr: 用 Low Resource Simulation 有意去踩失败,确认日志里是否留下上下文、善后是否执行、重试会不会把状态搞坏。目的不是制造异常,而是让坏法能被读懂。
inject["有意去踩失败"] --> q1["日志里留下上下文了吗"]
inject --> q2["clean up 执行了吗"]
inject --> q3["重试会不会搞坏"]
q1 --> goal["坏法能被读懂的状态"]
q2 --> goal
q3 --> goal
图11:fault injection 的目的不是制造异常,而是确认异常时的坏法能不能被读懂。
4.3. 确认句柄异常发生时能不能追下去
上篇里出现的句柄泄漏也是如此,句柄相关的问题 最后崩掉的地方和真正的原因很容易错位。
所以想确认的是下面这些。
- 出现 invalid handle stop 时,能不能用
!htrace追 open / close - 能不能和自研日志的
resourceId/sessionId/phase对应起来 - 失败之后 handle count 会不会回落
- 把 harness 做成短命进程时,泄漏的差值是不是更容易看清
看到这一步,就能从单纯的“出了 bug”,走到 “是哪个职责上的生命周期管理垮了”。
flowchart TB
accTitle: 确认句柄异常时能否追下去
accDescr: 出现 invalid handle stop 时能否用 htrace 追 open 和 close、能否与自研日志的上下文对应、handle count 是否回落,一直确认到定位出生命周期管理垮掉的职责。
stop["invalid handle stop"] --> c1["用 htrace 追 open 和 close"]
stop --> c2["与自研日志的上下文对应"]
stop --> c3["确认 handle count 是否回落"]
c1 --> goal["定位生命周期管理垮掉的职责"]
c2 --> goal
c3 --> goal
图12:句柄异常不要停在“出了 bug”,要确认能不能追到是哪个职责上的生命周期管理垮了。
5. 怎么制造类似内存不足、资源不足的现象
5.1. Low Resource Simulation 的思路
Low Resource Simulation 就是所谓的 fault injection。 与其说是把低资源环境原样复现出来,不如说是 人为掺入低资源时会出现的典型 API 失败。
所以它的用武之地相当明确。
- 确认 failure path 的善后
- 确认 retry / reconnect 的稳健程度
- 确认成功与失败交错的初始化流程
- 确认“平常不会发生的失败”出现时日志还留不留得下
这里的诀窍是 不要一开始就让什么都失败。 一上来就全开,日志会爆炸,反而搞不清“自己在看什么”。
flowchart TB
accTitle: fault injection 的收窄方式
accDescr: 一开始就让什么都失败会导致日志爆炸、搞不清在看什么,所以要从最接近想看的 failure path 的失败开始逐项打开。
all["一开始就全部失败"] --> noise["日志爆炸,读不下去"]
narrow["只打开想看的失败"] --> clear["在看什么很清楚"]
图13:fault injection 的诀窍是不要全开,从最接近想看的 failure path 的失败开始逐项打开。
5.2. 能让什么失败
Low Resource Simulation 典型能按概率让下面这些种类的 API 失败。
| 种类 | 例子 | 在设备控制应用中的例子 |
|---|---|---|
Heap_Alloc |
堆分配 | 临时缓冲区、图像 metadata、SDK 包装层内部的分配 |
Virtual_Alloc |
虚拟内存分配 | 较大的帧缓冲区、环形缓冲区 |
File |
CreateFile 等 |
保存路径或日志文件的 open |
Event |
CreateEvent 等 |
frame ready 通知、stop/reconnect 的同步 |
MapView |
CreateMapView 等 |
共享内存或 memory mapped file |
Ole_Alloc |
SysAllocString 等 |
COM / OLE 边界 |
Wait |
WaitForXXX 系列 |
同步等待失败周边 |
Registry |
注册表访问 | 配置读写或驱动周边的设置 |
实务上,与其把全部同时打开,从最接近本次想看的 failure path 的项目开始逐项打开 才是关键。
5.3. 实务中的使用方式
命令行的大致样子,例如下面这样。
appverif /verify CameraHarness.exe
appverif /verify CameraHarness.exe /faults
appverif -enable lowres -for CameraHarness.exe -with heap_alloc=20000 virtual_alloc=20000 file=20000 event=20000
appverif -query lowres -for CameraHarness.exe
光复制粘贴而读不懂意图就没有意义,所以逐行写一下各自在做什么。
| 命令 | 做什么 |
|---|---|
appverif /verify CameraHarness.exe |
对 CameraHarness.exe 启用 Basics 这组测试 |
appverif /verify CameraHarness.exe /faults |
在上面的基础上再启用 fault injection。但对象只有 OLE_ALLOC 和 HEAP_ALLOC |
appverif -enable lowres -for CameraHarness.exe -with heap_alloc=20000 ... |
启用 lowres(Low Resource Simulation),并逐项指定要让哪些种类的 API 失败以及概率 |
appverif -query lowres -for CameraHarness.exe |
显示当前哪些项目以多大概率被设置了 |
appverif /n CameraHarness.exe |
删除该 EXE 的设置(与 -disable * -for、-delete settings -for 目的相同) |
参数怎么读也一并说清楚。
- 概率是百万分率。 可以指定的是 0 到 1,000,000 的整数,
20000就是20000 / 1,000,000,也就是 2%。不是“两万次里 1 次”。Microsoft 的文档也把-with registry=20000 file=20000作为让注册表和文件的 API 以 2% 失败的例子列了出来。 /faults后面可以依次跟概率、宽限时间和 DLL 名。 格式是/faults [概率 [宽限毫秒数 [DLL ...]]]。省略概率就是 5%,省略宽限时间就是 500 毫秒。宽限时间的含义是“从进程启动起的这段时间内不注入 fault”,用来防止启动处理本身失败、导致什么都试不成。/n是解除。n可以理解成“no verifier”的意思。它是与启用成对的命令,用来避免一直开着不管。
-query lowres 的输出,大体会以下面这种形式返回。设置的概率有没有生效、有没有连没瞄准的种类也一起打开了,都可以在这里确认。
Settings for CameraHarness.exe:
Test [lowres] enabled.
Include = *
Exclude =
TimeOut = 2000 (0x7D0)
WAIT = 0 (0x0)
HEAP_ALLOC = 20000 (0x4E20)
VIRTUAL_ALLOC = 0 (0x0)
REGISTRY = 0 (0x0)
FILE = 20000 (0x4E20)
EVENT = 20000 (0x4E20)
MAP_VIEW = 0 (0x0)
OLE_ALLOC = 0 (0x0)
STACKS = false
Include 和 Exclude 是对目标模块的收窄,TimeOut 是启动之后不注入 fault 的时间。默认值会随设置方式而变,所以不要想当然,用这份输出来确认最可靠。
思路大致是这样。
- 先只用
Basics跑正常路径 - 再加上
Low Resource Simulation,带 fault injection 跑一遍 - 需要的话,只给
file或event等想看的失败设置概率 - 想只瞄准特定 DLL 的话,就收窄到那个 DLL 再注入
/faults 这个快捷方式很方便,但只用它的话 以 OLE_ALLOC 和 HEAP_ALLOC 为主。
想看 CreateFile 或 CreateEvent 的 failure path,写到 -enable lowres -with file=... event=... 这一步更稳妥。
在设备控制应用里,与其把 fault 撒遍整个应用,不如收窄到 camera wrapper 或保存路径的 DLL,往往更容易读。
flowchart TB
accTitle: 施加 fault injection 的顺序
accDescr: 先只用 Basics 跑正常路径,再加上 Low Resource Simulation 带 fault injection 跑一遍,只给想看的失败设置概率,必要时收窄到特定 DLL,这样分阶段地施加。
s1["只用 Basics 跑正常路径"] --> s2["加上 Low Resource 再跑"]
s2 --> s3["只给想看的失败设概率"]
s3 --> s4["收窄到特定 DLL 再注入"]
图14:不要一上来就精准打击,从 Basics 的正常路径开始,分阶段收窄 fault injection。
“收窄到 DLL”的具体写法也放在这里。/faults 的第 3 个及之后的参数,就是目标模块的指定。
appverif /verify CameraHarness.exe /faults 50000 1000 CameraSdkWrapper.dll
这样一来,启动 CameraHarness.exe 时,在从启动算起过了 1000 毫秒之后,只有从 CameraSdkWrapper.dll 发起的操作 会以 5%(50000 / 1,000,000)的概率失败。模块名要连扩展名一起写,不加路径。除了 .dll,.ocx 这类会被加载的模块也可以指定。
收窄有没有生效,可以用 appverif -query lowres -for CameraHarness.exe 的 Include 和 Exclude 这两行确认。这里还是 * 的话,说明对象仍然是整个进程。
flowchart TB
accTitle: 收窄到 DLL 的 fault injection 的行为
accDescr: 用 faults 的参数指定概率、宽限时间和目标模块后,从启动算起过了宽限时间,只有从指定 DLL 发起的操作会以指定概率失败,可以用 query 的 Include 和 Exclude 确认收窄。
arg["指定概率、宽限时间和 DLL 名"] --> grace["从启动起的宽限时间内不注入"]
grace --> target["只让指定 DLL 发起的操作失败"]
target --> check["用 query 的 Include 和 Exclude 确认"]
图15:收窄到 DLL 的 fault injection,会在宽限时间过后只让从指定模块发起的操作失败。
如果是在调试器下运行,也可以中途改变范围。
!avrf -trg dll CameraSdkWrapper.dll
!avrf -skp dll VendorSdk.dll
-trg 是“瞄准这里”,-skp 是“跳过这里”。还可以用 !avrf -flt 确认当前的 fault injection 设置,或者用 !avrf -flt stacks 10 查看最近注入的失败的堆栈。
例如,可以构造出这样的场景。
- reconnect 刚开始时的
CreateEvent失败 - 保存开始时的
CreateFile失败 - 临时缓冲区分配失败
- COM 转换中的
SysAllocString失败 - 等待 API 的失败路径确认
这些在平常的正常路径测试里基本踩不到。 正因如此,才值得有意让它踩上去。
6. 怎么观察句柄异常
6.1. Handles 检查
句柄相关的问题,先用 Handles。
这样就更容易检测出 invalid handle 的使用。
典型能奏效的是下面这些问题。
- 再次使用已经 close 的句柄
- 传入已损坏的句柄值
- 使用因中途失败而未被初始化的句柄
- 生命周期垮掉,从别的线程去碰
在长时间运转下看只是“偶尔冒出奇怪的错误”的问题,在 verifier 下有时会当场停下来。 这种提前非常帮得上忙。
flowchart TB
accTitle: Handles 检查能查出的问题
accDescr: 已 close 句柄的再次使用、损坏的句柄值、因中途失败而未初始化的句柄、生命周期垮掉导致从别的线程误用,这些问题在 verifier 下可以当场停下来。
a1["已 close 的再次使用"] --> stop["当场 verifier stop"]
a2["损坏的 handle 值"] --> stop
a3["未初始化的 handle"] --> stop
a4["生命周期垮掉的误用"] --> stop
图16:长时间运转下只会表现为“偶尔冒出奇怪的错误”的问题,Handles 检查会当场把它停下来。
6.2. 用 !htrace 看 open / close 的堆栈
Handles 让人省心的地方,在于 它和 handle tracing 很搭。
从这里开始要用调试器,所以先把 WinDbg 装好。它以 Debugging Tools for Windows 的形式发布,可以从与 Application Verifier 相同的 Windows SDK 安装程序里装上。
windbg -xd av -xd ch -xd sov CameraHarness.exe
!avrf
!htrace 0x00000ABC
第 1 行的选项不是什么咒语。Application Verifier 在检测时抛出的异常有 3 种。
| 选项 | 异常 | 什么时候出现 |
|---|---|---|
av |
访问违例(0xC0000005) |
检测到堆的缓冲区越界时 |
ch |
无效句柄(0xC0000008) |
检测到 invalid handle 的使用时 |
sov |
栈溢出(0xC00000FD) |
判断初始栈不足时 |
而 -xd 是指定 在 second chance 捕获该异常。原因是 first chance 由 Application Verifier 自己处理、用来组装 stop 的信息,如果调试器先插进来就不合适了。如果是在已经启动的调试器里设置,那就等同于敲 sxd av、sxd ch、sxd sov。
用 !htrace 想看的,大致是下面这些。
- 这个句柄是在哪里 open 的
- 在哪里 close 的
- 有没有被当作 invalid handle 引用
- open 有没有堆积得比预想更多
实际长什么样也一并放上来。下面是官方文档里刊载的输出示例,不是本公司环境里的输出,但形式是原样的。
踩到 invalid handle 时,首先会这样显示。
Invalid handle - code c0000008 (first chance)
===================================================
VERIFIER STOP 00000300: pid 0x558: invalid handle exception for current stack trace
C0000008 : Exception code.
0012FBF8 : Exception record. Use .exr to display it.
0012FC0C : Context record. Use .cxr to display it.
00000000 :
===================================================
This verifier stop is continuable.
After debugging it use 'go' to continue.
===================================================
接着敲 !avrf,就会显示当前启用了什么、正在发生哪个 stop。最后一行是要点。
0:000> !avrf
Global flags: 00000100
Application verifier global flag is set.
Application verifier settings (00000004):
- no heap checking enabled!
- handle checks
Page heap is not active for this process.
Current stop 00000300 : c0000008 0012fbf8 0012fc0c 00000000 .
Using an invalid handle (either closed or simply bad).
用 !htrace 查看那个句柄的历史,OPEN / CLOSE / BAD REFERENCE 就会各自带着堆栈排列出来。
0:000> !htrace 7DC
--------------------------------------
Handle 0x7DC - BAD REFERENCE:
0x801902BE: ntoskrnl!NtSetEvent+0x6C
0x010012C1: badhandle!mainCRTStartup+0xE3
--------------------------------------
Handle 0x7DC - CLOSE:
0x801E1EDD: ntoskrnl!NtClose+0x19
0x010012C1: badhandle!mainCRTStartup+0xE3
--------------------------------------
Handle 0x7DC - OPEN:
0x77DE265C: KERNEL32!CreateEventA+0x66
0x010011A0: badhandle!main+0x20
--------------------------------------
读法很直白,只要 CLOSE 之后出现了 BAD REFERENCE,就说明在重复使用已经关闭的句柄。看 OPEN 的堆栈,还能知道这个句柄是在哪里创建的。
句柄泄漏和 handle misuse 麻烦的地方,在于 最后出错的那个 API 并不是真正的原因。
有了 !htrace,就能相当具体地追出这个句柄的历史。
flowchart TB
accTitle: 用 htrace 读句柄历史的方法
accDescr: htrace 会把句柄的 OPEN、CLOSE、BAD REFERENCE 各自带着堆栈排列出来,只要 CLOSE 之后出现 BAD REFERENCE 就说明是已关闭句柄的重复使用,看 OPEN 的堆栈还能知道创建的位置。
open["OPEN〔创建的位置〕"] --> close["CLOSE〔关闭的位置〕"]
close --> bad["BAD REFERENCE"]
bad --> mean["判定为已关闭 handle 的重复使用"]
open -.-> stack["每条记录都带堆栈"]
图17:htrace 的读法很直白,只要 CLOSE 之后排着 BAD REFERENCE,就是已关闭句柄的重复使用。
6.3. 怎么和自研日志结合
话虽如此,光有 Application Verifier 还不够。 尤其是只靠它去排查长时间常驻的 EXE 的 leak,相当吃力。
所以实务上要把下面这些配合起来用。
- 定期的
Handle Count sessionIdresourceIdphase- create/open 与 close/dispose 的 lifecycle log
- verifier stop 时的 dump 与调试器输出
这样一来,例如可以按下面的顺序追下去。
- 在 heartbeat 里发现
Handle Count的斜率可疑 - 在 lifecycle log 里收窄到有
Create却没有Close的 resource - 用 verifier run 把 invalid handle 或 misuse 提前逼出来
- 用
!htrace看 open / close stack
这套组合拳一上,追查就顺手多了。
flowchart TB
accTitle: 自研日志与 verifier 组合使用的步骤
accDescr: 先在 heartbeat 里注意到 Handle Count 的斜率,再用 lifecycle log 收窄到没有 Close 的资源,接着用 verifier run 把误用提前逼出来,最后用 htrace 看 open 和 close 的堆栈。
s1["注意到 Handle Count 的斜率"] --> s2["用 lifecycle log 收窄资源"]
s2 --> s3["用 verifier run 提前逼出误用"]
s3 --> s4["用 htrace 看堆栈"]
图18:斜率的检测靠自研日志、误用的检测靠 verifier,这套分工就按这个顺序串起来。
7. 异常路径测试基础设施的搭建方法
7.1. 把运行单位收敛到 harness
Application Verifier 无法对正在运行中的进程事后启用。 顺序是先设置再启动。
而且设置会一直保留到明确删除为止。 所以实务上,比起正式应用本体,把它收敛到测试用的 harness EXE 更好处理。
例如下面这样的结构。
flowchart LR
A[Scenario Runner] --> B[CameraHarness.exe]
B --> C[CameraSdkWrapper.dll]
C --> D[Vendor SDK]
B --> E[Structured Log]
B --> F[Dump / Debugger]
图19:按 1 个场景 1 个进程来跑的 harness 结构。verifier 的对象不是 DLL,而是驱动它的 harness EXE。
这样一来,就有
- 可以按 1 个场景 1 个进程来跑
- 泄漏的差值容易看清
- AppVerifier 设置的开关容易切换
- 想测 DLL 时也能从 EXE 一侧处理
这些好处。
命令的大致样子是这样。
appverif /verify CameraHarness.exe
appverif /n CameraHarness.exe
/verify 是启用 Basics,/n 是删除设置(也请参考 5.3 的表)。
启用要在启动之前,解除要明确执行。
把这一块按 harness 的前提来跑,设置上的失误也更容易减少。
7.2. 把测试菜单拆开
在异常路径测试基础设施里,最好不要一次把全部做完。 大体拆成下面 3 条线会比较好读。
- 正常路径 + Basics
- 不注入任何失败
- 确认不出现 verifier stop
- fault injection 类
Low Resource Simulation- 瞄准
event/file/heap_alloc/virtual_alloc等让它失败
- heap 深挖类
Heaps- full page heap
- 在 debugger 下做局部复现
把这里拆开之后, “是平常的用法本身就坏了” 和 “只在低资源时才坏” 就不容易混作一团。
特别是有没有 fault injection,会让走到的 code path 差别相当大。 所以 不带 fault 的 run 和 带 fault 的 run 两边都跑比较好。
flowchart TB
accTitle: 拆成三条线的测试菜单
accDescr: 把测试拆成不注入任何失败的正常路径加 Basics、用 Low Resource Simulation 瞄准着让它失败的 fault injection 类、以及在 debugger 下跑 full page heap 的 heap 深挖类这三条线。
menu["异常路径测试的跑法"] --> m1["正常路径与 Basics"]
menu --> m2["fault injection 类"]
menu --> m3["heap 深挖类"]
m1 -.-> p1["确认不出现 stop"]
m2 -.-> p2["注入瞄准好的失败"]
m3 -.-> p3["在 debugger 下局部复现"]
图20:不要一次全做完,拆成三条菜单,哪里坏掉就不会混作一团。
7.3. 要收集的东西
最起码,下面这些是想留下来的。
| 种类 | 想要的内容 |
|---|---|
| 应用日志 | cameraId、sessionId、phase、handleCount、error code |
| 进程状态 | Handle Count、Private Bytes、Thread Count |
| 调试器信息 | !avrf、!htrace、必要时还有 !heap -p -a |
| dump | verifier stop 时,或者异常结束时 |
| AppVerifier 日志 | stop 的记录,必要时转成 XML 来汇总 |
需要的话,AppVerifier 一侧的日志也可以转成 XML 汇总。 不过只看那一份往往定不了原因,所以按“要和自研日志并排着读”的前提来做更贴近实务。
日志多本身并不值得夸耀。 事后因果能连得起来 才重要。
7.4. 合格条件
合格条件如果只有“没崩”,也太弱了。 在这次的脉络下,至少需要下面这些。
- 正常路径 + Basics 下不出现 verifier stop
- 即使带 fault injection,预想中的失败也会留在日志里
- 初始化只做了一半的资源能被干净地收拾掉
- reconnect / retry 之后
Handle Count回到接近 baseline 的位置 - 出现 verifier stop 时,能靠
sessionId/phase/ 堆栈追下去 - 不会变成“不知道发生了什么”的失败
这里重要的是, 把 不会坏 和 坏了能追 分开来评价。
flowchart TB
accTitle: 合格条件的两个维度
accDescr: 把合格条件分成正常路径下不出现 verifier stop 且资源能收拾干净的不会坏这一维度,以及预想中的失败留在日志里、能靠上下文和堆栈追下去的坏了能追这一维度来评价。
pass["合格条件"] --> a["不会坏"]
pass --> b["坏了能追"]
a --> a1["不出现 stop"]
a --> a2["资源能收拾干净"]
b --> b1["失败留在日志里"]
b --> b2["能靠堆栈追下去"]
图21:只有“没崩”太弱,要把不会坏和能追下去当作两个不同的维度来评价。
7.5. 注意事项
Application Verifier 相当方便,但不是魔法。
- 实际没有走到的代码路径不会被检验
- full page heap 很重
- 有时会在第三方 SDK 一侧冒出 stop
- 有没有 fault injection,走到的代码路径差别相当大
- 它不是用来一个人包办纯托管 heap leak 排查的工具
所以它的站位是这样。
- 长时间的斜率 靠自研日志和 counters
- 原生边界的误用 靠 Application Verifier
- 异常时因果的还原 靠 structured log + dump + debugger
这样的分工最贴近实务。
flowchart TB
accTitle: 排查分工的全貌
accDescr: 长时间的斜率用自研日志和 counters 看,原生边界的误用用 Application Verifier 看,异常时因果的还原用 structured log、dump 和 debugger 看,图中给出这一分工。
q1["长时间的斜率"] --> t1["自研日志和 counters"]
q2["原生边界的误用"] --> t2["Application Verifier"]
q3["异常时因果的还原"] --> t3["日志、dump 和 debugger"]
图22:Application Verifier 不是万能法杖,要按想看的东西让工具分工。
8. 粗略的分工使用
- 怀疑 invalid handle 或 double close
Handles+!htrace
- 怀疑 heap corruption / use-after-free
Heaps+ full page heap +!heap -p -a
- 想制造类似内存不足、资源不足的现象
Low Resource Simulation
- 长时间运转下慢慢坏掉
- 先看自研的
Handle Count/Private Bytes/ lifecycle log
- 先看自研的
- 想测试 DLL
- 对调用该 DLL 的 harness EXE 启用 Application Verifier
一开始就全开,基本上会变成一片日志的浓雾。 从最接近想看的 failure path 的刀刃开始下手,要清楚得多。
9. 总结
Application Verifier 的站位,是 Windows 的 native / Win32 边界上的 runtime verifier。用 Handles / Heaps / Locks / Memory / TLS / Low Resource Simulation 等,可以提前踩到平常不容易出现的 failure path。
在这次的脉络下见效的,是句柄异常发生时用 !htrace 好追、能在不弄垮整台机器的前提下制造出类似内存不足和资源不足的现象,以及能借此确认自研日志到底顶不顶用。
实务上的跑法是:把正常路径 + Basics 和 fault injection 类分开,准备好 harness EXE,用短命进程来跑场景。在此之上再与自研日志、dump、调试器信息组合,长时间 leak 的斜率本身则用自研 counters 来看,形成这样的分担。
Application Verifier 是 用来不再“碰运气等着”那些“难得一见的异常”,而是“主动把它迎过来”的工具。
对设备控制应用来说,不坏固然重要, 但坏了的时候 能说明发生了什么,同样重要。 从这个意义上说,我觉得它是相当贴近实务的工具。
10. 参考资料
- 上篇:工业相机长时间运行崩溃排查 - 句柄泄漏篇
- Application Verifier - Overview
- Application Verifier - Testing Applications
- Application Verifier - Tests within Application Verifier
- Application Verifier - Debugging Application Verifier Stops
- Application Verifier - Features
- !htrace (WinDbg)
- !avrf (WinDbg)
- Download Debugging Tools for Windows
- Windows SDK 下载
- GetProcessHandleCount 函数 (processthreadsapi.h)
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
工业相机长期运行崩溃调查 - handle 泄漏篇
本文以工业相机控制应用长时间运行后突然崩溃的实际案例,从 handle 泄漏的排查方法与日志设计的角度,整理 Windows 应用长时间运行后崩溃时的排查思路。
睡眠恢复后就出故障的应用——电源事件机制与扛得住恢复的业务应用设计
打开笔记本电脑时业务应用的通信已经断开——原因是设计没有考虑睡眠。本文依据一手资料讲解 WM_POWERBROADCAST 的通知流程、Modern Standby 的行为、断开与重连的设计、睡眠抑制以及调查命令。
DllMain 与加载器锁——“DLL 初始化里什么都别做”的真正原因
为什么不能在 DllMain 里调用 LoadLibrary 或与线程同步。本文依据一手资料,从串行化全部 DLL 通知的加载器锁机制,讲到死锁成立的典型场景、延迟初始化等正确设计,以及挂起的调查步骤。
“无响应”的真相——Windows 如何判定应用卡死,以及不会卡死的设计
Windows 的“无响应”是操作系统在窗口 5 秒未取出消息时作出判定、并换成幽灵窗口的机制。本文讲解判定的内部动作、卡死的常见原因、把繁重处理移出 UI 线程的设计,以及挂起的调查步骤。
WPR/WPA 实战——从系统整体排查“整台 PC 变慢”的性能调查入门
“整台 PC 变慢”“开机很慢”这类任务管理器追不到的性能问题,可以用采集并阅读整个 OS 的 ETW 跟踪的 WPR/WPA 来排查。本文从 wpr.exe 的采集步骤,一直讲到在 WPA 中怎么读 CPU、等待时间和磁盘 I/O。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
故障调查 & 长期运行故障
整理间歇性故障、通信诊断、长期运行崩溃、失败路径测试基础的主题页面。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
故障调查 & 根本原因分析
Application Verifier 与异常路径测试基础设施,是推进故障复现与原因定位的故障排查与原因分析的核心主题。
技术咨询 & 设计评审
如果想梳理异常路径测试和观测点应当在设计中织入到什么程度,可以作为技术咨询与设计评审来考虑。
常见问题
汇总了咨询这一主题时常见的问题。
- Application Verifier 是什么?
- 它是针对 Windows user-mode 应用的运行时检验工具。它监控运行中应用对 OS API 的使用和资源的处理方式,既能检测 invalid handle 的使用、heap corruption 这类可疑用法,也能故意注入失败。和静态分析、单元测试不同,它看的是实际走过这条代码路径时会怎么坏,因此适合把平常的功能测试看不到的 failure path 逼出来。
- 用 Application Verifier 能重现内存不足吗?
- 用 Low Resource Simulation,不必真的把机器的 RAM 耗尽,就能提前制造出接近内存不足、资源不足的现象。原理是 fault injection,让 HeapAlloc、VirtualAlloc、CreateFile、CreateEvent 等 API 调用按一定概率故意失败。还可以只瞄准特定 DLL 注入失败,所以在自研包装层与 vendor SDK 混在一起的结构里也好处理。不过一开始就让什么都失败会导致日志读不下去,诀窍是从最接近想看的 failure path 的项目开始,逐项打开。
- 排查句柄泄漏能用 Application Verifier 吗?
- 启用 Handles 检查后,可以检测出重复使用已 close 的句柄这类 invalid handle 用法,而且 handle tracing 也会自动启用,可以用 !htrace 追这个句柄的 open / close 堆栈。不过,把长时间常驻的 EXE 的泄漏排查全部丢给 Application Verifier 并不现实。实务上应与定期记录的 Handle Count、resource lifecycle 的自研日志结合,形成斜率的检测靠自研日志、误用的检测靠 verifier 的分工。
- 想用 Application Verifier 测试 DLL 该怎么做?
- 启用 Application Verifier 的对象,是实际驱动这个 DLL 的测试用 EXE。它无法对已经在运行的进程事后启用,必须先写好设置再启动。而且设置会一直保留到明确删除为止,所以比起正式应用本体,把它收敛到测试用的 harness EXE 更好处理。按 1 个场景 1 个进程来跑,泄漏的差值也更容易看清,设置的开关也更容易切换。