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内存管理的基石。下一层,我们会在这个基础上,构建更灵活的动态分配机制。不过那是后话了,先把静态这块吃透再说。