使用 mtrace 分析 “内存泄露”

674 阅读5分钟

本文首次发表于 使用 mtrace 分析 “内存泄露”

内存泄露导论

在工作中,特别是采用 C 语言编写程序时,动态内存分配是常有的事,而伴随动态内存分配而来的最大的问题就是所谓 “内存泄露”。所谓 “内存泄露” 的意思就是我们申请了内存,但忘记归还给系统,长此以往,系统的可分配内存越来越少,这种问题一旦出现必然很难查找,原因很简单,程序是人写的,写的人都忘记自己曾经在哪里分配了而没有释放,那系统就更不能随便帮助我们回收内存了。一旦 “内存泄露” 发生,特别是放生在一些生命周期较长的程序中(譬如后台服务这样的),从系统的角度来说,可用内存莫名其妙地越来越少,形象地我们就比喻系统上好像真的出现了一个洞,安装的内存从这个洞里被 “漏掉” 不见了。

mtrace 使用介绍

一旦发现系统有这个 “苗头”,当务之急就是要找到代码里哪里忘记归还了动态分配的内存。 而 “内存分配跟踪(malloc tracing)” 机制则是帮助我们检查 “内存泄露” 的好帮手,本文就来给大家介绍一下这个工具的使用,习惯上这个工具我们简称为 mtrace,下文也直接用 mtrace 指称这个工具。

mtrace 工具的主要思路是在我们的调用内存分配和释放的函数中装载 “钩子(hook)” 函数,通过 “钩子(hook)” 函数打印的日志来帮助我们分析对内存的使用是否存在问题。对该工具的使用包括两部分内容,一个是要修改源码,装载 hook 函数,另一个是通过运行修改后的程序,生成特殊的 log 文件,然后利用 mtrace 工具分析日志,判断是否存在内存泄露以及定位可能发生内存泄露的代码位置。

下面我们通过一个简单的例子,看一下如何利用 mtrace 机制分析 “内存泄露” 问题。mtrace 这个工具本身是 Glibc 的一部分,所以一般情况下大家的机器上都会有,无须特殊安装,本文演示的环境是 Ubuntu 16.04.6 LTS

修改源码,装载 “钩子” 函数

我们首先需要改动一下我们的源码。添加以下两个辅助函数:

#include <mcheck.h>

void mtrace(void);

void muntrace(void);

函数的具体介绍参考 man 3 mtrace。其中 mtrace() 用于开启内存分配跟踪,muntrace() 用于取消内存分配跟踪。具体的做法是 mtrace() 函数中会为那些和动态内存分配有关的函数(譬如 malloc()、realloc()、memalign() 以及 free())安装 “钩子(hook)” 函数,这些 hook 函数会为我们记录所有有关内存分配和释放的跟踪信息,而 muntrace() 则会卸载相应的 hook 函数。基于这些 hook 函数生成的调试跟踪信息,我们就可以分析是否存在 “内存泄露” 这类问题了。

这里演示用的源码文件 test_memleak.c 如下所示。

$ cat -n test_memleak.c

     1  #include <stdlib.h>
     2  #include <stdio.h>
     3  #include <mcheck.h>
     4
     5  int main(int argc, char **argv)
     6  {
     7          mtrace();
     8
     9          char *p = malloc(16);
    10
    11          free(p);
    12
    13          p = malloc(32);
    14
    15          muntrace();
    16
    17          return 0;
    18  }

其中我们希望调试的代码段是第 9 行到第 13 行,第 9 行调用 malloc() 申请了 16 个字节的内存,第 11 行调用 free() 函数释放了第 9 行分配的内存,第 13 行又调用 malloc() 申请了 32 个字节的内存。很显然,这段代码的第 13 行存在问题,由于第 13 行分配的内存没有被释放掉,会引起 “内存泄露”。以上是我们人工阅读代码后的分析结果,现在我们来看看如何利用 mtrace 机制帮助我们得到相同的结论。

首先我们需要用 mtrace()/muntrace() 这一对函数将我们关系的代码段括起来,所以我们在第 7 行添加了 mtrace() 函数,第 15 行添加了 muntrace() 函数。另外不要忘记包含 mcheck.h,这个可以参见上面代码的第 3 行。

然后就可以直接编译链接,生成可执行程序:

$ gcc -g test_memleak.c -o a.out

注意这里不要忘记加上 -g 参数,这个很重要,因为后面我们需要调试信息帮助我们定位出问题的代码行数。

生成日志文件并分析定位问题

mtrace 机制需要我们实际运行一下程序,然后才能生成跟踪的日志,但在实际运行程序之前还有一件要做的事情是需要告诉 mtrace (即前文提到的 hook 函数)生成日志文件的路径。具体的方法是通过定义并导出一个环境变量 MALLOC_TRACE,如下所示。

