有很长一段时间,只要线上机器出现“内存缓慢上涨,重启就好”的毛病,我都会先打开 PoolMon,而不是急着上用户态内存分析器。内核态的内存泄漏和用户态完全是两码事,前者用常规的 gflags、UMDH 那套东西基本无效,而 PoolMon 这类基于池标签的监控工具,几乎是一条最短路径。机器跑几天就变卡、深夜触发蓝屏、任务管理器看起来一切正常,这些症状组合在一起,十有八九是内核池在悄悄涨。
这篇文章我打算从排查链路讲起,先把 PoolMon 在整个定位过程中负责哪一段说清楚,再讲工具怎么用、输出怎么看,然后沿着一次完整的实操流程演示如何锁定可疑池标签,最后补充和内核调试器配合的深挖手段。适合正在跟驱动崩溃、系统蓝屏较劲的内核开发、驱动排障、运维底层的朋友参考。
1. 先定位再修复:泄漏排查的整体思路
1.1 池内存是什么,泄漏为什么漏得“看不见”
Windows 内核在运行时会从两种内存池里申请小块内存:非分页池(NonPaged Pool)和分页池(Paged Pool)。非分页池里的内存任何时候都必须待在物理内存里,不能被换到磁盘上,所以内核对象、自旋锁、IRP、设备对象这类要紧的东西都放在这里面;分页池则允许换出到磁盘,适合放一些不要求常驻的数据。驱动加载后要干活,几乎必然要在这两个池里分配内存,用完再通过匹配的释放接口还回去。
如果某个驱动分配了内存,却因为逻辑缺陷少了一次释放,这块内存就会一直挂在对应池里。日积月累,非分页池或者分页池的占用就会单调上涨。问题是这种上涨在任务管理器里并不直观。任务管理器、资源监视器给你展示的是每个进程的用户态内存、工作集、提交大小,内核池属于系统级资源,不归到具体某一个进程头上。你看到“物理内存还有好几个 GB”,就觉得内存没压力,其实非分页池可能已经涨到了一个危险水位,最终把系统内存拖垮或者触发蓝屏。
我用一个粗浅的类比:用户态内存出问题,就像你看到家里垃圾篮满了,能明确知道是谁丢的;内核池泄漏,则像是整栋楼只有一个公共垃圾房,你也知道垃圾房满了,但要找出是哪一户在持续往里丢,必须去翻垃圾的包装来源。PoolMon 干的就是这件事——它不负责告诉你“哪一行代码写错了”,但是能非常快速地把问题范围从“整个系统”缩小到“某个 4 字符的池标签”,这一步缩小是最关键的。
1.2 PoolMon 在整个定位链路里的分工
完整的内核内存泄漏定位链路通常是四步:确认现象、锁定池标签、归因到驱动、验证修复。PoolMon 负责第二段,它是第一线的排查探头。确认现象靠的是性能计数器或者任务管理器里“非分页缓冲池”的数值趋势;锁定池标签靠 PoolMon 的持续采样;归因到驱动,一般要靠内核调试器里的!poolused、!poolfind之类的扩展命令,必要时配合驱动验证器;修复后要重新跑 PoolMon 确认 Diff 列不再单调增长。
为什么 PoolMon 能在这一步替代调试器直接扫描?因为它基于 Windows 内核的池管理机制,每个分配都可以带上一个 4 字节的池标签,PoolMon 会把系统中所有内核池分配和释放按标签汇总成实时统计。你不用打断系统运行,不依赖调试器连接,也不需要额外装一堆符号文件,在控制台里跑起来就能看。对一个内存缓慢上涨的生产事故来说,这种“低成本、不打扰”的探针是很有价值的。
但也要把预期放准,PoolMon 不能告诉你具体的调用栈,也不能直接指出是哪个驱动文件。它给的是一个非常具体的“嫌疑人名单”,真正“定罪”还是要结合调试器或者驱动画像信息。我的习惯是:先让 PoolMon 持续跑一个周期,拿到嫌疑 Tag 后,再开 WinDbg 去验证对应分配点的详细信息。两条腿走路,效率最高。
2. 环境准备与工具上手:PoolMon 的安装、参数与输出解读
2.1 运行好 PoolMon 先搞清这几个前提
PoolMon 是 Sysinternals 套件里的经典小工具,整个程序只有一个可执行文件,不需要安装。下载后扔到任意目录,右键管理员权限启动命令行,直接运行PoolMon.exe就能看到实时统计。需要注意几点:必须以管理员身份运行,否则无法读取内核池的统计信息;部分环境可能需要关闭内核调试器的占用,或者在调试器附加期间会显示不完整;机器本身的内存压力如果已经很高,开启刷新反而会加剧负担,建议在一台有同类型负载、但还没完全崩溃的机器上排查。
我习惯把 PoolMon 的存放目录固定下来,比如C:\Tools\Sysinternals\PoolMon.exe,然后写一个小脚本,启动时自动记录当前时间和日志路径。因为这种问题往往是隔夜才发作的,人不可能整晚盯着控制台,把日志落盘才是正道。另外,在排查生产机之前,最好在一台测试机上先跑一遍,熟悉输出节奏和参数行为,避免生产机上手忙脚乱。
2.2 五分钟看懂输出:每一列都不是白给的
PoolMon 启动后,控制台会持续刷新,每一行对应一个池标签。这里我用一段伪数据示意关键列:
Tag Type Allocs Frees Diff Bytes Per Alloc TPro NonPaged 1,200,000 1,198,500 1,500 225,000 150 Mdl NonPaged 350,200 349,000 1,200 144,000 120 Io Paged 82,000 81,990 10 4,000 ...第一列Tag是这个内存分配的池标签,固定 4 个字符。内核开发者在调用分配接口时可以选择自定义标签,比如ExAllocatePoolWithTag里那个 Tag 参数,相当于是给内存块贴了一个归属标签。第二列Type表示落在非分页池还是分页池,不同场景下的嫌疑对象差别很大。
后面几列是整个工具的核心:Allocs是历史上分配的总次数,Frees是释放的总次数,这两个数相减得到的Diff,就是当前还“欠着”没有归还的分配对象数量。Bytes表示这些未归还对象一共占了多少字节,Per Alloc则是每个对象的平均大小。我判断泄漏的第一眼永远看Diff——如果一个标签下的 Diff 值随时间单调上涨,而且涨得没有停止迹象,就已经具备嫌疑了。
提示:不要把 Allocs 当成当前内存占用,它是累计分配次数的计数器,只有 Allocs 减 Frees 的差值,才是真正影响内存水位的东西。
2.3 保存快照与筛选:别指望肉眼看滚动台
默认情况下 PoolMon 是实时刷屏的,但人眼看滚动输出很难发现趋势。要把工具从“看一眼”变成“盯一晚”,靠的是参数。我常用的启动方式类似这样:
PoolMon.exe /r 10 /c /t /s C:\logs\poolmon_snapshot.txt/r 10表示每 10 秒刷新一次,这个间隔可以根据排查周期调整,轻量观察可以 30 秒,快速复现可以 2 秒。/c是压缩模式,只显示在刷新间隔内有变化的池标签,可以过滤掉大量长期不动的“死水”条目,让视线聚焦在活跃项上。/t让输出按标签名排序,找特定标签时比较方便。/s后面跟文件路径,则会把快照内容追加写入日志文件,这是长时间挂机采集最实用的一个功能。
如果你更看重那些不断增长的项,而不是某个具体标签,也可以按Diff或者Bytes排序。我在实际操作里一般会先记录一次基线快照,隔一段时间再记录一次,用tab键(或者手动切出日志文件)对比两次快照中的 Diff 差值,这样比盯着控制台有意义得多。毕竟工具只要在跑,数据就在积累,你要做的只是在几个关键时间点把现场数据“钉”下来。
3. 一次完整实操:从基线到锁定可疑池标签
3.1 建立基线并维持一个采集周期
真实的排查场景往往长这样:某台机器运行一个业务服务,过了几个小时后系统可用内存持续走低,重启后好一阵子,再犯。我先不急着下结论,而是先保证问题发生时 PoolMon 有数据。具体做法是:在服务刚启动或者系统刚重启时,记录一次基线快照,然后在预期可能出问题的时间段持续运行 PoolMon 写日志。
基线快照里我会重点关注几个默认高占用标签的 Diff 值,比如文件缓存相关的标签、TCP/IP 相关的标签、图形驱动相关标签。这些内容可能一开始就有较大的 Diff,但只要它是稳定的、不持续膨胀,就不算泄漏。基线采集周期最好覆盖一次完整的业务高峰和低谷,比如至少 12 到 24 小时。很多内核池泄漏不是每时每刻都在漏的,它只在特定操作触发时才分配新内存,所以采样周期太短,可能什么都看不出来。
在采集期间,我还会顺手把系统自己提供的性能计数器数据导一份,重点看Memory\Pool Nonpaged Bytes和Memory\Pool Paged Bytes两个计数器的走势。这两个计数器和 PoolMon 里的总量是否能对上,能交叉验证采集是否有效。如果计数器在涨,而 PoolMon 日志里却找不出对应标签在涨,那就要留意是不是池标签统计本身被关闭了,或者排障目标根本不在这台机器上。
3.2 对比快照,锁定“只增不减”的标签
等到问题明显出现的窗口期,我会把 /s 落盘的文件打开,直接看两次快照的差异。这里有个小技巧:直接把两份快照交给对比工具,按 Tag 做差,算出每个标签的 Diff 增量,然后按增量从大到小排。不需要写复杂脚本,Excel 都能干,只要注意快照文件里可能有重复刷新的行,先保留最后一次刷新对应的数据即可。
假设我拿到这样一个候选清单:某个标签从第一次快照的 Diff 是 1500,第二次变成 3200,第三次变成 5100,每次采样间隔内都在稳定增长,而且 Per Alloc 是固定 150 字节。这种形态非常典型——分配次数在涨,字节数在涨,单对象大小始终一致,说明每次都漏掉了一个结构体大小相近的对象。我可能带着这个标签去系统里搜一下历史资料,看看有没有对应所属模块的线索。
如果 App 层面对字节数要求不高,也可以把焦点放在Bytes增量最大的标签上。有次我在一个偏存储的场景里,就是因为一个标签的单次分配对象是几十 KB 的缓冲区,Diff 变化不大但 Bytes 涨得飞快,按 Diff 排序反而把真正的大头漏掉了。所以我的建议是:先按 Bytes 增量排一轮,再按 Diff 增量排一轮,两个维度都看,取交集。
3.3 排除合理增长:不是所有上涨都是泄漏
经过前面两步,你会得到一批“上涨”的标签,但先别急着把所有上涨项都当泄漏。有些池内存的增长本身就是合理行为,最典型的是文件缓存、网络缓冲、会话空间、驱动程序内部的对象缓存。比如某些存储驱动会做一个内存化的“最近访问”缓存,只要业务高峰来了,它的池占用就会涨,但涨到一定程度后就不再涨,或者会周期性回收。这类增长的特点是曲线呈“锯齿形”——快速爬升后回落,而不是一路单边向上。
判断合理增长和泄漏增长的另一个维度是看会不会回到基线。一个健康模块即使在业务高峰分配了大量对象,在业务空闲时也会释放掉,快照对比会看到 Diff 回落;而泄漏模块的特点是只上不下,空闲期也维持在高位。我习惯在凌晨业务低谷时再打一次快照,如果嫌疑标签的 Diff 依然和白天峰值接近,那基本就能排除“临时缓存”的嫌疑,坐实“只借不还”的结论。
还有就是分页池和非分页池要分开看。分页池的波动天然比非分页池剧烈,一些文件系统和网络系统会大量使用分页池做中间数据存储;非分页池的持续增长则更危险,因为每涨一兆都是在直接挤占物理内存。如果同一个标签在两个池里都有分配,优先盯非分页池那边的 Diff 走向。
4. 深挖到驱动:PoolMon 与内核调试器配合
4.1 让调试器接手:!poolused、!poolfind 怎么用
PoolMon 给出嫌疑标签后,下一步就是把这个标签映射到具体驱动,这一步我有九成情况会开内核调试器。在目标机器上打开内核调试通道,用 WinDbg 连接到被调试机,先加载符号,然后执行类似的命令:
!poolused 2 TPro这个命令会显示出所有以TPro为池标签的分配统计,包括分页池、非分页池各自的累计分配数、释放数和当前占用。如果系统里刚好只有某个驱动在用这个标签,输出会非常干净,一眼就能看出它是谁。如果命令输出一堆“unknown”或者找不到与可疑点的关联,说明标签被共享了,这时候我会换!poolfind TPro去找物理地址,再配合!pool检查单个地址块的详情感。
!poolfind会把带有该标签的所有池块地址列出来,你挑几个状态是“正在使用”的块做进一步检查。!pool加地址,可以看到这个池块属于哪个进程、上下文环境是什么,有时候还能附带显示所属驱动模块的信息。这套组合拳打下来,绝大多数场景都能把嫌疑锁定到一个驱动文件或者一个子系统上。
不过要提醒一点:WinDbg 显示的归属信息依赖准确的符号和池追踪机制,如果目标系统开启了某些优化或者调试信息缺失,命令输出可能不完整。这时候可以退一步,用lm列出已加载模块,结合 PoolMon 在加载某个驱动前后 Diff 发生跳变的时机,来反推是哪个模块干的。这种“时序归因法”不需要任何高级调试技巧,只要采集日志的时间点掐得准。
4.2 从分配点逆推引用:对象、IRP 与释放路径
定位到驱动之后,离最终修复还有一步:找到为什么它不释放。内核池泄漏的根因常常不是“少写了一次ExFreePoolWithTag”这么简单,而是某个对象一直有人持有引用,导致对象无法销毁,进而它的内存也一直留在池里。比如设备对象被反复打开,每次打开都漏掉一次ObDereferenceObject;或者 IRP 在完成例程里没有调用IoCompleteRequest,导致请求卡死,对应缓冲区也不释放。
拿到嫌疑池标签后,我一般会继续查这些信息的组合。先看分配的对象是什么类型,如果是设备对象相关,用!object查看对象引用计数;如果是 IRP 相关,用!irp查看请求状态;如果是同步事件或锁对象,用!thread、!process对比持有者的状态。这些命令不需要每一步都用,而是根据标签类型选择。常见情况是通过对象句柄表看到某个进程打开了一堆设备句柄没有关闭,顺着句柄就能追到上层调用方。
如果你手头没有实时调试条件,另一个不打断系统的思路是用驱动验证器(Driver Verifier)的池跟踪功能。启用验证器的特定规则后,系统会在驱动做池分配时记录完整调用栈,一旦发现释放异常或者分配-释放不匹配,会生成有详细栈信息的内存转储。这个手段比手工排查更暴力,直接让系统在“证据完整”的情况下崩溃,后续从转储里提取的调用栈会直接指出问题代码行,效率极高。
4.3 驱动验证器的妙用:把泄漏变成崩溃现场
如果普通调试查了两轮还摸不到头脑,我是建议直接开驱动验证器的。命令很简单:
verifier /standard /driver 某驱动.sys重启后系统会对目标驱动启用更强的内存检查,其中“特殊池分配”和“池跟踪”两项特别适合抓泄漏。特殊池会把内存块放到独立页边界,越界、重复释放、释放后使用都会被立即检测到;池跟踪则记录每次分配时的调用栈。很多驱动泄漏只要开了池跟踪,第一次触发时就可能在调试输出里留下“分配了但从未释放”的栈信息。
当然,驱动验证器也不是没有代价。它会显著放大内存分配开销,目标驱动所在机器的性能会有可见下降,而且某些老驱动在验证器下可能直接蓝屏。所以我的经验是,先在测试机或者非核心业务机上跑验证器,等确认了根因再考虑是否在生产环境复验。如果驱动本身存在未初始化的内存访问,验证器蓝屏反而是一个快速定位契机,不是坏事。
修复驱动后,一定要回到 PoolMon 再做回归:在同样负载下采集同样长时间,看嫌疑标签的 Diff 是否仍然单调上涨。如果 Diff 稳定在一个值附近或者开始回落,说明分配释放已经恢复平衡;如果还在涨,那就要考虑是不是驱动还依赖了另一个共享标签,并没有真正释放干净。这一步不能省,我见过太多人改了代码却忘了复测,结果问题换了个 Tag 继续发酵。
5. 容易翻车的细节与多年实践心得
5.1 池标签复用问题:别看到 Tag 就着急定罪
池标签只有 4 个字符,空间很有限,系统里不同的组件、不同的驱动共用同一个 Tag 的情况并不少见。一个看起来很可疑的标签,可能同时被某个驱动在用它做缓存,又被另一个驱动用来做临时缓冲区。我做存储方向时不止一次遇到过这种情况:PoolMon 显示某个 Tag 涨得飞快,但按标签搜出来的历史记录根本对不上号,最后发现真正上涨的其实是另一个模块。
面对这种复用情况,不要迷信历史资料,也不要急着下结论。先用调试器的!poolused确认标签归属,再看是否有多个模块同时在使用,如果确实分不开,就回到时序法:分别在模块加载前、加载后采样,观察 Tag 的 Diff 增量是否与模块活动强相关。我自己有个不成文的规矩:一个标签只有在“Diff 单调上涨 + 单对象大小规律 + 加载/卸载时序吻合”三条同时满足时,才会把它列为最终嫌疑人。
另外要注意 PoolMon 统计的完整性问题。某些驱动在分配时没有指定自定义 Tag,系统会填一时看到的默认值,这类分配往往聚集在一些通用标签下,归属信息基本无从谈起。此时与其花大力气去拆通用标签,不如把矛头集中在自定义标签上,因为自定义标签基本是驱动作者故意留下的“签名”,通常和问题点高度相关。
5.2 几个实用技巧:持续监控与联合检查
长时间挂机跑 PoolMon 时,日志文件可能膨胀得非常快。我一般在启动命令里加上cmd /c配合计划任务,把这个工具注册成开机自启并把日志输出到独立磁盘区域,避免频繁刷新把系统盘写满。日志文件建议按日期切分,排查结束后把前后快照导入表格做增量计算,比用文本工具直接翻靠谱得多。
还有一个容易忽略的点:排查前期就要把“发生过几次崩溃”这个信息利用起来。如果系统已经蓝屏过,WinDbg 打开内存转储时,可以先用!analyze -v看崩溃码,很多内核池泄漏在崩溃瞬间的状态里已经嵌入了线索,比如DRIVER_VERIFIER_DETECTED_VIOLATION、POOL_CORRUPTION这类错误码,能直接把问题圈定到某个驱动;再结合 PoolMon 的日志,你手里就有“崩溃前池水位”和“崩溃时调用栈”两条证据链,定位速度会快非常多。
另外,如果排查环境允许,我会在嫌疑驱动上做一次“最小化复现”:手动调用它对外暴露的读、写、打开、关闭操作各若干次,同时用 PoolMon 实时看对应 Tag 的 Diff 变化。这样的受控实验能快速确认问题的触发面——到底是普通 I/O 泄漏,还是某个异常路径才泄漏。这在开发调试阶段比在生产环境里守株待兔高效得多。
5.3 快速参考表:常见症状与下一步动作
最后整理一张我平时自用的速查表,按症状对号入座即可:
| 症状 | 第一步该看什么 | 典型 Tag 特征 | 下一步动作 |
|---|---|---|---|
| 非分页池持续上涨,最终蓝屏 | PoolMon 非分页池 Diff 增长项 | 自定义 4 字符 Tag,Per Alloc 固定 | WinDbg!poolused 2 Tag归因 |
| 系统变慢,但内存看着还有余量 | 性能计数器 Pool Paged Bytes 趋势 | Diff 白天涨夜间不回落 | 夜间低谷打快照确认无回收 |
| 某驱动加载后内存立刻上涨 | 加载前后 PoolMon 快照对比 | 新出现且持续增长的 Tag | 直接卸载驱动验证是否回落 |
| 频繁出现内存相关蓝屏 | 转储!analyze -v+ PoolMon 日志 | 错误码指向某驱动 | 开驱动验证器取调用栈 |
| 任务管理器“非分页缓冲池”数值跳变 | PoolMon 按 Bytes 增量排序 | 单个分配对象很大,Diff 变化小 | 优先看 Bytes 增量的标签,不只盯 Diff |
| 怀疑某标签但查不到归属 | !poolfind TagName找地址 | 共享标签或被其他组件占用 | 加载/卸载时序法缩小范围 |
上面这张表不是万能药,但它能避免我在半夜处理故障时头脑发热乱翻日志。工具的职责是把问题从“整个系统不可控”推进到“某个驱动有嫌疑”,剩下那些真正需要改代码、改配置的活,还是得靠对业务和驱动架构的理解。PoolMon 只是排障工具箱里的一个锤子,但它往往是最先抡出去、也最能快速敲出裂缝的那一把。
最后说一个我自己常做的小动作:排查完一次池泄漏,我会把 PoolMon 的启动脚本和两三个关键快照归档到项目目录里,等下次再看这个系统时,先翻旧账对照一下历史 Diff 水位。这样既能观察长期趋势,又能防止同样问题换台机器重新上演。内核池内存这玩意儿,涨得快不可怕,怕的是没人知道它在涨;只要监控跑起来了,绝大多数泄漏都藏不了多久。