内存泄露分析
嵌入式设备要长期运行,部署后连续跑几个月是常态。内存一天漏几十字节,几周后系统卡死或看门狗复位,故障现场随重启一起丢。现场排查只能靠猜,猜不中就要返场,代价不小。可观测工具把这类问题在开发期抓出来:记录每一次内存分配与释放,退出时核对配对,谁漏了、漏在哪,一次跑完就有答案。这次用四种工具实测同一个泄漏程序,看它们各自怎么用、输出什么。
工具核对每次malloc与free
可观测的思路:让工具记录每一次 malloc 和 free,程序退出时核对配对,把未释放的块连同调用栈一起报告。趋势靠全量记录呈现,不用人眼推演。
实测程序 leak1 故意埋了两个泄漏:100 次循环里,create_packet 每次分配后忘了释放,lost_pointer 先分配一块又覆盖指针,旧块直接丢失。后面四个工具的输出都来自它。
- 下载 leak1.c:valgrind、massif、ASan 三节用的程序
- 下载 leak1_mt.c:mtrace 版,带记录开关
valgrind:未释放块与调用栈
最常用的内存检查工具,不需要改代码。直接运行:
valgrind --leak-check=full ./leak1输出节选(真实数字):
==16075== 51,200 bytes in 100 blocks are definitely lost in loss record 1 of 2
==16075== at 0x488545C: malloc (vg_replace_malloc.c:446)
==16075== by 0x108913: lost_pointer (leak1.c:15)
==16075== 107,350 bytes in 100 blocks are definitely lost in loss record 2 of 2
==16075== at 0x488545C: malloc (vg_replace_malloc.c:446)
==16075== by 0x1088C3: create_packet (leak1.c:7)
==16075== LEAK SUMMARY:
==16075== definitely lost: 158,550 bytes in 200 blocks每个泄漏点一行:字节数、块数、调用栈。definitely lost 是确定漏掉的,still reachable 是进程结束前没释放但指针还在的,这类不算 bug。by 行直接指出泄漏发生在哪个函数的哪一行。
massif:堆内存增长曲线
valgrind 自带的堆画像工具,记录运行期间堆内存随时间的变化:
valgrind --tool=massif ./leak1
ms_print massif.out输出是一张文本版曲线图,纵轴堆内存、横轴运行时间(节选):
KB
161.4^ ###
| @@# :
| :@@@# :
| ::@:@:@@@# :
...
0 +----------------------------------------------------------------------->ki
0 149.8
Number of snapshots: 70
Detailed snapshots: [28, 34, 40, ..., 68 (peak)]正常程序的曲线锯齿但长期平稳,泄漏程序锯齿且整体持续上行。
只看最终数字可能漏掉缓慢增长,曲线让趋势直接可见。怀疑泄漏是每次操作漏一点的缓慢累积,先跑它看形状。
ASan:编译期插桩
地址消毒器,编译时改写代码里的内存访问,运行时检查:
gcc -fsanitize=address -g leak1.c -o leak1_asan
./leak1_asan输出泄漏报告(节选):
==934==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 107350 byte(s) in 100 object(s) allocated from:
#1 0x... in create_packet /home/codespace/tmp/leak.c:4
#2 0x... in main /home/codespace/tmp/leak.c:7
Direct leak of 51200 byte(s) in 100 object(s) allocated from:
#1 0x... in lost_pointer /home/codespace/tmp/leak.c:5
SUMMARY: AddressSanitizer: 158550 byte(s) leaked in 200 allocation(s).格式与 valgrind 类似:泄漏字节数、块数、调用栈。额外能查数组越界、重复释放。局限是依赖运行时环境,容器等受限环境可能无法启动,实测在 proot 容器里因 shadow memory 映射冲突直接崩溃。
mtrace:glibc内置记录
glibc 自带的内存记录机制,不依赖额外工具链,适合 valgrind 装不上的嵌入式环境。先在代码里开启记录:
#include <mcheck.h>
int main(void) {
mtrace(); /* 开始记录 */
/* 业务代码 */
muntrace(); /* 结束并写日志 */
return 0;
}编译时链接调试库,运行后生成日志:
gcc -g leak1_mt.c -o leak1_mt -lmalloc_debug
MALLOC_TRACE=mtrace.log ./leak1_mt日志按行记录每次分配与释放(节选):
= Start
@ ./leak1_mt:[0x984] + 0x30000224a0 0x400
@ ./leak1_mt:[0x9d4] + 0x30000228b0 0x200
@ ./leak1_mt:[0xa04] - 0x3000022ac0
= End- 是分配,记录地址与大小;- 是释放。离线把未释放的块按调用点聚合:
4096 bytes in 1 blocks @ /lib/...:(_IO_file_doallocate+e8)[0x6ec88]
107350 bytes in 100 blocks @ ./leak1_mt:[0x984]
51200 bytes in 100 blocks @ ./leak1_mt:[0x9d4]
TOTAL: 162646 bytes in 201 blocks第一行是 printf 的内部缓冲,属于正常分配,剔除后与 valgrind 的数字一致。glibc 2.35 之后需要显式调用 mtrace(),不再靠环境变量自动开启。
AI执行分析,人负责设计
这些工具的输出都交给 AI 解读:让 AI 执行分析命令、读报告、把泄漏点整理成结论。人不需要逐行翻代码,知道有哪些工具、各自输出什么就够。本地开发首选 valgrind 一步到位,怀疑缓慢增长先跑 massif 看曲线,装不了工具链的环境用 mtrace。执行与分析交给 AI,人把精力留给设计。
内存生命周期的所有权设计
工具定位泄漏,正解在架构:每个动态内存分配都要有明确的所有权。谁分配,谁负责释放;所有权转移要显式交接,由新持有者负责。malloc 与 free 成对是设计结果,靠约定保证,不靠运气。
实测程序的两个泄漏点,一个忘了释放,一个覆盖指针丢了所有权,都属于所有权没有落实。工具的价值是验证所有权是否被遵守:配对漏掉一次,跑完就现形。人负责把所有权架构设计好,AI 负责跑工具,工具负责验证设计。