18、内存管理架构:内存池设计、引用计数、RAII思想

内存管理,说白了就是嵌入式开发里的「命根子」。

我见过太多项目,功能写得花里胡哨,一上压力测试就崩。查到最后,十有八九是内存泄漏或者碎片化导致的。你想想看,一个跑在单片机上的系统,总共就那么几百K的RAM,稍微一折腾就没了。

这一章,我跟你聊聊三种实用的内存管理手段:内存池引用计数RAII思想。它们不是互相替代的关系,而是不同场景下的不同解法。

18.1 内存池设计:告别碎片化

先说说内存池。为什么需要它?

标准库的malloc/free,在长期运行的嵌入式系统里有个大问题——碎片化。你申请、释放、再申请、再释放,内存块就变得七零八落。最后明明还有空闲总量,却申请不到一块连续的大内存。

我在一个物联网网关项目里就吃过这个亏。设备跑了三天,突然连不上服务器了。查了半天,是内存碎片导致协议栈分配不出缓冲区。从那以后,我对内存池就特别上心。

核心思想:预先划分一块大内存,切成固定大小的块。每次申请直接拿一块,释放时还回去。没有碎片,速度还快。

内存池的设计其实不复杂。我习惯用链表来管理空闲块:

/* 内存池节点 */
typedef struct mem_pool_node {
    struct mem_pool_node *next;
} mem_pool_node_t;

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

/* 初始化内存池 */
int mem_pool_init(mem_pool_t *pool, void *buffer, 
                  size_t block_size, uint32_t num_blocks) {
    if (!pool || !buffer || block_size < sizeof(void*)) {
        return -1;
    }
    
    pool->pool_start   = buffer;
    pool->block_size   = block_size;
    pool->total_blocks = num_blocks;
    
    /* 把所有块串成空闲链表 */
    pool->free_list = (mem_pool_node_t *)buffer;
    mem_pool_node_t *node = pool->free_list;
    
    for (uint32_t i = 0; i < num_blocks - 1; i++) {
        node->next = (mem_pool_node_t *)((uint8_t*)node + block_size);
        node = node->next;
    }
    node->next = NULL;
    
    return 0;
}

/* 从内存池分配一个块 */
void *mem_pool_alloc(mem_pool_t *pool) {
    if (!pool || !pool->free_list) {
        return NULL;  /* 池已耗尽 */
    }
    
    mem_pool_node_t *block = pool->free_list;
    pool->free_list = block->next;
    
    return (void *)block;
}

/* 释放块回内存池 */
void mem_pool_free(mem_pool_t *pool, void *block) {
    if (!pool || !block) return;
    
    mem_pool_node_t *node = (mem_pool_node_t *)block;
    node->next = pool->free_list;
    pool->free_list = node;
}

我的习惯:每个块的大小至少能放下一个指针(sizeof(void*)),否则链表串不起来。另外,初始化时最好把整个池清0,方便调试时发现问题。

内存池的适用场景很明确:固定大小的对象频繁分配释放。比如网络协议栈的报文缓冲区、RTOS的任务控制块、音频编解码的帧缓冲区。

但要注意,内存池不是万能药。如果对象大小差异很大,固定块大小会造成内部碎片。这时候可以考虑多级内存池——不同大小对应不同池。

18.2 引用计数:共享资源的「安全绳」

再来说引用计数。这东西在C++里很常见,但C语言也能用,而且用得好了特别顺手。

引用计数的核心逻辑很简单:记录一个资源被引用的次数,当次数降到0时,自动释放

我为什么需要它?

举个例子。你有一个缓冲区,被多个模块同时使用。A模块在写,B模块在读,C模块在转发。如果A模块用完了直接free掉,B和C就踩空了。这就是经典的「悬空指针」问题。

引用计数就是来解决这个的。每个使用者先「引用」一下,计数加1;用完了「解引用」,计数减1。只有最后一个使用者释放时,才真正回收内存。

/* 引用计数结构 */
typedef struct {
    void    *data;      /* 实际数据指针 */
    uint32_t ref_count; /* 引用计数 */
    void   (*free_fn)(void *data); /* 自定义释放函数 */
} ref_counted_t;

/* 创建引用计数对象 */
ref_counted_t *ref_create(void *data, void (*free_fn)(void*)) {
    ref_counted_t *obj = malloc(sizeof(ref_counted_t));
    if (!obj) return NULL;
    
    obj->data      = data;
    obj->ref_count = 1;  /* 初始引用为1 */
    obj->free_fn   = free_fn;
    
    return obj;
}

/* 增加引用 */
void ref_inc(ref_counted_t *obj) {
    if (obj) {
        obj->ref_count++;
    }
}

/* 减少引用,如果为0则释放 */
void ref_dec(ref_counted_t *obj) {
    if (!obj) return;
    
    if (--obj->ref_count == 0) {
        if (obj->free_fn) {
            obj->free_fn(obj->data);
        }
        free(obj);
    }
}

