手写 C++ 内存泄漏检测器
- 1.引言
- 2.宏定义检测内存泄漏
- 3.Hook检测内存泄漏
- 4.AddressSanitizer检测内存泄漏
- 5.内存检测器整体设计
- 6.代码
- 7.死锁问题分析
- 8.Guard Bytes
- 9.结尾
1.引言
内存泄漏不同于程序崩溃、报错等显性bug,它不会让程序立即失效,而是以静默消耗资源的方式持续蚕食系统内存,导致程序运行速度逐渐卡顿、响应延迟升高,最终引发OOM内存溢出、服务宕机、程序闪退等严重问题。
本文将重点介绍如何检测内存泄漏,并实现一个检测内存泄漏的工具,帮助加深理解。
2.宏定义检测内存泄漏
检测内存泄漏的整体思路就是:记录每一次内存的申请和释放。哪个文件、哪一行申请的内存,记录下来。
在程序执行结束后,通过查看记录的内存分配和释放条目,就可以知道有没有发生内存泄漏了。
#include<iostream>#include<cstdlib>void*_malloc(size_tsize,constchar*file,intline){std::cout<<"[+]Allocating "<<size<<" bytes at "<<file<<":"<<line<<"\n";returnmalloc(size);}void_free(void*ptr){std::cout<<"[-]Deallocating memory at "<<ptr<<"\n";free(ptr);}#definemalloc(size)_malloc(size,__FILE__,__LINE__)#definefree(ptr)_free(ptr)intmain(){int*p1=(int*)malloc(100);int*p2=(int*)malloc(200);free(p2);// p1故意不释放return0;}执行结果:
以上就是一个极简的检测内存泄漏的方法,不用纠结它在实际场景下有没有用,关键在于理解这种思想——万变不离其宗。
程序的执行有四个过程,分别是:预处理、编译、汇编和链接。
其中,宏替换就发生在预处理阶段。main函数里的malloc,会被替换成_malloc(size, __FILE__, __LINE__),然后在_malloc函数内部,记录这次申请发生在哪个函数的哪一行。free也是同样的道理。这样,就做到了对内存分配和释放的追踪。
通过上面的运行结果,我们可以看到,申请了两次内存,但是只释放了一次,所以必然存在内存泄漏的问题。
3.Hook检测内存泄漏
Hook检测内存泄漏的原理是劫持系统调用,在真正执行系统调用之前,记录内存申请信息。
#include<stdio.h>#include<stdlib.h>#include<dlfcn.h>// 保存原来系统调用typedefvoid*(*sys_malloc_t)(size_t);typedefvoid(*sys_free_t)(void*);staticsys_malloc_tsys_malloc=NULL;staticsys_free_tsys_free=NULL;__attribute__((constructor))voidinit_hook(){sys_malloc=(sys_malloc_t)dlsym(RTLD_NEXT,"malloc");sys_free=(sys_free_t)dlsym(RTLD_NEXT,"free");}staticintin_hook=0;void*malloc(size_tsize){if(in_hook)returnsys_malloc(size);// 避免递归调用in_hook=1;// 标记进入hookvoid*call_addr=__builtin_return_address(0);printf("[+][%p]size: %ld\n",call_addr,size);in_hook=0;// 标记退出hookreturnsys_malloc(size);}voidfree(void*ptr){if(in_hook)returnsys_free(ptr);// 避免递归调用in_hook=1;// 标记进入hookvoid*call_addr=__builtin_return_address(0);printf("[-][%p]\n",call_addr);in_hook=0;// 标记退出hooksys_free(ptr);}intmain(){char*p1=(char*)malloc(10);char*p2=(char*)malloc(20);free(p1);return0;}这里解释一下代码中的一些疑点:
- attribute((constructor)):
init_hook函数被标记为构造函数,目的是让init_hook函数在main函数之前被调用。 - RTLD_NEXT:跳过当前模块,查找下一个匹配的符号,从而避免直接调用自身导致死循环。
- in_hook:这个字段是用来避免无限递归调用。因为printf函数内部会调用malloc,而系统的malloc已经被我们劫持,所以printf调到的其实是我们自定义的malloc。在自定义malloc里调自定义malloc,一直无限循环下去,最终就会栈溢出。
- __builtin_return_address(0):这是 GCC 编译器提供的一个内置函数。参数 0 表示获取当前函数的返回地址,也就是“是谁调用了这个 malloc/free”的内存地址。通过打印这个地址,我们就可以结合
addr2line工具,将内存地址转换为具体的代码文件名和行号,从而精准的定位内存泄漏。
执行结果:
4.AddressSanitizer检测内存泄漏
AddressSanitizer(简称 ASan)是由 Google 开发的一款高效的内存错误检测工具,现已集成在主流的 GCC 和 Clang 编译器中。它不仅能检测缓冲区溢出、使用已释放内存等运行时错误,还具备强大的堆内存泄漏自动检测能力。
要在 C/C++ 项目中启用 ASan,需要在编译和链接时添加特定的标志。
- -fsanitize=address:开启 AddressSanitizer3。
- -g:包含调试符号,以便输出具体的文件名和行号。
- -fno-omit-frame-pointer:保留帧指针,帮助 ASan 生成清晰的回溯调用栈。
- -O1 或 -O0:降低优化级别,避免内联等优化导致报错位置偏移。
我们以下面的简单代码为例,通过Asan来检测内存泄漏。
#include<stdio.h>#include<stdlib.h>intmain(){int*p1=(int*)malloc(100);int*p2=(int*)malloc(200);free(p2);// p1故意不释放return0;}gcc -fsanitize=address,leak -g memleak3.cpp -o test3
运行结果:AddressSanitizer默认只检测越界访问、Use After Free、栈溢出等,内存泄漏检测需要我们手动开启,编译选项加上
-fsanitize=leak即可。
5.内存检测器整体设计
memleak_project/
├── leak_detector.hpp
├── leak_detector.cpp
├── main.cpp
这是内存检测器小项目的代码框架,功能包括:
- malloc/free hook
- new/delete hook
- DBG_NEW(文件+行号)
- Leak检测
- Double Free检测
- Invalid Free检测
- Buffer Overflow检测(Guard Bytes)
- backtrace调用栈
- 线程安全
6.代码
#pragmaonce#include<unordered_map>#include<mutex>#include<string>#include<cstdint>#include<cstdlib>constexpruint64_tHEAD_MAGIC=0xDEADBEEFCAFEBABE;constexpruint64_tTAIL_MAGIC=0xABCDEF1234567890;constexprintMAX_BACKTRACE=16;structMemMeta{size_tsize;constchar*file;intline;bool freed;//标记该内存是否已经被释放void*stack[MAX_BACKTRACE];intstack_size;};class LeakDetector{public:staticinlinethread_local bool s_in_detector=false;structReentryGuard{ReentryGuard(){LeakDetector::s_in_detector=true;}~ReentryGuard(){LeakDetector::s_in_detector=false;}};staticvoid*allocate(size_tsize,constchar*file,intline);staticvoiddeallocate(void*ptr);staticvoidreport();private:staticstd::unordered_map<void*,MemMeta>_records;staticstd::mutex _mutex;};class AutoReporter{public:~AutoReporter();};externAutoReporter g_reporter;// hookvoid*dbg_malloc(size_tsize,constchar*file,intline);voiddbg_free(void*ptr);void*operatornew(size_tsize);voidoperatordelete(void*ptr)noexcept;void*operator new[](size_tsize);voidoperator delete[](void*ptr)noexcept;void*operatornew(size_tsize,constchar*file,intline);void*operator new[](size_tsize,constchar*file,intline);#defineDBG_NEWnew(__FILE__,__LINE__)#defineDBG_MALLOC(size)dbg_malloc(size,__FILE__,__LINE__)#defineDBG_FREE(ptr)dbg_free(ptr)这个头文件主要完成接口声明和结构体的定义的。
#include"leak_detector.hpp"#include<iostream>#include<execinfo.h>#include<cstring>// 定义std::unordered_map<void*,MemMeta>LeakDetector::_records;std::mutex LeakDetector::_mutex;AutoReporter g_reporter;void*LeakDetector::allocate(size_tsize,constchar*file,intline){if(s_in_detector){returnstd::malloc(size);}ReentryGuard guard;size_ttotal_size=sizeof(u_int64_t)+size+sizeof(u_int64_t);char*raw=(char*)malloc(total_size);if(!raw)throw std::bad_alloc();*(u_int64_t*)raw=HEAD_MAGIC;*(u_int64_t*)(raw+sizeof(u_int64_t)+size)=TAIL_MAGIC;void*user_ptr=raw+sizeof(u_int64_t);MemMeta meta;meta.size=size;meta.file=file;meta.line=line;meta.freed=false;meta.stack_size=backtrace(meta.stack,MAX_BACKTRACE);{std::lock_guard<std::mutex>lock(_mutex);_records[user_ptr]=meta;}returnuser_ptr;}voidLeakDetector::deallocate(void*ptr){if(!ptr)return;if(s_in_detector){std::free(ptr);return;}ReentryGuard guard;std::lock_guard<std::mutex>lock(_mutex);autoit=_records.find(ptr);if(it==_records.end()){std::cerr<<"\n[INVALID FREE]\n"<<"ptr = "<<ptr<<'\n';return;}MemMeta&meta=it->second;if(meta.freed){std::cerr<<"\n[DOUBLE FREE]\n"<<"ptr = "<<ptr<<'\n';return;}char*raw=(char*)ptr-sizeof(u_int64_t);uint64_thead=*(uint64_t*)raw;uint64_ttail=*(uint64_t*)(raw+sizeof(u_int64_t)+meta.size);if(head!=HEAD_MAGIC){std::cerr<<"\n[BUFFER UNDERFLOW]\n";}if(tail!=TAIL_MAGIC){std::cerr<<"\n[BUFFER OVERFLOW]\n";}meta.freed=true;free(raw);_records.erase(it);}voidLeakDetector::report(){std::lock_guard<std::mutex>lock(_mutex);if(_records.empty()){std::cout<<"\n[NO LEAK]\n";return;}std::cout<<"\n====================================\n";std::cout<<"MEMORY LEAK REPORT\n";std::cout<<"====================================\n";for(auto&[ptr,meta]:_records){std::cout<<"\nLeak Address: "<<ptr<<'\n';std::cout<<"Size: "<<meta.size<<" bytes\n";std::cout<<"Location: "<<meta.file<<":"<<meta.line<<'\n';char**symbols=backtrace_symbols(meta.stack,meta.size);std::cout<<"Backtrace:\n";for(inti=0;i<meta.stack_size;i++){std::cout<<" "<<symbols[i]<<'\n';}std::free(symbols);std::cout<<"--------------------------------\n";}}AutoReporter::~AutoReporter(){LeakDetector::report();}void*dbg_malloc(size_tsize,constchar*file,intline){returnLeakDetector::allocate(size,file,line);}voiddbg_free(void*ptr){LeakDetector::deallocate(ptr);}void*operatornew(size_tsize){returnLeakDetector::allocate(size,"Unkown",0);}voidoperatordelete(void*ptr)noexcept{LeakDetector::deallocate(ptr);}void*operator new[](size_tsize){returnLeakDetector::allocate(size,"Unkown",0);}voidoperator delete[](void*ptr)noexcept{LeakDetector::deallocate(ptr);}void*operatornew(size_tsize,constchar*file,intline){returnLeakDetector::allocate(size,file,line);}void*operator new[](size_tsize,constchar*file,intline){returnLeakDetector::allocate(size,file,line);}这是对头文件中接口的实现。
#include"leak_detector.hpp"#include<iostream>#include<cstring>voidleakTest(){std::cout<<"\n===== Leak Test =====\n";int*p=DBG_NEWint[10];}voiddoubleFreeTest(){std::cout<<"\n===== double Free Test =====\n";int*p=DBG_NEWint;delete p;delete p;}voidInvalidFreeTest(){std::cout<<"\n===== Invalid Free Test =====\n";intx=10;dbg_free(&x);}voidOverflowTest(){std::cout<<"\n===== Overflow Test =====\n";char*buf=(char*)DBG_MALLOC(16);std::strcpy(buf,"abcdefghijklmnopqrstuvwxyz");DBG_FREE(buf);}voidMallocTest(){std::cout<<"\n===== malloc/free Test =====\n";void*p1=DBG_MALLOC(128);DBG_FREE(p1);void*p2=DBG_MALLOC(256);(void)p2;}voidNewDeleteTest(){std::cout<<"\n===== new/delete Test =====\n";int*p=newint(10);delete p;}intmain(){leakTest();doubleFreeTest();InvalidFreeTest();OverflowTest();MallocTest();NewDeleteTest();return0;}这是测试代码。
编译命令:g++ leak_detector.cpp main.cpp -o test -ldl
7.死锁问题分析
void*LeakDetector::allocate(size_tsize,constchar*file,intline){......{std::lock_guard<std::mutex>lock(_mutex);_records[user_ptr]=meta;}......}我们看看调用链:调用allocate分配内存——>lock_guard加锁——>哈希表 _records[user_ptr] = meta内部,会调用operator new分配内存——>因为我们自定义了全局operator new,所以调用的哈希表内部调用的实际上就是我们自定义的operator new——>自定义的全局operator new调用allocate——>申请锁。
线程自己持有锁,进入了临界区,但是在临界区内,又申请同一把锁,所以导致死锁。
通过引入Reentrancy Guard,避免allocate被重入。
8.Guard Bytes
constexpr uint64_t HEAD_MAGIC = 0xDEADBEEFCAFEBABE;
constexpr uint64_t TAIL_MAGIC = 0xABCDEF1234567890;
这里引入了两个魔法数字,在申请内存时,实际上是这样分配的:
多分配了16个字节,HEAD_MAGIC和TAIL_MAGIC各占8字节。
这么做的原因在于malloc不检测内存的上溢和下溢,越界可能只是写到了隔壁的内存,程序可能不崩,晚点崩,随机崩。
在用户内存前后埋“哨兵”,如果魔法数字被修改,就说明内存溢出。
9.结尾
欢迎批评指正!