手写内存泄漏检测组件

前言

  本文介绍内存泄漏检测的核心需求以及注意点,一共3个版本的代码,本文3个版本源码git地址:内存泄漏检测组件

  常用的内存泄露检测工具有valgrind和mtrace。我们使用这两个工具的时候一般是已经发现了内存泄漏的现象了再去检测,那么有没有一种方法在内存使用的时候,就发现内存泄漏呢。

内存泄漏

内存泄漏的危害

  内存泄漏只有不带gc垃圾回收机制的语言才有,比如go和java,它们都自带gc,所以它们不会有内存泄漏。但是像c和c++是不带gc的,所以很可能发生内存泄漏的情况。这里我们以c语言举例,c在分配内存时调用malloc/calloc/realloc(本文全部以maloc举例),释放内存时调用free。
 
  那么内存泄漏的核心原因就很简单了,内存分配与内存释放没有做到匹配。换言之,调用了多少次malloc,就free多少次,那么就不会产生内存泄漏。如果malloc和free的次数不对等,那么一定是有问题的。

int main() {
	void *p1 = malloc(10);
	void *p2 = malloc(20);
	free(p1);
}

  以上的代码,分配了两块内存,只释放了p1,p2没有被释放,那么这个程序就产生了内存泄漏。
 
  内存泄漏的危害:随着工程代码量越来越多,自然内存泄漏的排查就成为了一个很头疼的问题。有分配没有释放,自然会使得进程堆的内存会越来越少,直到耗尽。会造成后面的运行代码不能成功分配内存。分配失败我们的程序就不能继续的往下执行。

内存泄漏检测组件的两个核心需求点

  现在知道了内存泄漏的危害和内存泄漏的原因,那么内存泄漏如何解决?内存泄漏是没有自动gc的编程语言所产生的,解决方案一,引入gc,这是根治内存泄漏的最好方案,但是这样的方案有失去了c/c++语言的优势。方案二,当发生内存泄漏的时候,能够精准的定位代码哪一行所引起的。这也是我们实现内存泄漏检测的核心实现需求。那么本文就是围绕下面两个需求展开的。

  1. 能够检测出来发生了内存泄漏
  2. 能够判断定位代码哪一行引起内存泄漏

  对于第一个需求,我们在下文中介绍。对于定位代码这里提前介绍两个方案。

# 宏
__FILE__,__FUNC__,__LINE__
# 编译器提供的函数,返回第N层调用函数地址,addr2line是一个命令行工具,将地址转换为代码行
builtin_return_address(N) + addr2line
func1->func2->func3->func4{ cnt= builtin_return_address(0) }   cnt=func3
func1->func2->func3->func4{ cnt= builtin_return_address(1) }   cnt=func2
func1->func2->func3->func4{ cnt= builtin_return_address(2) }   cnt=func1

第一版:__libc_malloc, __libc_malloc 与 __builtin_return_address,addr2line

hook malloc与free出现的问题

  我们运行下面的代码,发现出现段错误,并不符合我们的预期

//
// Created by 68725 on 2022/8/13.
//
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>

typedef void *(*malloc_t)(size_t);

malloc_t malloc_f;

typedef void (*free_t)(void *);

free_t free_f;

static int init_hook() {
    malloc_f = dlsym(RTLD_NEXT, "malloc");
    free_f = dlsym(RTLD_NEXT, "free");
}

void *malloc(size_t size) {
    printf("In malloc\n");
    return NULL;
}

void free(void *ptr) {
    printf("In free\n");

}

int main() {
	init_hook();
    void *p1 = malloc(10);
    void *p2 = malloc(20);
    free(p1);
}

root@wxf:/tmp/tmp.d4vz2dOyJP# gcc -o first first.c -ldl 
root@wxf:/tmp/tmp.d4vz2dOyJP# ./first 
Segmentation fault (core dumped)

  我们的代码看起来明明这么合理,为什么会段错误?我们进入gdb看一看,我们打印23行看一看,发现程序确确实实是走到了23行。但是我们发现,它递归的进入了printf这个函数,并且第一次malloc(size=10),而后面malloc (size=1024),这说明什么?说明printf里面也调用了malloc函数,而这个malloc函数被我们hook了,导致递归进入我们hook的函数里面了。那么我们下面就要去破坏这个递归。

root@wxf:/tmp/tmp.d4vz2dOyJP# gcc -o first first.c -ldl -g
root@wxf:/tmp/tmp.d4vz2dOyJP# gdb ./first 
Reading symbols from ./first...done.
(gdb) b 23
Breakpoint 1 at 0x741: file first.c, line 23.
(gdb) r
Starting program: /tmp/tmp.d4vz2dOyJP/first 

