手写 C++ 内存泄漏检测器
1.引言
内存泄漏不同于程序崩溃、报错等显性bug,它不会让程序立即失效,而是以静默消耗资源的方式持续蚕食系统内存,导致程序运行速度逐渐卡顿、响应延迟升高,最终引发OOM内存溢出、服务宕机、程序闪退等严重问题。
本文将重点介绍如何检测内存泄漏,并实现一个检测内存泄漏的工具,帮助加深理解。
2.宏定义检测内存泄漏
检测内存泄漏的整体思路就是:记录每一次内存的申请和释放。 哪个文件、哪一行申请的内存,记录下来。
在程序执行结束后,通过查看记录的内存分配和释放条目,就可以知道有没有发生内存泄漏了。
#include <iostream>
#include <cstdlib>
void* _malloc(size_t size, const char* file, int line) {
std::cout << "[+]Allocating " << size << " bytes at " << file << ":" << line << "\n";
return malloc(size);
}
void _free(void* ptr) {
std::cout << "[-]Deallocating memory at " << ptr << "\n";
free(ptr);
}
#define malloc(size) _malloc(size, __FILE__, __LINE__)
#define free(ptr) _free(ptr)
int main()
{
int* p1 = (int*)malloc(100);
int* p2 = (int*)malloc(200);
free(p2);
// p1故意不释放
return 0;
}
执行结果:

