一、项目背景:为什么我们需要一个内存管理模块?

说实话,嵌入式实时操作系统(RTOS)的内存管理,跟你在PC上写应用完全是两码事。PC上你可以随便malloc,内存不够了还有虚拟内存撑着。但在嵌入式环境里——尤其是那些只有几十KB RAM的MCU上——内存就是金子。

我早年做过一个工业控制的项目,用的是一颗Cortex-M3内核的芯片,总共64KB RAM。当时图省事,直接用了标准库的malloc/free。结果呢?跑了大概72小时后,系统莫名其妙就挂了。查了三天,最后发现是内存碎片导致的分配失败。嗯,从那以后,我再也不敢在实时任务里用标准malloc了。

所以这一章,我们要干一件实在的事:设计一个轻量级嵌入式RTOS内核的内存管理模块。它要满足几个硬性要求:

  • 确定性:分配和释放的时间必须是O(1)或可预测的
  • 无碎片:至少不能产生外部碎片
  • 低开销:每个控制块尽量控制在几个字节以内
  • 支持多任务安全:要有基本的临界区保护

核心思路:我们采用"固定大小内存块 + 空闲链表"的方案。说白了,就是把一大块内存预先切成若干固定大小的块,然后用一个单向链表把空闲块串起来。分配时从链表头部取一个,释放时再插回去。简单、粗暴、有效。

二、整体架构设计

先别急着写代码。我习惯在动手前先画一张图,把整个模块的脉络理清楚。你想想看,如果连自己都不知道要写什么,那写出来的代码八成是乱的。

轻量级RTOS内存管理模块架构 用户API层 os_malloc() / os_free() / os_mem_init() 核心逻辑层 空闲链表管理 / 分配算法 / 释放算法 / 临界区保护 数据结构层 内存池控制块(mem_pool_t) / 空闲块节点(free_node_t) 内存池数组 / 块大小表 硬件抽象层 关中断/开中断 / 内存屏障 / 对齐处理 调用方向 设计要点 • 固定块大小 • 无外部碎片 • O(1)分配/释放 • 支持多优先级 • 可配置块尺寸 • 内存对齐保证 • 线程安全 • 统计信息

三、数据结构设计

数据结构是内存管理的骨架。骨架歪了,上面长出来的肉(算法)再好也没用。我个人习惯先把数据结构定死,再围绕它写算法。

3.1 空闲块节点

每个空闲块的头几个字节,用来存下一个空闲块的地址。这就是所谓的"侵入式链表"——链表指针直接放在数据块内部,不额外分配内存。

// 空闲块节点结构
// 注意:这个结构必须放在每个空闲块的开头
typedef struct free_node {
    struct free_node *next;  // 指向下一个空闲块
} free_node_t;

小技巧:为什么只用4或8字节?因为当块被分配出去后,这个next指针就不需要了,整个块都交给用户使用。这就是"零开销"的奥妙所在。

3.2 内存池控制块

每个内存池管理一种固定大小的块。比如一个池管16字节的块,另一个池管32字节的块,以此类推。

// 内存池控制块
typedef struct mem_pool {
    void        *pool_start;    // 池起始地址
    uint32_t     block_size;    // 每个块的大小(字节)
    uint32_t     block_count;   // 块的总数
    free_node_t *free_list;     // 空闲块链表头
    uint32_t     free_count;    // 当前空闲块数量
    uint32_t     min_free;      // 历史最少空闲块数(用于监控)
} mem_pool_t;

3.3 内存池数组

我们通常会定义一组不同大小的内存池,覆盖常见的分配需求。比如:16、32、64、128、256字节。

// 内存池配置表
#define POOL_COUNT      5

// 每个池的块大小和数量
static const uint32_t pool_config[POOL_COUNT][2] = {
    {16,   32},   // 16字节块,32个
    {32,   16},   // 32字节块,16个
    {64,   8},    // 64字节块,8个
    {128,  4},    // 128字节块,4个
    {256,  2},    // 256字节块,2个
};

// 内存池数组
static mem_pool_t mem_pools[POOL_COUNT];
static uint8_t pool_memory[POOL_COUNT][4096];  // 每个池4KB内存