Breakpoint 1, malloc (size=10) at first.c:23
23	    printf("In malloc\n");
(gdb) c
Continuing.

Breakpoint 1, malloc (size=1024) at first.c:23
23	    printf("In malloc\n");
(gdb) c
Continuing.

Breakpoint 1, malloc (size=1024) at first.c:23
23	    printf("In malloc\n");
(gdb) c
Continuing.

Breakpoint 1, malloc (size=1024) at first.c:23
23	    printf("In malloc\n");
(gdb) 


  如何破坏递归呢?我们让第一次进入函数的部分执行我们的流程,而递归进去的算第二次进入函数,那么我们直接调用系统原来的函数即可。这里介绍两个函数__libc_malloc和__libc_free,它们是malloc和free底层调用的函数。可以看到下面代码注释的地方,我们直接改成这两个函数效果是一样的。不过既然我们都用hook了,那有何必再调用别的函数呢?这里讲__libc_malloc和__libc_free是为了引出malloc底层调用__libc_malloc,free底层调用__libc_free。

//
// Created by 68725 on 2022/8/13.
//
#define _GNU_SOURCE

#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>

extern void *__libc_malloc(size_t size);

extern void __libc_free(void *ptr);

typedef void *(*malloc_t)(size_t);

int enable_malloc_hook = 1;

malloc_t malloc_f;

typedef void (*free_t)(void *);

int enable_free_hook = 1;

free_t free_f;

static int init_hook() {
    malloc_f = dlsym(RTLD_NEXT, "malloc");
    free_f = dlsym(RTLD_NEXT, "free");
}

void *malloc(size_t size) {
    if (enable_malloc_hook) {
        enable_malloc_hook = 0;

        void *p = malloc_f(size);
        //void *p = __libc_malloc(size);

        printf("malloc--->ptr:%p size:%zu\n", p, size);


        enable_malloc_hook = 1;
        return p;
    }
    else {
        return malloc_f(size);
        //return __libc_malloc(size);
    }
}

void free(void *ptr) {
    if (enable_free_hook) {
        enable_free_hook = 0;
        printf("free  --->ptr:%p\n", ptr);

        free_f(ptr);
        //__libc_free(ptr);

        enable_free_hook = 1;
    }
    else {
        return free_f(ptr);
        //return __libc_free(ptr);
    }
}

int main() {
    init_hook();

    void *p1 = malloc(10);
    void *p2 = malloc(20);
    free(p1);
}

  我们现在就能正常执行程序了,并且我们肉眼可见的能够分析出哪个指针没有被释放。我们现在只知道是哪个指针没有被释放,但是我们并不能定位到是代码的哪一行。所以我们接着再进行优化。

root@wxf:/tmp/tmp.d4vz2dOyJP# gcc -o first first.c -ldl -g
root@wxf:/tmp/tmp.d4vz2dOyJP# ./first 
malloc--->ptr:0x55e80ef45260 size:10
malloc--->ptr:0x55e80ef45690 size:20
free  --->ptr:0x55e80ef45260

使用addr2line定位代码

  我们在malloc函数里面加上caller的打印,来看看它的值是什么。这里先提出一点,我在__builtin_return_address函数外面套了一层ConvertToVMA。目的是把返回的内存地址转换成虚拟地址(因为addr2line只能将虚拟内存地址转换为对应的文件行数,但是在ubuntu高版本中,使用__builtin_return_address返回的是一个值非常长的地址addr2line使用此地址不能将其转换为对应的文件行数,所以需要将其转为虚拟地址;低版本linux系统不需要做这种转换。这种情况可以描述到面试里去)。

//
// Created by 68725 on 2022/8/13.
//
#define _GNU_SOURCE

#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>
#include <link.h>

typedef void *(*malloc_t)(size_t);

int enable_malloc_hook = 1;

malloc_t malloc_f;

typedef void (*free_t)(void *);

int enable_free_hook = 1;

free_t free_f;

static int init_hook() {
    malloc_f = dlsym(RTLD_NEXT, "malloc");
    free_f = dlsym(RTLD_NEXT, "free");
}

void *ConvertToVMA(void *addr) {
    Dl_info info;
    struct link_map *link_map;
    dladdr1((void *) addr, &info, (void **) &link_map, RTLD_DL_LINKMAP);
    return addr - link_map->l_addr;
}