以上就是一个极简的检测内存泄漏的方法,不用纠结它在实际场景下有没有用,关键在于理解这种思想——万变不离其宗。
程序的执行有四个过程,分别是:预处理、编译、汇编和链接。
其中,宏替换就发生在预处理阶段。main函数里的malloc,会被替换成_malloc(size, __FILE__, __LINE__),然后在_malloc函数内部,记录这次申请发生在哪个函数的哪一行。free也是同样的道理。这样,就做到了对内存分配和释放的追踪。
通过上面的运行结果,我们可以看到,申请了两次内存,但是只释放了一次,所以必然存在内存泄漏的问题。
3.Hook检测内存泄漏
Hook检测内存泄漏的原理是劫持系统调用,在真正执行系统调用之前,记录内存申请信息。
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
// 保存原来系统调用
typedef void *(*sys_malloc_t)(size_t);
typedef void (*sys_free_t)(void *);
static sys_malloc_t sys_malloc = NULL;
static sys_free_t sys_free = NULL;
__attribute__((constructor))
void init_hook() {
sys_malloc = (sys_malloc_t)dlsym(RTLD_NEXT, "malloc");
sys_free = (sys_free_t)dlsym(RTLD_NEXT, "free");
}
static int in_hook = 0;
void *malloc(size_t size) {
if (in_hook) return sys_malloc(size); // 避免递归调用
in_hook = 1; // 标记进入hook
void *call_addr = __builtin_return_address(0);
printf("[+][%p]size: %ld\n", call_addr, size);
in_hook = 0; // 标记退出hook
return sys_malloc(size);
}
void free(void *ptr) {
if (in_hook) return sys_free(ptr); // 避免递归调用
in_hook = 1; // 标记进入hook
void *call_addr = __builtin_return_address(0);
printf("[-][%p]\n", call_addr);
in_hook = 0; // 标记退出hook
sys_free(ptr);
}
int main() {
char *p1 = (char *)malloc(10);
char *p2 = (char *)malloc(20);
free(p1);
return 0;
}
这里解释一下代码中的一些疑点:
- 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>
int main()
{
int* p1 = (int*)malloc(100);
int* p2 = (int*)malloc(200);
free(p2);
// p1故意不释放
return 0;
}
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.代码
#pragma once
#include <unordered_map>
#include <mutex>
#include <string>
#include <cstdint>
#include <cstdlib>
constexpr uint64_t HEAD_MAGIC = 0xDEADBEEFCAFEBABE;
constexpr uint64_t TAIL_MAGIC = 0xABCDEF1234567890;
constexpr int MAX_BACKTRACE = 16;
struct MemMeta {
size_t size;
const char *file;
int line;
bool freed;//标记该内存是否已经被释放
void *stack[MAX_BACKTRACE];
int stack_size;
};
class LeakDetector {
public:
static inline thread_local bool s_in_detector = false;
struct ReentryGuard {
ReentryGuard() { LeakDetector::s_in_detector = true; }
~ReentryGuard() { LeakDetector::s_in_detector = false; }
};
static void *allocate(size_t size, const char *file, int line);
static void deallocate(void *ptr);
static void report();
private:
static std::unordered_map<void*, MemMeta> _records;
static std::mutex _mutex;
};
class AutoReporter {
public:
~AutoReporter();
};
extern AutoReporter g_reporter;
// hook
void *dbg_malloc(size_t size, const char *file, int line);
void dbg_free(void *ptr);
void *operator new(size_t size);
void operator delete(void *ptr) noexcept;
void *operator new[](size_t size);
void operator delete[](void *ptr) noexcept;
void *operator new(size_t size, const char *file, int line);
void *operator new[](size_t size, const char *file, int line);
#define DBG_NEW new(__FILE__,__LINE__)
#define DBG_MALLOC(size) dbg_malloc(size,__FILE__,__LINE__)
#define DBG_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_t size, const char *file, int line) {
if (s_in_detector) {
return std::malloc(size);
}
ReentryGuard guard;
size_t total_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;
}
return user_ptr;
}
void LeakDetector::deallocate(void *ptr) {
if (!ptr) return;
if (s_in_detector) {
std::free(ptr);
return;
}
ReentryGuard guard;
std::lock_guard<std::mutex> lock(_mutex);
auto it = _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_t head = *(uint64_t*)raw;
uint64_t tail = *(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);
}
void LeakDetector::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(int i = 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_t size, const char *file, int line) {
return LeakDetector::allocate(size, file, line);
}
void dbg_free(void *ptr) {
LeakDetector::deallocate(ptr);
}
void *operator new(size_t size) {
return LeakDetector::allocate(size, "Unkown", 0);
}
void operator delete(void *ptr) noexcept {
LeakDetector::deallocate(ptr);
}
void *operator new[](size_t size) {
return LeakDetector::allocate(size, "Unkown", 0);
}
void operator delete[](void *ptr) noexcept {
LeakDetector::deallocate(ptr);
}
void *operator new(size_t size, const char *file, int line) {
return LeakDetector::allocate(size, file, line);
}
void *operator new[](size_t size, const char *file, int line) {
return LeakDetector::allocate(size, file, line);
}
这是对头文件中接口的实现。
#include "leak_detector.hpp"
#include <iostream>
#include <cstring>
void leakTest() {
std::cout
<<"\n===== Leak Test =====\n";
int *p = DBG_NEW int[10];
}
void doubleFreeTest() {
std::cout
<<"\n===== double Free Test =====\n";
int *p = DBG_NEW int;
delete p;
delete p;
}
void InvalidFreeTest()
{
std::cout
<<"\n===== Invalid Free Test =====\n";
int x=10;
dbg_free(&x);
}
void OverflowTest()
{
std::cout
<<"\n===== Overflow Test =====\n";
char* buf =
(char*)DBG_MALLOC(16);
std::strcpy(
buf,
"abcdefghijklmnopqrstuvwxyz");
DBG_FREE(buf);
}
void MallocTest()
{
std::cout
<<"\n===== malloc/free Test =====\n";
void* p1 = DBG_MALLOC(128);
DBG_FREE(p1);
void* p2 = DBG_MALLOC(256);
(void)p2;
}
void NewDeleteTest()
{
std::cout
<<"\n===== new/delete Test =====\n";
int* p = new int(10);
delete p;
}
int main() {
leakTest();
doubleFreeTest();
InvalidFreeTest();
OverflowTest();
MallocTest();
NewDeleteTest();
return 0;
}
这是测试代码。
编译命令:g++ leak_detector.cpp main.cpp -o test -ldl
7.死锁问题分析
void *LeakDetector::allocate(size_t size, const char *file, int line) {
......
{
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.结尾
欢迎批评指正!

1039

被折叠的 条评论
为什么被折叠?



