样章-第03讲-FreeRTOS在16KB-RAM上的裁剪与陷阱

第 3 讲 | FreeRTOS 在 16KB RAM 上的裁剪与陷阱

【本讲要点】FreeRTOS 默认配置是给 64KB+ RAM 的芯片准备的。搬进 16KB 的板子,你不裁剪它,它就撑死你。


一、那 10KB 是怎么来的

上一讲我们算过:一颗 16KB RAM 的芯片,FreeRTOS Heap 就占了 10KB。

很多人以为 FreeRTOS 很小、"内核才几 KB"。这句话没错------内核代码(Flash 占用)确实小,但它需要的内存(RAM)是你给多少它用多少。

默认模板 FreeRTOSConfig.h 里:

c 复制代码
#define configTOTAL_HEAP_SIZE    ((size_t)(17 * 1024))   /* 17KB! */
#define configMINIMAL_STACK_SIZE ((unsigned short)128)   /* 128 词 = 512 字节 */
#define configMAX_PRIORITIES     (7)

一颗 16KB RAM 的芯片,你给 FreeRTOS 划 17KB 的堆,剩下的还能干什么? 这就是"默认配置不能直接用"的原因。

下面逐项拆解该怎么改。


二、FreeRTOSConfig.h 逐项取舍

1. configTOTAL_HEAP_SIZE ------ 最关键的一项

这是 FreeRTOS 内存池的总大小。所有任务栈、队列、信号量、互斥量都从这里分配。

我的经验值:在一颗 16KB RAM 的芯片上,给它 8~10KB 已经很多了。

c 复制代码
#define configTOTAL_HEAP_SIZE    ((size_t)(9 * 1024))    /* 9KB */

怎么确定合适的大小? 不是拍脑袋,而是:

  1. 先按任务数量估算(下一节讲栈的算法);
  2. 编译后看 map 文件里 .bss 是否还有余量;
  3. 运行时用 xPortGetFreeHeapSize()确认还有富余。

判断标准:运行稳定后,heap free 应该还剩总大小的 20%~30%。 剩太少容易在极端情况下分配失败;剩太多说明你保守了,可以匀给业务缓冲。

2. configMINIMAL_STACK_SIZE ------ 空闲任务的栈

c 复制代码
/* 默认 128 词(512 字节)在 M0 上偏大 */
#define configMINIMAL_STACK_SIZE ((unsigned short)64)     /* 64 词 = 256 字节 */

注意单位是"字"(word),不是字节。 在 32 位内核上,1 字 = 4 字节。所以 64 = 256 字节。

空闲任务只做一次简单循环,256 字节通常够。但如果你在空闲钩子里写了逻辑,就要加。

3. configMAX_PRIORITIES ------ 优先级层数

每多一层优先级,调度器就要多一个就绪链表(开销不大,但没必要浪费)。

c 复制代码
#define configMAX_PRIORITIES     (5)     /* 通常 5 层足够 */

我的优先级设计习惯(5 层):

优先级 用途
4(最高) 通信任务(UART 收帧、MQTT 状态机)
3 传感器采集任务
2 业务处理任务
1 日志/低优先级任务
0 空闲任务(FreeRTOS 保留)

4. 关掉用不到的内核功能

c 复制代码
#define configUSE_TIMERS                  0    /* 不用软件定时器就关掉,省一个任务栈 */
#define configUSE_MUTEXES                 1    /* 需要就用,但注意优先级继承的开销 */
#define configUSE_RECURSIVE_MUTEXES       0
#define configUSE_COUNTING_SEMAPHORES     0    /* 用不到就关 */
#define configQUEUE_REGISTRY_SIZE         0    /* 不做可视化调试就关 */
#define configUSE_TRACE_FACILITY          0    /* 不跑 SystemView 就关 */
#define configGENERATE_RUN_TIME_STATS     0

每一行 0 都在省内存。 尤其是 configUSE_TIMERS = 1 会偷偷创建一个定时器任务(默认栈 512 字节),很多项目根本不知道它存在。

5. 打开栈溢出检测

c 复制代码
#define configCHECK_FOR_STACK_OVERFLOW     2
#define configUSE_MALLOC_FAILED_HOOK       1

调试阶段必开,量产阶段评估后可以关(检测有一定开销)。它们会调用:

c 复制代码
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
    log_error("STACK OVERFLOW: %s", pcTaskName);
    taskDISABLE_INTERRUPTS();
    for (;;);          /* 停在这里,靠调试器定位 */
}

void vApplicationMallocFailedHook(void)
{
    log_error("MALLOC FAILED, free=%u", xPortGetFreeHeapSize());
    taskDISABLE_INTERRUPTS();
    for (;;);
}

