4. 链接脚本语法入门

内存管理,说白了就是RTOS的“后勤部长”。你想想看,任务要栈、队列要缓冲区、信号量要控制块——这些内存从哪来?怎么分?分完怎么回收?回收完会不会变成一堆碎渣?

我刚开始做RTOS移植那会儿,觉得内存管理不就是malloc/free吗?后来在项目里吃了大亏,才明白这里面的门道有多深。今天咱们就把这块硬骨头啃下来。

4.1 静态分配 vs 动态分配:两种思路的较量

先看个最基础的问题:内存到底该在编译时定死,还是运行时再申请?

对比维度 静态分配 动态分配
分配时机 编译/链接阶段 运行时
内存位置 .bss / .data 段 堆区 (Heap)
分配大小 编译时固定 运行时可变
分配速度 零开销(已预分配) 有查找/分割开销
碎片问题 无碎片 可能产生碎片
确定性 完全确定 不确定(可能失败)
典型场景 任务栈、全局变量 消息缓冲区、动态创建对象

静态分配,就是你在代码里写死:uint8_t task_stack[1024];。编译器直接给你划好地方,运行时不费吹灰之力。我在一个工业控制器项目里,所有任务栈都是静态分配的——因为那个系统不允许任何运行时失败的可能。

动态分配,则是运行时调用 pvPortMalloc() 或 malloc()。好处是灵活:你不知道会来多少个网络连接?那就动态创建。但代价是什么?速度慢、可能失败、还会产生碎片。

核心原则:实时系统里,能用静态就别用动态。静态分配是“确定性”的基石,动态分配是“灵活性”的代价。

4.2 内存碎片问题:看不见的性能杀手

为什么会碎片?我画个图你就明白了。

内存碎片形成过程 初始: 连续空闲内存块 (64 KB) 分配 A、B、C: A (16 KB) B (24 KB) C (28 KB) 释放 B: A (16 KB) 空闲 (24 KB) C (28 KB) 分配 D (16 KB): A (16 KB) D (16 KB) 8K C (28 KB) 释放 A,申请 E (20 KB): 空闲 (16 KB) D (16 KB) 8K C (28 KB) 碎片:空闲总空间 24 KB,但最大连续块只有 16 KB,无法满足 20 KB 的 E

看到了吗?总空闲空间是够的,但被分割成小碎片,哪个单独都装不下新任务。这就是外部碎片。

还有一种叫内部碎片——你申请了17字节,分配器给了你一个32字节的块,多出来的15字节就浪费了。小碎片积少成多,系统跑着跑着就挂了。

我曾经在一个数据采集项目里,系统跑了72小时后突然申请不到内存。一查,堆里全是2~16字节的小碎片,最大的连续块只剩不到200字节。从那以后,我养成了定期打印堆状态的习惯。

4.3 标准malloc/free的局限性:为什么RTOS要另起炉灶

很多刚从Linux转过来的朋友会问:为什么RTOS不直接用标准C库的malloc/free?

原因很简单——标准malloc/free是为通用操作系统设计的,不是为实时系统设计的。具体来说,有这几个硬伤:

  1. 不可重入:标准malloc内部有全局锁,如果在中断里调用,直接死锁。RTOS里任务切换随时可能发生,不可重入的函数就是定时炸弹。
  2. 执行时间不确定:malloc在查找空闲块时可能遍历整个链表,最坏情况耗时几百微秒甚至更多。实时任务能等吗?不能。
  3. 碎片无法控制:标准malloc的合并策略很保守,碎片越积越多,没有主动整理机制。
  4. 没有内存保护:malloc不会检查你是否越界写,也不会在内存不足时给出明确的错误回调。

我建议:在RTOS里,永远不要直接调用标准库的malloc/free。用RTOS提供的 pvPortMalloc() / vPortFree(),或者自己实现一个固定大小块分配器。

4.4 RTOS内存分配策略:几种主流方案对比

不同的RTOS,内存管理策略也不一样。我整理了几种常见的:

