资讯动态

Valgrind Memcheck实战:动态插桩精准定位C/C++内存泄漏与非法访问

发布时间:2026/10/22 6:10:37 来源:尧图企业网站定制
你有没有碰上过这种场景程序本地跑得好好的线上跑了三天内存占用曲线一路往上爬最后被系统杀掉或者输入稍微复杂一点程序就隔三差五给你一个段错误core 文件拉出来一看全是汇编。排查这类问题我第一个拿起来的就是 Valgrind准确说是它自带的 Memcheck 工具。它不需要你改源码也不要求重新编译成特殊版本只要把带调试符号的程序往上一跑非法读写、使用未初始化变量、内存泄漏、双重释放基本都能精确到代码行号。这篇文章适合被 C/C 内存问题折磨过的开发者也适合用 Rust 写 FFI 边界想确认内存安全的朋友我会从工具原理讲到实际操作再给你一套可以直接抄作业的排查流程。1. Valgrind 不是“内存检查器”是一个虚拟机Valgrind 的核心不是某个单一功能而是一个动态二进制插桩框架。它把程序的机器码翻译成一种中间表示在翻译层里插入检查代码然后再翻译回机器码执行。Memcheck 只是它众多内置工具中的一种因为处理内存错误太出名很多人把 Valgrind 和 Memcheck 画了等号。理解这一层你就明白为什么它能做到“不改源码、不重新编译”却又能抓到问题Valgrind 的检查发生在运行时而不是编译期。打个比方你的程序是一条流水线上的快递包裹Valgrind 是在传送带旁边架设的全套安检设备。包裹还是原来的包裹但每个包裹经过安检仪的时候都被扫了一遍 X 光、称了重、录了影像。代价是整条流水线的速度被拖慢几十倍所以 Valgrind 适合用来做测试环境下的深度排查不适合直接怼在生产环境的请求链路上跑。Memcheck 具体在干什么它会为程序里每个已分配的内存块维护状态元数据记录“这块内存能不能读写”“这个字节的内容是不是已经初始化”。每次程序进行内存读写检查器就比对一下元数据每次分支或跳转如果依赖未初始化数据也会被记录下来。一旦发现违规它当场生成一个错误报告。1.1 为什么需要动态检查而不是静态扫描静态扫描工具比如编译器自带警告或者一些 SAST 工具能在代码层面发现“你可能忘记释放指针”的嫌疑但它们面对以下情况往往无能为力程序在编译期根本不知道某个数组越界会发生在运行时也不知道用户输入会不会把缓冲区填爆。Valgrind 这种动态检查则不同——它看到的是真实运行路径它知道这一次 malloc 申请了多少字节知道这一次写入发生在哪个地址、离边界还有多远。这带来一个很实际的收益误报率低很多。凡是 Memcheck 报出来的问题绝大多数是真问题不是“你这里可能有问题”的猜测。它还会附上当前线程、指令地址、调用栈让你能顺着线索找到“第一作案现场”。相比之下段错误只能告诉你炸弹在哪儿炸了而 Valgrind 能告诉你炸弹是什么时候埋下的。1.2 Memcheck 最擅长的六类错误我平时排查问题时几乎只看六类错误。下面这张表整理好了建议收藏错误类型含义典型症状Invalid read / Invalid write读写了不属于合法内存的区域越界、悬空指针、使用已释放内存Use of uninitialised value使用了未初始化的变量或内存结果不确定、偶发逻辑错乱Mismatched free / delete分配和释放方式不匹配new/delete 和 malloc/free 混用Invalid free释放了不是通过分配函数得到的地址重复释放、释放栈地址Memory leak堆内存不再被引用内存占用持续上涨Source/dest overlapmemcpy 等函数源与目标内存重叠数据被覆盖、结果异常其中前两类最隐蔽也最容易造成“线上偶发崩溃、本地怎么也复现不了”的情况。举一个 Invalid write 的标准报告片段你先感受一下格式456 Invalid write of size 4 456 at 0x1091D8: main (invalid.c:5) 456 Address 0x4a2a044 is 4 bytes after a block of size 16 allocd 456 at 0x4848899: malloc (vg_replace_malloc.c:442) 456 by 0x1091B5: main (invalid.c:3)Address ... is 4 bytes after a block of size 16这句非常关键它直接告诉你越界方向和距离。我第一次看懂这句话之后排查速度提升了一个量级。1.3 同一框架下的其他成员Valgrind 自带不少工具除了 Memcheck我日常还会用到这几个Callgrind一般配合callgrind_annotate看函数热点偏向性能分析。Cachegrind模拟 CPU 缓存行为看缓存命中率性能调优时用。Massif堆内存增长曲线分析和ms_print搭配能看出程序在不同时间点占了多少堆内存适合定位“内存持续上涨是不是某个阶段分配没释放”这类问题。Helgrind 和 DRD线程竞态检测排查多线程共享变量问题。这些工具共用同一套命令行参数风格学会 Memcheck 的参数再切换到 Massif 或 Helgrind 只是换一个--tool的事学习成本不高。不过本篇核心依然是内存错误定位所以后面重点都放在 Memcheck 上。2. 环境准备装好工具配好编译选项2.1 三种安装方式总有一种适合你第一选择当然是系统包管理器。Debian / Ubuntu 系执行sudo apt update sudo apt install valgrindRHEL / Fedora 系执行sudo dnf install valgrind装完直接valgrind --version验证。一般发行版仓库里的版本不会太老够用。如果你需要用源码编译我在 x86 架构上跑过流程很稳wget https://sourceware.org/pub/valgrind/valgrind-3.23.0.tar.bz2 tar xjf valgrind-3.23.0.tar.bz2 cd valgrind-3.23.0 ./configure --prefix/usr/local make -j$(nproc) sudo make install源码编译适合交叉编译环境或者仓库版本太旧的情况。还有一个场景容易被忽略如果你的程序跑在容器里可以在基础镜像里加 valgrind例如基于 Debian 镜像用 apt 安装跑容器时记得把调试信息能映射的路径都挂载进来否则报告里的源码路径对不上。2.2 编译选项三个原则必须有-g。没有调试符号Valgrind 虽然能检测到错误但给出的是地址不是文件:行号。对排查来说相当于一把手术刀变成了锤子。优化级别建议-O0。用-O2或者-O3编译后编译器会内联、重排指令行号会漂移。比如未初始化变量被优化掉Valgrind 可能不再报告也可能因为寄存器复用报出和你在源码看到的完全对不上的位置。能复现问题就已经很奢侈了别让优化级别替你掩盖证据。尽量避免-static。Valgrind 对静态链接程序支持有限如果程序是静态链接排查时请重编一个动态链接版本。虽然这会改变一部分符号但绝大多数内存错误行为不受影响。一个我经常用的编译命令gcc -g -O0 -fno-builtin -o demo demo.c-fno-builtin是为了防止编译器把strcpy、memcpy这些标准函数替换成内置版本减少调用栈被折叠的情况。2.3 第一条命令默认参数和最常用参数最简单的运行方式valgrind ./demo不带任何参数时Memcheck 是默认工具但错误报告不够详细比如泄漏信息不会完整展开。我日常更习惯用这个完整组合valgrind --toolmemcheck --leak-checkfull --show-leak-kindsall --track-originsyes ./demo--leak-checkfull泄漏检测后列出每个泄漏内存块的分配调用栈。--show-leak-kindsall显示全部泄漏类型。默认只显示 definite不显示 still reachable排查时少看一半信息就亏了。--track-originsyes当使用未初始化值时追踪未初始化值最初是在哪里创建的。这个参数会让程序慢更多但回报极高后文会展示。如果程序会 fork 子进程加--trace-childrenyes。如果程序输出和 Valgrind 报告混在一起不好看把报告重定向到文件valgrind --log-filevalgrind.%p.log --toolmemcheck --leak-checkfull \ --show-leak-kindsall --track-originsyes ./demo%p会被替换成进程号多进程场景下每个进程一个日志省去手动拆分。3. 实战一个链表程序三项内存错误3.1 构造一个有问题的样例先声明一下这不是教科书里的假题目这类代码在真实项目里经常出现。一个链表函数只释放了头节点另一个累计函数用了未初始化的局部变量。完整代码我放在下面你可以直接存成demo.c本地跑。#include stdio.h #include stdlib.h typedef struct Node { int id; struct Node *next; } Node; static Node *build_list(void) { Node *head NULL; Node *tail NULL; for (int i 0; i 3; i) { Node *n (Node *)malloc(sizeof(Node)); n-id i; n-next NULL; if (head NULL) head n; else tail-next n; tail n; } return head; } static int check_sum(Node *head) { int total; Node *p head; while (p ! NULL) { total p-id; p p-next; } return total; } int main(void) { Node *head build_list(); int total check_sum(head); printf(total%d\n, total); free(head); // 只释放了第一个节点 return 0; }编译命令gcc -g -O0 -o demo demo.c valgrind --toolmemcheck --leak-checkfull --show-leak-kindsall --track-originsyes ./demo3.2 报告怎么读三个线索运行完你会看到两部分关键输出。第一部分是未初始化变量错误12400 Use of uninitialised value of size 4 12400 at 0x1091D6: check_sum (demo.c:29) 12400 by 0x109133: main (demo.c:37) 12400 Uninitialised value was created by a stack allocation 12400 at 0x109182: check_sum (demo.c:26)第二段--track-originsyes的作用就体现出来了。它明确告诉你这个未初始化的值不是天上掉下来的而是在demo.c:26的栈分配中创建的也就是声明int total的地方。没有这个参数你只能看到demo.c:29使用了未初始化值还要自己去翻代码猜根源。第二部分是泄漏汇总。因为free(head)只释放了第一个节点第二个和第三个节点在程序退出时还挂在堆上而且已经没有任何指针能走到它们Memcheck 把它们标记为 definitely lost12400 LEAK SUMMARY: 12400 definitely lost: 32 bytes in 2 blocks 12400 indirectly lost: 0 bytes in 0 blocks 12400 possibly lost: 0 bytes in 0 blocks 12400 still reachable: 0 bytes in 0 blocks 12400 suppressed: 0 bytes in 0 blocks 12400 ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)32 bytes in 2 blocks刚好对应两个Node节点64 位机器上一个 Node 是 16 字节int 4 字节 对齐 4 字节 next 指针 8 字节。如果你看到的数字和系统上不完全一致先算算对齐别急着怀疑工具。3.3 修复并回归验证修复分两步。未初始化变量把int total;改成int total 0;。链表释放写一个专门遍历释放的函数不要在 main 里释放头节点了事static void free_list(Node *head) { while (head ! NULL) { Node *next head-next; free(head); head next; } }把 main 最后的free(head)改成free_list(head)重新编译运行。这个阶段你应该看到最让人放心的结尾12400 All heap blocks were freed -- no leaks are possible 12400 ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)完整排查流程其实是固定的三遍先跑一遍拿到全部错误清单按“错误报告 → 源码行号 → 数据结构”的顺序理解每一条修复后无条件重跑回归。不要修完第一个错误就去跑另一件事内存问题经常是连环性质的一次全量回归才能确认没有引入新问题。4. 内存泄漏类型、统计与追查习惯4.1 五种泄漏分类哪些真要处理Valgrind 在泄漏总结里给内存块分了几类。不少初学者被这些英文名词吓到其实拆开看很简单分类含义我的一般处理definitely lost程序里再也找不到指针引用它明确泄漏必须修通常直接对应某次被跳过的释放indirectly lost块本身不可达但被另一个 lost 块内的指针指向和 definitely lost 配套修复父块后一并恢复possibly lost只能通过部分被覆盖的指针链访问具体情况需要人工判断优先结合源码确认多半也是泄漏still reachable程序退出时仍有指针指向但没释放短暂进程问题不大长驻服务里就是泄漏suppressed由抑制文件过滤掉的报告用于处理第三方库干扰后文有专门说明definitely lost 和 indirectly lost 经常一起出现。比如一棵树的所有节点只通过根节点引用根节点的指针被覆盖导致整棵树不可达这时候最上层的根节点是 definitely lost子孙节点是 indirectly lost。修复根节点的释放后者随之清零。still reachable 最容易被误解。一个小工具跑几秒就退出即便有一块全局内存没释放也不影响机器稳定你可以暂时忽略但一个控制进程活一个月这样的“仍可达”内存积累起来就是内存泄漏。所以我把 still reachable 当作“灰色地带”常驻进程里一律按泄漏处理。4.2 加参数让泄漏报告更有用基本命令已经有--leak-checkfull和--show-leak-kindsall还差一个参数组合--num-callers20。默认调用栈深度是 12 层碰到深一点的框架封装分配点很容易被截掉栈只显示到中间层让你找不到业务代码。加大到 20 以后分配点往往就浮出来了。日志文件模式也很实用valgrind --leak-checkfull --show-leak-kindsall --num-callers20 \ --log-fileleak.%p.log ./demo程序输出和 Valgrind 报告分离后再用grep -A专门看泄漏grep -A 8 definitely lost leak.12345.log如果一次报告里分配点很多我会把输出按分配调用栈去重组织成“哪些代码路径创建了未释放内存”的清单。不要试图一次性修完先修出现次数最多的分配点收益最大。4.3 泄漏高发场景和我的排查习惯从我见过的问题里泄漏高发场景无非这几类链表/树遍历后中途 return 导致释放代码被跳过字符串拼接反复申请新缓冲区而不释放旧缓冲区结构体数组释放时只 free 了外层数组没先释放内层的指针全局单例的辅助结构在退出阶段没有清空。我的排查习惯是固定回答三个问题这个块在哪里分配它的生命周期应该被谁管理程序什么时候应该释放它把这三个问题写成注释放在分配点附近能避免绝大多数低级泄漏。另外写释放代码时遵循“谁分配谁释放”原则跨模块间借用的内存要协议明确不要指望调用者猜测内部实现。5. 进阶抑制文件、CI 集成与性能取舍5.1 第三方库误报用抑制文件而不是忍实际项目里最难缠的不是自己的代码报错而是第三方库在 Valgrind 下疯狂刷屏。比如某些闭源静态库内部有自己的内存池报告会一屏接一屏而你根本改不了它。这时候不要用眼睛硬筛应该启用抑制文件。第一步让 Valgrind 生成抑制规则valgrind --gen-suppressionsall --log-filevalgrind.log ./demo打开日志你会看到形如这样的段落{ insert a suppression name here Memcheck:Leak fun:_ZNSt7__cxx1112basic_stringI... }把大括号块整体复制到lib.supp文件给第一行写一个清晰的名字比如“libfoo_internal_pool”。然后通过--suppressionslib.supp指定valgrind --suppressionslib.supp --leak-checkfull ./demo此时这些第三方库的条目会被归类到 suppressed不再污染你的视线。我的经验是抑制文件宁可先做得窄一点也不要大把大把地屏蔽错误。一次只针对某个明确无害的库路径添加规则否则很容易把真实的业务错误一起滤掉那排查就失去意义了。5.2 把 Valgrind 塞进 CI自动卡内存错误Valgrind 真正的价值之一是作为回归检测闸门。加一个参数有内存错误就返回非零valgrind --error-exitcode99 --leak-checkfull \ --show-leak-kindsall --errors-for-leak-kindsdefinite,indirect \ --log-filevalgrind.%p.log ./demo--error-exitcode99让程序在检测到内存错误后以退出码 99 结束脚本只要判断退出码就知道有没有问题。--errors-for-leak-kindsdefinite,indirect表示把明确泄漏和间接泄漏也计入错误still reachable 不算避免老项目一接入就一堆历史包袱。CI 里跑 Valgrind 的代价是时间。程序被插桩后通常会慢 20 到 100 倍所以 CI 任务要用精简用例不要全量压测。我一般单独建一个make test-valgrind的 target只跑单元测试的核心路径。如果还想产物化Valgrind 支持--xmlyes --xml-fileout.xml输出 XML 报告可以对接自动化平台做解析展示。5.3 程序又慢又大怎么办有时候你面对的是一套遗留系统启动就要半分钟数据量一大Valgrind 跑一轮能赶上一次小型发布。我的建议按优先级来先减小输入规模。Valgrind 只需要“能复现问题”的最小输入不需要真实全量数据。只跑关键路径。把问题切到单一模块写个小驱动避免每次都启动整个服务。拆工具跑。Memcheck、Massif、Helgrind 不要同时用每个工具单独跑报告更干净时间也更好控制。真上生产环境级别的代码用--vgdbyes配合调试器做定点分析valgrind --vgdbyes --vgdb-error0 ./demo这时 Valgrind 会作为 gdbserver 启动程序执行到出错点附近时你可以用 gdb 或 lldb 附加过去看现场一些需要看寄存器、局部变量的疑难杂症会好查很多。5.4 和 AddressSanitizer 怎么选现在很多人也会用编译器的 AddressSanitizerASan两者不是二选一的关系而是互补关系。ASan 需要重新编译并链接运行时库速度损耗通常在 2 倍左右远快于 Valgrind但 Valgrind 不需要重新编译适合排查无法获得源码的历史二进制还能检测未初始化内存这点 ASan 并不默认覆盖。我的分工方式是新代码的日常测试用 ASan跑得快问题发现也早遇到线上偶发的乱码、段错误或者拿到一个没有源码的旧模块再上 Valgrind。两者绝对不要同时开Valgrind 会对 ASan 的运行时行为产生干扰报告也会互相覆盖基本等于白跑。6. 常见问题与避坑经验6.1 报告没有行号栈顶全是框架这个问题出现过太多次排除记录后基本都是同一个原因编译时没加-g或者优化级别太高导致符号行号漂移。验证方法很简单用 addr2line 直接解析错误里的地址addr2line -e ./demo 0x1091D6如果输出是??:0那就不是地址解析的问题而是二进制本身缺少调试符号。解决办法只有一个重新用-g -O0编译目标程序。如果是 C 程序函数名看着一团乱码这是名字改编的正常现象Valgrind 默认会做 demangle你在报告里看到的是还原后的函数名如果没还原确认没有传--demangleno。6.2 日志太大找不到重点当程序输出非常多Valgrind 报告和程序日志混在一起刷屏时第一反应应该是把两者物理分离也就是用--log-file。进程号占位符%p一定不要省多进程场景下没有它子进程日志会被同一个文件覆盖。筛选重点我常用两句话grep -E Invalid|uninitialised|lost|ERROR SUMMARY valgrind.12345.logValgrind 每条错误之间会有空行和时间戳标识可以按错误块阅读不要一次性读全文。有时候报告看起来巨大实际只有几个不同的错误根因只是每次触发点不同导致上下文重复出现这种时候看 ERROR SUMMARY 那一行的 contexts 数即可。6.3 多线程程序、daemon 和子进程默认情况下 Valgrind 只追踪当前进程不追踪 fork 出来的子进程。一个常见坑是程序主进程正常子进程里崩溃了Valgrind 主进程的报告却一片晴朗。解决方法是加valgrind --trace-childrenyes --child-silent-after-forkyes ./demo--child-silent-after-forkyes让子进程只在出错时输出报告减少重复打印。多线程场景下Memcheck 会每个线程独立报告但对竞态问题不敏感真要查数据竞争切到--toolhelgrind或者--tooldrd。如果目标是 daemon并且它已经 fork 到后台脱离终端建议先改成前台模式跑测试。前台模式跑通了内存问题解决后再回到 daemon 模式验证否则你会在进程管理、信号处理、输出重定向几件事之间来回横跳无法集中排查。6.4 报告盯着 C 标准库看半天用 g 编译的 C 程序new/delete 经过 libstdc 之后错误报告的调用栈会有一大段标准库内部函数比如operator new、_M_allocate之类。这时候别在库内函数上浪费时间往上翻调用栈找你自己的源文件路径和行号。--num-callers20能保证你的业务栈不被截断看到业务代码后再判断是不是 STL 容器使用问题。还有一种情况需要特别提醒still reachable里大量出现std::string或容器的分配点先检查是不是全局静态对象没有规范化清理。C 程序的退出阶段错误也经常被 Valgrind 捕捉为 Mismatched free/delete比如用 malloc 分配再调用 delete 释放这在混用 C 和 C 代码的老项目里尤其常见。最后分享一点个人的操作体会。我现在接手任何一个 C/C 模块第一件事不是通读代码而是先把 Valgrind 跑一遍没有症状也跑就当给模块做一次体检。内存问题的可怕之处在于它不一定会当场崩溃常常是在线上运行好几天之后才以数据错乱、内存暴涨、偶发死锁的形式暴露出来那时代价就大了。把排查前置到合入主干之前是我这些年减少线上事故最有效的习惯。还有一个小技巧如果你在一台旧服务器上看到valgrind: Unrecognised instruction之类的报错先别怀疑代码第一时间升级 Valgrind 版本。这类问题大概率是工具不认识这台机器的某些新指令而不是你的程序里出现了外星故障。我踩过这个坑当时绕了大半天才回到工具版本这个答案上。如果你手头有个一直复现不了的内存诡异问题不妨带着--track-originsyes跑一轮很多谜团的答案其实在根源处等了你很久。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