2. RTOS基础回顾
说到RTOS的内存管理,很多初学者第一反应就是malloc/free。但说实话,在嵌入式领域,尤其是对实时性要求高的场景,动态分配往往是第一个被砍掉的功能。我早年做的一个工业控制器项目,就因为一个malloc在中断里没处理好,导致系统死锁,整条产线停了半小时。从那以后,我对静态分配就格外上心。
这一章,我们就来聊聊最基础、也最可靠的静态内存分配方式。
2.1 什么是静态内存分配?
静态内存分配,说白了就是在编译时就确定好内存的布局和大小。程序跑起来之后,这些内存的位置和尺寸就固定了,不会变。它不像堆分配那样,运行时才去申请和释放。
在RTOS里,静态分配通常有两种形式:
- 全局变量与静态数组:直接在代码里定义,编译器帮你安排好地址。
- 固定大小内存池:预先划出一块连续区域,按固定大小切块管理。
这两种方式,本质上都是「事前规划,事后复用」。你想想看,对于确定性要求极高的实时系统,这种「不变」反而是最大的优点。
核心观点:静态分配没有内存碎片问题,分配和释放的时间都是确定的O(1)复杂度。这是RTOS内核最信赖的伙伴。
2.2 全局变量与静态数组:最朴素的方式
先看一个最简单的例子。我们在RTOS里定义任务栈,通常就是用静态数组:
// 定义两个任务栈
#define TASK1_STACK_SIZE 512
#define TASK2_STACK_SIZE 1024
static uint32_t task1_stack[TASK1_STACK_SIZE];
static uint32_t task2_stack[TASK2_STACK_SIZE];
// 定义消息缓冲区
#define MSG_BUF_SIZE 256
static uint8_t msg_buffer[MSG_BUF_SIZE];
这种方式的好处是简单、直观、零开销。编译器在链接阶段就把地址算好了,运行时直接拿来用。我在做一个小型传感器节点时,整个系统就靠几个全局数组撑起来的,跑了三年没出过内存问题。
但要注意几个坑:
- 栈空间浪费:每个任务都得按最大需求分配,实际可能用不满。
- 全局变量污染:不加static限制,别的模块也能乱改,调试起来很头疼。
- 链接顺序依赖:有些编译器对全局变量的初始化顺序有坑,跨模块依赖时要小心。
我曾经在一个多核项目里,两个模块都定义了同名的全局缓冲区,链接器没报错,结果运行时数据互相覆盖,查了整整两天。所以,全局变量一定要加static,或者用命名空间前缀。
2.3 固定大小内存池:从「散装」到「规整」
全局数组虽然简单,但灵活性不够。比如你要管理多个相同大小的对象(像任务控制块TCB、消息队列节点),用数组就得提前算好最大数量,多了浪费,少了不够。
固定大小内存池就是来解决这个问题的。它把一块连续内存切成若干个固定大小的块,用链表或位图来管理空闲块。分配时拿一块,释放时还回去。
我个人的习惯是,把内存池设计成下面这样:
/* 内存池控制块 */
typedef struct {
void *pool_start; /* 内存池起始地址 */
uint32_t block_size; /* 每个块的大小 */
uint32_t block_count; /* 总块数 */
void *free_list; /* 空闲块链表头 */
uint32_t free_count; /* 当前空闲块数 */
} mem_pool_t;
分配和释放的代码其实很简洁:
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;
pool->free_count--;
return block;
}
void mem_pool_free(mem_pool_t *pool, void *block) {
/* 把块放回空闲链表头部 */
*(void **)block = pool->free_list;
pool->free_list = block;
pool->free_count++;
}
你看,分配和释放都只是几次指针操作,没有遍历,没有排序,时间完全确定。这就是RTOS内核喜欢它的原因。
小技巧:空闲链表利用每个空闲块的前4个字节(或8个字节,取决于指针宽度)来存放下一个空闲块的地址。这样不需要额外的内存来维护链表,非常省。
2.4 内存池的初始化与对齐
初始化内存池时,有个细节很容易被忽略——内存对齐。如果内存池里的块地址不对齐,某些CPU访问时会触发异常,或者性能骤降。
我一般这样初始化:
void mem_pool_init(mem_pool_t *pool, void *buffer,
uint32_t block_size, uint32_t block_count) {
uint32_t i;
uint8_t *block_addr;
/* 确保块大小至少能存一个指针 */
if (block_size < sizeof(void *)) {
block_size = sizeof(void *);
}
/* 对齐块大小到4字节(或8字节) */
block_size = (block_size + 3) & ~3;
pool->pool_start = buffer;
pool->block_size = block_size;
pool->block_count = block_count;
pool->free_list = NULL;
pool->free_count = 0;
/* 把所有块串成空闲链表 */
block_addr = (uint8_t *)buffer;
for (i = 0; i < block_count; i++) {
*(void **)block_addr = pool->free_list;
pool->free_list = block_addr;
pool->free_count++;
block_addr += block_size;
}
}
这里做了两件事:一是保证块大小至少能存一个指针,二是做了对齐处理。为什么?因为空闲链表要用块本身的内存来存指针,块太小就存不下。对齐则是为了CPU访问效率。
| 参数 | 说明 | 常见值 |
|---|---|---|
| block_size | 每个块的大小(字节) | 32, 64, 128, 256 |
| block_count | 总块数 | 根据最大并发对象数定 |
| 对齐粒度 | 块地址对齐要求 | 4字节(ARM Cortex-M) |
| 最小块大小 | 至少能存一个指针 | 4或8字节 |
2.5 内存池的变体:多级池与伙伴系统
固定大小内存池有个明显的局限——所有块一样大。如果系统里需要不同大小的对象,就得建多个池子。我在一个网络协议栈项目里,就同时维护了4个内存池:64字节、256字节、1K和4K,分别对应不同大小的网络包。
这种多级池的设计思路是:
- 根据对象大小,选择合适的池子分配。
- 如果大池子不够,可以尝试从更大的池子借(但一般不这么做,会破坏确定性)。
- 每个池子独立管理,互不干扰。
还有一种更高级的变体叫「伙伴系统」(Buddy System),它把内存按2的幂次切块,分配时可以合并相邻的空闲块。但说实话,伙伴系统的实现复杂度比固定池高不少,而且合并操作的时间不确定。我个人在RTOS内核里很少用伙伴系统,除非是那些对内存利用率要求极高、对实时性要求相对宽松的场景。
我的建议:对于绝大多数RTOS应用,固定大小内存池配合多级池方案,已经能覆盖90%以上的需求。别为了追求那点内存利用率,把系统的确定性搞丢了。
2.6 本章知识体系
下面这张图,把静态内存分配的几种方式以及它们之间的关系梳理清楚了:
从这张图可以看得很清楚:静态内存分配的两条主线各有侧重。全局变量适合那些生命周期贯穿整个系统的对象,比如任务栈、系统配置。而内存池则适合那些需要频繁创建和销毁的同类对象,比如消息节点、设备句柄。
实际项目中的选择:如果对象数量固定且不大,用全局数组就够了。如果对象数量可变但类型单一,用固定大小内存池。如果对象类型多样,用多级池。别一上来就上复杂方案,简单够用才是王道。
2.7 避坑指南
最后,分享几个我在项目中踩过的坑:
- 内存池大小估算错误:我曾经把一个消息池的块数设少了,结果系统跑着跑着就分配失败。后来加了个统计接口,上线观察了一周才确定合理值。
- 忘记对齐:在Cortex-M0上跑一个池子,没做4字节对齐,结果每次分配出来的地址都是奇数,访问时触发硬 fault。查了两天才发现是对齐问题。
- 释放野指针:有人把不是从池子里分配的内存释放回池子,导致链表乱掉。解决方案是在池子控制块里加一个地址范围检查。
- 中断中分配:如果池子的分配函数不是可重入的,在中断里调用就会出问题。要么加关中断保护,要么专门给中断准备一个独立的池子。
嗯,静态内存分配的内容就这些。它虽然基础,但却是RTOS内存管理的基石。下一层,我们会在这个基础上,构建更灵活的动态分配机制。不过那是后话了,先把静态这块吃透再说。