有了这两个钩子,内存问题的定位效率会提升一个数量级。


三、heap_1 / 2 / 4 / 5 怎么选

FreeRTOS 提供了 5 种内存管理方案(heap_1 ~ heap_5),文件在 portable/MemMang/。

方案 是否支持释放 是否支持碎片整理 适用场景
heap_1 否 --- 确定性要求高、全程只分配不释放
heap_2 是 否(会产生碎片) 不推荐用于长期运行
heap_3 是 否(直接包装 malloc) 不推荐(依赖标准库)
heap_4 是 会合并相邻空闲块 最常用
heap_5 是 会合并 内存分布在多个不连续区域时

我的选择建议

16KB RAM 的联网固件,我强烈推荐 heap_1 或"尽可能不用动态分配"。

理由:

  • 联网固件的任务、队列、缓冲,全都在启动时创建一次,之后永不释放 ------这正是 heap_1 的场景;
  • heap_1 的实现极其简单(就是一个指针递增),没有任何碎片风险,且分配时间恒定;
  • heap_4 虽然支持释放,但一旦发生"分配-释放-分配"的循环,就有碎片;在小内存系统里,碎片是慢性死亡。
c 复制代码
/* heap_1 的核心就这几行 ------ 一个只会前进的指针 */
static size_t xNextFreeByte = 0;
static uint8_t ucHeap[configTOTAL_HEAP_SIZE];

void *pvPortMalloc(size_t xWantedSize)
{
    void *pvReturn = NULL;
    vTaskSuspendAll();
    {
        if ((xNextFreeByte + xWantedSize) <= configTOTAL_HEAP_SIZE) {
            pvReturn = &ucHeap[xNextFreeByte];
            xNextFreeByte += xWantedSize;
        }
    }
    xTaskResumeAll();
    return pvReturn;
}

如果你的任务确实需要动态创建/删除(比如按需启动某些功能),那用 heap_4,但一定要避免频繁的小块分配释放。


四、任务栈到底给多少才安全

这是最容易被拍脑袋决定的参数。给少了溢出,给多了浪费。

正确姿势:栈水位实测法

c 复制代码
void check_stack_usage(void)
{
    TaskHandle_t self = xTaskGetCurrentTaskHandle();
    UBaseType_t wm = uxTaskGetStackHighWaterMark(self);
    log_info("stack high watermark = %u words (%u bytes left)", wm, wm * 4);
}
  • uxTaskGetStackHighWaterMark 返回历史最小剩余量(单位:字);
  • 如果你配了 256 词(1KB),而水位显示 64,说明曾经最少只剩 64 词(256 字节);
  • 经验法则:留下至少 25% 的余量。 水位低于配置的 25% 就该加栈。

关键:一定要在所有代码路径都跑过之后再测(包括异常分支、错误处理),否则水位会偏乐观。

一个小内存系统的任务栈分配示例

任务 配置(字) 字节 说明
通信任务 192 768 依赖 Modbus/MQTT 调用栈深度
采集任务 128 512 逻辑简单
业务任务 128 512
空闲任务 64 256
定时器任务 0 0 已关闭
合计 512 2KB 另有队列/信号量约 500 字节

这就是 9KB Heap 的大致去向。 剩下的 6KB 左右,要给队列、信号量和运行时开销。


五、三个必须避开的陷阱

陷阱 1:栈溢出(现象:随机 HardFault)

最典型的症状:设备跑几个小时甚至几天后随机死机,复位后又能正常,用调试器打断却停在莫名其妙的位置。

根因:某个任务栈溢出,踩坏了相邻任务的 TCB 或栈,破坏了内核数据结构。

排查步骤:

  1. 打开 configCHECK_FOR_STACK_OVERFLOW = 2,看是否命中钩子;
  2. 打开调试版本的栈监视任务,打印每个任务的 uxTaskGetStackHighWaterMark;
  3. 重点怀疑局部大数组、递归、深层函数调用(如 printf 家族)。

最常见的凶手:sprintf / printf。 它们内部栈开销极大,在一个 512 字节栈的任务里调 sprintf,轻轻松松溢出。

陷阱 2:在中断里调用非 ISR 版本 API

c 复制代码
/* 错误:在中断里调用了非 FromISR 版本 */
void UART_IRQHandler(void)
{
    xQueueSend(q, &data, 0);        /* 危险!可能导致断言失败或死锁 */
}

/* 正确:必须用 FromISR 版本,并传 pxHigherPriorityTaskWoken */
void UART_IRQHandler(void)
{
    BaseType_t woken = pdFALSE;
    xQueueSendFromISR(q, &data, &woken);
    portYIELD_FROM_ISR(woken);      /* 必要时触发上下文切换 */
}

