本文分享自华为云社区《从源头解决内存泄漏问题:全面解析内存泄漏检测与修复技术》,作者 :Lion Long 

一、背景:什么是内存泄漏检测?

全面解析内存泄漏检测与修复技术_#define

1.1、内存泄漏产生原因

内存泄漏是在没有自动gc的编程语言里面经常发生的问题;因为没有gc,所以分配的内存需要程序自己调用释放。其核心是调用分配与释放没有符合开闭原则,没有配对,形成了有分配,没有释放的指针,从而产生了内存泄漏。
例如:

void func(size_t s1)
{
	void p1=malloc(s1);
	void p2=malloc(s1);
	// ...
	free(p1);
}

以上代码段,分配了两个s1大小的内存块,由 p1 与 p2 指向。而代码块执行完以后,释放了 p1,而 p2 没有释放。形成了有分配没有释放的指针,产生了内存泄漏。

1.2、 内存泄漏导致的后果

随着工程代码量越来越多,内存泄漏的排查就变得极为头疼,一个程序,其虚拟内存一直在增长,无法准确判断是程序需要还是内存泄漏。有分配没释放,自然使进程堆的内存越来越少,直到耗尽。会造成后面的运行代码不能成功分配内存。甚至使程序奔溃。

1.3、内存泄漏如何解决?

内存泄漏是没有自动 gc 的编程语言所产生的,解决方案一,引入 gc。这是根治内存泄漏的最好的方案。但是这样的方案有失去了 c/c++语言的优势。方案二,当发生内存泄漏的时候,能够精准的定位代码哪一行所引起的。这也是实现内存泄漏检测的核心实现需求。
(1)能够检测出来内存泄漏。
(2)能够判断是由代码哪一行引起的内存泄漏。
一个程序,其虚拟内存一直在增长,无法准确判断是程序需要还是内存泄漏;如果是内存泄漏也不知道是发生在哪一行代码。

二、地址转换为符号信息

2.1、addr2line工具

将地址转换为文件名和行号。

addr2line [-a|--addresses]
          [-b bfdname|--target=bfdname]
          [-C|--demangle[=style]]
          [-e filename|--exe=filename]
          [-f|--functions] [-s|--basename]
          [-i|--inlines]
          [-p|--pretty-print]
          [-j|--section=name]
          [-H|--help] [-V|--version]
          [addr addr ...]

描述:
addr2line将地址转换为文件名和行号。给定可执行文件中的地址或可重定位对象部分中的偏移量,它使用调试信息确定与之关联的文件名和行号。

要使用的可执行或可重定位对象是用-e选项指定的。默认为文件a.out。要使用的可重定位对象中的节是用-j选项指定的。

addr2line有两种操作模式。

  • 在第一个命令行中,十六进制地址在命令行中指定,addr2line显示每个地址的文件名和行号。
  • 在第二个命令中,addr2line从标准输入中读取十六进制地址,并在标准输出中打印每个地址的文件名和行号。在这种模式下,addr2line可以在管道中用于转换动态选择的地址。

注意:
addr2line是将地址转换为文件号,而文件是保存在磁盘中的,程序运行的地址是在虚拟内存里面的(代码段),在高版本Linux 中可能无法解析出地址在文件哪个位置。addr2line只能看虚拟区域的地址。

2.2、dladdr1()函数

将地址转换为符号信息。函数原型:

#define _GNU_SOURCE
#include <dlfcn.h>

int dladdr(void *addr, Dl_info *info);

int dladdr1(void *addr, Dl_info *info, void **extra_info, int flags);

// Link with -ldl.

描述:
函数dladdr()确定addr中指定的地址是否位于调用应用程序加载的一个共享对象中。如果是,则dladdr()返回与addr重叠的共享对象和符号的信息。此信息以Dl_info结构返回:

typedef struct {
    const char *dli_fname;  /* Pathname of shared object that contains address */
    void       *dli_fbase;  /* Base address at which shared object is loaded */
    const char *dli_sname;  /* Name of symbol whose definition overlaps addr */
    void       *dli_saddr;  /* Exact address of symbol named in dli_sname */
} Dl_info;

