memory leak & double free如何排查?

来源:互联网 发布:淘宝退款和售后规则 编辑:程序博客网 时间:2024/06/05 15:37
本文从自己动手构造一个内存泄露分析工具的方面入手,而不对具体内存排查工具的使用进行说明,以展示内存泄露排查的本质,提供一些思路,当在手头没有现成工具可以使用的情况下让自己不至于那么的无助,至少我们还可以自己构建工具解决它。

memory leak & double free

如果分配的多余释放的,在我们的代码中就是调用malloc(calloc、realloc、memalgin、new)的次数多于调用free(delete)的次数,那么就叫做内存泄漏;反之,如果释放的多余分配的,在我们的代码中就是free的次数多余malloc的次数,那么就是double free。

double free发生的情况不多,而memoryleak的情况却不少,原因是glibc会自动检查释放的地址有效性,如果出现double free则会在打印出backtrace后coredump退出程序,因此只要有double free存在基本都能被发现,具体能不能定位到原因另说。

排查的方法

         根据上面对内存泄露和doublefree的描述,可以得出要排查问题的方法应该由两部分组成:第一部分,也就是最重要的部分,就是收集分配和释放的信息,最简单的方法就是在调用分配函数如malloc(realloc、calloc、memalign、new)的地方记录下分配点(caller)、大小及所获得的内存地址,在free(delete)的地方记录下调用地址(caller)及所释放的内存地址;而第二部分就是对第一部分收集到的数据进行分析,也就是对分配所得的地址与释放的地址的差集。

         在及下来对第一部分数据的收集和第二部分数据的分析方法进行简单说明,最后对一些常用的内存泄露的工具进行介绍。

 

获取caller地址

为了能方便的定位到调用分配函数及释放内存的地方,需要记录下caller的地址来。如下图中的第二列就是caller地址,也就是在调用函数后返回时要执行的下一条指令的地址。


在代码中要获取此地址需要一点技巧才行,可能平时的编程中不那么常用到,下面举例几种方法。

方法一:

通过在函数中使用标签,随后获取标签地址,以此地址作为caller返回地址。一下以获取执行printf之后的地址作为caller地址为例进行说明,示例代码如下:


通过gcc –glabel.c –o label后得到可执行程序,执行此示例程序输出为0x4004b0

此地址就为所求的在调用printf后的返回地址。下面我们通过addr2line对此地址所表示的代码行进行翻译:

通过上面的输出可以看出指示的是label.c的第7行,这个地址和我们上面的源代码中的行号刚好相符。但是这地址明显不是我们要的printf那行地址,那行应该是第五行,这需要对获得的地址进行简单的加减运算即可,如下代码:


编译执行后得出输出的地址为0x4004ac,再一次用addr2line得出:


这才是我们需要的地址啊。随着我们使用的优化编译选项的不同,可能获得的地址会有差异,但偏差并不大,但结合代码场景不难分析出具体的行号来。

 

这方法有个唯一的缺点是在一个函数中不能存在重复的label,这样就导致使用宏替换的方法在一个函数中如果出现两次就行不通,因此这是个不可取的方法。

 

 

方法二:

既然通过label地址的方式不通用,那么通过高级语言本身提供的机制看来要获得caller地址是不可能的了,那么我只能考虑更低级别的汇编语言了,在汇编面前好像拿什么都行,想拿什么拿什么,获取个caller地址那是很容易的事情。

废话就不说了,代码最容易说明意图。下面就通过汇编获取caller地址进行代码示例:


简短说明:通过call指令调用标签1f,目的获得1f的地址压入堆栈,紧急着使用pop指令把堆栈顶的数据推入caller变量,因为此时堆栈顶存的就是1f的地址,因此caller得到的就是1f的地址,此地址就是代码所在行地址。

下面给出调用演示,获取caller的地址:


编译执行后输出为0x4004a5, 通过addr2line得出对应的代码行是6行,这方法看来很靠谱,的确是所调用的那行地址。

 