$ export MALLOC_TRACE=./test.log

上述的结果就是告诉 mtrace 在生成日志信息时,在当前路径下创建一个名为 test.log 的文件,并将日志输出到这个文件中去。

然后就可以直接运行程序了。

$ ./a.out

运行结束后,我们可以发现当前路径下果然生成了一个 test.log 文件。

$ ls
a.out  test_memleak.c  test.log

好奇的我忍不住打开这个日志文件看了一下:

$ cat test.log
= Start
@ ./a.out:[0x400624] + 0x852450 0x10
@ ./a.out:[0x400634] - 0x852450
@ ./a.out:[0x40063e] + 0x852470 0x20
= End

其实这个文件的内容还是蛮好懂的。三行 “有效” 记录(除去第一行 = Start 和最后一行 = End),分别对应这前面我们给大家介绍的源文件的三次 malloc -> free -> malloc 操作。

先看一下每一行的具体格式,以第一行 @ ./a.out:[0x400624] + 0x852450 0x10 为例。./a.out 显然指的是我们运行的可执行程序的名字。[0x400624] 这里的数值是对应代码中第一次调用 malloc() 的指令,但注意这是机器码的地址,恰好我们在编译可执行程序的时候利用 -g 带上了调试信息,所以我们完全可以利用 addr2line 这个工具,基于该值(0x400624)反推出源文件的行数。具体做法如下:

$ addr2line -f -e a.out 0x400624
main
/home/u/samples/test_memleak.c:9

的确就是第 9 行,一点都没有错。

继续分析日志行的信息,接着后面的是一个符号 +,表明这一行对应的是分配内存,反之 - 表示是释放。再往后是一个数值 0x852450,这又是一个地址值,只不过是 malloc() 函数分配的内存的首地址。继续,最后是 0x10,换算成十进制就是 16,正是我们代码中第 9 行分配的内存的字节大小。

了解了具体格式后我们从三行有效日志中可以得出什么结论呢,因为第一行是分配,其分配的内存首地址是 0x852450,而第二行释放的内存的首地址也是 0x852450,自然说明是一对,相互抵消,不存在内存泄露。第三行分配的内存首地址是 0x852470,后面没有匹配的释放日志,则说明这里出现了 “内存泄露”。

这么分析对于这里的简单的例子也许是足够了,但是在实际工作中的场景代码绝对不会就这么几行的,那怎么办,人为的分析岂不是一件很麻烦的事情,或许在了解了日志文件的格式后我们聪明的程序员自己也会开发一个日志分析工具来做这件事。这么自然而然的事情当然 mtrace 的设计人员早就为我们想到了。系统提供了一个叫做 mtrace 的命令行工具可以帮助我们完成对日志的分析。

赶紧来试一下。输入如下命令:

$ mtrace ./a.out $MALLOC_TRACE
Memory not freed:
-----------------
           Address     Size     Caller
0x0000000000852470     0x20  at /home/u/samples/test_memleak.c:13

输出的结果已经告诉我们了一切。mtrace 这个工具需要至少两个参数,一个是我们生成的可执行程序文件的路径,还有一个是日志文件的路径。man 1 mtrace 告诉我们 mtrace 这个工具实际上是一个 Perl 脚本,至于为什么这个命令需要这两个参数,以及这个 Perl 脚本里干了些啥,经过我们这一路走来的分析,我想聪明的读者您应该自己可以想明白,我这里就不多解释了。

根据 man 3 mtrace 的说明 mtrace 还能帮助我们查找 “重复释放” 问题(man 手册上的原话叫 “free nonallocated memory”)。我试了一下,发现在我的环境中实际编译运行以下程序会直接报错,也就无法生成 mtrace 的日志。或许对于此类问题,采用 mtrace 工具并不是最好的做法。这里我只给出了我所看到的 “重复释放” 的代码例子和执行的命令结果,供大家简单参考:

$ cat -n test_dupfree.c
     1  #include <stdio.h>
     2  #include <malloc.h>
     3  #include <mcheck.h>
     4
     5  int main(int argc, char *argv[])
     6  {
     7          char *s = NULL;
     8
     9          mtrace();
    10
    11          s = malloc(32);
    12
    13          free(s);
    14
    15          free(s); // <-- free nonallocated memory
    16
    17          muntrace();
    18
    19          return 0;
    20  }
$ gcc -g test_dupfree.c -o a.out
$ export MALLOC_TRACE=./test.log
$ ./a.out
*** Error in `./a.out': double free or corruption (fasttop): 0x00000000018a7450 ***