函数dladdr1()类似于dladdr(),但通过参数extra_info返回附加信息。返回的信息取决于标志中指定的值,标志可以具有以下值之一:
(1)RTLD_DL_LINKMAP。获取指向匹配文件的链接映射的指针。extra_info参数指向<link.h>中定义的link_map结构(即struct link_map**)的指针。

struct link_map {
    ElfW(Addr) l_addr;  /* Difference between the address in the ELF file and the address in memory */
    char      *l_name;  /* Absolute pathname where object was found */
    ElfW(Dyn) *l_ld;    /* Dynamic section of the shared object */
    struct link_map *l_next, *l_prev;
                        /* Chain of loaded objects */

    /* Plus additional fields private to the implementation */
};

(2)RTLD_DL_SYMENT。获取指向匹配符号的ELF符号表条目的指针。extra_info参数是指向符号指针的指针:const ElfW(Sym)**ElfW()宏定义将其参数转换为适合硬件体系结构的ELF数据类型的名称。例如,在64位平台上,ElfW(Sym)生成数据类型名称Elf64_Sym,该名称在<elf.h>中定义:

typedef struct  {
    Elf64_Word    st_name;     /* Symbol name */
    unsigned char st_info;     /* Symbol type and binding */
    unsigned char st_other;    /* Symbol visibility */
    Elf64_Section st_shndx;    /* Section index */
    Elf64_Addr    st_value;    /* Symbol value */
    Elf64_Xword   st_size;     /* Symbol size */
} Elf64_Sym;

封装:

void * ConvertToElf(void *addr)
{
	Dl_info info;
	struct link_map *link;

	dladdr1(addr, &info, (void **)&link, RTLD_DL_LINKMAP);

	// 偏差纠正
	return (void *)((size_t)addr - link->l_addr);
}

三、内存泄漏检测的实现

内 存 泄 漏 是由于 内 存 分 配 与 内 存 释放, 不 匹 配 所引起 的 。 对 内 存 分 配 函 数malloc/calloc/realloc,以及内存释放 free 进行“劫持”hook。能够统计出内存分配的位置,内存释放的位置,从而判断是否匹配。

3.1、方式一:使用mtrace

mtrace()和muntrace()函数

mtrace跟踪日志。函数原型:

#include <mcheck.h>

void mtrace(void);

void muntrace(void);

描述:
mtrace()函数为内存分配函数安装hook(钩子)函数【malloc()、realloc()、memalign(),free()】。这些hook函数记录有关内存分配和释放的跟踪信息。跟踪信息可用于发现内存泄漏,并尝试释放程序中未分配的内存。
muntrace()函数禁用mtrace()安装的hook函数,这样就不再为内存分配函数记录跟踪信息。如果mtrace()没有成功安装任何钩子函数,则muntrace()不执行任何操作。

调用mtrace()时,它会检查环境变量MALLOC_TRACE的值,该变量应包含要记录跟踪信息的文件的路径名。如果路径名成功打开,则其长度将被截断为零。
如果未设置MALLOC_TRACE,或者它指定的路径名无效或不可写,则不会安装hook函数,并且mtrace()无效。在set-user-ID 和 set-group-ID程序中,MALLOC_TRACE被忽略,mtrace()无效。

setenv()和unsetenv()函数

改变或添加环境变量。函数原型:

#include <stdlib.h>

int setenv(const char *name, const char *value, int overwrite);

int unsetenv(const char *name);

/* 
Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

setenv(), unsetenv():
    _BSD_SOURCE || _POSIX_C_SOURCE >= 200112L || _XOPEN_SOURCE >= 600
*/

描述:

如果名称不存在,setenv()函数会将变量name添加到值为value的环境中。如果环境中确实存在name,且overwrite非零,则其值将更改为value;如果overwrite为零,则name的值不变(setenv()返回成功状态)。此函数复制name和value指向的字符串(与putenv(3)相反)。

