第十五章:内存管理模块——内存池、malloc/free封装与泄漏检测

说到嵌入式系统的内存管理,我估计不少朋友都吃过亏。我自己刚入行那会儿,在一个跑RTOS的项目里,频繁调用malloc/free,结果系统跑着跑着就挂了。查了三天,发现是内存碎片把堆给撑爆了。从那以后,我对内存管理就格外上心。

这一章,我们来聊聊三个实战中绕不开的话题:内存池的设计思想malloc/free的封装、以及内存泄漏检测的简单实现。说白了,就是教你如何把内存这块“硬骨头”啃下来。

15.1 内存池:为什么需要它?

你想想看,在嵌入式环境里,malloc/free虽然方便,但有两个硬伤:

  • 分配时间不确定——malloc内部要遍历空闲链表,最坏情况下的耗时你根本没法预估。
  • 内存碎片——频繁申请释放,堆空间会变得支离破碎,最后明明有内存,却分配不出一个连续块。

我在一个物联网网关项目里就遇到过这种情况。设备运行两周后,突然连不上服务器了。一查,堆碎片率高达70%,一个512字节的包都申请不出来。嗯,从那以后,我就在关键路径上全面改用内存池。

内存池的核心思想其实很简单:
预先从堆里划出一大块连续内存,然后把它切成固定大小的块。每次申请,直接拿一个块;释放,就把块还回去。没有碎片,没有遍历,时间复杂度是O(1)。

适用场景

  • 频繁申请/释放固定大小内存(如网络数据包、消息队列)
  • 实时性要求高的中断上下文
  • 不允许出现内存碎片的长期运行系统

15.2 内存池的简单实现

下面我给出一份我常用的内存池实现。它用了一个空闲链表来管理所有可用块。

#include <stdint.h>
#include <stdlib.h>

/* 内存池控制块 */
typedef struct {
    void     *pool_start;   /* 池起始地址 */
    uint32_t  block_size;   /* 每个块的大小(字节) */
    uint32_t  block_count;  /* 块的总数 */
    void     *free_list;    /* 空闲块链表头 */
} mem_pool_t;

/* 初始化内存池 */
int mem_pool_init(mem_pool_t *pool, void *buf, uint32_t blk_size, uint32_t blk_cnt) {
    if (!pool || !buf || blk_size == 0 || blk_cnt == 0)
        return -1;

    pool->pool_start  = buf;
    pool->block_size  = blk_size;
    pool->block_count = blk_cnt;

    /* 构建空闲链表:每个块的前4/8字节存放下一个块的地址 */
    uint8_t *p = (uint8_t *)buf;
    void **prev = &pool->free_list;
    for (uint32_t i = 0; i < blk_cnt; i++) {
        *prev = (void *)p;          /* 当前块加入链表 */
        prev  = (void **)p;         /* 下一个指针位置 */
        p    += blk_size;
    }
    *prev = NULL;  /* 最后一个块指向NULL */
    return 0;
}

/* 从内存池申请一个块 */
void *mem_pool_alloc(mem_pool_t *pool) {
    if (!pool || !pool->free_list)
        return NULL;

    void *blk = pool->free_list;
    pool->free_list = *(void **)blk;  /* 摘下第一个空闲块 */
    return blk;
}

/* 释放一个块回内存池 */
void mem_pool_free(mem_pool_t *pool, void *blk) {
    if (!pool || !blk)
        return;

    /* 把块插回空闲链表头部 */
    *(void **)blk = pool->free_list;
    pool->free_list = blk;
}

这段代码我用了很多年,核心逻辑就两个动作:取头插头。没有循环,没有比较,干净利落。

个人习惯:我会在初始化时把每个块的首字节设为指向下一个块的指针。这样free_list本身就是个单向链表,管理起来非常轻量。

15.3 封装malloc/free:给标准接口加一层“保险”

有时候项目里既有内存池,又免不了要用标准malloc。我的做法是:封装一层,统一入口。

为什么要封装?两个理由:

  • 方便加调试信息——每次分配/释放时记录文件名、行号。
  • 方便切换后端——调试时用标准malloc,发布时切到内存池,只需改一个宏。
#include <stdio.h>
#include <stdlib.h>

/* 调试宏:自动记录位置 */
#define MY_MALLOC(size)    my_malloc(size, __FILE__, __LINE__)
#define MY_FREE(ptr)       my_free(ptr, __FILE__, __LINE__)

void *my_malloc(size_t size, const char *file, int line) {
    void *p = malloc(size);
    if (p == NULL) {
        fprintf(stderr, "[MEM] malloc failed at %s:%d, size=%zu\n", file, line, size);
    }
    /* 这里可以插入内存池逻辑 */
    return p;
}

