前几天帮朋友排查一个很折磨人的问题:一台 Windows 服务器运行一周后内存占用就会慢慢爬到 90% 以上,重启后立刻恢复,但过几天又开始重复。任务管理器里看不出哪个进程异常,跑内存诊断也找不出症结。后来问题定位到内核层——是某个驱动在内核池里反复分配内存却不释放,也就是典型的内核池内存泄漏。排查这类问题最快的入口,就是 PoolMon 这个老牌工具。
这篇文章不打算罗列文档式的 API 说明,而是带着你从一次真实的泄漏排查过程出发,说清楚 PoolMon 怎么用、输出怎么看、哪些情况它查不了,以及怎么和 Driver Verifier 配合把问题钉死。适合做驱动开发、系统集成或者服务器运维的读者,哪怕之前没碰过内核调试,照着操作也能上手。
1. 动手之前:先搞清内核池泄漏藏在哪里
1.1 分页池与非分页池,以及任务管理器为什么"骗"了你
Windows 给内核组件和驱动准备了一套独立的内存分配区域,叫做内核池(Kernel Pool)。它和用户态进程的堆完全不是一个东西,所以你在任务管理器里看进程内存,永远看不到池的真实状态。内核池又分成两种:分页池(Paged Pool)和非分页池(Nonpaged Pool)。
分页池的好坏参半:它允许在内存压力大时把部分内容换出到页面文件,所以尺寸可以很大,系统也能扛得住;非分页池则完全不同,它必须始终驻留在物理内存里,因为中断处理、DMA 操作、自旋锁保护等代码路径根本无法承受缺页中断。正因如此,非分页池泄漏是最致命的一类——它每涨一点,可用的物理内存就少一点,而且无法通过任何手段缓解,等耗尽之后系统会直接陷入卡死、蓝屏或必须强制重启的境地。
很多朋友习惯用"任务管理器 + RAMMap"来判断内存去哪了。RAMMap 能看到 Nonpaged Pool 的总量,但它只能告诉你总量涨了,不能告诉你是哪个组件涨的。这种时候 PoolMon 的价值就体现出来了:它能把内核池里所有带 Pool Tag 的分配行为按标签聚合,让你直观看到"谁在一直分配却不释放"。
进程内内存泄漏和内核池泄漏的现象也完全不同:
- 进程泄漏:任务管理器中某个进程的"内存"列持续增长,结束进程后内存立刻回落。
- 内核池泄漏:没有任何进程异常,但系统总体内存(或者提交大小)稳定爬升,重启后一切恢复。
- 另一种典型现象:系统运行一段时间后,用户能明显感觉变卡,但打开任务管理器看每个进程都正常,硬凑也凑不出系统报的高内存数据。
如果你遇到的是后两种,PoolMon 就是第一件该拿出来的工具。
1.2 PoolMon 在整个排查链条中的定位
我先把话说清楚:PoolMon 不是一键查出内存泄漏的神器。它的本质是一个"按 4 字符 Pool Tag 聚合统计"的监视器。驱动在调用ExAllocatePoolWithTag一类函数时,必须传一个 4 字节的 ASCII 标记来表明这是谁分配的内存,PoolMon 读取系统池表,把这些标签的累计分配、累计释放、当前存活块数显示出来。
它的核心能力只有一句话:告诉你系统里哪个 Pool Tag 的存活分配越来越多。
而它做不到的也很多:它不告诉你哪一行代码泄漏的,不告诉你哪个进程间接导致的,也不覆盖句柄泄漏、引用计数泄漏等非池类型的资源泄漏。所以在完整排查链路里,PoolMon 适合做第一轮宏观排查,把可疑范围从"整个内核池"缩小到"某个标签/某类驱动";后面再用 Driver Verifier 或 WinDbg 去精确定位具体的代码路径。
我见过不少团队把顺序搞反:一上来就开着 Driver Verifier 全量检查,结果系统慢到没法用,蓝屏信息又看不懂;还有的只在生产环境装 PoolMon 盯着屏幕看,一看半小时没变化就下结论"没有泄漏"。这两种极端都不可取。正确姿态是:先用 PoolMon 拿到增长最快的几个标签,再做精准打击。后面的章节我就按这个思路展开。
2. PoolMon 上手:启动、参数和输出逐列拆解
2.1 从哪拿到工具,怎么启动
PoolMon 的官方名字叫 Pool Monitor,随 Windows Driver Kit(WDK)一起发布。安装了 WDK 的机器上,直接在安装目录里搜poolmon.exe就能找到,通常位于 Windows Kits 下某个版本目录的bin子目录中。也可以从调试工具包中单独复制出来,放到自己的常用工具目录里,给服务器用的时候会顺手很多。
启动方式很简单:以管理员身份打开 CMD,进入 PoolMon 所在目录,直接运行poolmon。如果系统报找不到命令,说明当前 CMD 没有切到正确的目录;如果用普通权限启动,可能看不到完整的池信息,所以管理员权限是硬性要求。
运行起来之后,屏幕会列出系统当前所有活跃的 Pool Tag,默认显示的是分页池的统计。交互界面支持几个常用快捷键,这一点很多人不知道,很容易只盯着命令行参数表:
P:在分页池和非分页池视图之间反复切换;C:清空当前计数,相当于重新设定基线;B:切换排序依据,比如按存活字节数还是按分配次数排序;I:设置过滤标签,只看你关心的那个 Pool Tag;Q:退出。
顺便说一句,远程服务器上不需要非登录桌面不可。你可以用计划任务或远程 PowerShell 把poolmon /i 某个标签 /p这类固定命令定时执行,并把输出重定向到文件,日志拿下来再分析,比人肉盯屏靠谱得多。
2.2 高频参数速查
| 命令/操作 | 作用 |
|---|---|
poolmon /? | 查看完整帮助和控制键列表 |
poolmon /c | 启动后自动清空计数,从当前时刻重新统计 |
poolmon /i FkMn | 启动后直接过滤显示标签为 FkMn 的统计 |
运行时按P | 切换分页池/非分页池显示 |
运行时按C | 清空计数重建基线 |
运行时按B | 切换排序字段 |
运行时按Q | 退出 |
只要记住这套组合,基本就能解决 90% 的日常排查。后面涉及的"看 Diff 增长""比较前后两个时间点",也全靠这些基础操作反复组合。
2.3 输出字段逐一拆解
PoolMon 的界面里每一行对应一个 Pool Tag,看起来像下面这样:
Tag Type Allocs Frees Diff Bytes Per Alloc FkMn NonP 21345 20147 1198 382932 320 Ntfs NonP 145012 144996 16 8192 512字段含义说清楚,你才能真正看懂它在说什么:
- Tag:4 字符的 ASCII 标签,由驱动分配内存时传入。这是定位组件的关键线索;
- Type:
NonP表示非分页池,Paged表示分页池; - Allocs:从开机或者清空计数以来,该标签的累计分配次数;
- Frees:累计释放次数;
- Diff:
Allocs - Frees,当前仍然存活的内存块数量。这个数比 Allocs 绝对值重要得多; - Bytes:当前该标签仍占用的总字节数;
- Per Alloc:平均每次分配多少字节。
判断是否存在泄漏,核心就看Diff和Bytes是不是持续稳定上升。比如系统文件系统驱动Ntfs的 Diff 常年停在十几这个量级,说明它每次分配都有对应释放;而某个 FkMn 标签如果一小时前 Diff 是 50,一小时后就变成了 468,那就是板上钉钉的可疑持续增长。
2.4 第一次运行应该看的四步
我建议拿到一台陌生服务器时有条理地做四次动作,而不是开机就盯着屏幕发呆:
- 选择一个基准时刻,运行
poolmon /c清空计数,把所有历史累计值归零; - 按
P切到非分页池视图——优先处理物理内存杀手,但分页池也别丢,后面切回来看看; - 观察 30 分钟到数小时,最好这段时间业务处于正常负载,模拟最真实的分配压力;
- 中途不要试图记住整张表,只需要挑 Diff 涨幅最大的前 15 个标签记录下来。
这里有一个非常容易踩的坑:不要在系统刚启动、驱动还在初始化的时候急着下结论。很多驱动的启动路径上会批量分配内存,随后慢悠悠释放,头一二十分钟的数据天生就带有抖动。等一切稳定后再看斜率,才是真实的行为曲线。
3. 排查实录:当某个 Tag 的 Diff 只增不减
3.1 场景:一台应用服务器上的诡异内存爬升
虚构一个典型场景。某公司一台文件共享服务器,每隔四五天内存占用就升到必须重启的程度。任务管理器和 RAMMap 都试过了,用户态进程看起来没有任何一个有问题,Event Log 也没有内存相关的报错。于是决定用 PoolMon 做内核池排查。
登录后第一步按 2.4 的流程做了基线清空,把系统切到非分页池,业务正常跑了两个小时。首屏数据里大部分系统标签的 Diff 很稳,唯独有一个标签FkMn显得很扎眼——它的 Diff 从几十块逐步爬升,没有任何回落的迹象。从名字看很像某个文件监控驱动的自定义标签,但仅凭名字不能下结论,得用数据说话。
3.2 分步骤演示:基线、负载、二次采样
假设刚刚跑过的清空操作已经把计数归零,这时执行:
poolmon /i FkMn /p注意我这里先用了/i FkMn做过滤器,后面/p的意义是告诉工具只关注非分页池(不同版本参数名称略有差异,保险起见你可以只加/i FkMn,再用 P 键切池)。假设第一次抓到的输出是:
Tag Type Allocs Frees Diff Bytes Per Alloc FkMn NonP 100 50 50 12800 256一小时后再抓一次:
Tag Type Allocs Frees Diff Bytes Per Alloc FkMn NonP 880 412 468 119808 256Diff 从 50 涨到 468,Bytes 从 12.8KB 涨到 117KB。一小时涨了约 400 块,而Per Alloc稳定在 256 字节,说明是同样的分配模式在不断重复,而不是某个偶发的大块分配。这种规律性增长基本可以排除"负载抖动"的解释——真正常见的对象缓存会在平台值附近震荡,而 FkMn 是一路不回头。
3.3 反查 Tag 的"主人":三种手段配合
到了这一步,核心问题变成了:"FkMn 到底是哪个驱动申请的?"
第一种方法最省事:查系统现有的 Pool Tag 映射表。WDK 工具目录下一般会带一个pooltag.txt,里面收录了大量已知标签和模块的对应关系;注册表HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PoolTag下面也可能有类似记录。很多微软自带的系统标签都能在这里找到。但问题在于,第三方驱动自定义的标签基本不会被收录,FkMn 显然就不在表里。
第二种方法是字符串扫描。Pool Tag 本质就是 ASCII 字符串,你可以搜所有驱动文件,在二进制里查找对应的字节序列。比如 FkMn 对应的十六进制是46 6B 4D 6E,用 Hex 编辑器或者写一个简单脚本,把C:\Windows\System32\drivers目录下的.sys文件扫一遍,命中率很高。大多数驱动会把 Tags 列表写在二进制文件的只读数据区,这一步能直接锁定出嫌疑驱动。
第三种方法最朴素也最可靠:二分排除。去设备管理器或服务管理器里,把可疑驱动对应的设备或服务临时禁用,再观察 PoolMon 的 Diff 是否停止增长。如果 FkMn 的 Diff 立刻冻结,那前因后果就串起来了;如果还在涨,说明另有其人。尤其遇到已经加载多年的驱动,这个方法比猜测有效得多。
实际排查时,三种手段经常会叠加。比如字符串扫描命中了一个名为类似FakeMon.sys的过滤驱动,再到服务列表里确认它确实随系统启动,然后用设备管理器禁用它验证,一条线索就从 Pool Tag 贯穿到了具体的驱动文件。
3.4 修复验证:Diff 斜率归零才算收工
定位到某个内部监控组件驱动的更新版本引入泄漏后,我开始验证修复效果。操作路径是:测试环境先替换驱动的旧版本,重新加载,清空 PoolMon 计数,继续业务负载两小时。观察 FkMn 的 Diff 斜率,如果从"每小时 +400 块"变成"每小时接近 0",才算真正修复。
这里要注意一个细节:卸载驱动前一定确认它对当前业务的影响,特别是杀毒、备份、文件过滤这一类驱动,直接在生产环境禁用可能触发断链。稳妥做法是在测试环境完整复现,验证通过后再到生产环境替换。修复之后最好继续观察至少 24 小时,因为有些延迟释放机制要等对象老化后才触发释放,只看两小时会有幸存者偏差。
到这里,一次典型的 PoolMon 排查已经闭环:可疑标签 → 数据确认 → 锁定驱动 → 修复验证。
4. PoolMon 的盲区与进阶组合拳
4.1 先别高兴太早:Diff 高不一定就是泄漏
排查经验越丰富,越不会看到一个高 Diff 就拍板"泄漏"。驱动里存在大量合法的长生命周期对象:文件系统缓存的元数据、网络连接的会话表、设备栈上的 Overlapped 结构,都可能长时间不退。区分它们的关键是看曲线形态。
正常的"池峰值"行为是这样:驱动启动后 Diff 快速上涨到一个平台值,之后无论负载怎么变化,平台值基本稳定。比如某网络驱动维护连接池,连接数 100 以内时 Diff 就到 100,后续新连接来了旧连接走后,Diff 始终在 95 到 105 之间波动。
真正的泄漏是这样:Diff 一路爬升,负载降下来它也绝不回去。你要做的是拉长观察窗口,至少跨一个完整的业务周期,比如从早高峰看到晚低谷,看它有没有"回落"的打算。如果负载跌到最低,Diff 仍然不回头,那就是泄漏,不是缓存策略。
4.2 进程、句柄、引用计数泄漏:PoolMon 看不见的另一片黑暗
池泄漏只是内核资源泄漏中的一类,还有许多问题不会出现在 PoolMon 的 Tag 表里:
- 进程/线程引用计数泄漏:驱动对某个进程或线程对象执行
ObReferenceObject后忘了ObDereferenceObject,对象永远不会销毁,但池表看不出对应关系; - 句柄表泄漏:驱动打开了某个句柄却没有关闭,系统的句柄表一路膨胀,内存占用也在涨,但 PoolMon 看不到"句柄"这种非池资源;
- 内核事件、DMA 缓冲、IRP 未释放:这些资源确实可能对应某次池分配,但 Tag 生命周期和资源生命周期不一致,单看 Diff 无法判断哪个 IRP 没完成。
这类问题要配合其他工具链:性能监视器里的 Handle Count、对象计数器,WinDbg 里的!htrace(句柄跟踪)和对象引用追踪,才能找到根。PoolMon 可以帮你确认"池确实没问题",但不能帮你证明"系统内存问题与内核无关"——这是它的边界,别指望一把螺丝刀拧所有型号的螺丝。
4.3 组合拳:PoolMon 缩小范围,Driver Verifier 精确定位
如果你已经锁定某个可疑驱动,但仍想知道具体是哪一行代码泄漏,就该请出 Driver Verifier 了。
在测试机上复现问题环境后,打开 Driver Verifier Manager,勾选Pool Tracking(池跟踪)和Special Pool(特殊池),把前面锁定的驱动文件加进验证列表,然后重启。这两项的作用不同:
- Pool Tracking:会对目标驱动每次分配记录调用栈,系统蓝屏生成 dump 后,你能直接从栈回溯到分配点;
- Special Pool:会在每次分配的边缘填充魔数,一旦发生越界写、释放后读写,系统立刻触发蓝屏,提供精确的违规地址和上下文。
注意不能一上来就在生产开全量 Verifier。开启这两项后,系统对目标驱动的每次分配都会额外做内存对齐、边界检查、调用栈记录,性能损耗可能高达一个数量级,尤其是 IO 密集型驱动。所以我的习惯是先 PoolMon 做宏观侦查,再用 Verifier 做微观抓捕,两步配合半天内就能定案。
如果 Verifier 重启后目标驱动触发蓝屏,别慌,那正是它干活的表现。抓下 dump,用 WinDbg 打开,按调用栈往前翻,找到最后一次调用ExAllocatePoolWithTag却找不到成对释放的函数,基本就是泄漏点。
4.4 采样节奏与实操坑:几次差点误判的教训
最后列几条我踩过的坑,每一条都差点让人得出完全相反的结论。
单次采样极易误判。Diff 天生就会因负载波动而浮动,单看一个时间点的"高值"就下结论,十有八九要翻车。正确做法是固定频率持续取样,至少 10 个点以上,画出一条趋势线再判断。
多核机器上 CPU 分桶会造成跳变。Windows 的池分配按 CPU 分桶管理,某些驱动只在特定处理器上运行。你按下刷新键时可能正好从一个 CPU 切到另一个 CPU,Diff 数据瞬间跳变。这不算异常,持续观察才能消除噪声。
Tag 重用是最阴间的坑。有相当一部分第三方驱动习惯使用系统自带的 Tag,比如Ntfs、FMfn。你在 PoolMon 里看到 Ntfs 疯涨,未必是微软的文件系统驱动在泄漏,可能是某个过滤驱动借用同名标签。这种情况必须靠字符串扫描或 Verifier 进一步拆分,否则会找错替罪羊。
不要忽略分页池。非分页池泄漏最急迫,但分页池泄漏会让系统提交大小持续膨胀,最后同样引发内存不足。我见过一个虚拟打印驱动的分页池泄漏,硬是扛了两周才崩,比非分页池隐蔽得多。所以每次排查都应该把两种池类型都过一遍。
定时输出比人肉盯屏可靠。长时间盯着 PoolMon 屏幕,既痛苦又容易漏掉关键变化。可以用一个小脚本定时执行poolmon /i FkMn /p并追加到文本文件,再把日志里的 Diff 列按时间画出来。有了时间序列,真假泄漏一眼就能分辨。
就我个人的实际体验来说,PoolMon 从来不是一个"一键修好"的工具,它更像一把外科手术刀,帮你把问题从整个内核池缩小到一个标签、一个驱动。真正熟练之后,我的固定套路就是先 PoolMon 宏观定位,再 Driver Verifier 精确打击,两步配合通常半天内能把问题钉死。
另外,强烈建议在每一台陌生服务器上先花十分钟跑一遍poolmon /c,把基线状态下 Diff 排行靠前的标签记录存档。等到内存真出问题时,直接和基线对比,效率比临时查表高得多——尤其在故障时间窗只有短短几个小时的时候,这份基线几乎能救命。
如果之后想往自动化巡检方向发展,可以试试用 PowerShell 周期调用poolmon输出到日志,再写一个脚本对 Diff 做线性回归,算出每小时增长斜率。持续跑上几周,很多缓慢泄漏就能在酿成故障之前被发现。这套方法虽然朴素,但在生产环境里比任何花哨的监控面板都管用。希望这篇实战记录能帮你少走一点弯路。