第 4 讲 | 告别 malloc:静态分配 + 内存池的固件架构
【本讲要点】在 16KB RAM 的联网固件里,
malloc不是"方便",而是"定时炸弹"。这一讲给你一套可直接移植的定长内存池。
一、为什么 malloc 是灾难
先问一个问题:你的固件要连续运行多久?
如果是消费类产品,答案是"用户不拔电就一直跑"——可能是几个月、几年。
在这个时间尺度下,malloc有三个致命问题:
问题 1:内存碎片(最致命)
假设你有 4KB 可用堆。程序运行中做这样的循环:
void*a=malloc(64);void*b=malloc(128);free(a);void*c=malloc(96);/* 能分配成功吗? */看起来"总空闲 = 64 + 剩余",应该够。但如果a和b相邻,free(a)释放的 64 字节是个"洞",而c需要 96 字节连续空间——分配失败。
碎片是渐进的、不可逆的。系统跑得越久,可用连续内存越少,最终某次分配失败,设备死机。这就是"跑几天就挂"的典型成因。
问题 2:时间不确定性
malloc的耗时依赖于当前堆的状态——可能是几十个周期,也可能是几万个周期(需要遍历空闲链表、合并碎片)。
在实时系统里,一段代码的执行时间不可预测,就是设计缺陷。
问题 3:分配失败的善后
malloc失败返回NULL。你的代码检查了吗?
uint8_t*buf=malloc(len);memcpy(buf,src,len);/* 如果 buf 是 NULL,直接崩 */在小内存系统里,分配失败不是"异常情况",而是"迟早会发生的情况"。靠一套庞大的错误处理去兜住它,成本极高。
问题 4:没有 MMU,踩了就真踩了
PC 上有 MMU,越界访问会被操作系统拦住。MCU 上没有 MMU,malloc越界一个字节,踩掉的就是别的变量——而且可能几小时后才表现出症状。
结论:在资源受限的联网固件里,所有内存分配都应该是"启动期确定、运行期零分配"。
二、替代方案总览
| 方案 | 适用场景 | 确定性 |
|---|---|---|
| 静态数组 | 数量固定、生命周期贯穿全程 | 最高 |
| 定长内存池 | 数量不定但结构同构(协议帧、包) | 高,O(1) |
| FreeRTOS heap_1 | 只在启动期分配 | 高 |
| malloc / heap_4 | 不可避免时 | 低 |
本讲重点讲"定长内存池"——它覆盖了 90% 的业务缓冲需求(协议帧、MQTT 包、日志条目),而且实现简单、性能确定。
三、定长内存池:完整实现
核心思想:所有块大小相同,空闲块串成一个链表。分配 = 摘链表头,释放 = 挂回链表头。O(1),无碎片。
/* ==================== mem_pool.h ==================== */#ifndefMEM_POOL_H#defineMEM_POOL_H#include<stdint.h>#include<stdbool.h>typedefstructmem_poolmem_pool_t;/* 创建内存池:blk_size 单块字节数,blk_cnt 块数 * 返回 NULL 表示内存不足 */mem_pool_t*mem_pool_create(void*storage,uint32_tblk_size,uint32_tblk_cnt);void*mem_pool_alloc(mem_pool_t*pool);/* 分配一块,失败返回 NULL */voidmem_pool_free(mem_pool_t*pool,void*blk);/* 归还一块 */uint32_tmem_pool_free_count(constmem_pool_t*pool);uint32_tmem_pool_used_count(constmem_pool_t*pool);#endif/* ==================== mem_pool.c ==================== */#include"mem_pool.h"/* 空闲块用前 4 字节存下一块地址,所以单块最小要能放下一个指针 */#definePOOL_MIN_BLK_SIZE(sizeof(void*))structmem_pool{void*free_list;/* 空闲块链表头 */uint32_tblk_size;/* 每块字节数(已对齐) */uint32_tblk_cnt;/* 总块数 */uint32_tfree_cnt;/* 当前空闲块数 */};/* 把 size 向上对齐到 4 字节边界 */staticuint32_talign4(uint32_tsize){return(size+3u)&~3u;}mem_pool_t*mem_pool_create(void*storage,uint32_tblk_size,uint32_tblk_cnt){if(storage==NULL||blk_cnt==0){returnNULL;}blk_size=align4(blk_size);if(blk_size<POOL_MIN_BLK_SIZE){blk_size=POOL_MIN_BLK_SIZE;}/* 池头放在 storage 开头,后面跟数据块区域 */mem_pool_t*pool=(mem_pool_t*)storage;uint8_t*base=(uint8_t*)storage+align4(sizeof(mem_pool_t));pool->blk_size=blk_size;pool->blk_cnt=blk_cnt;pool->free_cnt=blk_cnt;pool->free_list=NULL;/* 从后往前串链表,保证返回的地址是升序,便于调试 */for(int32_ti=(int32_t)blk_cnt-1;i>=0;i--){void*blk=base+(uint32_t)i*blk_size;*(void**)blk=pool->free_list;pool->free_list=blk;}returnpool;}void*mem_pool_alloc(mem_pool_t*pool){if(pool==NULL||pool->free_list==NULL){returnNULL;/* 池空,绝不阻塞、绝不崩溃 */}/* 临界区:多任务/中断环境下必须保护 */void*blk=pool->free_list;pool->free_list=*(void**)blk;pool->free_cnt--;returnblk;}voidmem_pool_free(mem_pool_t*pool,void*blk){if(pool==NULL||blk==NULL){return;}*(void**)blk=pool->free_list;pool->free_list=blk;pool->free_cnt++;}uint32_tmem_pool_free_count(constmem_pool_t*pool){returnpool?pool->free_cnt:0;}uint32_tmem_pool_used_count(constmem_pool_t*pool){returnpool?(pool->blk_cnt-pool->free_cnt):0;}怎么用
所有存储都是静态的,启动时一次性分配好,运行期零开销:
/* 16KB RAM 上的实际配置 */#defineFRAME_BLK_SIZE64#defineFRAME_BLK_CNT8/* 静态存储区,编译期确定,不占用堆 */staticuint8_ts_frame_pool_storage[sizeof(mem_pool_t)+4+FRAME_BLK_SIZE*FRAME_BLK_CNT]__attribute__((aligned(4)));staticmem_pool_t*s_frame_pool;voidcomm_init(void){s_frame_pool=mem_pool_create(s_frame_pool_storage,FRAME_BLK_SIZE,FRAME_BLK_CNT);/* 上线前自检:池必须创建成功 */configASSERT(s_frame_pool!=NULL);}收益对比:
| 方案 A:固定 8 个静态数组 | 方案 B:内存池 | |
|---|---|---|
| RAM 占用 | 8 × 64 = 512B | 8 × 64 = 512B(相同) |
| 使用数组索引 | 需手工管理哪块空闲 | 自动 |
| 支持动态数量 | 否 | 是(最多 8 块) |
| 碎片 | 无 | 无 |
| 分配耗时 | 0 | O(1),恒定 |
内存池用同样的 RAM,换来了"按需分配"的灵活性,且没有 malloc 的碎片风险。这就是它值得用的原因。
四、跨任务零拷贝:队列传指针
有了内存池,跨任务传递数据就变得很干净:分配一块 → 填数据 → 把指针塞进队列 → 消费者用完归还。
/* 队列里存的是"指针",不是数据本身 —— 这就是零拷贝的含义 */staticQueueHandle_t s_frame_queue;typedefstruct{uint8_t*data;uint16_tlen;}frame_msg_t;/* 生产者任务:采集 */voidproducer_task(void*arg){for(;;){uint8_t*blk=mem_pool_alloc(s_frame_pool);if(blk==NULL){/* 池满:说明消费者跟不上,丢弃本帧并计数 */stat_inc(STAT_FRAME_DROP);vTaskDelay(pdMS_TO_TICKS(100));continue;}uint16_tlen=sensor_pack(blk,FRAME_BLK_SIZE);frame_msg_tmsg={.data=blk,.len=len};if(xQueueSend(s_frame_queue,&msg,0)!=pdPASS){mem_pool_free(s_frame_pool,blk);/* 队满,归还,不留残块 */stat_inc(STAT_QUEUE_DROP);}vTaskDelay(pdMS_TO_TICKS(1200));}}/* 消费者任务:上报 */voidconsumer_task(void*arg){frame_msg_tmsg;for(;;){if(xQueueReceive(s_frame_queue,&msg,portMAX_DELAY)==pdPASS){iot_publish(msg.data,msg.len);mem_pool_free(s_frame_pool,msg.data);/* 用完必须归还 */}}}三个关键点:
- 零拷贝:队列只传 8 字节指针,数据本身不搬;
- 绝不泄露:
xQueueSend失败也必须归还块; - 绝不阻塞在分配上:池空就丢帧记账,保证实时性。
五、双缓冲:解决"发送中被覆盖"
第 7 讲留过一个尾巴:一帧正在发送时,新数据来了会覆盖它,导致数据混帧。
用内存池,这个问题天然就解决了——发送中的块还在被引用,新数据会去申请新块:
时间线: T1 数据帧 A 申请 blk1,投入队列 T2 消费者取出 blk1,开始发送(blk1 被占用) T3 新数据帧 B 到达 → 申请 blk2(而不是覆盖 blk1)✓ T4 blk1 发送完成 → 归还给池 T5 消费者取出 blk2 → 发送"发送中"和"待发送"自然分离,这就是零拷贝架构的额外红利。
六、内存池的四个注意事项
1. 临界区保护。mem_pool_alloc / free里的链表操作不是原子的。如果可能被中断或多个任务调用,必须加保护:
void*mem_pool_alloc(mem_pool_t*pool){taskENTER_CRITICAL();/* 或 configASSERT 单任务独占 */void*blk=pool->free_list;if(blk){pool->free_list=*(void**)blk;pool->free_cnt--;}taskEXIT_CRITICAL();returnblk;}在中断里调用要用taskENTER_CRITICAL_FROM_ISR。
2. 块大小要"按最大需求+对齐"定。
如果一帧最大 48 字节,别把块定成 32,会溢出。留 20% 余量。
3. 块数要算清楚最坏情况。
"生产者比消费者快"时,队列会积压。块数 ≥ 队列深度 + 在途帧数 + 1。否则池会频繁空。
4. 一定要做统计。free_cnt的长期最小值,就是你内存压力的真实指标。加上"丢弃计数",才能在量产设备上远程定位问题。
/* 串口周期性输出,用于现场诊断 */log_info("pool: used=%u free=%u, drop=%u/%u",mem_pool_used_count(s_frame_pool),mem_pool_free_count(s_frame_pool),stat_get(STAT_FRAME_DROP),stat_get(STAT_QUEUE_DROP));七、小结
三句话记住:
1. 联网固件里,运行期绝不能有 malloc——碎片和时间不确定性是致命的。
2. 定长内存池 = 摘/挂链表头,O(1)、无碎片、可实现、可统计。
3. 队列传指针 = 零拷贝;顺带解决"发送中被覆盖",自然实现双缓冲。
下一讲(第 5 讲),我们进入通信链路的第一站:如何用UART + DMA + 空闲中断 + 环形缓冲,搭出一套"绝不掉包"的收帧架构。它正是第 7 讲那个上报问题的数据源头。