unsetenv()函数用于从环境中删除变量名。如果环境中不存在名称,则函数成功,环境不变。

返回值:
setenv()函数在成功时返回零,在错误时返回-1,并设置errno以指示错误的原因。

unsetenv()函数在成功时返回零,在错误时返回-1,并设置errno以指示错误的原因。

错误:

错误码

含义

EINVAL

name为NULL,指向长度为0的字符串,或包含“=”字符。

ENOMEM

内存不足,无法向环境中添加新变量。

使用步骤

(1)在调用内存分配函数之前调用mtrace();
(2)在程序结束或者不需要追踪内存泄漏的地方调用muntrace();
(3)设置环境变量MALLOC_TRACE的值(setenv函数或者export命令);
(4)编译时要带上-g参数。
(5)当出现内存泄漏时,使用addr2line工具定位内存泄漏的位置。

$ addr2line -f -e memleak -a 0x4006b8

示例中memleak是程序名,0x4006b8是内存泄漏的地址。

例如:

$ cc -g t_mtrace.c -o t_mtrace
$ export MALLOC_TRACE=/tmp/t
$ ./t_mtrace
$ mtrace ./t_mtrace $MALLOC_TRACE

示例代码

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

#include <mcheck.h>

int main(int argc,char **argv)
{

	setenv("MALLOC_TRACE", "./mem.txt", 1);

	mtrace();

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

	free(p3);
	free(p2);
	muntrace();

	unsetenv("MALLOC_TRACE");

	return 0;
}

内存泄漏检测文件内容:

$ cat mem.txt 
= Start
@ ./memleak:[0x4006b8] + 0x1886580 0xa
@ ./memleak:[0x4006c6] + 0x18865a0 0x14
@ ./memleak:[0x4006d4] + 0x18865c0 0x1e
@ ./memleak:[0x4006e4] - 0x18865c0
@ ./memleak:[0x4006f0] - 0x18865a0
= End

定位内存泄漏位置:

$ addr2line -f -e memleak -a 0x4006b8
0x00000000004006b8
main
memleak.c:13

3.2、方式二:采用宏定义

Linux中有两个宏__FILE____func____LINE__,分别指示当前的文件名、函数名和行号,利用宏定义封装内存分配函数和释放函数。

#define malloc(size)	_malloc(size,__FILE__,__LINE__)
#define free(p)			_free(p,__FILE__,__LINE__)

自己在_malloc函数和_free函数中调用malloc函数和free函数,并且做一些操作。
前提:宏一定要放在内存分配之前定义,这样预编译阶段才会替换malloc为我们自己实现的_malloc_free

全面解析内存泄漏检测与修复技术_#define_02

示例代码:

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

void *_malloc(size_t size,const char*filename,int line)
{
	void *p = malloc(size);
	printf("[+] %s : %d, %p\n", filename, line,p);
	return p;
}

void _free(void *p, const char*filename, int line)
{
	printf("[-] %s : %d, %p\n", filename, line,p);
	return free(p);
}

#define malloc(size)	_malloc(size,__FILE__,__LINE__)
#define free(p)			_free(p,__FILE__,__LINE__)

int main(int argc,char **argv)
{
	void *p1 = malloc(10);
	void *p2 = malloc(20);
	void *p3 = malloc(30);

	free(p3);
	free(p2);

	return 0;
}

采用宏定义方法的优缺点:
(1)优点,实现简单。
(2)缺点,只适合单文件,宏定义要放在文件的最前面。

使用文件替换打印:
程序运行时总是打印不必要的信息即影响效率也不美观,可以在一个文件夹里创建、删除文件来统计内存泄漏。
将指针值作为文件名,分配内存创建文件,释放内存删除文件,在文件里面记录分配内存的文件名和行号。
如果文件夹里有文件则存在内存泄漏,没有文件就代表没有内存泄漏。

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

#define LEAK_FILE_PATH	"./mem/%p.mem"

