
1. 从一次线上告警说起GODService 内存泄漏到底是怎么回事GODService 是我们内部一个常驻型的后台服务负责设备状态采集、指令下发和日志回传跑在 Windows 平台上以系统服务方式启动7×24 小时不重启。它本身逻辑不算复杂但依赖了一个第三方动态库 libdlt 做日志落盘和转发。问题出在上线运行大概两周之后任务管理器里这个进程的私有工作集一路缓慢上涨从最初的 80MB 涨到 1.2GB最后被系统的资源监控直接判定为异常并触发重启。一开始我以为是业务代码里有容器只增不减或者某个回调注册了没注销。但用Performance Monitor盯了几天发现一个很典型的特征分页缓冲池Paged Pool和非分页缓冲池Non-Paged Pool都在涨而且非分页缓冲池涨得更凶。这个信号非常关键——非分页缓冲池是内核态内存普通应用层的 new/malloc 泄漏一般不会直接体现在这里除非有内核对象、句柄或者驱动层交互出了问题。结合当时 Win11 上关于分页/非分页缓冲池泄漏的讨论热度我基本锁定方向泄漏点大概率不在纯业务逻辑而在与 libdlt 交互的那一层。这篇文章就是这次排查和修复的完整复盘。我会把怎么从现象定位到strdup、怎么用符号拦截把问题钉死、以及最后怎么改代码和验证效果全部摊开讲。适合正在做 Windows 服务端开发、遇到过类似内存缓慢增长、或者对符号拦截这种调试手段感兴趣的同学参考。哪怕你现在的项目没用到 libdlt这套“从内存计数器反推泄漏点”的思路也是通用的。2. 内存泄漏的定位思路别急着翻代码先看计数器2.1 为什么先看分页/非分页缓冲池而不是私有字节很多人一遇到内存涨第一反应是打开任务管理器看“内存”那一列然后开始翻业务代码找 new 没 delete 的地方。这个习惯在纯应用层项目里没错但在带第三方库、带系统服务、带驱动交互的场景里效率极低。原因是任务管理器那个“内存”列展示的是工作集工作集里混了文件缓存、共享内存、内核映射等一堆东西涨了不代表泄漏可能只是缓存策略。真正能帮你快速分流的是Performance Monitor里的几个计数器计数器含义泄漏时的典型表现Process\Private Bytes进程私有提交内存持续上涨最直观Process\Handle Count句柄数若同步上涨怀疑句柄泄漏Memory\Pool Paged Bytes系统分页缓冲池缓慢上涨可能内核对象泄漏Memory\Pool Nonpaged Bytes系统非分页缓冲池上涨明显高度怀疑内核态分配Process\Pool Nonpaged Bytes进程占用的非分页池定位到具体进程的关键我当时的做法是同时挂上Process\Private Bytes、Process\Pool Nonpaged Bytes和Memory\Pool Nonpaged Bytes三条曲线采样间隔 10 秒跑一个通宵。第二天看曲线三条线斜率几乎一致说明泄漏源就在 GODService 进程内部而且分配的是非分页内存。这一步直接把范围从“整个系统”缩小到“这个进程”省掉了大量瞎猜。提示非分页缓冲池的分配通常来自内核对象、驱动、或者调用了会触发内核分配的 API。普通用户态的 malloc 走的是堆不会直接进非分页池。所以一旦看到进程的非分页池在涨优先怀疑第三方库或系统交互层。2.2 用 UMDH 和 DebugDiag 做第一轮筛查确定是进程内非分页池泄漏后我用了两个工具做交叉验证。第一个是UMDHUser-Mode Dump Heap它通过抓取两次堆快照做 diff能直接告诉你哪个调用栈的分配在两次快照之间净增长最多。操作上就是先gflags给进程打开ustuser stack trace标志然后跑一段时间抓两次快照umdh -p:pid -f:snap1.log # 等待若干分钟 umdh -p:pid -f:snap2.log umdh snap1.log snap2.log -f:diff.logdiff 结果里排名靠前的调用栈会直接指向分配点。当时我看到一个很可疑的栈libdlt.dll!dlt_log_write下面挂着一串strdup相关的符号。第二个工具是DebugDiag它的内存分析脚本能按模块统计分配量结果同样指向 libdlt。但这里有个坑UMDH 抓的是用户态堆而我们的泄漏在非分页池。为什么 UMDH 还能抓到因为 libdlt 内部有一部分分配是通过HeapAlloc走的而某些 Heap 分配在特定情况下会触发内核映射。更准确地说UMDH 帮我锁定了模块而不是最终的内核分配点。锁定模块这一步已经足够关键了剩下的就是进 libdlt 内部看它到底在干什么。2.3 为什么怀疑到 strdup 头上libdlt 是第三方日志库我们没有源码只有符号文件。从 UMDH 的栈里反复出现strdup这个函数本身很普通——复制一个字符串并返回新指针。问题在于strdup 分配的内存必须由调用方 free如果调用方忘了 free就是标准的泄漏。而 libdlt 作为一个日志库每次写日志都会把消息、上下文、时间戳等字符串复制一遍如果某条路径上复制了却没释放泄漏速度会非常稳定正好符合我们观察到的“缓慢但持续上涨”。到这里逻辑链已经比较清晰了GODService 调用 libdlt 写日志 → libdlt 内部 strdup 复制字符串 → 某条路径没有 free → 内存持续泄漏。接下来要做的就是证明这个假设并且找到具体是哪条调用路径漏了 free。3. 符号拦截实战把 strdup 的每一次调用都记录下来3.1 符号拦截的基本原理和选型符号拦截Hook的核心思路是在目标函数被调用时先跳到我们自己的函数里记录信息或者做额外处理然后再决定是否调用原函数。在 Windows 上做这件事常见方案有三种IAT Hook修改导入地址表把目标函数的地址换成我们的函数。优点是简单缺点是只能拦本模块导入的函数跨模块调用拦不到。Inline Hook直接改目标函数开头的机器码插入跳转。优点是能拦所有调用缺点是要处理指令长度、线程安全容易崩。Detours微软官方的库封装了 Inline Hook 的细节稳定性和兼容性都比较好。考虑到 libdlt 是独立 DLLstrdup 的调用发生在它内部IAT Hook 从我们主模块出发拦不到所以我选了Detours。它能对指定模块的导出函数做拦截正好适合这种“拦第三方库内部调用”的场景。3.2 搭建拦截环境的具体步骤第一步是准备 Detours。下载后编译出detours.lib然后在工程里配置好头文件和库路径。接着写拦截函数。这里要注意strdup 的签名是char* strdup(const char* src);我们的拦截函数必须保持完全一致的签名和调用约定否则栈会乱。拦截函数里做两件事记录调用栈和返回的指针然后调用原函数。#include detours.h #include dbghelp.h static char* (__cdecl* Real_strdup)(const char* src) strdup; char* __cdecl Hook_strdup(const char* src) { char* result Real_strdup(src); // 记录调用栈和返回指针 void* stack[16]; USHORT frames CaptureStackBackTrace(0, 16, stack, NULL); LogAllocation(result, stack, frames); return result; }第二步是安装和卸载拦截。Detours 的标准流程是DetourTransactionBegin→DetourUpdateThread→DetourAttach→DetourTransactionCommit。这里有个细节必须对当前进程的所有线程调用DetourUpdateThread否则正在执行的线程可能跑到一半指令被改直接崩。我当时的做法是枚举进程内所有线程逐个更新。DetourTransactionBegin(); DetourUpdateThread(GetCurrentThread()); // 枚举其他线程并更新 DetourAttach((PVOID)Real_strdup, Hook_strdup); DetourTransactionCommit();第三步是记录数据。我用了一个简单的哈希表key 是 strdup 返回的指针value 是调用栈。每次拦截到 strdup 就插入同时还要拦截 freefree 的时候把对应指针从表里删掉。跑一段时间后表里剩下的就是分配了但没释放的指针它们的调用栈就是泄漏点。3.3 拦截 free 时踩到的坑拦截 free 比拦截 strdup 麻烦。因为 libdlt 内部可能调用的是free、_free、HeapFree等不同版本甚至可能直接调 CRT 的释放函数。我一开始只拦了free结果发现表里的指针只增不减明显不对——说明 libdlt 释放时走的不是free。后来用API Monitor观察了一下发现 libdlt 释放字符串用的是HeapFree而且堆句柄是它自己创建的私有堆。这就解释了为什么只拦free没用。于是我改成同时拦HeapFree并且在拦截函数里判断堆句柄是否属于 libdlt 的私有堆。这个判断很关键否则会把系统其他地方的 HeapFree 也记进来数据全乱。注意拦截系统级函数时一定要先确认目标库实际调用的 API 版本。不同 CRT 版本、不同编译选项下同一个逻辑可能走完全不同的底层函数。用 API Monitor 先看一眼比盲目拦一堆函数高效得多。3.4 从拦截数据里读出泄漏路径跑了一个小时左右拦截表里稳定残留了大约 3000 多个未释放指针而且还在缓慢增长。把它们的调用栈聚合统计后发现 90% 以上都指向同一条路径libdlt.dll!dlt_log_write libdlt.dll!format_log_message libdlt.dll!strdup再往上看调用者是 GODService 里一个高频心跳日志的调用点。这个心跳每 5 秒发一次每次都会带一个动态拼接的上下文字符串。libdlt 在format_log_message里对这个字符串做了 strdup但在后续的“日志级别过滤”分支里如果级别不满足会直接 return跳过了 free。这就是根因。4. 修复方案与代码实现三条路我选了最稳的那条4.1 三种修复思路的取舍找到根因后修复其实有三条路可走方案 A改 GODService 调用方式。既然泄漏发生在“级别不满足直接 return”的分支那我可以在调用 libdlt 之前先自己判断级别不满足就不调用。这个方案改动最小但治标不治本——libdlt 本身的 bug 还在换个调用点照样漏。方案 B升级或替换 libdlt。最彻底但第三方库升级涉及兼容性验证周期长而且我们拿不到源码无法确认新版本是否修了这个分支。方案 C在 GODService 侧做拦截兜底。用 Detours 在运行时把 libdlt 的 strdup 行为接管或者在日志调用返回后主动清理。这个方案不改第三方库也不改业务逻辑但引入了运行时 Hook有维护成本。我最终选了A C 组合先用 A 快速止血把高频心跳日志的级别判断前置立刻降低泄漏速率同时用 C 做一个兜底拦截防止其他调用点再触发同样的分支。等 libdlt 官方修复后再撤掉 C。这个组合的好处是上线风险可控A 是纯业务改动C 是独立模块出问题可以单独关闭。4.2 方案 A 的具体改法原来的调用是这样的// 旧代码不管级别先拼字符串再调用 char ctx[256]; snprintf(ctx, sizeof(ctx), heartbeat seq%d status%d, seq, status); dlt_log_write(DLT_LEVEL_DEBUG, ctx);问题在于即使当前日志级别是 INFODEBUG 日志不该输出但字符串已经拼好了libdlt 内部还是会 strdup 一次再判断级别。改成先判断级别// 新代码级别不满足直接跳过连字符串都不拼 if (g_current_log_level DLT_LEVEL_DEBUG) { char ctx[256]; snprintf(ctx, sizeof(ctx), heartbeat seq%d status%d, seq, status); dlt_log_write(DLT_LEVEL_DEBUG, ctx); }这个改动看起来简单但效果立竿见影。因为心跳日志占了总日志量的 70% 以上而线上默认级别是 INFO等于直接砍掉了大部分泄漏来源。实测改完后非分页池的增长斜率下降了约 85%。4.3 方案 C 的兜底拦截实现兜底拦截的思路是在 libdlt 的dlt_log_write返回后检查这次调用是否产生了未释放的 strdup 指针如果有就主动释放。实现上我复用了第 3 节的拦截框架但这次不是记录而是在 dlt_log_write 的 Hook 里做清理。static int (__cdecl* Real_dlt_log_write)(int level, const char* msg) NULL; int __cdecl Hook_dlt_log_write(int level, const char* msg) { int ret Real_dlt_log_write(level, msg); // 清理本次调用中未被释放的 strdup 指针 CleanupLeakedStrings(); return ret; }CleanupLeakedStrings的逻辑是维护一个“本次调用期间分配的指针列表”在 dlt_log_write 进入时清空strdup 拦截时插入free 拦截时删除dlt_log_write 返回时把列表里剩下的全部 free 掉。这样即使 libdlt 内部漏了 free我们也能兜住。提示兜底拦截一定要做好边界控制。比如要确认指针确实属于 libdlt 的私有堆否则 free 错内存会直接崩。另外拦截函数里不要做耗时操作否则会拖慢日志写入影响主业务。4.4 修复后的验证方法改完之后不能只看“感觉不涨了”要有量化验证。我用了三个指标指标修复前修复后72 小时进程私有字节80MB → 1.2GB80MB → 95MB进程非分页池持续上涨基本持平句柄数稳定稳定验证方法是部署修复版本后用 Performance Monitor 连续采样 72 小时每 10 秒一个点导出 CSV 后用脚本算斜率。修复前私有字节的斜率大约是 0.16MB/分钟修复后降到 0.003MB/分钟基本可以认为是正常波动。同时用 UMDH 再抓一次 diff确认之前那条 strdup 调用栈已经不再增长。5. 常见问题与排查技巧实录5.1 内存泄漏排查速查表现象可能原因排查手段私有字节涨非分页池不涨用户态堆泄漏UMDH、DebugDiag非分页池涨私有字节也涨内核对象或第三方库Performance Monitor API Monitor句柄数同步上涨句柄泄漏Handle、Process Explorer只在特定操作后涨业务逻辑分支泄漏代码审查 日志埋点涨到一定程度后稳定缓存策略非泄漏观察长时间曲线5.2 几个我踩过的坑第一个坑是过早下结论。我一开始看到内存涨直接去翻业务代码花了两天没找到问题。后来才想起来看非分页池计数器方向一下就对了。所以遇到内存问题先花十分钟看计数器比翻两天代码划算。第二个坑是拦截函数签名不匹配。strdup 在不同 CRT 下可能是__cdecl也可能是__stdcall我一开始没注意拦截后直接崩。后来用dumpbin /exports确认了调用约定才改对。做 Hook 之前一定要确认目标函数的完整签名。第三个坑是忽略私有堆。libdlt 用自己的私有堆分配字符串我一开始只拦全局堆的 free数据完全对不上。后来用 API Monitor 看到HeapCreate和HeapFree的配对才意识到要按堆句柄区分。5.3 给后来者的几条实操建议如果你的项目也依赖第三方库而且出现了缓慢内存增长我的建议是先隔离再定位最后修复。隔离是指用计数器把范围缩小到进程甚至模块定位是指用 UMDH、API Monitor、符号拦截等工具找到具体调用栈修复则优先考虑业务侧规避其次才是运行时兜底。不要一上来就改第三方库也不要一上来就上 Hook顺序错了会浪费大量时间。另外符号拦截虽然强大但它是调试手段不是长期方案。我这次用兜底拦截只是为了争取时间等 libdlt 官方修复后就会撤掉。长期依赖 Hook 会带来兼容性风险和性能开销能不用就不用。最后再分享一个小技巧如果你怀疑某个函数是泄漏点但又没有源码可以先用API Monitor把它的调用序列录下来看看分配和释放是否配对。这个工具不需要写代码上手快很多时候看一眼调用序列就能发现问题比直接上 Detours 省事得多。