方法三:

通过backtrace分析到caller的地址,是否想过有一天自己也能拿到和在gdb上输入bt后显示的backtrace一样的调用栈帧数据呢。其实要拿到这个数据自己通过分析调用栈可以得到,但这是个很繁琐的过程,首先需要懂一些底层的汇编知识,其次需要了解编译器在函数调用、传参方面怎么如何堆栈进行布局的。

只需要强大的基础功底,只有很少的人掌握,好在存在一些给菜鸟用的工具,直接使用高手们提供的功能就可以了,比如我们可以使用现成的api搞定这事。

下面我们就通过backtrace()和backtrace_symbols()两个api来获取调用栈框数据:


说明:通过上面的代码片段模拟出平时函数调用时的场景,在frame2()中调用frame1(),那么我们就能得到一个嵌套的调用关系frame2——> frame1,在在frame1()中打印出此时的栈帧来应该是frame1在前frame2在后。下面我们编译并执行得出:


其中中括号中的数字所表示的就是caller地址,这和在gdb上敲入bt时返回的第二列完全一样。下面是通过addr2line对获得的地址进行查找后得出的文件行号及所处的函数名:

 

方法四:

使用gcc提供的编译选项-finstrument-functions并结合__cyg_profile_func_enter()__cyg_profile_func_exit()获取caller的地址,如下面的code所示,其中的函数参数call_site就是caller地址。


通过gcc加选项-finstrument-functions编译并执行后得到下面的结果:


可以看到通过这种方式也是一种得出caller地址的方法,但这种方法有一个问题是需要在源代码中把不需要收集caller地址的函数属性声明为__attribute__((__no_instrument_function__))这在一个已经存在成百上千个函数的项目中可不是个容易的活。

 

收集内存分配和释放信息

通过前面介绍的获取caller地址的方法,我们可以获取到所发起调用的函数中的地址,有了这些知识后,我们要收集发起内存分配(malloc、realloc、calloc、memalgin、new)与释放(free、delete)的数据那就很容易了。

而排查memory leak和double free也就是在分配和释放之间记录下所发起的活动过程而已,说白了也就是一个如何组织分配内存时获得的信息与释放过程时释放掉的内存的过程罢了。

下面介绍两种数据收集的方法,两种方法其实都差不多,只是调度的时机不同而已。

方法一:

通过宏替换掉发起内存分配的函数(malloc、realloc、calloc、memalgin、new),在自己提供的宏或者函数中收集分配信息。通过这个简单的替换,不声不响的做了自己需要的事情,而对调用者却是透明的。这和设计模式上的包装器模式类似,提供更多的功能却不破坏原来的接口,当然这里不属于设计模式的范畴。

下面给出示例代码,只是在调用具体的内存函数前先获取caller地址,并把这些信息收集起来打印到标准错误流里而已。我这里使用的是收集调试数据与分析数据分开的方法,所以我只是把收集到的输出来而已,而如果要做成在线实时收集处理需要构造自己的数据结果用于存储收集到的数据和实时分析处理。

下面是一些用来替换内存分配函数的宏定义。


上面的宏覆盖了主要的内存分配请求函数,但在c++中常用的内存分配和释放分别是new和delete两个操作符,而此两个操作符结构比较特殊,不好通过一个宏函数的方式解决,因此这个比较麻烦,好在无所不能的宏加上操作符重载勉强能搞定,虽然不那么美观,但至少的确是一种解决方法。

下面是为new、delete操作符定义的宏:


再加上精心准备的重载操作符函数new、delete:


在宏中获取caller地址,而在重载操作符中得到返回的内存地址和请求的内存大小,一起配合起来解决要获取caller和请求得到的内存地址的问题。

 

说明:这个方法比较灵活,也很方便,只需要把内存函数用宏替换掉就可以,当不需要调试的时候把宏注销即可,并不影响系统性能。但这个方法有个不足,它只能在自己手边能拿到源代码的情况下使用,如果没有代码那就无能为力了。

 

