GODService 内存泄漏排查:从 libdlt 缓冲区到 strdup 释放的完整链路

发布时间:2026/10/7 20:52:33
GODService 内存泄漏排查:从 libdlt 缓冲区到 strdup 释放的完整链路 1. 从一次深夜告警说起GODService 内存泄漏的发现过程那天凌晨两点多监控面板上一条持续攀升的内存曲线把我从床上拽了起来。GODService 这个后台常驻服务平时内存占用稳定在 80MB 上下结果那晚在六个小时内一路涨到了 1.2GB而且完全没有回落的意思。更诡异的是重启之后内存确实降下来了但过不了几个小时又开始缓慢爬升像一只永远吃不饱的虫子。这种重启就好、跑一会儿又涨的模式基本可以断定是内存泄漏而不是单纯的内存占用高。因为正常的高内存占用服务在负载下降后内存会回落或者至少保持平稳而泄漏的特征是单调递增、不随负载波动、重启即清零。这三个特征一凑齐方向就明确了。GODService 这个服务本身承担的是设备侧的数据采集与转发任务跑在一个基于 QEMU 模拟的 ARM64 环境里底层用的是 Alpine Linux 精简系统。整个链路里涉及 libdlt 这个日志与追踪库还有大量字符串处理逻辑。关键词里出现的 libdlt、strdup、QEMU其实已经把嫌疑范围圈得比较小了——libdlt 负责日志缓冲strdup 负责字符串堆分配QEMU 提供运行环境。内存泄漏十有八九就藏在这三者的交界处。这篇内容适合谁看如果你正在维护长期运行的后台服务、在做嵌入式或模拟环境下的内存问题排查、或者单纯被内存只涨不降折磨过那接下来的排查链路和修复思路应该能帮上忙。我会把从发现、定位、验证到修复的完整过程摊开讲包括中间走过的弯路因为那些弯路往往比结论更有参考价值。2. 内存泄漏的三种典型形态与 GODService 的归属判断在动手之前先花点时间把内存泄漏分个类这决定了你后面用什么样的工具、走什么样的排查路径。很多人一上来就抓 valgrind结果发现跑得慢到无法忍受或者根本抓不到就是因为没先判断泄漏的形态。2.1 堆泄漏、句柄泄漏与缓冲区泄漏的区别从工程实践角度内存泄漏大致分三类。第一类是堆内存泄漏也就是 malloc/calloc/realloc/strdup 这类分配出去的内存没有对应的 free这是最经典的一类valgrind 和 ASan 都能抓。第二类是句柄或资源泄漏比如文件描述符、socket、线程句柄没释放这类泄漏在 top 里看内存可能不明显但会拖垮系统资源。第三类是缓冲区泄漏典型代表就是日志库的环形缓冲区、消息队列生产者一直往里塞、消费者没及时取或者取的时候没释放导致缓冲区无限膨胀。GODService 的现象是 RSS 持续增长而且增长速度和日志量正相关——日志打得越多涨得越快。这个和日志量正相关的线索非常关键它直接把矛头指向了 libdlt 相关的日志缓冲逻辑而不是普通的业务堆分配。2.2 为什么先怀疑 libdlt 而不是业务代码libdlt 是 Diagnostic Log and Trace 的缩写在车载和嵌入式领域用得很多它内部维护了一套日志缓冲区负责把日志从应用侧传递到消费侧。这类库的典型设计是应用调用 dlt_log 之类的接口库内部把消息拷贝进缓冲区然后由后台线程或者外部消费者取走。问题就出在这个拷贝进缓冲区的环节。如果库内部对每条日志消息都做一次 strdup 或者等价的内存分配而消费侧在取出消息后没有正确释放那么每打一条日志就泄漏一块内存。GODService 的日志量在夜间其实并不小因为有很多心跳和状态上报这就解释了为什么内存涨得那么稳定、那么线性。提示判断泄漏是否和某个子系统相关最直接的办法是做变量控制——关掉日志、关掉某个模块观察内存曲线是否还涨。这一步能省掉后面大量的无效排查。2.3 用 /proc 和 pmap 做第一轮粗筛在 QEMU 模拟的 ARM64 环境里很多桌面工具不一定好使但 /proc 文件系统是永远可靠的。我做的第一件事是每隔十分钟抓一次/proc/pid/status里的 VmRSS同时抓/proc/pid/smaps做对比。# 每隔 600 秒记录一次 RSS 和堆区大小 while true; do pid$(pgrep GODService) rss$(grep VmRSS /proc/$pid/status) heap$(grep -A1 \[heap\] /proc/$pid/smaps | grep Size) echo $(date %H:%M) $rss | heap: $heap /tmp/god_mem.log sleep 600 done跑了一晚上日志清楚地显示RSS 的增长几乎全部来自[heap]段而不是栈、不是 mmap 的匿名映射。这就把范围进一步缩小到了堆分配上也就是 malloc/strdup 这一族。如果是 mmap 增长那可能是线程栈或者大块映射如果是栈增长那多半是递归或者大局部变量。堆增长基本锁定 strdup 或 malloc 未释放。3. 在 QEMU 模拟 ARM64 环境里搭一套可用的排查工具链排查内存泄漏工具是绕不开的。但 GODService 跑在 QEMU 模拟的 ARM64 Alpine Linux 上这套环境有几个坑Alpine 用的是 musl libc 而不是 glibc很多预编译工具跑不了QEMU 模拟本身有性能损耗valgrind 这种重量级工具会慢到怀疑人生ARM64 架构下部分工具的包不一定齐全。3.1 musl 环境下 valgrind 的可用性与性能代价先说 valgrind。Alpine 的仓库里其实有 valgrindapk add valgrind就能装上。但问题是在 QEMU 全模拟TCG 模式下valgrind 会让程序慢 50 到 100 倍。GODService 正常跑一小时能复现泄漏挂上 valgrind 可能要跑好几天而且 QEMU 模拟本身还可能引入额外的内存行为干扰。我的建议是valgrind 适合在 x86 开发机上用同样的代码逻辑做验证而不是直接在 QEMU ARM64 目标环境里跑。因为内存泄漏是代码逻辑问题和架构无关你在 x86 上复现出来的泄漏点在 ARM64 上是一样的。把排查和运行环境解耦效率会高很多。3.2 用 LD_PRELOAD 拦截 malloc/free 做轻量级追踪如果非要在目标环境里查一个轻量得多的办法是 LD_PRELOAD 一个自己写的 malloc/free 拦截库。原理很简单你实现自己的 malloc 和 free在里面记录每次分配的地址和大小free 的时候把记录删掉程序退出或者定时 dump 出还没被释放的地址。#define _GNU_SOURCE #include stdio.h #include dlfcn.h #include pthread.h static void* (*real_malloc)(size_t) NULL; static void (*real_free)(void*) NULL; static pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; void* malloc(size_t size) { if (!real_malloc) real_malloc dlsym(RTLD_NEXT, malloc); void* p real_malloc(size); pthread_mutex_lock(lock); fprintf(stderr, ALLOC %p %zu\n, p, size); pthread_mutex_unlock(lock); return p; } void free(void* p) { if (!real_free) real_free dlsym(RTLD_NEXT, free); pthread_mutex_lock(lock); fprintf(stderr, FREE %p\n, p); pthread_mutex_unlock(lock); real_free(p); }这个办法的代价是日志量巨大但你可以只记录大小超过某个阈值的分配或者只统计分配次数减释放次数的差值。如果这个差值随时间线性增长就说明确实有堆泄漏而且能算出平均每条日志泄漏多少字节。3.3 在 x86 开发机上用 ASan 快速复现真正让我快速定位到问题的是在 x86 开发机上用 AddressSanitizer 重新编译 GODService。ASan 的优势是快相比 valgrind 只慢 2 倍左右而且能直接给出泄漏点的调用栈。# 编译时加上 ASan 和调试符号 gcc -fsanitizeaddress -fno-omit-frame-pointer -g -O1 \ -o GODService_asan GODService.c -ldlt # 运行时开启泄漏检测 ASAN_OPTIONSdetect_leaks1:log_path/tmp/asan_log ./GODService_asan跑了几分钟ASan 就在退出时打印出了泄漏报告直接指向了 libdlt 内部一处对日志消息做 strdup 之后没有释放的代码路径。到这里问题基本就锁定了。4. strdup 与 libdlt 缓冲区泄漏点的完整定位链路定位到 libdlt 之后接下来要做的是把哪一行、什么条件、为什么没释放这三件事彻底搞清楚。这一步不能靠猜必须把调用链和生命周期画清楚。4.1 strdup 的语义陷阱谁分配谁释放strdup 这个函数看起来人畜无害它的语义是复制一个字符串并返回新分配的指针。问题在于strdup 分配的内存必须由调用者负责 free这是它的契约。很多泄漏就出在这个契约被打破A 函数 strdup 了一块内存传给 BB 以为这块内存是 A 管理的用完没释放或者 B 释放了但 A 在另一条路径上又用了一次。在 libdlt 的场景里典型模式是这样的应用调用 dlt_loglibdlt 内部把消息 strdup 一份放进发送队列然后由发送线程取出、发送、释放。如果发送线程在某个错误分支上提前 return 了没有走到 free那这块内存就泄漏了。而且因为是错误分支平时不一定触发只有在特定条件下比如队列满、socket 写失败才会走到这就解释了为什么泄漏是缓慢但持续的。4.2 用 gdb 在 QEMU 里挂载调试符号定位调用栈在 QEMU 环境里gdb 是可以用的apk add gdb装上之后attach 到 GODService 进程在 strdup 上下断点然后看调用栈。gdb -p $(pgrep GODService) (gdb) break strdup (gdb) commands bt 8 continue end (gdb) continue这样每次 strdup 都会打印 8 层调用栈。跑一段时间后统计哪些调用栈出现次数最多再结合这些地址后来有没有被 free可以用之前 LD_PRELOAD 的记录交叉验证就能锁定是哪条路径在泄漏。我实测下来出现频率最高的调用栈是dlt_log - dlt_buffer_push - strdup而对应的 free 路径dlt_buffer_pop - free的调用次数明显少于 push。差值就是泄漏量。4.3 缓冲区满与消费失败泄漏触发的真实条件进一步分析发现泄漏的触发条件和缓冲区满强相关。libdlt 的发送队列有容量上限当消费侧比如日志落盘或者网络发送变慢时队列会满。队列满的时候push 逻辑会走一个丢弃最旧消息的分支但那个分支里只把旧消息从队列里摘掉了没有 free 掉旧消息对应的 strdup 内存。这就是根因丢弃逻辑漏了释放。平时队列不满这个分支不触发所以泄漏不明显夜间日志量大、消费侧偶尔卡顿队列一满泄漏就开始累积。这个发现也解释了为什么白天负载高反而不明显——白天消费侧处理得快队列很少满。现象对应原因验证方式内存线性增长每次队列满泄漏固定大小统计队列满次数 × 单条消息大小重启后清零泄漏在进程堆内重启释放所有堆内存与日志量正相关泄漏点在日志路径关闭日志后曲线走平夜间更明显消费侧夜间处理慢对比昼夜队列深度5. 修复方案的设计取舍与代码落地找到根因之后修复本身其实不难难的是怎么修得干净、不引入新问题。这里有几个方案可以选我最终选了改动最小、风险最低的那个。5.1 三种修复思路的对比第一种思路是在丢弃分支里补上 free。这是最直接的修法改动就几行风险低。缺点是如果还有其他分支也漏了 free得一个个补容易遗漏。第二种思路是把裸指针换成带 RAII 语义的封装比如用一个结构体把消息和它的生命周期绑在一起析构时自动释放。这个方案最彻底但改动面大在 C 项目里引入这种模式需要谨慎。第三种思路是改缓冲区设计让缓冲区不持有 strdup 的内存而是持有消息的引用计数或者直接内联存储。这个方案性能最好但改动最大适合重构窗口期做。考虑到 GODService 是线上服务我选了第一种同时加了一个防御性的检查。5.2 补上 free 并加防御性断言修复的核心代码就是在丢弃旧消息的分支里先取出旧消息指针free 掉再从队列里移除。/* 修复前只摘除不释放 */ if (dlt_buffer_is_full(buf)) { dlt_buffer_drop_oldest(buf); /* 内部只做了链表摘除 */ } /* 修复后先释放内存再摘除 */ if (dlt_buffer_is_full(buf)) { DltMessage *old dlt_buffer_peek_oldest(buf); if (old ! NULL) { if (old-payload ! NULL) { free(old-payload); /* 释放 strdup 出来的字符串 */ old-payload NULL; } free(old); } dlt_buffer_drop_oldest(buf); }同时我在 dlt_buffer_push 的入口加了一个断言确保每次 push 之后队列里的消息数量和实际分配的内存块数量一致通过一个计数器维护。这样如果将来再有人改这块逻辑漏了 free测试阶段就能立刻发现。/* 维护一个分配计数push 加一pop/drop 减一 */ assert(buf-alloc_count buf-msg_count);5.3 回归验证连续跑 72 小时的内存曲线修完之后不能只看感觉好了得用数据说话。我把修复版本部署到测试环境连续跑了 72 小时每十分钟记录一次 RSS。# 72 小时内存监控脚本 for i in $(seq 1 432); do pid$(pgrep GODService) rss$(awk /VmRSS/{print $2} /proc/$pid/status) echo $(date %s) $rss /tmp/god_mem_fixed.log sleep 600 done结果很干净72 小时内 RSS 在 78MB 到 85MB 之间小幅波动没有单调上升趋势。对比修复前的曲线六小时涨 1.1GB效果一目了然。为了更严谨我还特意在测试环境里人为制造了队列满的场景把消费侧限速确认泄漏不再发生。6. 这类内存泄漏的通用排查套路与避坑经验修完这个 bug 之后我复盘了一下整个流程发现有些经验是可以复用到其他项目的。内存泄漏排查这件事方法论比工具更重要。6.1 先分类再动手别一上来就上重工具很多人一遇到内存泄漏就条件反射地开 valgrind结果在 QEMU 或者 musl 环境下各种水土不服浪费大量时间。正确的顺序应该是先用 /proc 和 smaps 判断是堆泄漏还是映射泄漏再用 LD_PRELOAD 做轻量统计确认泄漏存在最后才上 ASan 或 valgrind 精确定位。工具是分层的用错层级就是浪费。6.2 关注分配-释放配对而不是单看分配内存泄漏的本质是分配和释放不配对。所以排查时不要只盯着 malloc/strdup 的调用点更要盯着 free 的调用点看哪些分配路径没有对应的释放路径。一个实用的技巧是在代码里给每类分配打上标签比如用宏包一层统计每类标签的分配次数和释放次数差值不为零的就是嫌疑。6.3 错误分支和边界条件是泄漏高发区这次泄漏出在队列满这个错误分支上这不是偶然。正常路径通常被测试覆盖得很好而错误分支、边界条件往往测试不足恰恰是泄漏的高发区。所以排查时要有意识地去看那些if (error) return;的地方看 return 之前有没有漏掉清理逻辑。这类问题在代码审查时也很难发现因为逻辑看起来是对的只是资源没释放。注意在 C 项目里任何提前 return 的分支都要问一句我进来时分配的东西释放了吗。这是血泪教训。6.4 在模拟环境里排查要把环境因素和代码因素分开QEMU 模拟 ARM64 这个环境本身会引入一些干扰比如模拟的内存行为和真实硬件不完全一致性能损耗也会影响时序相关的 bug 复现。我的做法是代码逻辑问题在 x86 上查环境相关问题才在 QEMU 里查。内存泄漏是纯代码逻辑问题所以在 x86 上用 ASan 查是最快的查到了再回到目标环境验证修复效果。把这两件事分开效率能提升好几倍。最后分享一个我在实际排查中养成的习惯每次修完内存泄漏我都会在代码里留一个轻量的内存水位监控定期打印关键缓冲区的深度和分配计数。这样下次再出问题不用从零开始看一眼监控日志就能判断是不是又漏了、漏在哪一块。这个习惯帮我省下的时间远比写这几行监控代码的成本高得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询