ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Win11工控机内存泄漏排查:GODService内核驱动非分页池泄漏修复

Win11工控机内存泄漏排查:GODService内核驱动非分页池泄漏修复 1. 症状确认一台Win11机器是怎么被慢慢拖垮的先说结论这次 GODService 内存泄漏修复问题不在 GODService.exe 的进程内存占用而在 Windows 11 的分页缓冲池和非分页缓冲池上。客户反馈比较典型一批 Win11 工控机跑两三天后内存占用不断攀升直到接近 100%系统卡到鼠标都飘。打开任务管理器按内存排序几个业务进程加起来还不到 3GB可 “内核内存” 大得吓人。最后定位到 GODService 内核驱动里一个被遗忘的失败分支属于典型的内核池泄漏。那几台机器都是 Windows 11 22H2部署的自研 GODService 硬件守护服务主要做传感器轮询、温度采集、看门狗上报。正常情况下一个无头工控机内存占用很稳定但用户反馈的曲线是凌晨重启后正常两三天后内存平滑上升再过半天连远程桌面都连不上。这个 “平滑上升” 非常关键说明不是突发流量导致的内存暴涨而是有东西在持续分配且不归还。1.1 第一封故障报告里的有效信息客户的故障报告写得很简单“系统内存不足任务管理器显示内存 95%重启后恢复。” 这种描述其实隐藏了大量有效信息。第一“95%” 是物理内存占用率但谁是元凶任务管理器不一定能看出来第二“重启后恢复” 说明泄漏不是永久性损坏而是有生命周期的对象没被释放第三重启后能撑两天左右再复发说明泄漏速率大概是每小时几百MB。我先看了一眼 GODService.exe 的进程内存只有 80 多MB完全正常。用户态服务不是嫌疑对象。再切到任务管理器的 “性能” 标签发现提交内存Commit明显高于进程占用总和物理内存里还有一大块被标记为 “内核缓冲池”。这时候我才意识到问题很可能出在 Windows 内核的缓冲池也就是非分页缓冲池和分页缓冲池。这个判断让排查范围一下子缩窄了很多。用户态进程内存异常直接抓 dump 看堆栈就行内核池异常要么是驱动不断分配不释放要么是系统组件有 bug。结合 GODService 是一个带内核驱动的系统守护服务且故障机全部部署了它嫌疑自然落在 GODService 的驱动身上。1.2 分清分页缓冲池和非分页缓冲池才能少走弯路Windows 内核里有两块著名的池分页缓冲池Paged Pool和非分页缓冲池NonPaged Pool。很多人在这一步就被术语绕晕了。我用一个比较土的类比帮你理解非分页池像放在急诊室里的急救箱任何紧急时刻都可以直接拿不能搬到外面去分页池像普通仓库平时可以放到外部库房内存紧张时能换出到页面文件。非分页池之所以不能换出是因为它可能被中断、DPC、高IRQL代码访问这些上下文里一旦触发缺页中断系统直接蓝屏。所以非分页内存必须全程驻留在物理内存里。也正因为这一点非分页池泄漏比分页池泄漏更危险它吃的是实打实的物理内存没有任何缓解手段。分页池虽然可以换出但底层引用和提交计数同样会占资源泄漏一样会拖垮系统。排查前我习惯先用一条 PowerShell 命令看当前两个池的基准Get-Counter \Memory\Pool Paged Bytes,\Memory\Pool Nonpaged Bytes输出里能看到当前分页池和非分页池占用的字节数。健康机器上这两个值应该是长期稳定的即使有波动过一阵子也能自己回收。如果某个池的值持续走高那就是明确的泄漏信号。我们那批故障机上Pool Nonpaged Bytes在半小时里涨了约 300MB基本可以确定有内核驱动在非分页池里只分配不释放。1.3 为什么直接把嫌疑锁定在GODService确定是非分页池泄漏后不能急着卸驱动先用控制变量法缩小范围。我们在一台故障工控机上停掉 GODService 用户态服务结果非分页池还在涨然后通过设备管理器禁用 GODService 的内核驱动并重启再观察非分页池曲线终于走平。这里有个容易踩的坑GODService 的用户态服务和内核驱动是绑定的但单独结束GODService.exe并不会停掉内核驱动驱动还在系统里继续工作。所以验证时一定要把驱动也停掉严格说还要确认设备节点被禁用。否则你可能得出一个错误的结论“停了服务内存还在涨说明不是 GODService 的锅。”另外GODService 驱动里用到的池标签是GODS这个四位字符是我们在代码里统一规定的。一开始看到 PoolMon 里GODS标签的占用在非分页池里排第一我心里基本有数了。一个自定义标签能直接对上自家驱动省去了大量瞎猜的功夫。这也是为什么我一直强调所有驱动的池分配必须带上规范且唯一的标签。2. 内存泄漏排查链路从计数器到池标签2.1 建立连续采样让“泄漏速率”开口说话排查内存泄漏第一件事不是抓 dump而是建立连续采样的基线。靠肉眼看任务管理器的瞬时值很容易被干扰正确的是用性能计数器持续记录让泄漏速率自己暴露出来。我当时在故障机上跑了一段 PowerShell$samples Get-Counter -Counter \Memory\Pool Paged Bytes,\Memory\Pool Nonpaged Bytes -SampleInterval 5 -MaxSamples 720 $samples.CounterSamples | Export-Csv -Path pool_trend.csv -NoTypeInformation这条命令会每 5 秒采一次持续 1 小时结果导出到 CSV。拿到数据后随便用 Excel 或 Grafana 拉一条曲线比在任务管理器前瞪着眼看一个小时直观得多。从曲线能读出很关键的信息非分页池是每 5 秒稳定上涨约 800KB分页池几乎没变化。稳定涨意味着泄漏发生在固定的调用路径上不是随机偶发。而且这个速率非常规律说明有某个周期任务在反复分配内存但没释放。GODService 刚好有个 5 秒一次的传感器轮询定时器这和时间轴能对上。量化泄漏速率还有一个好处修复好之后可以用同样方式复测对比修复前后的斜率直接证明问题已经解决。2.2 PoolMon 登场按池标签定位分配来源确定是非分页池泄漏并锁定了 GODService 驱动后我上了 Windows 驱动工具包里的 PoolMon全名是 Pool Monitoring Tool。这个东西在 WDK 安装目录的 tools 文件夹下几十KB的 exe不用安装直接管理员运行。PoolMon 的作用是统计系统里所有缓冲池分配按池标签聚合。我用的是poolmon.exe /p /g/p同时显示分页池和非分页池/g按字节数动态排序。运行几秒后屏幕上会刷新一张表列包括 Tag、Type、Allocs、Frees、Diff 和 Bytes。重点看 Diff 和 Bytes。那台机器上GODS 标签的数据大致是TagTypeAllocsFreesDiffBytesPer AllocGODSNonPaged1,204,9211,204,75017110,485,76064KBNDISNonPaged58,10258,090123,932,16032KB核心看点是Diff列它等于 Allocs 减 Frees。如果某个标签的 Diff 值在持续增大不用怀疑这个标签对应的分配路径在泄漏。GODS 的 Diff 从最初的 20 一路涨到 171而其他标签基本稳定证据已经非常清晰。PoolMon 还可以只过滤某一个标签避免被其他标签干扰poolmon.exe /i GODS注意 PoolMon 需要管理员权限否则它读不到内核池统计信息。另外它自己也会消耗一点点内存但这相比泄漏量可以忽略不计。我建议观察 10 分钟以上短时间看到 Diff 增长有可能是系统缓存或正常波动长时间单向增长才能定性为泄漏。2.3 WinDbg 里验证标签对应的分配栈PoolMon 定位了标签WinDbg 则是用来坐实证据的。理论上有 PoolMon 的 Diff 增长就够说明问题了但真正提交修复报告时最好再抓一份内核转储看一眼这个标签的池块到底是谁分配的。我推荐优先用 WDK 自带的 LiveKd 抓一份“活转储”而不是等系统卡死或蓝屏后再抓完整转储。LiveKd 能在系统还运行的时候抓取内核内存快照这样现场信息都在不会因为内存耗尽导致转储缺失。用法很简单管理员运行livekd生成的转储文件用 WinDbg 打开后先执行!poolused 2这个命令会按占用大小列出非分页池的统计GODS 标签应该排在很靠前的位置。接下来找几个具体的 GODS 池块!poolfind GODS输出会显示一堆被标记为 GODS 的池地址比如0xFFFF9E8A4A123000。挑一个地址执行!pool 0xFFFF9E8A4A123000能看这个池块的大小、所属进程、分配线程初始栈等信息。如果之前开启了驱动验证器的池跟踪甚至能看到调用栈。不过依赖调用栈不一定最靠谱因为我们自己写的驱动能通过标签直接反查到代码里所有ExAllocatePoolWithTag(..., GODS)的位置。接下来就是逐行审查释放路径。WinDbg 这一步最大的价值是排除“PoolMon 看到的 GODS 其实是系统内存标签被字符串误伤”这种极端情况。实际上我看到池块确实是 GODS并且块大小和驱动里分配的大小吻合基本可以进入修复阶段。2.4 驱动验证器加速泄漏暴露的“重锤”定位到标签后我还在测试环境里用驱动验证器Driver Verifier做了一次辅助验证。驱动验证器会拦截驱动的内存分配、释放和 IRQL 操作加入额外检查配合池跟踪可以更快暴露问题。我只针对 GODService.sys 开启避免对整个系统所有驱动验证导致无关蓝屏verifier /flags 0x9 /driver GodService.sys0x9是 0x1特殊池和 0x8池跟踪的组合。特殊池会为每次分配单独放一页并在页边界附件标记一旦有越界或重复释放日志会非常容易发现。池跟踪则会记录分配调用栈。开启后需要重启让 GODService 驱动在验证器模式下加载。在验证器模式下跑一轮同样的传感器轮询场景如果还有泄漏WinDbg 抓到的转储里会直接看到分配栈往往能把泄漏点缩小到具体函数。这一步很适合在代码审查之前做能节省不少时间。但注意驱动验证器是重锤不要在生产环境批量开启否则机器可能很快蓝屏。要在虚拟机或测试机上跑跑完及时关闭。3. 修复GODService泄漏点的实操拆解3.1 根因一个被遗忘的失败分支修复之前我先带着池标签GODS把驱动代码里所有分配点列出来。驱动的核心是一个查询传感器数据函数逻辑不复杂分配一块MONITOR_BLOCK结构体调用底层读取函数成功则处理数据最后释放。问题出在失败分支。修复前的代码大概是这样的NTSTATUS GodSrvQuerySensor(PVOID context) { PMONITOR_BLOCK block (PMONITOR_BLOCK)ExAllocatePoolWithTag( NonPagedPoolNx, sizeof(MONITOR_BLOCK), GODS); if (block NULL) { return STATUS_INSUFFICIENT_RESOURCES; } NTSTATUS status ReadSensorData(block, sizeof(MONITOR_BLOCK)); if (!NT_SUCCESS(status)) { // 早期版本这里直接返回block 没有释放 return status; } // 正常处理数据 ProcessSensorData(block); ExFreePoolWithTag(block, GODS); return STATUS_SUCCESS; }单看这段代码正常人第一反应是“成功路径上明明有 ExFreePoolWithTag怎么会漏”问题就藏在if (!NT_SUCCESS(status))这个分支。当传感器断线、数据头异常或缓冲区长度不足时ReadSensorData会返回类似STATUS_BUFFER_TOO_SMALL的错误码此时函数直接 return分配到的 block 就没人管了。这种失败分支平时很难触发。因为业务正常时传感器不会频繁断线测试环境也没人刻意构造短包异常。但工控机现场的电磁干扰、线缆松动、传感器反复跳变导致这个分支被高频率走到。每失败一次泄漏 64KB看起来量不大但每 5 秒轮询一次几个小时就能积累几百MB。这也是为什么客户反馈“跑两三天后才卡死”——泄漏速率不高但持续时间足够长。3.2 修复代码让释放和分配严格配对修复的方案很简单就是让所有失败分支都释放内存。我习惯用单出口写法减少后续维护时漏掉的概率NTSTATUS GodSrvQuerySensor(PVOID context) { PMONITOR_BLOCK block NULL; NTSTATUS status STATUS_SUCCESS; block (PMONITOR_BLOCK)ExAllocatePoolWithTag( NonPagedPoolNx, sizeof(MONITOR_BLOCK), GODS); if (block NULL) { return STATUS_INSUFFICIENT_RESOURCES; } status ReadSensorData(block, sizeof(MONITOR_BLOCK)); if (!NT_SUCCESS(status)) { goto cleanup; } status ProcessSensorData(block); if (!NT_SUCCESS(status)) { goto cleanup; } cleanup: ExFreePoolWithTag(block, GODS); return status; }这段代码的优点是不论哪个分支跳到 cleanupExFreePoolWithTag都只会执行一次也不会漏执行。有人可能觉得goto风格在业务代码里不好看但内核驱动里这种单出口清理是常见且清晰的做法尤其当一个函数里有多次失败判断时能避免重复释放或遗漏释放。如果团队代码规范不允许 goto也可以在每个失败分支里重复写释放逻辑效果一样。我个人的建议是宁可多个分支写多次释放也不要只指望某一条成功路径的释放。尤其在多出口函数里代码审查时一定要逐个出口检查是否存在“分配了没释放”的情况。修复之后还有一个小细节ExAllocatePoolWithTag的第一个参数我用了NonPagedPoolNx而不是老的NonPagedPool。前者是非可执行内存安全性更好WDK 新代码推荐使用。如果你维护的是老驱动看到NonPagedPool建议顺手换掉。3.3 验证修复回归与压力测试的注意点修复写完后光在测试机开机跑几分钟是不够的因为这种泄漏在正常路径下根本看不出来。我当时的验证分了三步。第一步是常规验证加载新驱动重启系统跑一遍核心业务确认传感器查询、上报、看门狗都正常。这步通过后直接上 PoolMon 看 GODS 标签的 Diff正常运行时应该稳定在一个小数值附近不再持续增长。第二步是针对性故障注入。为了模拟现场异常我在测试机上把传感器数据线做成可快速插拔的状态同时写了一个循环脚本每分钟触发 20 次GodSrvQuerySensor期间随机拔掉传感器再插回去人为制造STATUS_BUFFER_TOO_SMALL分支。这个环境跑满 12 小时后再看 PoolMon 的 GODS Diff 值修复前在这种场景下能涨上千修复后 Diff 在个位数徘徊。第三步是监控指标回归。我重新跑了一遍之前的 PowerShell 采样脚本取一个小时的数据$a (Get-Counter \Memory\Pool Nonpaged Bytes).CounterSamples[0].CookedValue Start-Sleep -Seconds 600 $b (Get-Counter \Memory\Pool Nonpaged Bytes).CounterSamples[0].CookedValue 10分钟非分页池增量: $([math]::Round($b - $a, 2)) bytes修复前10 分钟增量通常是几十MB修复后增量在几MB以内波动属于系统正常波动。把这个数据贴进修复报告比写一百句“经过验证内存平稳”都更有说服力。4. 让泄漏无处遁形监控与基线建设4.1 在开发阶段用驱动验证器拦截修复完这个坑之后我回头想了很久为什么这种问题会流到客户现场因为平时开发测试正常路径通过率高异常分支被完全覆盖。驱动代码不像纯业务代码那么容易做单元测试内存管理问题往往要靠工具兜底。所以我把驱动验证器纳入了开发阶段的强制流程。每个驱动组件在提测前都会在 CI 测试机上开启verifier /flags 0x9 /driver xxx.sys跑至少一晚上核心负载。如果驱动有池泄漏、越界访问或 IRQL 问题验证器会比用户更早发现。虽然这会显著降低驱动运行效率但测试机不需要性能只要能暴露问题。同时我也要求所有驱动代码里必须使用统一且唯一的池标签。比如 GODService 内部有传感器数据缓冲、看门狗状态、 IO 请求包上下文至少三个模块每个模块都用自己的四位标签。这样查 PoolMon 时一眼就能看出是哪个子模块在泄漏而不是所有分配都挤在一个标签里。4.2 用性能计数器给服务建立内存画像内存泄漏最怕的是“没有基线”。没有基线你根本不知道当前的非分页池 300MB 是正常还是异常。所以我给 GODService 部署了一套最简单的内存画像靠 typeperf 就能做。在服务器或工控机上管理员跑typeperf \Memory\Pool Paged Bytes \Memory\Pool Nonpaged Bytes -si 60 -o pool_metrics.csv -f CSV这条命令每 60 秒采样一次输出到 CSV。跑一两天后把 CSV 导入 Excel就能得到 GODService 运行环境下的内存基准曲线。正常时应该是一条水平线最多有小幅锯齿。如果哪天曲线开始稳定抬头说明有泄漏正在发生。如果你已经有监控系统比如 Prometheus 或 Zabbix也可以直接把这两个性能计数器采集上去告警规则写成“非分页池连续 1 小时增长超过 X MB”。没有大型监控平台也没关系Windows 自带的任务计划程序加 PowerShell 脚本就够用了。4.3 上线后的预警阈值设置修复后的 GODService 正常工作时非分页池基本是平的。我在告警脚本里设置的初始阈值是“连续 30 分钟非分页池增长超过 20MB”就触发告警。这个阈值不能拍脑袋定应该从基线数据里取正常波动的最大值再乘个系数。一个很简单的 PowerShell 巡检脚本长这样$before (Get-Counter \Memory\Pool Nonpaged Bytes).CounterSamples[0].CookedValue Start-Sleep -Seconds 300 $after (Get-Counter \Memory\Pool Nonpaged Bytes).CounterSamples[0].CookedValue $delta $after - $before if ($delta -gt 20MB) { Write-EventLog -LogName Application -Source GodServiceMonitor -EventId 1001 -Message 可疑的非分页池增长: $delta bytes / 5分钟 }把这个脚本放到任务计划程序里每 5 分钟跑一次基本能保证下一次内存泄漏发生时你在几个小时甚至几十分钟内就知道而不是等客户抱怨系统卡死。加粗强调一下告警阈值不要太激进否则每天半夜都会收到误报。先跑一周基线用数据说话。5. 这次排查踩过的坑和留下的经验5.1 常见误判杀软和系统组件背过的锅排查初期我差点让第三方杀软驱动背了锅。在 PoolMon 输出里某个安全软件驱动的标签也占了不小比例Diff 也有些波动。如果只看瞬时数据很容易误判成是它在泄漏。后来我们用控制变量法先停 GODService 驱动非分页池明显走平再退掉杀软驱动非分页池依然平稳。事实证明杀软那块只是瞬时波动不是泄漏。这个经验是看到某个标签 Diff 很高不要急着下结论一定要看它是否“持续单向增长”。偶尔波动和持续增长是两回事。而且最好用停驱动的方式做对照实验而不是靠猜。无论工具多花哨严谨的控制变量永远是最可靠的。5.2 分页池泄漏和非分页池泄漏的处理差异这次问题主要发生在非分页池但我也见过不少分页池泄漏的案例。两种池泄漏的排查思路类似但处理优先级完全不同。对比维度分页池Paged Pool非分页池NonPaged Pool能否换出到磁盘可以不可以通常关联对象内核对象、文件缓存、用户态有映射关系的内存DPC、中断、驱动内部数据结构泄漏后可观察性任务管理器“内核内存”增长同样增长且物理内存压力更大典型引发源句柄泄漏、分页缓冲分配未释放驱动中断路径、解除引用失败、池分配未释放紧急程度较缓非常紧急可能导致系统假死非分页池泄漏要更严重因为等内存耗尽时系统可能连蓝屏的机会都没有直接卡死。遇到物理内存不断被吃掉且无法回收的情况优先怀疑非分页池不要花大量时间在一个个进程内存里翻找。5.3 现场勘查比事后转储更重要的一次教训有一次我试图等系统完全卡死后抓完整转储结果等到最后系统基本失去响应转储文件只能抓到一半信息严重缺失。后来我改成 LiveKd 抓活转储才顺利拿到完整的池信息。这个教训让我记住内存泄漏类问题最好在还能操作的时候抓证据不要拖到濒死状态。另外开启驱动验证器后一定要在测试环境复测。我第一次开着verifier /flags 0x9直接在一台性能较弱的测试机上跑结果没几分钟就蓝屏了现场一片狼藉。后来才意识到特殊池会让每次分配多消耗一页内存在低内存环境里很容易触发新的问题。先把普通复测跑完再用验证器做专项验证顺序不能反。5.4 几个值得固化的排查习惯解决这次问题后我给自己和团队列了几条硬性要求。第一所有驱动新增的ExAllocatePoolWithTag必须在代码 review 时逐个检查释放路径尤其是失败分支。第二每个驱动的池标签要登记在一个共享文档里不许复用其他模块的标签。第三提测前至少跑 24 小时 Driver Verifier。第四每次发布前对比一次 PoolMon 的标签 Diff 基线任何持续增长都要有解释。第五线上监控不能只看进程内存分页池和非分页池要作为常规指标采集。这些习惯听起来都不复杂但越简单的事越容易被忽略。这次问题的根因本质上就是一个return status少写了一行释放如果没有标签、没有 PoolMon、没有基线它可能要在现场反复横跳很久才能被揪出来。说真的内存泄漏修复给我的最大感触不是命令多高级、工具多强大而是“异常分支”这个诡异的位置总能把一个小疏漏滚成大事故。修复之后我特意把这个失败分支打印成了驱动日志每次触发都留下一条GODS leak path hit记录。以后再有类似现场直接看日志就知道问题路径有没有被走到。这个习惯也一并分享给你不要只修复代码还要为异常路径留下可观测的痕迹否则下一次排查还会是从零开始。
返回列表