void *malloc(size_t size) {
    if (enable_malloc_hook) {
        enable_malloc_hook = 0;
        void *p = malloc_f(size);

        void *caller = ConvertToVMA(__builtin_return_address(0));
        printf("[+%p]--->ptr:%p size:%zu\n", caller, p, size);
        char command[256];
        Dl_info info;
        dladdr(malloc, &info);
        snprintf(command, sizeof(command), "addr2line -f -e %s -a %p >1.txt", info.dli_fname, caller);
        printf("%s\n", command);

        system(command);

        enable_malloc_hook = 1;
        return p;
    }
    else {
        return malloc_f(size);
    }
}

void free(void *ptr) {
    if (enable_free_hook) {
        enable_free_hook = 0;
        void *caller = __builtin_return_address(0);
        printf("[-%p]--->ptr:%p\n", caller, ptr);

        free_f(ptr);

        enable_free_hook = 1;
    }
    else {
        return free_f(ptr);
    }
}

int main() {
    init_hook();

    void *p1 = malloc(10);
    void *p2 = malloc(20);
    free(p1);
}

  我们可以看到,__builtin_return_address返回的第一个0xb44,它其实是代码段上的一个地址,通过这个地址,我们使用addr2line可以计算出来是在哪个函数,哪个文件,哪行。只不过我这里偷懒在代码里用system直接执行了,一般来说我们是在bash里面通过log记录的地址,再去使用addr2line的。至于addr2line的用法,直接百度搜好了,很简单。

root@wxf:/tmp/tmp.d4vz2dOyJP# gcc -o first first.c -ldl -g
root@wxf:/tmp/tmp.d4vz2dOyJP# ./first 
[+0xb44]--->ptr:0x558bf404f260 size:10
addr2line -f -e ./first -a 0xb44
0x0000000000000b44
main
/tmp/tmp.d4vz2dOyJP/first.c:76
[+0xb52]--->ptr:0x558bf404f690 size:20
addr2line -f -e ./first -a 0xb52
0x0000000000000b52
main
/tmp/tmp.d4vz2dOyJP/first.c:77
[-0x558bf1ea0b62]--->ptr:0x558bf404f260

root@wxf:/tmp/tmp.d4vz2dOyJP# addr2line -f -e ./first -a 0xb44
0x0000000000000b44
main
/tmp/tmp.d4vz2dOyJP/first.c:76

  那么我们现在已经也解决了定位的问题,下面我们再来看看怎么做内存检测。从上面我们其实已经可以看到第一个+的prt和最后-的ptr地址是一样的,也就是说malloc的地址被free掉了,而第二个没有被free(-),因为我们没有看到减号。

root@wxf:/tmp/tmp.d4vz2dOyJP# gcc -o first first.c -ldl -g
root@wxf:/tmp/tmp.d4vz2dOyJP# ./first 
[+0xb44]--->ptr:0x558bf404f260 size:10
[+0xb52]--->ptr:0x558bf404f690 size:20
[-0x558bf1ea0b62]--->ptr:0x558bf404f260

  那也就意味着,我们现在需要设计一种方案,在malloc的时候把ptr加进去,free的时候把对应的ptr去掉,在程序结束之后我们可以看到有哪些ptr还存在,这些存在的ptr就是没有被free的,如此一来,就能检测到内存泄漏了。

检测内存泄露的最佳方案

  读者在这里可以思考一下上面说的方案怎么做最好,其实如果用map,用链表,我个人感觉都不好,因为在程序中用这两个数据结构,那么数据还是保存在堆栈上的,在程序结束之前需要打印出来。那如果用文件的方法呢?malloc的时候,以ptr内存地址为文件名,把<文件,函数,行号>写入文件,free的时候,把对应的文件删除。那么我们只需要通过ls即可清楚的看到哪些内存被泄露了,用cat看一下文件就能定位。下面我们对代码再次优化。
 
  还记得上面我们偷懒使用的system吗?这里就派上用场了,在拼接字符串的时候,我们用> 将标准输出定位到文件即可,在删除的时候用unlink删除。

//malloc
snprintf(command, sizeof(command), "addr2line -f -e %s -a %p > ./mem/%p.mem", info.dli_fname, caller, p);
system(command);
//free
char buff[128] = {0};
sprintf(buff, "./mem/%p.mem", ptr);
if (unlink(buff) < 0) {
    printf("double kill:%p\n",ptr);
}