注意:每个池的总内存大小 = block_size × block_count。上面配置的总内存大约是:(16×32)+(32×16)+(64×8)+(128×4)+(256×2) = 512+512+512+512+512 = 2560字节。实际项目中要根据你的RAM大小调整。

四、核心算法实现

算法部分其实不复杂。我见过很多新手把简单问题搞复杂了——加了一堆花里胡哨的优化,结果bug比功能还多。咱们就老老实实写最朴素的版本。

4.1 初始化:把内存切成块

初始化时,我们把一整块连续内存切成等大小的块,然后用链表串起来。这个过程只在系统启动时执行一次。

void os_mem_init(void) {
    for (int i = 0; i < POOL_COUNT; i++) {
        mem_pool_t *pool = &mem_pools[i];
        uint32_t blk_size = pool_config[i][0];
        uint32_t blk_cnt  = pool_config[i][1];
        
        // 确保块大小至少能放下一个指针
        if (blk_size < sizeof(free_node_t)) {
            blk_size = sizeof(free_node_t);
        }
        
        // 对齐到4字节边界
        blk_size = (blk_size + 3) & ~3;
        
        pool->block_size  = blk_size;
        pool->block_count = blk_cnt;
        pool->pool_start  = pool_memory[i];
        pool->free_count  = blk_cnt;
        pool->min_free    = blk_cnt;
        
        // 构建空闲链表
        uint8_t *addr = (uint8_t *)pool_memory[i];
        free_node_t *prev = NULL;
        for (uint32_t j = 0; j < blk_cnt; j++) {
            free_node_t *node = (free_node_t *)addr;
            node->next = prev;
            prev = node;
            addr += blk_size;
        }
        pool->free_list = prev;  // 链表头指向最后一个节点
    }
}

4.2 分配:从链表头部取一个

分配就是取链表头节点,然后返回它的地址。时间复杂度O(1),没有任何搜索过程。

void *os_malloc(uint32_t size) {
    // 找到能容纳size的最小池
    for (int i = 0; i < POOL_COUNT; i++) {
        if (size <= mem_pools[i].block_size) {
            mem_pool_t *pool = &mem_pools[i];
            
            // 关中断保护临界区
            uint32_t saved = cpu_enter_critical();
            
            if (pool->free_list == NULL) {
                cpu_exit_critical(saved);
                return NULL;  // 没有空闲块了
            }
            
            // 从链表头部取一个
            free_node_t *node = pool->free_list;
            pool->free_list = node->next;
            pool->free_count--;
            
            // 更新历史最小值
            if (pool->free_count < pool->min_free) {
                pool->min_free = pool->free_count;
            }
            
            cpu_exit_critical(saved);
            return (void *)node;
        }
    }
    return NULL;  // 没有合适的池
}

4.3 释放:插回链表头部

释放更简单——把块插回链表的头部。也是O(1)。但这里有个关键问题:怎么知道这个块属于哪个池?

我见过两种做法:一种是在块头部存池的索引,另一种是通过地址范围判断。我个人偏爱第二种,因为它不占用额外的内存。

void os_free(void *ptr) {
    if (ptr == NULL) return;
    
    // 根据地址找到所属的池
    for (int i = 0; i < POOL_COUNT; i++) {
        mem_pool_t *pool = &mem_pools[i];
        uint8_t *start = (uint8_t *)pool->pool_start;
        uint8_t *end   = start + pool->block_size * pool->block_count;
        
        if ((uint8_t *)ptr >= start && (uint8_t *)ptr < end) {
            // 关中断保护
            uint32_t saved = cpu_enter_critical();
            
            free_node_t *node = (free_node_t *)ptr;
            node->next = pool->free_list;
            pool->free_list = node;
            pool->free_count++;
            
            cpu_exit_critical(saved);
            return;
        }
    }
    // 如果走到这里,说明ptr不属于任何池——非法指针!
    // 在实际产品中,这里应该触发断言或错误处理
}

关键点:为什么释放时不需要知道块大小?因为同一个池里的块大小是固定的。你只要知道块属于哪个池,池的block_size就是块的大小。这就是"固定块大小"设计带来的简化。

五、内存对齐处理

嵌入式世界里,对齐是个绕不开的话题。很多MCU的硬件要求数据必须按2字节、4字节甚至8字节对齐,否则会触发异常或降低访问效率。

