C++不用工具,如何检测内存泄漏?
在知乎上面看到一个很有意思的问题,“C++不用工具,如何检测内存泄漏?”。年入百万的知乎科学家大概有四个思路,一种思路是重载operator new,一种思路是hook malloc,另外一种是使用内存池。
关于内存管理的一些前置背景知识:
https://github.com/zhaozhengcoder/CoderNoteBook/blob/master/note/c++%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86.md
1. 重载operator new
在C++中operator new是可以被重载的,一般有两种重载的方式,全局重载和在某个类内重载,这两种的区别就不在这里展开写的。通过重载一个全局的operator new的函数,在new的时候附加上参数文件名和行号。然后,定义一个新的宏,在new的时候带上申请内存的文件名和行号。这样当业务代码new的时候,会调用重载的operator new函数,这样就获得了申请内存的代码文件名和行号,甚至都可以带上backtrace做更加精确的定位。
// libc中的定义
void* operator new(std::size_t sz)
// 定义一个宏,给new附带上两个参数 文件名和行号
#define new new(__LINE__,__FILE__)
// 重载operator new
void* operator new (size_t size , const char *file ,
unsigned int line ) {
if (void *ptr = malloc (size))
{
// print backtrace();
cout << endl << "new : " << file << " "<< line << endl ;
return ptr ;
}
throw std::bad_alloc () ;
}
// 重新实现operator delete (void *ptr)
// 由于c++lib中operator delete (void *ptr) 是一个弱符号的函数,所以可以重新实现一个,且不会出现重定义
void operator delete (void *ptr)
{
if (ptr == nullptr) return ;
cout << ptr << "\tsource has been released !" << endl ;
free (ptr);
ptr = nullptr;
}
这种方案会两个小问题:
- new的宏定义只有include这个宏的地方才会生效,对于库函数或者无法include宏的地方就没有办法插入log。
- 只能记录内存是被哪些逻辑分配出去的,并没有非常准确的定位到是那块内存泄漏了,需要根据申请内存的日志去进一步分析。(比如一些通用的内存信息,有非常多的业务逻辑都会调用申请,那么知道了是这个类型的内存泄露只能缩小问题的范围,并没有办法准确定位)。
对于问题1,可以重新实现operator new来解决。因为libc中默认的operator new是weak符号,可以重新实现一个strong符号去覆盖。对于没有include宏的地方,也可以进入void * operator new (size_t size)
的函数中。
void * operator new (size_t size)
{
cout << endl << "new size : " << size << endl ;
void *p = malloc(size);
return p;
}
int main()
{
{
int * q1 = new int();
delete q1;
}
{
// 底层的内存分配算法也会走重新实现的operator new函数
shared_ptr<A> a = make_shared<A>();
}
}
对于问题2,见到过有个很有意思的实现方式,可以在分配内存的时候,额外多申请一块(head + 实际的内容),head中记录申请内存的文件名和行号,然后将head插入到一个全局的list中。在free的时候,根据free的指针,ptr-sizeof(head) 找到head的头部。把head从全局的list中释放掉。当程序结束的时候,全局的list中就维护了申请但是还没有释放的内存信息。执行效果如下:
自己实现了一个乞丐版:
https://github.com/zhaozhengcoder/CoderNoteBook/tree/master/example_code/memleak_check/mem_leak_tool
2. hook malloc
malloc函数和operator new不一样的是,它并不是一个weak的符号(但在glibc下是一个弱符号),没有办法想new一样,重新实现一个去覆盖。因此,会看到很多hook malloc的方案,常见的有:
- 重新实现一个malloc函数,通过preload 动态库的方式去覆盖默认的malloc函数;
- 另一种思路是使用 wrap 编译参数(好像也叫编译垫片)
关于什么是弱符号和强符号,正常情况下我们定义的函数都是强符号,这样如果定义了两个相同强符号,就会出现重定义的报错。因此,很多库函数的实现思路是,定义为弱符号,这样链接的时候,如果出现了同名的强符号,就会被覆盖,而不是报错。关于什么是强符号和弱符号
-
preload动态库的思路是:
malloc函数是在libc.so库中,loader在进行动态链接的时候,会将有相同符号名的符号覆盖成LD_PRELOAD指定的so文件中的符号。换句话说,可以用我们自己的so库中的函数替换原来库里有的函数,从而达到hook的目的。// main.cpp int main(int argc, char **argv) { // ... // hock strcmp函数 if (!strcmp(passwd, argv[1])) { printf("Correct Password!\n"); return 0; } printf("Invalid Password!\n"); return 1; }
// hack.cpp #define _GNU_SOURCE #include <stdio.h> #include <dlfcn.h> extern "C" int strcmp(const char *s1, const char *s2) { printf("hack function invoked. s1=<%s> s2=<%s>\n", s1, s2); return 0; }
g++ -Wall -fPIC -shared -o hack.so hack.cpp -ldl g++ checkpasswd.cpp -o checkpasswd LD_PRELOAD=./hack.so ./checkpasswd 90 // 系统strcmp函数已经被自己实现的strcmp hook
假设要统计molloc被调用的次数,也可以利用preload的方式去hook。
main.c
的定义如下:#include <stdio.h> #include <stdlib.h> int main(int argc, char** argv) { int* p = (int *)malloc(sizeof(int)); free(p); return 0; }
利用 dlsym 编写如下文件
dlsym_test_preload.c:
#include <stdio.h> #include <dlfcn.h> static unsigned int invoke_times = 0; void* malloc(size_t sz) { void* (*my_malloc)(size_t) = dlsym(RTLD_NEXT, "malloc"); invoke_times += 1; printf("my malloc invoked\n"); return my_malloc(sz); }
同样可以把
dlsym_test_preload.c:
编译为一个动态库,然后preload这个动态库之后,运行main程序,实现hook malloc函数。
- 使用编译器选项wrap重载malloc函数
ld链接选项wrap定义
按照文档的说法,加上wrap的选项之后,调用malloc的时候,会调用wrap的版本。而系统的malloc被重命名为real_malloc,可以通过调用real_malloc来调用系统malloc函数
extern "C" void * __real_malloc(size_t size);
extern "C" void __real_free(void *ptr);
extern "C" void * __wrap_malloc(size_t size) {
printf("my malloc: %zu\n", size);
return __real_malloc(size);
}
extern "C" void __wrap_free(void *ptr) {
printf("my free: %p\n", ptr);
__real_free(ptr);
}
// g++ main.cpp test1.cpp test2.cpp memory_op.cpp -Wl,-wrap=malloc -Wl,-wrap=free
自己实现了一个乞丐版:
https://github.com/zhaozhengcoder/CoderNoteBook/tree/master/example_code/memleak_check/mem_hook
3. 引入内存池解决
业务层实现内存池来进行分配,这样内存分配上面业务层有更高的权限,也可以做更多的事情来掌控内存的分配。比如定义malloc(size, type) ,业务层分配内存的时候,带上一个type,这样就很容易监控到某某type分配了多少块。
http://www.almostinfinite.com/memtrack.html
4. 使用工具
内存泄漏检测工具
valgrind、ASan、mtrace、ccmalloc、debug_new
对于内存方面的常见错误,valgrind还是很好使用的。
// 内存泄漏
int mem_leak()
{
int * p = new int;
int * arr = new int[100];
return 0;
}
// 内存访问越界
int mem_out_of_boundary()
{
const int size = 10;
int * arr = new int[size];
for (int i = 0; i <= size; i++)
{
arr[i] = i;
}
return 0;
}
// free后再次访问
int mem_use_after_free()
{
const int size = 10;
int * arr = new int[size];
delete[] arr;
arr[0] = 100;
return 0;
}
// 多次free
int mem_invalid_free()
{
const int size = 10;
int * arr = new int[size];
int * p = arr;
delete arr;
delete p;
return 0;
}
# valgrind --tool=memcheck ./a.out
其他补充:
-
强符号和弱符号的区别
-
两个静态库or动态库包含同样的符号,链接会怎么样
https://zhuanlan.zhihu.com/p/352668522
https://blog.csdn.net/Solstice/article/details/6423342
(写不动了,后面再说吧。。