第4章:动态内存检测原理:Hook技术拦截malloc/free

动态内存泄漏,说白了就是“借了不还”。你malloc了一块内存,用完了却忘了free,这块内存就永远“失踪”了。在单片机这种资源紧张的环境里,泄漏几KB可能就让系统崩溃。那怎么抓出这些“老赖”呢?

我早年做智能家居项目时,设备运行三天必死机。查了整整一周,最后发现是一个回调函数里malloc了缓冲区,但某个异常分支忘了free。从那以后,我就养成了一个习惯——所有动态内存操作,必须配Hook监控。

4.1 Hook技术:偷梁换柱的核心思想

Hook,说白了就是“截胡”。系统原本调用的是标准库的malloc/free,我们写一个自己的函数,名字也叫malloc/free,在里面先干点“私活”,再调用真正的标准函数。

你想想看,这样我们就能在每次分配和释放时,偷偷记录下信息——谁申请的、申请了多少、什么时候释放的。这就是检测泄漏的基础。

核心原理:利用C语言的符号解析规则,在链接阶段用自己的函数覆盖标准库函数。

4.2 实现方式:三种主流方案

方案 原理 适用场景 缺点
宏替换 #define malloc(size) my_malloc(size, __FILE__, __LINE__) 小型项目,快速验证 无法Hook第三方库内部调用
链接器wrap --wrap=malloc 链接选项 GCC工具链,嵌入式常用 依赖链接器支持
动态库LD_PRELOAD 环境变量注入自定义so Linux用户态调试 单片机裸机不适用

我个人最常用的是链接器wrap方案。在STM32、ESP32这类MCU上,GCC的--wrap选项非常稳定。你只需要写两个函数:__wrap_malloc和__wrap_free,系统会自动把标准调用导向这里。

4.3 记录分配与释放的调用栈

光知道谁malloc了还不够,你得知道是谁调用了malloc。比如A函数调用了B函数,B函数里malloc了内存。如果只记录malloc本身,你只能看到B函数,但真正的问题可能在A的逻辑里。

怎么拿到调用栈?嗯,这里有个小技巧——利用编译器的内建函数。

// 调用栈记录结构体
typedef struct {
    void*   ptr;        // 分配的内存地址
    size_t  size;       // 分配大小
    uint32_t caller[4]; // 最多记录4层调用栈
    uint32_t timestamp; // 分配时间戳
} mem_record_t;

// Hook后的malloc实现
void* __wrap_malloc(size_t size) {
    void* ptr = __real_malloc(size);  // 调用真正的malloc
    
    if (ptr) {
        mem_record_t* rec = find_free_record();
        rec->ptr = ptr;
        rec->size = size;
        rec->timestamp = get_tick();
        
        // 获取调用栈地址
        rec->caller[0] = (uint32_t)__builtin_return_address(0);
        rec->caller[1] = (uint32_t)__builtin_return_address(1);
        rec->caller[2] = (uint32_t)__builtin_return_address(2);
        rec->caller[3] = (uint32_t)__builtin_return_address(3);
    }
    return ptr;
}

经验之谈:__builtin_return_address(0)获取的是当前函数的返回地址,也就是谁调用了malloc。层级越深,信息越模糊。我一般只记录3-4层,再多就不可靠了。

4.4 统计未释放内存块

有了记录表,统计就简单了。每次free时,我们在Hook函数里把对应的记录标记为“已释放”。然后定期扫描整个表,找出那些“已分配但未释放”的记录。

我曾经在一个项目中,用这种方法抓出了7处泄漏。其中一处特别隐蔽——中断服务程序里malloc了缓冲区,但中断触发频率太高,导致内存耗尽。没有Hook,这种问题几乎不可能定位。

// 泄漏检测函数
void check_memory_leak(void) {
    uint32_t leak_count = 0;
    uint32_t leak_bytes = 0;
    
    for (int i = 0; i < MAX_RECORDS; i++) {
        if (records[i].ptr != NULL && !records[i].freed) {
            leak_count++;
            leak_bytes += records[i].size;
            
            // 打印泄漏信息
            printf("[LEAK] addr=0x%08X, size=%u, caller=0x%08X\n",
                   records[i].ptr, records[i].size, records[i].caller[0]);
        }
    }
    
    if (leak_count > 0) {
        printf("Total: %u blocks, %u bytes leaked!\n", leak_count, leak_bytes);
    }
}

注意:记录表本身也需要内存。我建议用静态数组预分配,大小根据项目规模定。一般256条记录足够,每条记录32字节,总共8KB。对于STM32F4级别MCU,这个开销可以接受。

4.5 整体架构:一张图看懂

下面这张图展示了Hook检测系统的完整流程。从应用程序调用malloc开始,到最终输出泄漏报告,每一步都清晰可见。

Hook内存泄漏检测系统架构 应用程序 Hook拦截层 __wrap_malloc / __wrap_free 记录分配信息 地址/大小/调用栈 标记已释放 清除记录/回收 内存记录表 (mem_record_t[256]) ptr size caller[0..3] freed 0x20000100 64 0x08001234 ✓ 0x20000200 128 0x08005678 ✗ 泄漏检测引擎 定时扫描 / 手动触发 泄漏报告输出 串口 / 日志 / LED指示 泄漏判定:freed == false 且ptr != NULL

4.6 实战中的避坑指南

  • 记录表溢出:我曾经遇到过记录表被写满的情况。解决方案是采用环形缓冲区,新记录覆盖最旧的未释放记录,同时打印警告。
  • 中断安全:如果在中断里调用malloc/free,Hook函数必须关中断保护。否则记录表可能被破坏。
  • 性能开销:每次malloc/free都多执行几十条指令。对于高频分配的场景,建议只开启采样模式,比如每100次记录一次。
  • 栈回溯精度:__builtin_return_address在优化等级-O2以上可能失效。我建议调试阶段用-O0,或者用汇编手动保存LR寄存器。

我的习惯:在工程里加一个编译开关,比如ENABLE_MEM_LEAK_DETECT。开发调试时打开,正式发布时关闭。这样既不影响性能,又能随时启用检测。

好了,Hook的原理和实现就讲到这里。这套方法我用了快十年,从ARM7到Cortex-M7,从FreeRTOS到裸机,几乎通吃。你只要把代码框架搭好,以后每个项目都能复用。