3、动态内存管理:malloc/free的陷阱与替代方案、内存池设计原理、伙伴算法与slab分配器在MCU上的实现
动态内存管理,这个话题在嵌入式圈子里争议很大。有人觉得MCU上就该禁用malloc,有人觉得用好了也没问题。我个人习惯是:能不动态分配,就别动态分配。但现实项目中,有些场景确实绕不开——比如网络协议栈、动态配置的传感器列表。那我们就得把动态内存的坑摸清楚。
3.1 malloc/free的陷阱
先说说标准库的malloc和free。很多初学者觉得这玩意儿挺好用,需要内存就申请,用完就释放。但我在车载项目里吃过它的亏。
你想想看,频繁地申请和释放不同大小的内存块,堆区会变得像蜂窝煤一样——空闲块很多,但都不连续。我曾经遇到一个情况:系统运行72小时后,明明还有30%的堆空间,但申请一个128字节的缓冲区却失败了。这就是典型的外部碎片。
malloc的执行时间是不确定的。它需要遍历空闲链表,找到合适大小的块。在实时性要求高的中断服务函数里调用malloc?嗯,我劝你别这么干。我在一个项目中就见过同事在CAN中断里调malloc,结果导致报文超时。
这个不用我多说了吧。申请了忘记释放,或者释放了又继续使用。在MCU上,内存泄漏的后果比PC上严重得多——系统直接挂掉,连个错误日志都来不及打印。
3.2 替代方案:内存池设计原理
既然标准malloc不靠谱,那怎么办?我的建议是:用内存池。说白了,就是提前划分好一块内存区域,自己管理分配和释放。
内存池的核心思想很简单:
- 预先分配一大块连续内存
- 按固定大小切成若干小块
- 用链表管理空闲块
- 分配时从链表头部取一块
- 释放时放回链表头部
这样做的好处很明显:分配和释放都是O(1)时间复杂度,没有碎片问题。我在一个车载T-Box项目里就用内存池管理CAN报文缓冲区,效果很好。
来看一个简单的实现:
// 内存池结构体
typedef struct {
void *pool_start; // 池起始地址
uint32_t block_size; // 每个块的大小
uint32_t block_count; // 块数量
void *free_list; // 空闲块链表头
} mem_pool_t;
// 初始化内存池
void mem_pool_init(mem_pool_t *pool, void *buffer,
uint32_t blk_size, uint32_t blk_cnt) {
pool->pool_start = buffer;
pool->block_size = blk_size;
pool->block_count = blk_cnt;
pool->free_list = buffer;
// 构建空闲链表
uint8_t *p = (uint8_t *)buffer;
for (uint32_t i = 0; i < blk_cnt - 1; i++) {
*(void **)p = (void *)(p + blk_size);
p += blk_size;
}
*(void **)p = NULL; // 最后一个块指向NULL
}
// 分配一个块
void *mem_pool_alloc(mem_pool_t *pool) {
if (pool->free_list == NULL) {
return NULL; // 池已耗尽
}
void *block = pool->free_list;
pool->free_list = *(void **)block;
return block;
}
// 释放一个块
void mem_pool_free(mem_pool_t *pool, void *block) {
*(void **)block = pool->free_list;
pool->free_list = block;
}
- 块大小要按实际需求设定,别太大也别太小
- 如果需求有多种大小,可以设计多级内存池
- 记得加互斥保护,多任务环境下会出问题
3.3 伙伴算法在MCU上的实现
内存池虽然好,但有个缺点:所有块大小固定。如果应用需要不同大小的内存块,怎么办?这时候伙伴算法就派上用场了。
伙伴算法的核心思想是:把内存按2的幂次分成块。比如总内存是4KB,那就分成1个4KB块,或者2个2KB块,或者4个1KB块,以此类推。
分配时,如果请求256字节,就从256字节的链表中取一块。如果没有,就向上找512字节的块,把它拆成两个256字节的伙伴块,一个分配出去,一个挂到256字节链表。
释放时,检查它的伙伴块是否空闲。如果是,就把两个合并成更大的块。这样能有效减少外部碎片。
我在一个车载网关项目里用过伙伴算法,管理128KB的SRAM。说实话,实现起来比内存池复杂,但灵活性确实高。
// 伙伴算法简化实现
#define MIN_BLOCK_SIZE 32 // 最小块大小
#define MAX_ORDER 12 // 2^12 = 4KB
typedef struct {
void *base_addr; // 内存基址
uint32_t total_size; // 总大小
list_t free_list[MAX_ORDER + 1]; // 空闲链表数组
} buddy_system_t;
// 分配内存
void *buddy_alloc(buddy_system_t *buddy, uint32_t size) {
// 计算需要的order
uint32_t order = 0;
uint32_t block_size = MIN_BLOCK_SIZE;
while (block_size < size) {
block_size <<= 1;
order++;
}
// 查找可用块
for (uint32_t i = order; i <= MAX_ORDER; i++) {
if (!list_empty(&buddy->free_list[i])) {
// 如果有更大块,需要拆分
while (i > order) {
// 拆分逻辑:取一块,分成两个伙伴
// 一个挂到下一级链表,一个继续拆分
i--;
}
// 从free_list[order]取一块返回
return remove_from_list(&buddy->free_list[order]);
}
}
return NULL; // 分配失败
}
- 优点:分配灵活,内部碎片可控(最多浪费50%)
- 缺点:实现复杂,合并操作耗时,有内部碎片
- 适用场景:需要多种大小内存块,且对实时性要求不是极端苛刻
3.4 slab分配器在MCU上的实现
最后说说slab分配器。这玩意儿在Linux内核里很出名,但MCU上也能用。它的思路和内存池有点像,但更高级。
Slab分配器的核心是:为每种常用数据结构创建专属缓存。比如,专门为task_struct结构体创建一个slab缓存,里面每个块大小正好是sizeof(task_struct)。
这样做的好处:
- 没有内部碎片(块大小和数据结构完全匹配)
- 分配速度快(直接从缓存取)
- 缓存友好(相同结构的数据在内存中连续)
我在一个车载信息娱乐系统里用过简化版的slab。系统里有多种消息结构体,每种大小不同。我为每种消息类型创建了独立的slab缓存,效果比用伙伴算法好很多。
// slab分配器简化结构
typedef struct {
void *base; // slab内存基址
uint32_t obj_size; // 对象大小
uint32_t obj_count; // 对象数量
void *free_list; // 空闲对象链表
uint32_t free_count; // 空闲对象数量
} slab_cache_t;
// 创建slab缓存
slab_cache_t *slab_cache_create(uint32_t obj_size, uint32_t count) {
slab_cache_t *cache = (slab_cache_t *)malloc(sizeof(slab_cache_t));
cache->obj_size = obj_size;
cache->obj_count = count;
cache->base = malloc(obj_size * count);
// 初始化空闲链表
cache->free_list = cache->base;
uint8_t *p = (uint8_t *)cache->base;
for (uint32_t i = 0; i < count - 1; i++) {
*(void **)p = (void *)(p + obj_size);
p += obj_size;
}
*(void **)p = NULL;
cache->free_count = count;
return cache;
}
// 从slab缓存分配
void *slab_alloc(slab_cache_t *cache) {
if (cache->free_count == 0) {
return NULL;
}
void *obj = cache->free_list;
cache->free_list = *(void **)obj;
cache->free_count--;
return obj;
}
在MCU上做动态内存管理,我一般这样选型:
- 如果只有1-2种固定大小需求 → 用内存池,简单可靠
- 如果需要多种大小,但数量不大 → 用伙伴算法
- 如果对象类型多,且每种数量大 → 用slab分配器
- 如果实在搞不定 → 静态分配,别用动态内存
最后说一句:没有银弹。每种方案都有适用场景。我见过有人把伙伴算法吹上天,结果在资源紧张的MCU上跑得稀烂。也见过有人死守静态分配,结果代码臃肿得没法维护。关键还是看你的具体需求。
嗯,动态内存管理这块就聊到这儿。下一章我们聊聊栈空间优化,那个坑也不少。