方法二:

由于上面提到的在没有源代码的情况下方法一就不好使了,因此我们需要寻找别的替换方案在没有源代码的情况下也能跑得起来才是王道。

能不能存在一种方法在没有源代码的情况下也能获取内存分配信息呢?答案是肯定存在的,不然如valgrind之类的内存分析工具怎么能存在呢?的确存在这样的一些方法,比如glibc内存分配就以钩子的方式提供了支持,下面是malloc.h给出的hook声明,具体可以通过查看man文档,比如man__malloc_hook


根据man文档所描述,当调用malloc的时候会触发钩子__malloc_hook,在钩子函数中传入的参数为所请求的内存块大小__size和所发起内存分配请求的caller地址。因此只要用自己内存分配函数的函数替换掉__malloc_hook既可以同时获得所请求的内存大小尺寸,同时能得到caller地址,当然更重要的是能把自己分配的内存记录下来。

话题说到此,如果只关心c语言的使用场景,那么直接实现__malloc_hook已经可以算一段落完满的划下句号了。但是如果我们涉及到c++的开发,就不可能光使用malloc而不涉及到new操作符了。在c++中使用new获得内存,大家都知道最后也是调用底层的c内存分配函数malloc等。因此如果我们不进行特殊处理,那么在new的使用场景下__malloc_hook获取到的caller地址就不是我们所关心的地址,那个地址是new操作符实现函数里面发起malloc调用时的地址,这种情况下获取的caller就没有意义。因此针对c++的此种情况进行特使处理。

此时,我们在上面谈到的获取caller地址的方法三,用backtrace分析调用栈的方法就有用武之地了。通过对得到的backtrace进行分析发现在__malloc_hook所得到的调用堆栈中前面两帧分别是libc.o 和libstdc++.so库里面的函数调用,因此我们可以把这两帧跳过后就能得到我们代码中发起内存分配请求的地址。下面所示的宏就是获得caller地址所在的栈帧,存放在symbol中返回:


下面给出__malloc_hook的示例代码实现,别的hook函数类似,由于篇幅的原因这里就不贴出来了,可以查看附件代码:

 

从收集的数据中分析内存问题

具体的分析方法取决于前面谈到的收集方式,而我们在这里是直接把信息打印到标准错误流输出,因此把错误输出重定向到一个文件就能收集到,而收集到的数据格式是根据数据收集的组织而定的,我们这里按下面的格式组织数据:


其中等号后面的值是分配内存是返回的地址,括号中的size代表所请求的内存分配大小,而free、delete中的ptr后面的值是要释放的内存地址。分配和释放的内存值一一对应,如果对应不上就说明发生内存问题了,要么泄露要么double free。我们只需要用脚本写个工具对收集得到的数据进行parse即可。

在这里需要注意的一点技巧性问题是,parse的时候最后从文件后面开始,否则如果按顺序从前往后处理的过程中,再分析double free的时候就不容易处理,因为为了分析double free的发生我们需要一直记录下alloc的地点来,这样如果数据量很大会对机器造成很大影响,但是如果从后往前分析的话就可以把free和malloc配对上的清除掉,配对上说明不存在内存问题。只要遇到alloc的地方都可以丢掉前面累积下来的配对数据,这样需要保存下来的场景数据很小。

由于篇幅的关系,这里不准备把parse脚本贴出来了。

 

示例演示

通过上面的这么多的铺垫,下面给出一个完整示例,演示内存泄露检查的一般方法。

1、  编写测试代码

 

2、  编译测试程序

使用的是malloc hook的方式:

g++  -g  -o  test1  test.c mem_debug.c

使用的是宏替换malloc的方式:

g++ -g  -DMEM_DEBUG  -o test2  test.c

 

3、  执行程序收集调试数据

执行test1及输出结果:


执行test2及输出结果:


