☰
样章-第04讲-告别malloc-静态分配与内存池
2026/10/29 10:51:55 网站建设 项目流程

第 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 = 512B8 × 64 = 512B(相同)
使用数组索引需手工管理哪块空闲自动
支持动态数量否是(最多 8 块)
碎片无无
分配耗时0O(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);/* 用完必须归还 */}}}

三个关键点:

  1. 零拷贝:队列只传 8 字节指针,数据本身不搬;
  2. 绝不泄露:xQueueSend失败也必须归还块;
  3. 绝不阻塞在分配上:池空就丢帧记账,保证实时性。

五、双缓冲:解决"发送中被覆盖"

第 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 讲那个上报问题的数据源头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询