这是 FreeRTOS 最高频的错误之一。 轻则断言失败,重则在中断上下文里阻塞死机。

陷阱 3:中断优先级与 configMAX_SYSCALL_INTERRUPT_PRIORITY

这是一个极其隐蔽、又极其致命的坑。

FreeRTOS 规定:优先级数值高于 configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断,绝对不能调用任何 FreeRTOS API。

c 复制代码
/* FreeRTOSConfig.h */
#define configPRIO_BITS                         4                       /* 4 位优先级 */
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY  5
#define configKERNEL_INTERRUPT_PRIORITY \
    (configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))

在 Cortex-M 上,优先级数值越小 = 优先级越高。 所以"高于"指的是数值小于 5 的中断。

规则总结:

  • 要用 FreeRTOS API 的中断 ,优先级数值必须 ≥ 5;
  • 极高实时性、绝不调用 API 的中断 (如电机 PWM),可以设成 0~4,但里面绝不能碰 FreeRTOS。

很多"偶发死机"就是这个坑 :某个中断优先级设成了 3,里面又调了 xQueueSendFromISR,大部分时间没事,一旦和调度器临界区撞上就崩。

验证方法 :CubeMX 或标准外设库里的 NVIC_SetPriority() 参数,逐个核对。


六、裁剪清单(照着改就行)

c 复制代码
/* ========== 16KB RAM 芯片的 FreeRTOSConfig.h 精简版 ========== */

#define configUSE_PREEMPTION                    1
#define configUSE_IDLE_HOOK                     0     /* 不用就关 */
#define configUSE_TICK_HOOK                     0
#define configCPU_CLOCK_HZ                      (48000000UL)  /* 按实际主频 */
#define configTICK_RATE_HZ                      ((TickType_t)1000)  /* 1ms tick */
#define configMAX_PRIORITIES                    (5)
#define configMINIMAL_STACK_SIZE                ((unsigned short)64)
#define configTOTAL_HEAP_SIZE                   ((size_t)(9 * 1024))
#define configMAX_TASK_NAME_LEN                 (8)   /* 默认 16,改小 */
#define configUSE_16_BIT_TICKS                  0
#define configUSE_MUTEXES                       1
#define configUSE_RECURSIVE_MUTEXES             0
#define configUSE_COUNTING_SEMAPHORES           0
#define configUSE_TIMERS                        0     /* 省一个任务的栈 */
#define configQUEUE_REGISTRY_SIZE               0
#define configUSE_TRACE_FACILITY                0
#define configCHECK_FOR_STACK_OVERFLOW          2     /* 调试期开 */
#define configUSE_MALLOC_FAILED_HOOK            1
#define configSUPPORT_DYNAMIC_ALLOCATION        1     /* 用 heap_1 */
#define configSUPPORT_STATIC_ALLOCATION         0

七、小结

三个要点:

1. 默认配置是给 64KB+ 芯片的,16KB 必须逐项裁剪。

2. configTOTAL_HEAP_SIZE 控制在 8~10KB,heap_1 优先,避免碎片。

3. 任务栈用 uxTaskGetStackHighWaterMark 实测,别拍脑袋;中断优先级必须核对 configMAX_SYSCALL_INTERRUPT_PRIORITY。

下一讲(第 4 讲),我们把"不用 malloc"这个原则真正落地:用定长内存池管理所有业务缓冲,并实现跨任务的零拷贝数据传递。这是让固件"确定性"的关键一步。

相关推荐
爱读书的小胖1 小时前
Codewars Sum of a Sequence Hard Core Version C 解题分析
c语言·数据结构·算法
Amelio_Ming1 小时前
rk3588 pcie驱动分析之二 (pcie设备树节点分析)
linux·嵌入式硬件
霖霖7141 小时前
【ESP32笔记】LED操作和呼吸灯实现
笔记·单片机·嵌入式硬件
kiros_wang1 小时前
网络状态监听&WiFi信息获取:弱网提示、断网兜底方案
开发语言·网络·php
geovindu1 小时前
rust: pool Pattern (续)
开发语言·后端·设计模式·rust·对象池简写模式
夏虫不可与之言冰1 小时前
使用Flash Download Tool给ESP32烧录固件
c语言·c++·windows·单片机·物联网·iot
一条泥憨鱼1 小时前
拦截器与过滤器:一次请求穿过 Spring 时,会先经过谁呢
java·开发语言·后端·spring
晴天的雨.9921 小时前
【C++】 priority_queue知识点总结
开发语言·数据结构·c++
zjxtxdy1 小时前
STM32hal库开发-GPIO
stm32·单片机·嵌入式硬件