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解决「怎么保证释放」。三者结合,你的内存管理就稳了。
公众号:蓝海资料掘金营,微信deep3321