第 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 ==================== */
#ifndef MEM_POOL_H
#define MEM_POOL_H
#include <stdint.h>
#include <stdbool.h>
typedef struct mem_pool mem_pool_t;
/* 创建内存池:blk_size 单块字节数,blk_cnt 块数
* 返回 NULL 表示内存不足 */
mem_pool_t *mem_pool_create(void *storage, uint32_t blk_size, uint32_t blk_cnt);
void *mem_pool_alloc(mem_pool_t *pool); /* 分配一块,失败返回 NULL */
void mem_pool_free(mem_pool_t *pool, void *blk); /* 归还一块 */
uint32_t mem_pool_free_count(const mem_pool_t *pool);
uint32_t mem_pool_used_count(const mem_pool_t *pool);
#endif
/* ==================== mem_pool.c ==================== */
#include "mem_pool.h"
/* 空闲块用前 4 字节存下一块地址,所以单块最小要能放下一个指针 */
#define POOL_MIN_BLK_SIZE (sizeof(void *))
struct mem_pool {
void *free_list; /* 空闲块链表头 */
uint32_t blk_size; /* 每块字节数(已对齐) */
uint32_t blk_cnt; /* 总块数 */
uint32_t free_cnt; /* 当前空闲块数 */
};
/* 把 size 向上对齐到 4 字节边界 */
static uint32_t align4(uint32_t size)
{
return (size + 3u) & ~3u;
}
mem_pool_t *mem_pool_create(void *storage, uint32_t blk_size, uint32_t blk_cnt)
{
if (storage == NULL || blk_cnt == 0) {
return NULL;
}
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_t i = (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;
}
return pool;
}
void *mem_pool_alloc(mem_pool_t *pool)
{
if (pool == NULL || pool->free_list == NULL) {
return NULL; /* 池空,绝不阻塞、绝不崩溃 */
}
/* 临界区:多任务/中断环境下必须保护 */
void *blk = pool->free_list;
pool->free_list = *(void **)blk;
pool->free_cnt--;
return blk;
}
void mem_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_t mem_pool_free_count(const mem_pool_t *pool)
{
return pool ? pool->free_cnt : 0;
}
uint32_t mem_pool_used_count(const mem_pool_t *pool)
{
return pool ? (pool->blk_cnt - pool->free_cnt) : 0;
}
怎么用
所有存储都是静态的,启动时一次性分配好,运行期零开销:
/* 16KB RAM 上的实际配置 */
#define FRAME_BLK_SIZE 64
#define FRAME_BLK_CNT 8
/* 静态存储区,编译期确定,不占用堆 */
static uint8_t s_frame_pool_storage[
sizeof(mem_pool_t) + 4 + FRAME_BLK_SIZE * FRAME_BLK_CNT
] __attribute__((aligned(4)));
static mem_pool_t *s_frame_pool;
void comm_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 的碎片风险。这就是它值得用的原因。
四、跨任务零拷贝:队列传指针
有了内存池,跨任务传递数据就变得很干净:分配一块 → 填数据 → 把指针塞进队列 → 消费者用完归还。
/* 队列里存的是"指针",不是数据本身 —— 这就是零拷贝的含义 */
static QueueHandle_t s_frame_queue;
typedef struct {
uint8_t *data;
uint16_t len;
} frame_msg_t;
/* 生产者任务:采集 */
void producer_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_t len = sensor_pack(blk, FRAME_BLK_SIZE);
frame_msg_t msg = { .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));
}
}
/* 消费者任务:上报 */
void consumer_task(void *arg)
{
frame_msg_t msg;
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();
return blk;
}
在中断里调用要用 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 讲那个上报问题的数据源头。
本文针对资源受限的联网固件(如STM32),深入剖析malloc在嵌入式环境中的四大致命缺陷:内存碎片、时间不确定性、分配失败难处理及无MMU导致的越界风险。提出以静态分配为基础的定长内存池方案,实现O(1)分配释放、零碎片、确定性时延,并支持跨任务零拷贝、双缓冲等关键机制,适用于物联网协议帧、MQTT包等典型场景。
376

被折叠的 条评论
为什么被折叠?