我曾经踩过的坑:引用计数在多线程环境下必须加锁!否则两个线程同时inc/dec,计数会错乱。轻则内存泄漏,重则double free直接崩掉。

引用计数的变体很多。比如弱引用——只观察不持有,避免循环引用。还有侵入式引用计数——把计数直接放在数据结构内部,省一次内存分配。

我个人建议:在嵌入式系统里,优先用侵入式。少一次malloc就少一分风险。

18.3 RAII思想:C语言也能「自动释放」

RAII是C++里的概念,全称是「资源获取即初始化」。说白了就是:对象的生命周期决定了资源的生命周期。对象创建时获取资源,对象销毁时释放资源。

C语言没有构造函数和析构函数,但RAII的思想完全可以借鉴。怎么做?用作用域和goto,或者用cleanup属性(GCC扩展)。

先看一个典型的「手动释放」代码,你肯定写过:

void process_data(void) {
    char *buf = malloc(1024);
    FILE *fp = fopen("data.bin", "rb");
    
    if (!buf || !fp) {
        /* 这里要小心处理,容易漏 */
        free(buf);
        if (fp) fclose(fp);
        return;
    }
    
    /* 中间逻辑... */
    if (some_error) {
        free(buf);
        fclose(fp);
        return;
    }
    
    /* 正常路径 */
    free(buf);
    fclose(fp);
}

看到了吗?每个return前面都要手动释放。漏一个就是泄漏。我早期写代码就经常漏,后来学乖了,用goto统一收尾

void process_data_raii(void) {
    char *buf = NULL;
    FILE *fp = NULL;
    
    buf = malloc(1024);
    if (!buf) goto cleanup;
    
    fp = fopen("data.bin", "rb");
    if (!fp) goto cleanup;
    
    /* 中间逻辑... */
    if (some_error) {
        goto cleanup;
    }
    
    /* 正常路径 */
    
cleanup:
    if (buf) free(buf);
    if (fp) fclose(fp);
}

RAII的核心原则:资源在获取的同时就注册好释放方式。不管中间有多少个return或goto,最终都落到同一个清理点。

如果你用GCC,还有更优雅的方式——__attribute__((cleanup))

/* 定义清理函数 */
static inline void free_ptr(void *p) {
    free(*(void **)p);
}

/* 使用cleanup属性 */
void process_data_cleanup(void) {
    __attribute__((cleanup(free_ptr))) char *buf = malloc(1024);
    
    /* 不管怎么退出,buf都会自动free */
    if (!buf) return;
    
    /* 中间逻辑... */
    if (some_error) return;  /* 自动释放 */
    
    /* 正常返回,也自动释放 */
}

嗯,这个写法是不是清爽多了?变量离开作用域时,编译器自动调用清理函数。不过要注意,这是GCC扩展,移植性差一些。如果是跨平台项目,我建议还是用goto统一收尾。

18.4 三种手段的对比与选择

说了这么多,到底什么时候用哪个?我整理了一张表:

手段 适用场景 优点 缺点
内存池 固定大小对象、高频分配释放 无碎片、速度快、实时性好 内部碎片、大小不灵活
引用计数 共享资源、多模块同时使用 自动管理生命周期、避免悬空指针 循环引用、多线程需加锁
RAII思想 函数级资源管理、异常安全 代码简洁、不易遗漏释放 C语言支持有限、依赖编码规范

实际项目中,这三种手段经常组合使用。比如:内存池管理小块内存,引用计数管理大块共享缓冲区,RAII思想确保每个函数内的临时资源不泄漏。

我参与的一个音频处理项目,就是这种组合。音频帧用内存池分配,避免频繁malloc;帧数据在多个处理模块间传递时用引用计数;每个处理函数内部用RAII风格管理临时缓冲区。整个系统跑了几个月,内存纹丝不动。

一个小建议:不管用哪种手段,都建议在调试版本里加上内存统计和泄漏检测。比如记录每次alloc的调用栈,释放时校验。这样出了问题能快速定位。

18.5 知识体系总览

最后,我用一张图把本章的核心逻辑串起来。你一看就明白了:

内存管理架构:三种核心手段 内存管理架构 内存池设计 固定大小块 无碎片化 高频分配释放 引用计数 共享资源管理 自动生命周期 避免悬空指针 RAII思想 作用域绑定资源 goto统一收尾 cleanup属性 组合使用:内存池 + 引用计数 + RAII = 稳定高效的内存管理 根据场景选择,不要盲目套用

这张图把三种手段的关系讲清楚了。它们不是孤立的,而是可以互相配合。内存池解决「怎么分配快」,引用计数解决「什么时候释放」,RAII解决「怎么保证释放」。三者结合,你的内存管理就稳了。


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