第15章:任务栈与内存管理:栈大小估算、动态内存分配、内存碎片处理

各位同学,今天我们来聊聊RTOS里一个特别“实在”的话题——内存管理。说实话,我早年做运动控制项目时,最怕的就是系统跑着跑着突然崩了。查来查去,十有八九是栈溢出或者内存碎片惹的祸。你想想看,一个插补算法正在计算加减速曲线,突然栈溢出了,电机直接飞车……那场面,啧啧。

所以这一章,我把自己踩过的坑、总结的经验,全部分享给你们。咱们从栈大小估算开始,再到动态内存分配,最后聊聊怎么对付内存碎片。

核心观点:在RTOS中,内存管理不是“能用就行”,而是要“精确到字节”。尤其是运动控制这种硬实时场景,内存分配失败是不可接受的。

15.1 任务栈大小估算——别拍脑袋

很多新手喜欢给任务栈分配一个“看起来够大”的值,比如1024字节。我刚开始也这么干,直到有一次在STM32F4上跑三轴插补,任务栈设了512字节,结果一跑圆弧插补就死机。查了两天,才发现是栈溢出了。

栈大小估算,说白了就是要知道你的任务函数调用链最深能到多少层,每层用多少局部变量。我给大家一个实用方法:

  1. 静态分析法:手动计算函数调用栈深度。每个函数调用的返回地址、局部变量、参数传递都要算进去。
  2. 动态监测法:在任务入口和出口处记录栈指针位置,跑一遍典型工况,看最大差值。
  3. 填充标记法:初始化时用0xDEAD或0xAA填充整个栈,运行一段时间后检查有多少被覆盖了。

我个人习惯用第三种,简单粗暴。下面给个示例代码:

// 栈填充标记法示例
#define STACK_FILL_PATTERN 0xDEADDEAD

void task_init_stack(uint32_t *stack, uint32_t size_words) {
    for (uint32_t i = 0; i < size_words; i++) {
        stack[i] = STACK_FILL_PATTERN;
    }
}

uint32_t task_get_stack_usage(uint32_t *stack, uint32_t size_words) {
    uint32_t used = 0;
    for (uint32_t i = 0; i < size_words; i++) {
        if (stack[i] != STACK_FILL_PATTERN) {
            used = size_words - i;
            break;
        }
    }
    return used * 4; // 返回字节数
}

我的经验:实际栈大小 = 估算值 × 1.5。别问我为什么,这是血的教训换来的安全系数。我曾经在估算时漏了一个中断嵌套,结果现场调试时差点把板子烧了。

15.2 动态内存分配——RTOS里的双刃剑

动态内存分配在RTOS里是个敏感话题。一方面,运动控制中有些数据结构(比如插补缓冲区)确实需要动态创建;另一方面,mallocfree的不可预测性让人头疼。

我建议遵循以下原则:

  • 初始化时分配:所有动态内存尽量在系统启动时一次性分配完。运行时只使用,不释放。
  • 固定大小块:如果必须运行时分配,用固定大小的内存块池(memory pool),避免变长分配。
  • 避免频繁分配释放:插补算法中,每产生一个轨迹点就malloc一次,再free一次?别闹,这会导致严重的碎片问题。

下面是我在项目中常用的固定块内存池实现:

// 固定大小内存池
typedef struct {
    uint8_t *pool;          // 内存池起始地址
    uint32_t block_size;    // 每个块大小
    uint32_t block_count;   // 块数量
    uint32_t free_list;     // 空闲块链表头
} mem_pool_t;

void* mem_pool_alloc(mem_pool_t *pool) {
    if (pool->free_list == POOL_EMPTY) return NULL;
    
    uint32_t index = pool->free_list;
    uint32_t *next = (uint32_t*)(pool->pool + index * pool->block_size);
    pool->free_list = *next;
    
    return (void*)next;
}

void mem_pool_free(mem_pool_t *pool, void *ptr) {
    uint32_t offset = (uint8_t*)ptr - pool->pool;
    uint32_t index = offset / pool->block_size;
    
    uint32_t *block = (uint32_t*)ptr;
    *block = pool->free_list;
    pool->free_list = index;
}

注意:千万不要在中断服务函数里调用动态内存分配函数!我曾经在一个定时器中断里用了malloc,结果系统死锁了。原因是malloc内部用了信号量保护,而中断里不能pend信号量。

15.3 内存碎片处理——看不见的杀手

内存碎片是RTOS里最隐蔽的问题。你看着剩余内存还有好几KB,但就是分配不出一个256字节的块。为什么会这样?说白了就是频繁分配释放导致内存被切成了很多小块。

我给大家画个图,看看内存碎片是怎么产生的:

内存碎片形成过程 初始状态: 连续空闲内存 (400字节) 分配A(100B): A 空闲 (300B) 分配B(150B): A B 空闲 (150B) 释放A: 空闲(100B) B 空闲 (150B) 碎片结果: 100B B(150B) 150B 碎片!

你看,释放A之后,空闲内存被B分割成了两块。如果这时候要分配一个200字节的块,虽然总空闲有250字节,但任何一个连续块都不够,分配就失败了。

对付内存碎片,我有几个实战经验:

  • 伙伴算法(Buddy System):把内存按2的幂次分成块,分配和释放时合并相邻块。我曾在FreeRTOS上移植过,效果不错。
  • 内存池分层:根据插补数据的大小,建立16B、32B、64B、128B等多个内存池。小数据用小池,大数据用大池。
  • 定期整理:在系统空闲时,执行内存压缩。不过运动控制中要小心,压缩期间不能有任务在访问内存。

避坑指南:我曾经在一个四轴联动项目中,用了标准malloc/free,结果跑了8小时后系统崩溃。最后发现是内存碎片导致插补缓冲区分配失败。后来改成固定块内存池,问题彻底解决。所以我的建议是:运动控制中,能用静态分配就别用动态,能用内存池就别用malloc。

15.4 实战建议——我的内存管理清单

好了,说了这么多,我给大家总结一个内存管理检查清单,每次做项目时对照着看:

检查项 说明 优先级
栈大小估算 用填充标记法实测,留50%余量
动态分配时机 尽量在初始化时完成,运行时避免
内存碎片防护 使用固定块内存池或伙伴算法
中断安全 中断中绝不调用动态分配函数
内存泄漏检测 定期检查已分配块数量是否异常增长

嗯,这一章的内容就到这里。内存管理这东西,说白了就是“预则立,不预则废”。你花在设计阶段的时间,会在调试阶段十倍百倍地省回来。我见过太多项目因为内存问题延期,希望大家能引以为戒。