void my_free(void *ptr, const char *file, int line) {
    if (ptr == NULL) {
        fprintf(stderr, "[MEM] warning: free NULL at %s:%d\n", file, line);
        return;
    }
    free(ptr);
}

你看,封装之后,所有内存操作都经过同一道门。出了问题,日志里直接告诉你哪个文件的哪一行出了问题。我曾经靠这个特性,半小时定位了一个野指针导致的崩溃。

注意:封装层不要引入太多开销。我建议只在调试版本里启用文件/行号记录,发布版本直接透传标准malloc/free,用宏控制。

15.4 内存泄漏检测:简单但有效

内存泄漏是嵌入式开发里的“慢性病”。症状不明显,但积累到一定程度,系统就会莫名其妙地挂掉。

我分享一个我常用的轻量级检测方法:记录每次分配,在释放时核对

#include <string.h>

#define MAX_RECORDS 1024

typedef struct {
    void   *ptr;
    size_t  size;
    char    file[64];
    int     line;
} mem_record_t;

static mem_record_t records[MAX_RECORDS];
static int record_count = 0;

void *dbg_malloc(size_t size, const char *file, int line) {
    void *p = malloc(size);
    if (p == NULL) return NULL;

    if (record_count < MAX_RECORDS) {
        records[record_count].ptr  = p;
        records[record_count].size = size;
        strncpy(records[record_count].file, file, sizeof(records[record_count].file) - 1);
        records[record_count].line = line;
        record_count++;
    }
    return p;
}

int dbg_free(void *ptr) {
    if (ptr == NULL) return -1;

    /* 查找记录并移除 */
    for (int i = 0; i < record_count; i++) {
        if (records[i].ptr == ptr) {
            free(ptr);
            /* 用最后一条记录覆盖当前记录 */
            records[i] = records[record_count - 1];
            record_count--;
            return 0;
        }
    }
    /* 没找到记录:说明重复释放或非法指针 */
    fprintf(stderr, "[LEAK] double free or invalid pointer: %p\n", ptr);
    return -1;
}

void dump_leaks(void) {
    if (record_count == 0) {
        printf("[MEM] No leaks detected.\n");
        return;
    }
    printf("[MEM] === Memory Leak Report ===\n");
    for (int i = 0; i < record_count; i++) {
        printf("  Leak %d: %p (%zu bytes) at %s:%d\n",
               i, records[i].ptr, records[i].size,
               records[i].file, records[i].line);
    }
    printf("[MEM] Total %d leaks, %zu bytes lost.\n",
           record_count, total_leaked_bytes());
}

这个实现虽然简单,但足够实用。我在一个工控项目里用它抓出了7处泄漏,其中有一处是中断服务程序里malloc了却没free,排查了整整两天。

我的建议:把这个检测模块放在调试版本里,每次退出前调用dump_leaks()。如果报告非空,坚决不发版。这个习惯救了我好几次。

15.5 知识体系总览

下面这张图总结了本章的核心逻辑,你可以对照着梳理思路:

内存管理模块知识体系 内存池 malloc/free封装 泄漏检测 固定块大小 空闲链表 统一入口 文件/行号记录 分配记录表 释放时核对 三者协同工作流 应用层 → 封装层(malloc/free) → 内存池(快速分配) / 标准堆(大块) 泄漏检测模块全程监控,退出时生成报告

15.6 避坑指南与经验总结

最后,我把自己踩过的坑整理成几条原则,希望能帮你少走弯路:

  1. 内存池的块大小要选对——我一般取项目中最大常用结构体的大小,再对齐到4或8字节。太小了不够用,太大了浪费。
  2. 封装层不要过度设计——加太多功能反而影响性能。我通常只加文件/行号记录和空指针检查,够用就好。
  3. 泄漏检测要尽早介入——不要等到系统崩溃了才想起来查。我在每个模块的单元测试里都会调用dump_leaks()。
  4. 多任务环境下加锁——如果内存池被多个任务共享,记得用互斥锁保护。我见过一个案例,两个任务同时alloc,把链表指针搞乱了,系统直接hard fault。

我曾经在一个项目里,因为内存池的块大小没对齐,导致ARM Cortex-M4的DMA传输总是出错。查了两天才发现是地址没按16字节对齐。从那以后,我初始化内存池时都会强制对齐:blk_size = (blk_size + 15) & ~15;

好了,这一章的内容就到这里。内存管理是嵌入式开发的“基本功”,把内存池、封装层和泄漏检测这三板斧练好,你的系统稳定性能上一个台阶。


公众号:蓝海资料掘金营,微信deep3321