内存泄露分析

嵌入式设备要长期运行,部署后连续跑几个月是常态。内存一天漏几十字节,几周后系统卡死或看门狗复位,故障现场随重启一起丢。现场排查只能靠猜,猜不中就要返场,代价不小。可观测工具把这类问题在开发期抓出来:记录每一次内存分配与释放,退出时核对配对,谁漏了、漏在哪,一次跑完就有答案。这次用四种工具实测同一个泄漏程序,看它们各自怎么用、输出什么。

工具核对每次malloc与free

可观测的思路:让工具记录每一次 malloc 和 free,程序退出时核对配对,把未释放的块连同调用栈一起报告。趋势靠全量记录呈现,不用人眼推演。

实测程序 leak1 故意埋了两个泄漏:100 次循环里,create_packet 每次分配后忘了释放,lost_pointer 先分配一块又覆盖指针,旧块直接丢失。后面四个工具的输出都来自它。

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 负责跑工具,工具负责验证设计。