手写 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工具,将内存地址转换为具体的代码文件名和行号,从而精准的定位内存泄漏。

执行结果:
Hook

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

运行结果:
AsanAddressSanitizer默认只检测越界访问、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.结尾

欢迎批评指正!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值