void *_malloc(size_t size,const char*filename,int line)
{
	void *p = malloc(size);
	//printf("[+] %s : %d, %p\n", filename, line,p);
	char buff[128] = { 0 };
	sprintf(buff, LEAK_FILE_PATH, p);
	FILE *fp = fopen(buff, "w");
	fprintf(fp, "[+] %s : %d, addr: %p, size: %ld\n", filename, line, p, size);
	fflush(fp);//刷新数据到文件中
	fclose(fp);

	return p;
}

void _free(void *p, const char*filename, int line)
{
	//printf("[-] %s : %d, %p\n", filename, line,p);
	char buff[128] = { 0 };
	sprintf(buff, LEAK_FILE_PATH, p);
	if (unlink(buff) < 0)
	{
		printf("double free %p\n", p);
		return;
	}
	return free(p);
}

#define malloc(size)	_malloc(size,__FILE__,__LINE__)
#define free(p)			_free(p,__FILE__,__LINE__)

int main(int argc,char **argv)
{

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

	free(p3);
	free(p2);

	return 0;
}

注意,工具只能加快分析,不能100%确定内存泄漏,因为复杂的系统情况比较复杂。
检测内存泄漏不是一开始就加上,一般通过”热更新“的方式在需要的时候打开,即在配置文件中有一个打开内存泄漏检测的标志位。只有需要的时候才开启,这样不影响程序效率。

3.3、方式三:hook(钩子)

hook使用步骤:
(1)定义函数指针。

typedef void *(*malloc_t)(size_t size);
typedef void(*free_t)(void *p);

malloc_t malloc_f = NULL;
free_t free_f = NULL;

(2)函数实现,函数名与目标函数名一致。

void *malloc(size_t size)
{
// ...
}

void free(void *ptr)
{
// ...
}

(3)初始化hook,调用dlsym()。

static init_hook()
{
	if (malloc_f == NULL)
		malloc_f = (malloc_t)dlsym(RTLD_NEXT, "malloc");

	if (free_f == NULL)
		free_f = (malloc_t)dlsym(RTLD_NEXT, "free");
}

注意:
hook的时候,要考虑其他函数也用到所hook住的函数,比如在printf()函数里面也调用了malloc,那么就需要防止内部递归进入死循环。
例如:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#include <dlfcn.h>

typedef void *(*malloc_t)(size_t size);
typedef void(*free_t)(void *p);

malloc_t malloc_f = NULL;
free_t free_f = NULL;
void *malloc(size_t size)
{
	printf("malloc size: %ld", size);
	return NULL;
}

void free(void *ptr)
{
	printf("free: %p\n",ptr);
}

static int init_hook()
{
	if (malloc_f == NULL)
		malloc_f = (malloc_t)dlsym(RTLD_NEXT, "malloc");

	if (free_f == NULL)
		free_f = (free_t)dlsym(RTLD_NEXT, "free");

	return 0;
}

int main(int argc,char **argv)
{
	init_hook();
	void *p1 = malloc(10);
	void *p2 = malloc(20);
	void *p3 = malloc(30);

	free(p3);
	free(p2);
	return 0;
}

以上代码会出现段错误,使用gdb调试会发现在malloc函数的printf()调用进入了无限递归;栈溢出。

解决方案就是添加标志。
gcc内置功能:void * __builtin_return_address(无符号整数级别)
此函数返回当前函数或其调用方之一的返回地址。参数是向上扫描调用堆栈的帧数。值 产生当前函数的返回地址,值 生成当前函数调用方的返回地址,依此类推。内联预期行为时,函数返回返回的函数的地址。若要变通解决此问题,请使用函数属性。
level:
该参数必须是常量整数。
在某些计算机上,可能无法确定除当前功能之外的任何函数的返回地址;在这种情况下,或者当到达堆栈的顶部时,此函数返回未指定的值。此外,可用于确定是否已到达堆栈的顶部。

示例代码:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define LEAK_FILE_PATH	"./mem/%p.mem"

#include <dlfcn.h>

static int enable_malloc_hook = 1;
static int enable_free_hook = 1;

