文章总结: 本文详述C++服务内存泄漏排查实战,结合Valgrind与ASan工具,定位并修复了malloc泄漏、memcpy越界、字节对齐及SQLite3.12.1版本Bug导致的mmap增长问题。最终升级库版本彻底解决故障。文中对比了工具优劣,并建议在开发期用ASan,测试期用Memcheck,运行期用Massif进行全生命周期内存管理。
综合评分: 91
文章分类: 安全开发,实战经验,安全工具,漏洞分析,应用安全
C++内存泄露问题定位实战分享
原创
王占川
王占川
威努特安全网络
2026年1月22日 07:59
北京
一
引 言
C/C++ 开发绕不开内存管理的深坑:泄漏、越界、资源未释放——这些隐患平时隐匿无踪,却在系统长期运行时骤然爆发,导致崩溃或性能暴跌。
最近,我们的一个大型C++服务tservice出现了典型内存症状:内存占用随运行时间持续增长。为精准定位问题根源,通过综合运用Valgrind、AddressSanitizer (ASan) 等工具进行深度剖析。本文完整记录此次排查过程,分享关键的分析策略、工具实践细节,并揭示最终发现的核心Bug。
这是通过 top 监控到的进程占用内存的曲线图,很明显内存泄漏很严重。
二
工具选型
tservice 内存持续增长问题的定位,需要覆盖内存错误检测(如泄漏、越界、UaF)和堆内存消耗模式分析两个关键维度。单一工具难以兼顾深度、性能和宏观视图。因此,选择了功能互补的工具进行组合分析:Valgrind Memcheck、Valgrind Massif 和 AddressSanitizer (ASan)。实际排查中,按 Memcheck -> ASan -> Massif 的顺序依次使用它们。
Valgrind Memcheck
(基础内存错误检测)
工作原理:基于动态二进制插桩 (DBI) 和影子内存 (Shadow Memory)。
工作方式:
- 运行时动态插入检测代码。
- 维护影子内存系统,精确追踪每个字节状态(分配、初始化、释放)。
- 检查每次内存操作(读/写/分配/释放),捕获错误(如未初始化读取、释放后访问、内存泄漏)。
- 报告包含详细错误位置和调用栈。
工作特点: 检测最全面深入,是内存错误的“金标准”。但性能开销巨大(通常使程序运行速度降低 10 – 30 倍),适合离线深度分析,限制测试场景和时长。
AddressSanitizer
(ASan,高效内存错误检测)
工作原理: 基于编译期插桩、紧凑影子内存映射、红区(Redzones)和隔离区(Quarantine)。
工作方式:
-
编译时通过 -fsanitize=address 插入检查指令。
-
使用高效紧凑的影子内存映射(如8:1比例),影子字节编码对应实际内存区域的可寻址状态。
-
每次内存访问(读/写)前快速检查影子状态,发现非法访问(如越界、访问释放后内存)立即终止程序并输出详细错误报告(类型、地址、调用栈)。
-
增强机制:
红区:在分配的堆块、栈对象、全局变量周围自动添加填充区(标记不可访问),用于检测缓冲区溢出(Overflow)和下溢(Underflow)。
隔离区:释放的内存延迟归还系统,增加捕获“释放后使用”(UaF) 错误的概率。
工作特点: 运行时开销低(通常仅2倍左右),影子内存占用小(约物理内存 1/8 + 偏移)。能够实时捕获并精准报告错误。虽然检测范围略窄于Memcheck(如对未初始化值不敏感),但其低开销使其非常适合集成到开发、测试及持续集成 (CI) 流程中,进行更长时间、更高负载的测试以捕获间歇性或特定条件触发的错误。
Valgrind Massif
(堆内存剖析)
工作原理: 基于 Valgrind DBI,结合周期性堆快照。
工作方式:
- 运行时周期性或在关键堆操作(malloc/free 等)时捕获堆内存完整快照。
- 记录每个活跃(未释放)内存块的大小及其分配调用栈。
- 收集运行过程中的堆状态序列。
- 生成报告(通过 ms_print 工具可视化),展示堆内存总量随时间变化曲线图,并标记内存峰值点及该点的详细分配来源(调用栈)。
工作特点: 提供程序堆内存消耗模式的宏观视图(如持续线性增长-泄漏迹象、周期性峰值-缓存行为、特定路径高消耗)。帮助理解“谁在分配、何时分配、是否合理释放?”。开销较 Memcheck 小但仍显著(慢于原生速度),且仅关注堆内存,不直接检测内存错误本身。
工具核心特点对比
三
排查过程
根据工具的特点,整体排查过程如下:
第一步:Memcheck 打基础。首先进行深度离线扫描,利用其无与伦比的深度排除基础内存错误(尤其是泄漏、严重的越界/非法访问)。目标是建立一个相对“干净”的代码基线,确保后续观察到的增长不是由明显的、可被Memcheck捕获的错误直接导致。
第二步:ASan 做延伸。在 Memcheck 确保基础健康后,利用 ASan 低开销的优势,对 tservice 进行更长时间、更高负载、更接近生产环境的测试。这极大提高了捕获那些 Memcheck 因速度限制而难以覆盖到的场景下出现的错误的概率,例如:依赖特定请求序列或高并发压力触发的条件竞争性错误,如特定时序下的UaF;长时间运行后才逐渐显现的间歇性错误,如缓慢积累的越界写入。
第三步:Massif 抓源头。在通过 Memcheck 和 ASan 尽可能排除内存错误后,针对内存持续增长的核心症状,使用 Massif 聚焦分析内存消耗模式本身。它提供的堆快照和分配栈信息是回答关键问题的核心:“内存被谁(调用栈)、在何处、何时分配?增长是持续(泄漏)还是波动(缓存/池)?峰值点在哪里?”——这一步可以帮助区分真正的泄漏与设计上允许(但可能需要优化)的资源池或缓存增长。
1
Valgrind memcheck检测
首先使用Valgrind memcheck工具进行检测,它能检测如下问题:
- definitely lost(确定泄漏)
- indirectly lost(间接泄漏)
- still reachable(仍然可达)
- possibly lost(可能泄漏)
(1)
时间同步技术实现
(2)
找到泄漏点
跑了约1天后,生成一个700M的valgrind.log日志文件。在日志中查找关键字“definitely lost”,找到一处25条记录,肉眼筛查,除去全局的不释放的内存分配,发现了一个malloc后在错误返回时没有free的内存泄漏点。
修改代码重新运行。运行了一个半小时,查看RES曲线还是直线上升,和之前的图形比较,泄漏虽然少了,但还是有。
然后再查找间接泄漏点,通过关键字“indirectly lost”过滤出一条,查看代码,这个地方根本不是泄漏点。
再排查可能的泄漏点,通过关键字“possibly lost”过滤出350段(每条一个小段落),挨个查看,都是sqlite库或者是std::string::_Rep::_S_create的记录。因为程序已经好多年了,所以排除了sqlite库的泄漏,查看调用std::string::_Rep::_S_create的地方,都是局部变量,std::string::_Rep::_S_create也排除了。
最后查看“仍然可达”,通过关键字“still reachable”过滤出950段记录,挨个查看,发现很多是程序启动时初始化的,还有是线程启动初始化的,还有一些公共库初始化,每条记录必须确定是否是初始化过程中的。
考虑到950多段,需要很长时间才能查看完,通过编写脚本,将每一段输出一个文件,然后通过“启动初始化的关键字”过滤掉系统启动时、线程初始化时的段落,剩下50多个文件进行肉眼查看,没有找到泄漏点。
也就是通过valgrind memcheck工具,只在关键字“indirectly lost”中发现了一处明显的内存泄漏后,内存还有泄漏。
2
ASan检测
使用Valgrind Memcheck虽然找到了一处泄漏,但是还有泄漏没有找到,因为是在进行性能测试时出现的泄漏,所以怀疑是性能占用太大。因为没有找出问题,所以继续使用ASan进行测试。
Asan是现代C++开发中必备的内存安全工具。它在编译期注入代码,运行时实时检查非法内存访问,比如:堆/栈越界、使用后释放(use-after-free)、内存泄漏(部分)。
(1)
修改编译方式
要编译Asan,需要增加参数“-fsanitize=address,leak,undefined,float-cast-overflow,float-divide-by-zero -g”。服务是通过CMake编译的,所以修改首目录的CMakeLists.txt增加参数,如下:
任务开始
(2)
运行时设置
启动程序前设置环境变量,可以控制日志输出。
任务开始
| | |
| — | — |
| 选项 | 作用 |
| halt_on_error=0 | 检测到错误后继续 |
| log_path=asan.log | 输出写入 asan.log.
| color=never | 日志中禁用彩色,适合 grep / 查看 |
| detect_leaks=1 | 程序退出时检测内存泄漏 |
| 策略5,10次 | 策略5,10次 |
(3)
启动程序查看结果
① 内存越界问题
启动后,程序马上就退出了,查看日志,有如下信息:
任务开始
从上面的提示可以看出,从本来只有19个字节的地址读取了256个字节,查代码,原来是这样的:
memcpy(szTmpAddress, address, sizeof(curl_address));
address是动态分配的字符串,长度19个字节;szTmpAddress是字符数组变量,curl_address是另一个字符数组变量。
这里有两个问题,一是计算szTmpAddress的长度时错误地使用了curl_address变量,虽然实际都是256个字节。二是使用了memcpy, 将本来只有19个字节的address读出了256个字符。
修改代码如下:
strncpy(szTmpAddress, address, sizeof(szTmpAddress) - 1);
兼顾原字符串长度和目的字符串长度,都不越界。
② 字节对齐问题
修改编译后重新启动运行,输出有下面的信息,都是字节对齐的问题,截图说明如下。
任务开始
下面截图是“4字节对齐”问题的信息
下面截图是“8字节对齐”问题的信息
从日志看,PASSWD_RULES_ST结构需要4字节对齐,但是显示却没对齐,查看代码:
任务开始
该结构都是int类型,确实需要4字节对齐,但是怎么会出现不对齐的情况呢?继续查看代码,发现是引用头文件的问题,代码是这样的:
Pack加到了头文件前。
本文件中有个结构包含std::vector的变量:
std::vector需要8字节对齐,但被限制了1字节对齐。
于是,修改代码,将#pragma pack(1)、#pragma pack()只加在特殊结构上,如下:
修改后再编译再运行,字节对齐的问题解决了。
③ 内存泄漏问题
第三次输出信息:
这里明确说明了内存泄漏了很多72704 字节,但是具体位置是
首先设置
/proc/sys/kernel/randomize_va_space为0,让库的基址保持不变,然后重新启动程序,将进程的map拷贝出来,sudo cp /proc/pid/maps map.pid.txt。Kill掉程序,查看asan的输出日志,过滤出
查找7fffc411fe95的具体代码,cat ts.log.178620 | grep unknown | cut -b 10- | sort。然后从map.pid.txt找到对应的位置及相对偏移
通过命令addr2line查找到对应的代码。这个是系统库内存的分配,是程序启动时初始化动态库分配的内存,也不属于内存泄漏。
RES占用还是直线上升的。Asan检测不到泄漏,所以考虑使用massif看看内存到底被谁占用了。
3
Valgrind Massif检测
(1)
编译启动程序
重新编译了一个没有Asan的版本,通过下面命令进行启动:
2
mmap内存泄漏问题
运行2小时,停掉程序,通过命令massif-visualizer查看日志massif.out.进程号,Mmap直线上升。
massif-visualizer解析valgrind –tool=massif生成的日志时,里面的mmap内存指的是通过mmap(2)系统调用分配的堆外内存,而不是传统的malloc/new分配的堆内存。
更具体地说,Massif 中内存分类如下:
heap (堆内存)
Massif 默认跟踪通过malloc、calloc、realloc、new等函数分配的内存。
stack (栈内存)
Massif 也可以跟踪栈的使用(如果加 –stacks=yes)。
mmap (匿名映射)
对应程序中直接调用mmap系统调用,或者某些库内部用mmap分配的大块内存。例如:1)malloc在分配特别大的对象时,glibc可能不是走堆的sbrk/brk,而是直接用mmap,可以避免碎片。2)显式调用mmap做大内存块分配。3)内存映射文件时分配的空间。
从上面的内存趋势图可以看出mmap申请的内存一直在增加。取两个快照进行比较,一个是运行时间不久的快照,一个是峰值时的快照。
从比较图上可以看出问题代码出现在elfimage.cpp的620行。查看代码,是这样的:
在620行显示调用了mmap,获取了pmap地址,在中间的循环中,一直mmap文件到这个地址,在函数结束调用了munmap释放了该地址。
这段代码已经用了很多年,怎么会出错呢?原来是这样的:
massif 不仅统计“活跃 heap”,还统计匿名映射 (mmap 区域)。现在这种模式,虽然 MAP_FIXED 会销毁旧 VMA,但 massif 的行为有几个坑:
massif 的“heap”只跟踪 malloc/free 家族
- mmap 本质上创建 VMA,不走 malloc;
- massif 里看见的增长,其实是 新的 VMA 逐渐被建出来。
massif 的快照延迟
- 旧 VMA 被销毁了,但 massif 记录的是“累计分配”,而不是实时回收量;
- 所以曲线看起来在“累加”,好像越来越多。
文件映射页不会立刻回收
- 如果是 MAP_PRIVATE,内核还是会分配页缓存;
- massif 会认为这是“活跃占用”。
- 直到 munmap 时,massif 才把这部分减掉。
最后 munmap 用了 mm_org_size
- 如果 mm_org_size > mm_size,那中间 mmap 的一部分区域可能没覆盖完整;
- massif 统计上就会一直显示“占用增加”。
最后分析结论
- 并没有真正泄漏,实际上内核每次MAP_FIXED已经释放旧映射。
- massif把新映射累积记录,但对“旧页释放”的时机识别不准确。
- 所以曲线表现为“只升不降”,函数退出时munmap一次性释放掉。
虽然不是真正的内存泄漏,但还是修改了代码,改成每次mmap后都munmap,重新编译运行。
3
SQLite内存泄漏问题
运行2小时,停掉程序,通过命令massif-visualizer查看日志massif.out.进程号。Mmap呈现断断续续上升。
再次取两个快照进行比较,一个是运行时间不久的快照,一个是峰值时的快照。
这个增长点是sqlite库函数:CppSQLite3Query CppSQLite3DB::execQuery(const char *, bool *);查看当前使用的sqlite版本是3.12.1,查找相关资料显示:在SQLite 3.12.1里,确实有一个和 pcache1InitBulk() 有关的缺陷。缺陷描述如下:
- 触发条件:当启用 page cache bulk allocation,并且并发查询超过了预设缓存页数时,SQLite 会出现缓存页重复分配而未正确释放的情况。
- 表现:表现出来就是内存看似“泄漏”,sqlite3_release_memory() 返回 0,Massif 也能看到 mmap 的内存曲线持续上升。
- 根因:初始化逻辑在边界情况下没有正确处理 bulk 分配的尾部页,导致重复引用。
- 修复:在 SQLite 3.12.2 (2016-05-20) 的更新中,这个问题被明确修复,相关补丁修改了 pcache1InitBulk() 和释放逻辑。
SQLite官方在release notes里有提到类似“Fix a boundary condition error in the page cache bulk allocation logic”的修复说明。
这是已确认的 3.12.1 缺陷,升级到 3.12.2 或更高版本才能完全规避。
于是,下载3.12.2版本的sqlite并升级,重新编译并观察,一天后的RES曲线图:
从图上可以看出内存问题终于完全解决了。
四
总结建议
Valgrind的memcheck日志很庞大,但是能快速地从“definitely lost”关键点中找到泄漏点。要想继续分析indirectly lost(间接泄漏)、still reachable(仍然可达)、possibly lost(可能泄漏)日志中的信息,不好分析,尤其是大程序。有些信息很容易被人为地过滤掉。
AddressSanitizer(ASan)能很容易发现越界、空指针、字节对齐、函数没有返回等的潜在问题,还能发现泄漏。只是用起来不方便,需要修改编译脚本,还需要高版本的编译器。
Valgrind的massif能直观地看出有哪些申请的内存没有释放,会把一些mmap的内存误认为泄漏,但也能发现问题。
所以遇到内存泄漏,需要使用三个工具都检测一下,尤其是大型的服务程序。
在产品研发和上线过程中建议尽早进行内存异常检查:
1)开发期:强烈建议启用ASan进行Debug编译,能发现越界、空指针、字节对齐、函数返回等典型问题。
2)测试期/上线前:用Memcheck全面扫描,查看内存释放有无泄漏。
3)运行期:使用Massif运行一段时间,观察服务是否持续涨内存及各个模块内存的分配问题。
渠道合作咨询 田先生 15611262709
稿件合作 微信:shushu12121
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:威努特安全网络 王占川
王占川《C++内存泄露问题定位实战分享》