root@wxf:/tmp/tmp.d4vz2dOyJP# gcc -o first first.c -ldl -g
root@wxf:/tmp/tmp.d4vz2dOyJP# ./first 
[+0xc3a]--->ptr:0x55f613d27260 size:10
[+0xc48]--->ptr:0x55f613d27690 size:20
[-0xc58]--->ptr:0x55f613d27260
root@wxf:/tmp/tmp.d4vz2dOyJP# ls
first  first.c	mem
root@wxf:/tmp/tmp.d4vz2dOyJP# cd mem/
root@wxf:/tmp/tmp.d4vz2dOyJP/mem# ls
0x55f613d27690.mem
root@wxf:/tmp/tmp.d4vz2dOyJP/mem# cat 0x55f613d27690.mem 
0x0000000000000c48
main
/tmp/tmp.d4vz2dOyJP/first.c:82

  至此,我们的第一版内存泄漏检测组件的代码就完成了,完整代码可去前言的源码超链接中获取。现在再来回顾一下两个核心需求
 

1.能够检测出来发生了内存泄露
2.能够判断定位代码哪一行引起内存泄露

  我们现在通过mem文件夹里面的文件就可以看出来有没有发生内存泄漏,因为只有没有被free的地址才会有文件。有文件就说明发生了内存泄漏。怎么定位代码呢,我们这里用的是__builtin_return_address 和 addr2line。其实内存泄漏检测没有想象中的这么恐怖,在我初知内存泄漏的时候,我感觉那些内存泄漏的检测软件很厉害,那么在本文抽丝剥茧之后,这种对于未知的恐惧就消失了,甚至于本文下面还能接着优化。万变不离其宗,在写内存泄漏组件的时候,围绕着上面两个需求去做就好了。
 
  使用__builtin_return_address 和 addr2line的第一版代码不知道读者有没有感觉到这里十分的麻烦,下面第二版代码我们使用简洁的宏定义来做。

第二版:采用宏定义

巧用宏机制

  在我们第一版代码实现中,更多的是想像读者介绍一些函数。而且我们用hook的时候还遇到了递归的问题,以及__builtin_return_address的VMA的问题,对于后面这个函数,我们使用系统提供的三个宏即可解决。现在我们想一下,这个hook用在这里真的合适吗?
 
  我们知道函数预编译的时候,会把对应宏下面的内容全部替换掉,那么我们是否可以定义malloc的宏呢?例如下面两段代码,main中的malloc被替换成了malloc_def,而malloc_def中的malloc却没有被替换。我们使用这个机制,就可以避开hook的风险。并且我们可以在宏定义的上下加个开关,如果代码想要进行内存泄漏检测就打开,不想就走原来的系统调用即可。(真正在线上运行的时候,这个宏开关是写在.conf配置文件中的,刚开始是关闭的状态,当我们使用htop命令发现内存在一点点增加的时候,即可能出现了内存泄漏,这时我们把宏开关打开,系统需要采用热更新机制(具体可以上网了解一下))

void *malloc_def(size_t size, const char *file, const char *func, int line) {
	void *p = malloc(size);
}

#define malloc(size) malloc_def(size,__FILE__,__FUNCTION__ ,__LINE__)

int main() {
    void *p1 = malloc(10);
}

void *malloc_def(size_t size, const char *file, const char *func, int line) {
	void *p = malloc(size);
}

#define malloc(size) malloc_def(size,__FILE__,__FUNCTION__ ,__LINE__)

int main() {
    void *p1 = malloc_def(10,second.c,main,15);
}

#define check_mem_leak

#ifdef check_mem_leak
#define malloc(size) malloc_def(size,__FILE__,__FUNCTION__ ,__LINE__)
#define free(p) free_def(p,__FILE__,__FUNCTION__ ,__LINE__)
#endif

  短短50行,我们就实现了比第一版更为优雅的内存泄漏检测组件。可以看到,在第一个需求如何检测内存泄漏 ,我们使用的都是统一的一个方案,malloc的时候创建一个文件,free的时候删除一个文件。在定位代码的时候有两个解决方案,这里比较推荐的就是宏的方法。

优雅的代码

//
// Created by luffy on 2023/05/29.
//
#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>
#include <unistd.h>

#define check_mem_leak


void *malloc_def(size_t size, const char *file, const char *func, int line) {
    void *p = malloc(size);

    char buff[128] = {0};
    sprintf(buff, "./mem/%p.mem", p);

    FILE *fp = fopen(buff, "w");
    fprintf(fp, "[+%s:%s:%d] --> addr:%p, size:%ld\n", file, func, line, p, size);
    fflush(fp);
    fclose(fp);

    return p;
}

