PoolMon实战:从内核池内存泄漏到驱动归因

发布时间:2026/10/22 23:38:50
PoolMon实战:从内核池内存泄漏到驱动归因 有很长一段时间只要线上机器出现“内存缓慢上涨重启就好”的毛病我都会先打开 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 字符 TagPer Alloc 固定WinDbg!poolused 2 Tag归因系统变慢但内存看着还有余量性能计数器 Pool Paged Bytes 趋势Diff 白天涨夜间不回落夜间低谷打快照确认无回收某驱动加载后内存立刻上涨加载前后 PoolMon 快照对比新出现且持续增长的 Tag直接卸载驱动验证是否回落频繁出现内存相关蓝屏转储!analyze -v PoolMon 日志错误码指向某驱动开驱动验证器取调用栈任务管理器“非分页缓冲池”数值跳变PoolMon 按 Bytes 增量排序单个分配对象很大Diff 变化小优先看 Bytes 增量的标签不只盯 Diff怀疑某标签但查不到归属!poolfind TagName找地址共享标签或被其他组件占用加载/卸载时序法缩小范围上面这张表不是万能药但它能避免我在半夜处理故障时头脑发热乱翻日志。工具的职责是把问题从“整个系统不可控”推进到“某个驱动有嫌疑”剩下那些真正需要改代码、改配置的活还是得靠对业务和驱动架构的理解。PoolMon 只是排障工具箱里的一个锤子但它往往是最先抡出去、也最能快速敲出裂缝的那一把。最后说一个我自己常做的小动作排查完一次池泄漏我会把 PoolMon 的启动脚本和两三个关键快照归档到项目目录里等下次再看这个系统时先翻旧账对照一下历史 Diff 水位。这样既能观察长期趋势又能防止同样问题换台机器重新上演。内核池内存这玩意儿涨得快不可怕怕的是没人知道它在涨只要监控跑起来了绝大多数泄漏都藏不了多久。