typedef void *(*malloc_t)(size_t size);
typedef void(*free_t)(void *p);

malloc_t malloc_f = NULL;
free_t free_f = NULL;
void *malloc(size_t size)
{
	void *p;
	if (enable_malloc_hook)
	{
		enable_malloc_hook = 0;
		
		p = malloc_f(size);
		printf("malloc size: %ld,p=%p\n", size,p);
		// 获取上一层调用malloc地方的地址,这个地址用于addr2line工具将其转换为行号
		void *caller = __builtin_return_address(0);

		char buff[128] = { 0 };
		sprintf(buff, LEAK_FILE_PATH, p);
		FILE *fp = fopen(buff, "w");
		fprintf(fp, "[+] %p , addr: %p, size: %ld\n", caller, p, size);
		fflush(fp);//刷新数据到文件中
		fclose(fp);

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

void free(void *p)
{
	if (enable_free_hook)
	{
		enable_free_hook = 0;
		//printf("free: %p\n",p);
		char buff[128] = { 0 };
		sprintf(buff, LEAK_FILE_PATH, p);
		if (unlink(buff) < 0)
		{
			printf("double free %p\n", p);
			//enable_free_hook = 1;
			free_f(p);
			return;
		}
		free_f(p);
		enable_free_hook = 1;
	}
	else
		free_f(p);
}

static int init_hook()
{
	if (malloc_f == NULL)
		malloc_f = (malloc_t)dlsym(RTLD_NEXT, "malloc");

	if (free_f == NULL)
		free_f = (free_t)dlsym(RTLD_NEXT, "free");

	return 0;
}

int main(int argc,char **argv)
{

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

	free(p3);
	free(p2);
	
	return 0;
}

通过__builtin_return_address(0)得到的地址需要addr2line工具将其转换为文件行号即可定位内存泄漏的位置。

3.4、方式四:使用__libc_malloc和__libc_free

思路和hook的一样,因为malloc和free底层调用的也是__libc_malloc__libc_free

示例代码:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

// 要记得手动创建一个mem文件夹
#define LEAK_FILE_PATH	"./mem/%p.mem"

extern void *__libc_malloc(size_t size);
extern void __libc_free(void *p);

static int enable_malloc_hook = 1;

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

		p = __libc_malloc(size);
		printf("malloc size: %ld,p=%p\n", size, p);
		// 获取上一层调用malloc地方的地址,这个地址用于addr2line工具将其转换为行号
		void *caller = __builtin_return_address(0);

		char buff[128] = { 0 };
		sprintf(buff, LEAK_FILE_PATH, p);
		FILE *fp = fopen(buff, "w");
		fprintf(fp, "[+] %p , addr: %p, size: %ld\n", caller, p, size);
		fflush(fp);//刷新数据到文件中
		fclose(fp);

		enable_malloc_hook = 1;
	}
	else
		p = __libc_malloc(size);

	return p;
}

void free(void *p)
{
	char buff[128] = { 0 };
	sprintf(buff, LEAK_FILE_PATH, p);
	if (unlink(buff) < 0)
	{
		printf("double free %p\n", p);
	}
	__libc_free(p);
}

int main(int argc,char **argv)
{

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

	free(p3);
	free(p2);

	return 0;
}

3.5、方式五:__malloc_hook(不推荐)

这种方式适用于比较老的Linux 版本,属于旧版本的API,__malloc_hook是指针的方式,是一个固定的值。本质上也是一种hook技术。
函数原型:

#include <malloc.h>

void *(*__malloc_hook)(size_t size, const void *caller);

void *(*__realloc_hook)(void *ptr, size_t size, const void *caller);

void *(*__memalign_hook)(size_t alignment, size_t size, const void *caller);

void (*__free_hook)(void *ptr, const void *caller);

void (*__malloc_initialize_hook)(void);

void (*__after_morecore_hook)(void);

描述:
GNUC库允许您通过指定适当的钩子函数来修改malloc、realloc和free的行为。例如,您可以使用这些挂钩来帮助调试使用动态内存分配的程序。