策略 原理 优点 缺点 典型RTOS
固定块分配 预划分固定大小的块 无碎片、速度极快 内部碎片、灵活性差 FreeRTOS heap_4
首次适应 找到第一个足够大的块 实现简单 容易产生小碎片 uC/OS-II
最佳适应 找到最接近需求大小的块 减少大块浪费 查找慢、碎片多 较少使用
伙伴系统 按2的幂次分割合并 合并快、碎片少 内部碎片(最多50%) Linux内核
slab分配器 对象缓存+着色 高速、低碎片 实现复杂 VxWorks

我个人最常用的是固定块分配 + 首次适应的组合方案。固定块分配处理频繁申请/释放的小对象,首次适应处理偶尔的大块申请。这样既保证了速度,又控制了碎片。

4.5 实战:一个简单的固定块分配器实现

光说不练假把式。咱们手写一个固定块分配器,你就明白原理了。

/* 固定块分配器 - 头文件 */
typedef struct {
    uint32_t block_size;    /* 每个块的大小 */
    uint32_t block_count;   /* 块总数 */
    uint32_t free_count;    /* 当前空闲块数 */
    void    *free_list;     /* 空闲块链表头 */
    uint8_t *pool_start;    /* 内存池起始地址 */
} FixedBlockAllocator_t;

/* 初始化:将内存池划分为固定大小的块,串成链表 */
void FixedBlockAlloc_Init(FixedBlockAllocator_t *alloc,
                          void *pool, uint32_t blk_size, uint32_t blk_cnt)
{
    alloc->block_size  = blk_size;
    alloc->block_count = blk_cnt;
    alloc->free_count  = blk_cnt;
    alloc->pool_start  = (uint8_t *)pool;

    /* 将空闲块串成单向链表 */
    uint8_t *p = alloc->pool_start;
    for (uint32_t i = 0; i < blk_cnt - 1; i++) {
        *(void **)p = (void *)(p + blk_size);
        p += blk_size;
    }
    *(void **)p = NULL;  /* 最后一个块指向NULL */
    alloc->free_list = alloc->pool_start;
}

/* 分配:从链表头部取一个块 */
void *FixedBlockAlloc_Alloc(FixedBlockAllocator_t *alloc)
{
    void *block = alloc->free_list;
    if (block != NULL) {
        alloc->free_list = *(void **)block;
        alloc->free_count--;
    }
    return block;
}

/* 释放:将块放回链表头部 */
void FixedBlockAlloc_Free(FixedBlockAllocator_t *alloc, void *block)
{
    *(void **)block = alloc->free_list;
    alloc->free_list = block;
    alloc->free_count++;
}

关键点:这个分配器的时间复杂度是 O(1),无论分配还是释放,都是几条指令的事。而且永远不会产生外部碎片——因为所有块一样大。代价是什么?内部碎片。如果你申请17字节,块大小是32字节,那15字节就浪费了。

4.6 避坑指南:内存管理中的常见陷阱

做RTOS内存管理,有几个坑我几乎每个项目都见过:

  • 陷阱1:忘记检查返回值——malloc返回NULL还继续用,直接硬件异常。我建议每次分配后都加断言。
  • 陷阱2:在中断里分配/释放——除非你的分配器是中断安全的,否则别这么干。我曾经因为这个坑,查了三天才找到原因。
  • 陷阱3:内存泄漏——分配了没释放。用静态分析工具或者自己写一个内存跟踪模块,记录每次分配的位置和大小。
  • 陷阱4:越界写——分配了32字节,写了40字节,把下一个块的头部信息覆盖了。可以在每个块前后加“魔数”做边界检查。
  • 陷阱5:优先级反转——低优先级任务持有内存锁,高优先级任务等内存,中间优先级任务抢CPU。用优先级继承或非阻塞分配可以解决。

我曾经在一个无人机飞控项目里,就是因为中断里调用了malloc,导致系统随机死机。后来换成中断安全的无锁环形缓冲区 + 固定块分配器,问题彻底解决。记住:中断里只做最轻量的事。

4.7 小结:选型决策树

最后,给你一个简单的决策思路:

  • 如果对象大小固定且频繁申请/释放 → 固定块分配器
  • 如果对象大小变化但实时性要求高 → 伙伴系统 + 内存池
  • 如果内存充足且追求简单 → 首次适应 + 定期整理
  • 如果资源极度受限 → 全静态分配,不用动态