void free_def(void *p, const char *file, const char *func, int line) {
    char buff[128] = {0};
    sprintf(buff, "./mem/%p.mem", p);

    if (unlink(buff) < 0) { // no exist
        printf("double free: %p\n", p);
        return;
    }

    free(p);
}


#ifdef check_mem_leak
#define malloc(size) malloc_def(size,__FILE__,__FUNCTION__ ,__LINE__)
#define free(p) free_def(p,__FILE__,__FUNCTION__ ,__LINE__)
#endif

int main() {

    void *p1 = malloc(10);
    void *p2 = malloc(20);
    void *p3 = malloc(30);
    void *p4 = malloc(40);
    free(p1);
    free(p2);
    free(p4);
    free(p4);
}

root@wxf:/tmp/tmp.d4vz2dOyJP# gcc -o second second.c 
root@wxf:/tmp/tmp.d4vz2dOyJP# ./second 
double free: 0x5571e5c55500
root@wxf:/tmp/tmp.d4vz2dOyJP# cd mem
root@wxf:/tmp/tmp.d4vz2dOyJP/mem# ls
0x5571e5c554d0.mem
root@wxf:/tmp/tmp.d4vz2dOyJP/mem# cat 0x5571e5c554d0.mem 
[+second.c:main:48] --> addr:0x5571e5c554d0, size:30

  注意:
 
  使用这种方式一定要宏定义以及自己实现的这些内存泄漏代码定位相关的逻辑写在文件的开始部分。

第三版:第三方库mtrace的使用


#include <stdlib.h>
#include <mcheck.h>

int main()
{
    mtrace();

    void *p1 = malloc(10);
    void *p2 = malloc(20);
    void *p3 = malloc(30);

    free(p1);
    muntrace();
    return 0;
}

luffy@luffy-server:~/share/0voice/linux-C-C-Study/3.2.5内存泄露检测组件$ gcc -o memleap_mtrace memleap_mtrace.c
luffy@luffy-server:~/share/0voice/linux-C-C-Study/3.2.5内存泄露检测组件$ ./memleap_mtrace
luffy@luffy-server:~/share/0voice/linux-C-C-Study/3.2.5内存泄露检测组件$ export MALLOC_TRACE=./mtrace.log
luffy@luffy-server:~/share/0voice/linux-C-C-Study/3.2.5内存泄露检测组件$ ./memleap_mtrace
luffy@luffy-server:~/share/0voice/linux-C-C-Study/3.2.5内存泄露检测组件$ ls
mem  memleap_hook.c  memleap_macro  memleap_macro.c  memleap_mtrace  memleap_mtrace.c  mtrace.log
luffy@luffy-server:~/share/0voice/linux-C-C-Study/3.2.5内存泄露检测组件$ cat mtrace.log
= Start
@ ./memleap_mtrace:[0x560840c801c4] + 0x5608427f3690 0xa
@ ./memleap_mtrace:[0x560840c801d2] + 0x5608427f36b0 0x14
@ ./memleap_mtrace:[0x560840c801e0] + 0x5608427f36d0 0x1e
@ ./memleap_mtrace:[0x560840c801f0] - 0x5608427f3690
= End
luffy@luffy-server:~/share/0voice/linux-C-C-Study/3.2.5内存泄露检测组件$ addr2line -f -e ./memleap_mtrace -a 0x560840c801d2
0x0000560840c801d2
??
??:0
luffy@luffy-server:~/share/0voice/linux-C-C-Study/3.2.5内存泄露检测组件$


注意:
 
  1.使用这种方式必须要先export MALLOC_TRACE=./mtrace.log,然后重启系统才能正常的检测,显然这种方式比不上上面那种宏定义开关热更新的方式。
 
  2.当使用linux高版本时,使用addr2line将地址转换为函数调用处的代码行时会出现??的情况,跟第一版出现的情况一样。
 
总结:
 
  使用宏定义的方式,代码既简洁,也不会出现相关的问题。

全文总结

  本文一共介绍了__libc_malloc, __libc_malloc ,__builtin_return_address,addr2line, mtrace。更多的是了解以下这些函数,那么对于内存泄漏检测来说,最核心的两个需求我们也解决了。
 
 1.能够检测出来发生了内存泄漏
 
 2.能够判断定位代码哪一行引起内存泄漏

  • 如何检测内存泄漏?我们采用malloc创建一个文件,在free的时候删除对应文件
  • 如何定位代码?我们可以使用__builtin_return_address+addr2line,但是更推荐宏定义的方法
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值