变量__malloc_initialize_hook指向在初始化malloc实现时调用一次的函数。这是一个弱变量,因此可以在应用程序中使用如下定义覆盖它:

void(*__malloc_initialize_hook)(void)=my_init_hook();

现在函数my_init_hook()可以初始化所有钩子。

__malloc_hook__realloc_hooks__memalign_hooke__free_hooky指向的四个函数的原型分别与函数malloc、realloc和memalign。

方案:
交换法,自定义函数指针,实现具体函数,将自己实现的函数与系统提供的__malloc_hook交换。

示例代码:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

// 要记得手动创建一个mem文件夹
#define LEAK_FILE_PATH	"./mem/%p.mem"

#include <malloc.h>

/*
typedef void *(*malloc_t)(size_t size);
typedef void(*free_t)(void *p);

malloc_t malloc_f = NULL;
free_t free_f = NULL;
*/

static int enable_malloc_hook = 1;

static void my_init_hook(void);
static void *my_malloc_hook(size_t, const void *);
static void my_free_hook(void *, const void *);

/* Variables to save original hooks. */
static void *(*old_malloc_hook)(size_t, const void *);
static void(*old_free_hook)(void *, const void *);
/* Override initializing hook from the C library. */
void(*__malloc_initialize_hook) (void) = my_init_hook;

static void
my_init_hook(void)
{
	old_malloc_hook = __malloc_hook;
	__malloc_hook = my_malloc_hook;

	old_free_hook = __free_hook;
	__free_hook = my_free_hook;
}

static void *
my_malloc_hook(size_t size, const void *caller)
{
	void *result;

	/* Restore all old hooks */
	__malloc_hook = old_malloc_hook;

	/* Call recursively */
	//result = malloc(size);

	if (enable_malloc_hook)
	{
		enable_malloc_hook = 0;

		result = malloc(size);
		/* printf() might call malloc(), so protect it too. */
		printf("malloc(%u) called from %p returns %p\n",
			(unsigned int)size, caller, result);

		char buff[128] = { 0 };
		sprintf(buff, LEAK_FILE_PATH, result);

		FILE *fp = fopen(buff, "w");
		fprintf(fp, "[+] %p , addr: %p, size: %ld\n", caller, result, size);
		fflush(fp);//刷新数据到文件中
		fclose(fp);

		enable_malloc_hook = 1;
	}
	else
		result = malloc(size);

	/* Save underlying hooks */
	old_malloc_hook = __malloc_hook;
	/* Restore our own hooks */
	__malloc_hook = my_malloc_hook;

	return result;
}

static void my_free_hook(void *ptr, const void *caller)
{
	__free_hook = old_free_hook;

	free(ptr);

	old_free_hook = __free_hook;

	/* printf() might call malloc(), so protect it too. */
	printf("free(%p) called from %p\n",
		ptr, caller);

	char buff[128] = { 0 };
	sprintf(buff, LEAK_FILE_PATH, ptr);
	if (unlink(buff) < 0)
	{
		printf("double free %p\n", ptr);
	}

	/* Restore our own hooks */
	__free_hook = my_free_hook;
}

int main(int argc,char **argv)
{

	my_init_hook();

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

	free(p3);
	free(p2);

	return 0;
}

编译的时候会出现警告,系统不推荐使用这样的方法。


总结

  • 内存泄漏检测的核心是要知道有没有内存泄漏已经在哪里出现的内存泄漏。
  • 检测内存泄漏的方式有:mtrace、hook、宏定义、libc_malloc、__malloc_hook。其中mtrace需要设MALLOC_TRACE环境变量并且需要重启;宏定义适用于单文件;__malloc_hook已经淘汰。
  • 在编译程序时加上-g可以使用addr2line工具定位内存泄漏在文件中的位置。
  • 为了提高程序效率,release程序采用“热更新”的方式在需要的时候设置配置文件标志符为进行内存泄漏检测。

 点击关注,第一时间了解华为云新鲜技术~