我在项目中遇到过一个问题:某个传感器驱动返回的缓冲区地址是奇数,结果DMA传输直接崩了。查了半天,最后发现是内存分配时没有做对齐处理。

所以我们的内存管理模块必须保证:所有分配的地址都是对齐的。最简单的做法是让块大小本身就是对齐的(比如4的倍数),并且池的起始地址也对齐。

// 对齐宏
#define ALIGN_UP(x, a)   (((x) + (a) - 1) & ~((a) - 1))
#define ALIGN_DOWN(x, a) ((x) & ~((a) - 1))

// 确保池起始地址对齐
static uint8_t pool_memory[POOL_COUNT][4096] __attribute__((aligned(4)));

六、统计与调试接口

写完了核心功能,别忘了加几个调试接口。我吃过亏——系统跑着跑着内存不够了,但没有任何手段知道是哪个池满了。后来我养成了习惯:每个模块都留一个"体检"接口。

// 内存池状态结构
typedef struct {
    uint32_t block_size;
    uint32_t block_count;
    uint32_t free_count;
    uint32_t min_free;
    uint32_t used_count;
} mem_pool_status_t;

// 获取指定池的状态
void os_mem_get_status(int pool_id, mem_pool_status_t *status) {
    if (pool_id < 0 || pool_id >= POOL_COUNT) return;
    mem_pool_t *pool = &mem_pools[pool_id];
    
    status->block_size  = pool->block_size;
    status->block_count = pool->block_count;
    status->free_count  = pool->free_count;
    status->min_free    = pool->min_free;
    status->used_count  = pool->block_count - pool->free_count;
}

// 打印所有池的状态(调试用)
void os_mem_dump(void) {
    for (int i = 0; i < POOL_COUNT; i++) {
        mem_pool_status_t st;
        os_mem_get_status(i, &st);
        printf("Pool[%d]: size=%d, total=%d, free=%d, used=%d, min_free=%d\n",
               i, st.block_size, st.block_count, 
               st.free_count, st.used_count, st.min_free);
    }
}

七、性能对比

口说无凭,咱们拿数据说话。下表对比了标准malloc和我们的固定块分配器在几个关键指标上的表现:

指标 标准malloc 固定块分配器
分配时间复杂度 O(n) ~ O(log n) O(1)
释放时间复杂度 O(n) ~ O(log n) O(1)
外部碎片
内部碎片 少量 有(取决于块大小配置)
确定性
每块额外开销 8~16字节 0字节(释放时)
适用场景 通用计算 实时嵌入式系统

我的建议:如果你的系统里任务数量固定、通信模式固定,用固定块分配器是最稳的。但如果你的应用需要分配各种大小的内存(比如处理变长协议包),那就得考虑更复杂的伙伴系统或slab分配器了。不过那是进阶话题,咱们后面再聊。

八、避坑指南

最后,分享几个我踩过的坑,希望能帮你省点调试时间:

  • 中断中分配内存:我曾经在中断服务函数里调用了os_malloc,结果死锁了。原因是中断里关中断后,如果分配失败返回NULL,但中断处理又不能阻塞等待——直接导致系统卡死。解决方案:中断里尽量不要动态分配内存,如果非要分配,用专门的中断安全池。
  • 内存泄漏检测:固定块分配器虽然不会碎片化,但泄漏问题依然存在。我习惯在每个池里加一个"分配计数",如果某个池的used_count持续增长不下降,那八成是有任务忘了释放。
  • 块大小配置:块大小不是越多越好。我见过有人配了20种不同大小的池,结果大部分池利用率极低。一般来说,3~5种大小就够用了,覆盖最常见的分配需求即可。
  • 对齐陷阱:有些MCU的DMA要求缓冲区地址对齐到缓存行(比如32字节)。如果你的内存池起始地址没对齐到32字节,那DMA就可能出问题。记得在初始化时做对齐检查。

好了,这一章的内容就到这里。代码虽然不多,但每一行都是经过实战检验的。你可以在自己的项目里直接拿去用,也可以根据具体芯片的RAM大小调整池的配置。记住:嵌入式内存管理,简单就是美。


公众号:蓝海资料掘金营,微信 deep3321