ARTICLE DETAIL

资讯详情

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

Valgrind内存泄漏检测实战:从编译参数到CI门禁

Valgrind内存泄漏检测实战:从编译参数到CI门禁 1. 先讲清楚Valgrind 到底帮你盯着什么你是不是也遇到过这种场景C/C 程序在本地怎么跑都正常一部署到服务器就每天凌晨内存一路飙升重启后好一阵子过几天又涨回来。线上不敢直接上压力各种 dump 看半天摸不到头绪最后翻代码发现 malloc 了几千个块free 的寥寥无几。内存泄漏这东西如果手头没有一个趁手的工具排查起来能把人逼疯。Valgrind 就是 Linux 平台上最经典的内存检测工具也是我处理代码质量问题时第一个会装的东西。它默认使用 Memcheck 引擎不用重编译程序直接对二进制做动态二进制插桩。除了内存泄漏它还能顺带查出越界读写、使用未初始化变量、重复释放、错误释放等一堆“C/C 程序员才能懂的痛”。这篇文章我会按实际项目里的用法从编译选项、运行命令、报告解读到把 Valgrind 塞进 CI 当质量门禁完整走一遍。适合正在写 C/C、做后端服务、或者给项目做代码质量体系的同学参考。内存泄漏的本质很简单你从操作系统手里借了一块堆内存用完之后没有还回去而且因为指针被覆盖或者函数退出后没人保存之后谁也找不到这块内存没办法再还。程序不退出泄漏的内存就一直在。短命的命令行工具未必有明显影响因为进程一结束操作系统会统一回收真正被拖垮的是那些全年无休常驻的服务进程一点点泄漏积累下去迟早变成“隔几天必须重启一次”的玄学现场。Valgrind 能定位这件事的核心思路是它在程序运行时拦截了 malloc、free、new、delete 这一整套内存管理函数把每次分配的调用栈、大小、地址都记录下来。程序退出的时候它会扫描寄存器和程序的栈、全局区去看每个还活着的数据块到底还有没有指针能指向它。如果没有指针指向了那这块就是泄漏。这个机制听起来简单实际工程里非常可靠因为它不依赖你代码里加任何监控逻辑对被检测程序来说是透明的。注意一个前提Valgrind 的默认工具 Memcheck 是在模拟环境里执行的速度通常会慢 20 到 100 倍。所以它不是用来在线上全量压测的而是用来在回归测试、单元测试、复现场景里把问题暴露出来的。这也是后面我会反复强调的一点Valgrind 要变成工程流程的一部分而不是线上事故后的救火工具。2. 动手之前先做准备编译参数、环境与一份“必泄漏”的示例2.1 编译参数怎么开-g -O0很多人第一次跑 Valgrind报告里全是“???”或者只有函数地址没有行号第一反应是工具坏了。其实十有八九是编译时没带调试信息。Valgrind 要在报告里给你看“哪一行代码分配的没用上”前提是二进制里有对应的源码行号这靠的就是调试符号。我的建议是检测用的二进制统一用下面这套基础参数gcc -g -O0 -fno-omit-frame-pointer -o demo_leak demo_leak.c-g生成调试信息。没有它栈回溯只能到函数名甚至完全不可读。-O0关闭优化。开优化后编译器可能内联、重排或复用变量报出的行号会指错地方干扰排查。如果你后续想看 optimize build 下的内存行为等工具流程跑通再单独处理。-fno-omit-frame-pointer保留栈帧指针让回溯信息更完整。如果你用的是 C 项目建议别用 Release 配置直接跑 Valgrind。一套合理的做法是单独建一个 Debug 构建目录把 CMAKE_BUILD_TYPE 设成 Debug或者至少确保有 -g再在这个二进制上跑内存检查。我见过有人在 -O3 LTO 的产物上跑 Valgrind 追了一个下午最后追到的位置和真实泄漏点差了十万八千里问题就出在优化折叠。2.2 一段“必泄漏”的 C 程序为了演示我们写一段短小但典型的泄漏代码。它有两个问题函数内部 malloc 后没有释放退出函数后指针丢失main 里再次 malloc 后同样没有释放就 return。// demo_leak.c #include stdio.h #include stdlib.h #include string.h void create_leak(void) { char *p (char *)malloc(1024); strcpy(p, hello valgrind); // 这里忘了 free(p) } int main(void) { for (int i 0; i 3; i) { create_leak(); } char *q (char *)malloc(2048); // q 也没释放 printf(done\n); return 0; }这段代码在绝大多数 Linux 机器上都能正常编译运行甚至打印完 “done” 后正常退出系统不报任何错。但这恰恰是麻烦的地方“能跑”和“没有泄漏”是两码事。进程退出时操作系统会把整个进程的地址空间回收所以短跑几次不会炸但如果你把 main 里的逻辑包进一个常年运行的 while 循环很快就得扩容或重启。2.3 跑出第一条 Valgrind 报告编译好之后直接在项目目录执行valgrind --leak-checkfull --show-leak-kindsall --track-originsyes ./demo_leak第一次跑可能有点不习惯因为程序输出和 Valgrind 输出会混在一起。实际结果大概是这样的12345 Memcheck, a memory error detector 12345 Copyright (C) 2002-2017, and GNU GPLd, by Julian Seward et al. 12345 Using Valgrind-3.16.1 and LibVEX; rerun with -h for copyright info 12345 Command: ./demo_leak 12345 Parent PID: 1024 12345 12345 12345 HEAP SUMMARY: 12345 in use at exit: 5,120 bytes in 4 blocks 12345 total heap usage: 5 allocs, 1 frees, 6,144 bytes allocated 12345 12345 1,024 bytes in 1 blocks are definitely lost in loss record 1 of 4 12345 at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so) 12345 by 0x10916E: create_leak (demo_leak.c:6) 12345 by 0x1091A5: main (demo_leak.c:14) 12345 12345 3,072 bytes in 3 blocks are definitely lost in loss record 2 of 4 12345 at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so) 12345 by 0x10916E: create_leak (demo_leak.c:6) 12345 by 0x1091B5: main (demo_leak.c:15) 12345 12345 2,048 bytes in 1 blocks are definitely lost in loss record 3 of 4 12345 at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so) 12345 by 0x10917B: main (demo_leak.c:19) 12345 12345 LEAK SUMMARY: 12345 definitely lost: 5,120 bytes in 4 blocks 12345 indirectly lost: 0 bytes in 0 blocks 12345 possibly lost: 0 bytes in 0 blocks 12345 still reachable: 0 bytes in 0 blocks 12345 suppressed: 0 bytes in 0 blocks 12345 12345 For counts of detected and suppressed errors, rerun with: -v 12345 ERROR SUMMARY: 4 errors from 4 contexts (suppressed: 0 from 0)第一眼看上去全是字母数字但信息其实非常清楚。HEAP SUMMARY 说程序退出时还有 5120 字节在 4 个块里没释放总共分配了 5 次、只释放了 1 次。再看 LEAK SUMMARY全部是 definitely lost意味着这 4 个块没有任何指针还能找到它们就是真真实实的泄漏。每条用 “definitely lost in loss record” 开头的记录下面跟了一个分配时的调用栈。比如第一条告诉你malloc 发生在 demo_leak.c 第 6 行也就是 create_leak 函数里再往下能看到 create_leak 是被 main 第 14 行调用。三条记录合在一起就能把代码里所有泄漏点串起来了。3. 读懂 Valgrind 输出五类泄漏和两个容易被忽略的细节3.1 先看 LEAK SUMMARY再看 HEAP SUMMARY很多人习惯从头到尾把 Valgrind 日志读完其实效率很低。我一般只看两块LEAK SUMMARY先判断泄漏总量、泄漏类型分布。如果 definitely lost 为 0后面的问题大概率是“该清理但没清理”严重程度完全不同。HEAP SUMMARY重点看 in use at exit 与 total allocs/frees 的对比。比如分配了 100 次只释放了 5 次那即便 LEAK SUMMARY 显示 still reachable也是值得关注的资源管理问题。HEAP SUMMARY 里的 total heap usage 是观察整个进程生命周期分配的不只是泄漏。它不能直接等价于“泄漏了多少”因为有些内存虽然退出时没释放但可能在运行中被重用或者作为全局缓存存在。所以两条 summary 要配合着看先看有没有不可达的块再看整体释放比例。3.2 五类泄漏分别代表什么Valgrind 的默认报告里常出现五类状态很多初学者只认识 definitely lost导致一见到 still reachable 就很慌或者反过来完全忽略。我整理了一张表泄漏类型内存块状态是否要处理实际含义definitely lost没有任何指针指向也不存在内部指针能绕回来必须处理你没留任何释放途径是真正意义的内存泄漏indirectly lost一个 definitely lost 块内部还指向另一/多个块这些块随“父块”一起丢失跟随 definitely lost 一起处理典型是丢了一条链表/树头节点丢失导致子节点也找不回来possibly lost只找到了指向内存块内部某处的指针而不是指向块首地址尽量处理需人工确认可能是程序刻意保存了块内字段的地址也可能是计算粒度导致误判still reachable还有合法的全局变量、局部变量或静态变量指向块首视工程规范决定严格说不算泄漏但退出前没释放仍属于资源清理不彻底suppressed通过 suppression 文件显式忽略的告警忽略通常是第三方库或系统库已知问题still reachable 是最容易引起争议的。举个例子你在 main 里 malloc 了一块内存给全局配置用进程退出前没 free操作系统会回收进程也不是长期泄漏因为全局指针一直存在。但从代码质量角度我还是建议在进程退出路径上把它释放掉。Valgrind 把 still reachable 单列出来就是提醒你别太紧张但也不等于你可以心安理得地无视。3.3 分配栈的读取技巧分配点不等于责任点Valgrind 最大的价值是告诉你“哪一行代码分配的内存泄漏了”但它不会告诉你“是在哪一步让它失去指针的”。比如我们 demo 里 create_leak 函数malloc 之后就丢了指针。Valgrind 报的分配栈在 create_leak 第 6 行但真正要改的可能不是这一行而是 create_leak 的调用方有没有契约去接收并释放返回的内存。实际工程里泄漏的根因往往跨好几个函数、好几个类A 创建了对象传给 BB 用完后没有调用销毁接口A 也早已不持有引用最后就是一个 nobody owns it 的状态。所以拿到分配栈之后别急着在那一行加 free先把“这块内存的所有权到底该归谁”想清楚。配合做得更多一些你会用到这几个选项--num-callers30加大栈回溯深度三层栈很多时候不够用。--track-originsyes除了查泄漏如果你想连使用未初始化变量的来源也一起定位开这个选项。代价是更慢但信息量很足。--verbose输出每个模块的加载信息某些场景排查“为什么没检测到某个模块”会用到。4. 从检测到修复再到让 CI 替你盯内存质量4.1 修复一个最朴素的泄漏回到 demo_leak.c。修复思路很直白create_leak 里用完就释放main 里的缓冲区也在 return 前释放。改成这样#include stdio.h #include stdlib.h #include string.h void create_leak(void) { char *p (char *)malloc(1024); strcpy(p, hello valgrind); printf(%s\n, p); free(p); } int main(void) { for (int i 0; i 3; i) { create_leak(); } char *q (char *)malloc(2048); printf(done\n); free(q); return 0; }再跑一遍同样的命令报告会干净很多12346 HEAP SUMMARY: 12346 in use at exit: 0 bytes in 0 blocks 12346 total heap usage: 5 allocs, 5 frees, 6,144 bytes allocated 12346 12346 LEAK SUMMARY: 12346 definitely lost: 0 bytes in 0 blocks 12346 indirectly lost: 0 bytes in 0 blocks 12346 possibly lost: 0 bytes in 0 blocks 12346 still reachable: 0 bytes in 0 blocks 12346 suppressed: 0 bytes in 0 blocks 12346 12346 All heap blocks were freed -- no leaks are possible看到 “All heap blocks were freed” 是我每次最舒服的时刻。这里想提醒一点不要为了让 Valgrind 闭嘴而到处补 free。这个例子简单所以直接补没问题真实项目里我更倾向把资源生命周期约束在一个明确的 owner 上C 用 RAII、智能指针C 至少做到 create/init 和 destroy 成对出现。4.2 链表达理解 definitely lost 与 indirectly lost上面的例子只有平铺指针漏掉了一个很常见的复杂场景你丢的不是一块内存而是一整条链表。#include stdio.h #include stdlib.h typedef struct Node { int val; struct Node *next; } Node; void leak_chain(void) { Node *head (Node *)malloc(sizeof(Node)); Node *second (Node *)malloc(sizeof(Node)); head-next second; second-val 42; // head 和 second 都没有释放而且函数结束后都找不到了 } int main(void) { leak_chain(); return 0; }跑一下 Valgrind你会看到类似这样的结果12347 16 bytes in 1 blocks are definitely lost in loss record 1 of 2 12347 at 0x483B7F3: malloc (in ...vgpreload_memcheck-amd64-linux.so) 12347 by 0x10917E: leak_chain (demo_chain.c:10) 12347 by 0x1091A6: main (demo_chain.c:17) 12347 12347 16 bytes in 1 blocks are indirectly lost in loss record 2 of 2 12347 at 0x483B7F3: malloc (in ...vgpreload_memcheck-amd64-linux.so) 12347 by 0x109192: leak_chain (demo_chain.c:11) 12347 by 0x1091A6: main (demo_chain.c:17) 12347 12347 LEAK SUMMARY: 12347 definitely lost: 16 bytes in 1 blocks 12347 indirectly lost: 16 bytes in 1 blocks 12347 possibly lost: 0 bytes in 0 blocks 12347 still reachable: 0 bytes in 0 blocks 12347 suppressed: 0 bytes in 0 blockshead 这个头节点本身丢了所以它是 definitely lostsecond 是顺着 head-next 才能访问的head 丢了之后 second 也找不回来所以是 indirectly lost。很多人第一次见间接泄漏会误以为是双份泄漏实际上修复只要把链表从头到尾遍历释放一遍indirectly lost 会一起消失Node *cur head; while (cur ! NULL) { Node *next cur-next; free(cur); cur next; }这个场景给我们的经验是看到 indirectly lost 不用单独去记子节点地址先把 definitely lost 的根节点处理掉间接泄漏自然消失。反之如果你只释放了 second 却没释放 headValgrind 报告仍然会报 definitely lost。4.3 给 CI 加一道内存质量门禁手工跑 Valgrind 只能解决“这次没泄漏”解决不了“下个提交又把泄漏加回来”。所以我会把 Valgrind 集成到 CI 里作为单元测试或回归测试之后的一道门禁。要让脚本能根据检测结果失败关键是加两个参数valgrind --leak-checkfull --show-leak-kindsall \ --errors-for-leak-kindsdefinite,possible \ --error-exitcode1 \ ./demo_leak--error-exitcode1 表示一旦检测到 errorValgrind 以非零码退出。配合 --errors-for-leak-kindsdefinite,possible可以做到只有确实不可达的泄漏或可能的越界类问题才让流水线失败still reachable 只作为提示不直接打断构建。这样能避免因为全局单例没清理之类的问题把整个 CI 弄红。如果你用 GitLab CI可以在 .gitlab-ci.yml 里写类似这样的任务memory-leak-check: stage: test script: - gcc -g -O0 -fno-omit-frame-pointer -o demo_leak demo_leak.c - valgrind --leak-checkfull --show-leak-kindsall --errors-for-leak-kindsdefinite,possible --error-exitcode1 --log-filevalgrind.log ./demo_leak artifacts: paths: - valgrind.log expire_in: 1 week为了不让日志被多线程进程刷爆也可以给 --log-file 加 %p 后缀比如 valgrind.log.%p让每个进程一份日志。跑完后 CI 会保存日志即使任务失败也能直接打开看是哪段泄漏。如果项目用 CMake 和 CTest我还会把 memcheck 作为一个单独的测试注册进去find_program(VALGRIND valgrind) if(VALGRIND) add_test( NAME memcheck_demo COMMAND ${VALGRIND} --leak-checkfull --show-leak-kindsall --errors-for-leak-kindsdefinite,possible --error-exitcode1 $TARGET_FILE:demo_leak ) endif()这样只要本机装了 Valgrindctest 就会额外跑一次内存检查没装也不会阻断常规测试。实际上不管是 CI 脚本还是本地脚本思路都一样让 Valgrind 在错误发生时用退出码说话而不是让人肉翻日志。5. 外部库刷屏怎么办suppression 的正确用法5.1 先搞清楚什么是“伪泄漏”实际操作中最头疼的不是自己代码的泄漏而是第三方库、系统库在退出时留下来的一堆 still reachable 或可能的告警。比如 OpenSSL、某些 GUI 框架或驱动库会有意把一块全局缓存留到进程退出或者把自己内部的小块内存挂在某个静态链表上。这些不是你的 bug但如果不去管每次 CI 日志里都有一大片噪音真正的泄漏点反而被淹没。Valgrind 对这种场景提供了 suppression 机制直译是“抑制”。它不是把问题藏起来而是告诉 Valgrind这条告警栈匹配到声明的屏蔽规则不用输出。使用上要非常克制只屏蔽确实排查过、确认不是自己代码问题的条目。5.2 生成 suppression 文件最简单的生成方式是让 Valgrind 自己打印出匹配用的 suppression 块valgrind --leak-checkfull --show-leak-kindsall --gen-suppressionsall --log-filevalgrind.log ./your_program打开 valgrind.log会看到类似这样的内容{ insert_a_suppression_name_here Memcheck:Leak match-leak-kinds: possible fun:SomeThirdPartyInit fun:main }把这段复制到一个 .supp 文件里给名字改成可读的描述比如openssl-possible-leak-in-init然后运行valgrind --suppressionsmy.supp --leak-checkfull --show-leak-kindsall ./your_program之后匹配到的告警就不会再刷屏了。需要注意 suppression 块是精确匹配调用栈的第三库升级后栈变化旧规则可能失效所以隔一段时间要重新检查一下 suppression 文件里还有多少规则是被真正命中的。5.3 什么时候不该用 suppression我见过最夸张的用法是一个人把整个工程的告警全部 gen-suppressions 导出来然后一把梭加进仓库。这样 Valgrind 干净是干净了但等于直接把检测关了。合理的边界是只抑制你亲自溯源过、确认是第三方库已知行为且无法修改的告警。每次 suppression 都写清楚原因和日期比如 “openssl exit-time global cache, tracked in issue #1234”。自己代码里的泄漏永远不要进 suppression。真出现这种冲动说明这个工具已经失去了意义。6. “android 内存泄漏”热搜背后的两层理解搜索热词里出现了“android 内存泄漏”这里很有必要帮大家拆一下。Android 环境里的内存泄漏其实分成完全不同的两层Valgrind 只在其中一层起作用。6.1 原生 C/C 层Valgrind 能帮忙如果你在用 NDK 写 C/C或者项目里引入了第三方 .so内存泄漏问题同样存在。Valgrind 在 Android/Linux 环境下也能跑通常需要 root 过的模拟器或测试机直接安装对应架构的 Valgrind 二进制通过 adb shell 启动被测进程。但我必须说实话Android 设备上跑 Valgrind 的体验远不如在桌面 Linux 上流畅一是性能损耗更大二是日志收集和设备兼容性都很折腾。所以我在 Android 原生层做代码质量更常用的是 AddressSanitizer。编译时加上 -fsanitizeaddress配合 NDK 的 wrap.sh 机制可以较方便地在 CI 或真机上跑出内存错误报告。它和 Valgrind 不是二选一的关系本地疑难问题、想不重新编译就检测的时候用 Valgrind日常大规模回归、追求速度和集成度的时候用 ASan。6.2 Java/Kotlin 层Valgrind 管不到很多做 Android 应用的开发提到“内存泄漏”实际上指的是 Activity、Fragment、Bitmap 之类的 Java/Kotlin 对象被长生命周期对象持有导致无法被 GC 回收。这种泄漏发生在 ART 虚拟机管理的堆上Valgrind 作为用户态工具管不到。你需要的是 LeakCanary、Android Studio 自带的 Memory Profiler或者严格做 Activity 生命周期检查。所以拿到“android 内存泄漏”这个关键词第一件事不是打开 Valgrind而是先判断泄漏发生在 native 层还是 Java/Kotlin 层。选错工具排查方向全错可能折腾一整天都定位不到问题。7. 常见问题与排查技巧实录最后把我这些年用 Valgrind 踩过的坑和解决思路总结成一张速查表你可以直接贴在项目文档里现象常见原因处理建议报告一堆“Conditional jump or move depends on uninitialised value”有未初始化变量且它被用于分支或数组下标先用 --track-originsyes 定位并修掉再回来查泄漏未初始化问题会污染后续分析程序正常跑完但看不到 LEAK SUMMARY进程不是通过 return/exit 退出而是被 abort、_exit 或信号杀了保证测试路径正常退出如果是长驻服务换 toolmassif 观察堆增长只在崩溃前出现大量泄漏平时干净并发线程各自持有资源出现竞态时无法释放先修并发/生命周期问题Valgrind 跑单线程场景可能复现不了报告中的行号不对编译时没加 -g或者优化等级过高用 -g -O0 -fno-omit-frame-pointer 重新编译再检测still reachable 巨多全局单例、常量缓存、第三方库生命周期长区分“理论可达”和“应该释放”在退出路径统一清理别用 suppression 一关了事多线程程序跑 Valgrind 慢到想放弃Valgrind 会对线程做串行化时间放大严重缩小数据量用代表性回归用例不做真实并发压力测试两条一样的报错重复出现几十次每个泄漏块都会单独打一条记录调整测试输入规模关注 loss record 合并后的总字节数不数单条记录日志太多看不清重点缺省输出到终端进程多了互相混用 --log-filevalgrind.log.%p 控制输出如果你刚开始在项目里推 Valgrind不要指望一次就把所有历史包袱清完。我的习惯是先把新代码的提交门禁装上definite/possible lost 直接置为失败老代码的历史问题单独开一个清单分模块、按服务逐步清零。这样团队既不会因为存量问题太大而抵触又能保证增量代码不再继续堆垃圾。最后再分享一个这几年反复验证的小经验Valgrind 报告里的“分配栈”是起点不是终点。真正的高手会顺着分配栈去问三个问题——这块内存被创建后所有权交给了谁释放的契约在哪个函数/对象里异常路径上这个契约有没有可能被跳过三个问题想清楚内存泄漏基本就能在写代码阶段避免而不是等到 Valgrind 把你钉在工位上赶工修。和所有代码质量工具一样Valgrind 自己不会提高代码质量但它能把模糊的“程序好像越来越慢”变成一行行可追踪的分配栈这就足够让它成为 C/C 工程里最值得投入的工具之一。希望这篇文章能帮你把这条路走顺。
返回列表