由于调试数据是通过标准错误流输出的,因此可以通过重定向标准错误输出到文件中进行数据收集,如下所示:


程序test1和test2的输出分别被收集到文件debug_info_test1.txt和debug_info_test2.txt中

说明:由于此给出的程序存在double free的示例成分,因此在执行的时候可能会导致coredump的现象发生,并伴随着会在屏幕上打印Backtrace的现象,打出的Backtrace如下图所示:

 

4、  分析收集到的数据

由于上面已经收集到用于调试memory leak和double free的数据,因此这里用分析程序mem_debug_analyzer.py对数据进行分析,看看是否程序存在内存泄露和double free的问题。

先对test1进行分析:

./mem_debug_analyzer.py test1 debug_info_test1.txt


通过上面的分析得出,程序在test.c中的第9行分配了内存,但没有释放,存在内存泄露现象;在第6行分配的内存空间,分别在第14行、15行进行了free操作,存在double free现象。

下面对test2进行分析:

./mem_debug_analyzer.py test2 debug_info_test2.txt


通过上面的分析得出,程序在test.c中的第9行分配了内存,但没有释放,存在内存泄露现象;在第6行分配的内存空间,分别在第14行、13行进行了free操作,存在double free现象。

说明:由于malloc hook的行号是从堆栈上的caller地址进行收集得出的,而caller地址是把当前执行指令位置处的下一条指令地址作为caller地址的,因此最终得出的行号会有稍微的出入,特别是在代码进过优化选项处理后差异更大,但可以结合代码上下文进行分析也很容易定位到代码点。同时为了更准确的定位到代码行号,推荐使用宏替换malloc的方式。

 

一些常用工具

mtrace

mtrace是glibc提供的内存trace功能,可以从man文档获取关于它的说明,及具体使用方法。它里面使用的也是钩子回调的方式,也是实现了提供了自己的__malloc_hook实现的,他在头文件mcheck.h中提供了mtrace()muntrace()的函数声明.


对于mtrace的具体用法参考glibc手册《The GNU C Library Reference Manual》,有兴趣的同学可以直接从glib获取帮助,这里不再废话。

 

valgrind

概述

Valgrind是一款用于内存调试、内存泄漏检测以及性能分析的软件开发工具。Valgrind这个名字取自北欧神话中英灵殿的入口。

Valgrind的最初作者是JulianSeward,他于2006年由于在开发Valgrind上的工作获得了第二届Google-O'Reilly开源代码奖。

Valgrind遵守GNU通用公共许可证条款,是一款自由软件。

网址为:http://valgrind.org/

 

演示


从上面的代码片段中我们人为的构造了两个内存问题,一个是memory leack,一个是数组越界,valgrind最擅长于排查这方面的内存问题。同时为了揭示它排查内存的本质,我们怀疑它是使用__malloc_hook钩子的方式进行内存分配行为收集的,因此我们把__malloc_hook的地址打印出来,如果它用的不是hook方式那么__malloc_hook值为NULL,否则为它提供的钩子函数地址,下面我们进行验证:

首先我们进行编译后不使用valgrind而直接执行程序,检查原先的__malloc_hook的值,如下所示:


可以看到__malloc_hook为null,因为我们并没有给它设置它肯定是null啊,接下来使用valgrind对程序进行分析,如下所示:


其中可以看到__malloc_hook=0x3664675770,虽然不知道valgrind内部是如何具体实现的,但至少我们发现了一些很有意思的细节,很有可能它就是一提供hook的方式搞的,当然有兴趣的话可以拿valgrind的代码来读读,看看它是怎么搞的。

而这里我们主要关心的是valgrind的使用,关心它是否能真正给我们找出内存问题来,从上面的输出看出它不负所望两个bug都已经找到了,并且告诉问题所发生的地方、以及问题的类型。

valgrind的能力不仅于此,具体更多的功能可以从官网得到,这里就不在废话了。

 

原创粉丝点击