第 3 讲 | FreeRTOS 在 16KB RAM 上的裁剪与陷阱
【本讲要点】FreeRTOS 默认配置是给 64KB+ RAM 的芯片准备的。搬进 16KB 的板子,你不裁剪它,它就撑死你。
一、那 10KB 是怎么来的
上一讲我们算过:一颗 16KB RAM 的芯片,FreeRTOS Heap 就占了10KB。
很多人以为 FreeRTOS 很小、“内核才几 KB”。这句话没错——内核代码(Flash 占用)确实小,但它需要的内存(RAM)是你给多少它用多少。
默认模板FreeRTOSConfig.h里:
#defineconfigTOTAL_HEAP_SIZE((size_t)(17*1024))/* 17KB! */#defineconfigMINIMAL_STACK_SIZE((unsignedshort)128)/* 128 词 = 512 字节 */#defineconfigMAX_PRIORITIES(7)一颗 16KB RAM 的芯片,你给 FreeRTOS 划 17KB 的堆,剩下的还能干什么?这就是"默认配置不能直接用"的原因。
下面逐项拆解该怎么改。
二、FreeRTOSConfig.h 逐项取舍
1. configTOTAL_HEAP_SIZE —— 最关键的一项
这是 FreeRTOS 内存池的总大小。所有任务栈、队列、信号量、互斥量都从这里分配。
我的经验值:在一颗 16KB RAM 的芯片上,给它 8~10KB 已经很多了。
#defineconfigTOTAL_HEAP_SIZE((size_t)(9*1024))/* 9KB */怎么确定合适的大小?不是拍脑袋,而是:
- 先按任务数量估算(下一节讲栈的算法);
- 编译后看 map 文件里
.bss是否还有余量; - 运行时用
xPortGetFreeHeapSize()确认还有富余。
判断标准:运行稳定后,heap free应该还剩总大小的 20%~30%。剩太少容易在极端情况下分配失败;剩太多说明你保守了,可以匀给业务缓冲。
2. configMINIMAL_STACK_SIZE —— 空闲任务的栈
/* 默认 128 词(512 字节)在 M0 上偏大 */#defineconfigMINIMAL_STACK_SIZE((unsignedshort)64)/* 64 词 = 256 字节 */注意单位是"字"(word),不是字节。在 32 位内核上,1 字 = 4 字节。所以64= 256 字节。
空闲任务只做一次简单循环,256 字节通常够。但如果你在空闲钩子里写了逻辑,就要加。
3. configMAX_PRIORITIES —— 优先级层数
每多一层优先级,调度器就要多一个就绪链表(开销不大,但没必要浪费)。
#defineconfigMAX_PRIORITIES(5)/* 通常 5 层足够 */我的优先级设计习惯(5 层):
| 优先级 | 用途 |
|---|---|
| 4(最高) | 通信任务(UART 收帧、MQTT 状态机) |
| 3 | 传感器采集任务 |
| 2 | 业务处理任务 |
| 1 | 日志/低优先级任务 |
| 0 | 空闲任务(FreeRTOS 保留) |
4. 关掉用不到的内核功能
#defineconfigUSE_TIMERS0/* 不用软件定时器就关掉,省一个任务栈 */#defineconfigUSE_MUTEXES1/* 需要就用,但注意优先级继承的开销 */#defineconfigUSE_RECURSIVE_MUTEXES0#defineconfigUSE_COUNTING_SEMAPHORES0/* 用不到就关 */#defineconfigQUEUE_REGISTRY_SIZE0/* 不做可视化调试就关 */#defineconfigUSE_TRACE_FACILITY0/* 不跑 SystemView 就关 */#defineconfigGENERATE_RUN_TIME_STATS0每一行0都在省内存。尤其是configUSE_TIMERS = 1会偷偷创建一个定时器任务(默认栈 512 字节),很多项目根本不知道它存在。
5. 打开栈溢出检测
#defineconfigCHECK_FOR_STACK_OVERFLOW2#defineconfigUSE_MALLOC_FAILED_HOOK1调试阶段必开,量产阶段评估后可以关(检测有一定开销)。它们会调用:
voidvApplicationStackOverflowHook(TaskHandle_t xTask,char*pcTaskName){log_error("STACK OVERFLOW: %s",pcTaskName);taskDISABLE_INTERRUPTS();for(;;);/* 停在这里,靠调试器定位 */}voidvApplicationMallocFailedHook(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虽然支持释放,但一旦发生"分配-释放-分配"的循环,就有碎片;在小内存系统里,碎片是慢性死亡。
/* heap_1 的核心就这几行 —— 一个只会前进的指针 */staticsize_txNextFreeByte=0;staticuint8_tucHeap[configTOTAL_HEAP_SIZE];void*pvPortMalloc(size_txWantedSize){void*pvReturn=NULL;vTaskSuspendAll();{if((xNextFreeByte+xWantedSize)<=configTOTAL_HEAP_SIZE){pvReturn=&ucHeap[xNextFreeByte];xNextFreeByte+=xWantedSize;}}xTaskResumeAll();returnpvReturn;}如果你的任务确实需要动态创建/删除(比如按需启动某些功能),那用heap_4,但一定要避免频繁的小块分配释放。
四、任务栈到底给多少才安全
这是最容易被拍脑袋决定的参数。给少了溢出,给多了浪费。
正确姿势:栈水位实测法
voidcheck_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 或栈,破坏了内核数据结构。
排查步骤:
- 打开
configCHECK_FOR_STACK_OVERFLOW = 2,看是否命中钩子; - 打开调试版本的栈监视任务,打印每个任务的
uxTaskGetStackHighWaterMark; - 重点怀疑局部大数组、递归、深层函数调用(如 printf 家族)。
最常见的凶手:sprintf/printf。它们内部栈开销极大,在一个 512 字节栈的任务里调sprintf,轻轻松松溢出。
陷阱 2:在中断里调用非 ISR 版本 API
/* 错误:在中断里调用了非 FromISR 版本 */voidUART_IRQHandler(void){xQueueSend(q,&data,0);/* 危险!可能导致断言失败或死锁 */}/* 正确:必须用 FromISR 版本,并传 pxHigherPriorityTaskWoken */voidUART_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。
/* FreeRTOSConfig.h */#defineconfigPRIO_BITS4/* 4 位优先级 */#defineconfigLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY5#defineconfigKERNEL_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()参数,逐个核对。
六、裁剪清单(照着改就行)
/* ========== 16KB RAM 芯片的 FreeRTOSConfig.h 精简版 ========== */#defineconfigUSE_PREEMPTION1#defineconfigUSE_IDLE_HOOK0/* 不用就关 */#defineconfigUSE_TICK_HOOK0#defineconfigCPU_CLOCK_HZ(48000000UL)/* 按实际主频 */#defineconfigTICK_RATE_HZ((TickType_t)1000)/* 1ms tick */#defineconfigMAX_PRIORITIES(5)#defineconfigMINIMAL_STACK_SIZE((unsignedshort)64)#defineconfigTOTAL_HEAP_SIZE((size_t)(9*1024))#defineconfigMAX_TASK_NAME_LEN(8)/* 默认 16,改小 */#defineconfigUSE_16_BIT_TICKS0#defineconfigUSE_MUTEXES1#defineconfigUSE_RECURSIVE_MUTEXES0#defineconfigUSE_COUNTING_SEMAPHORES0#defineconfigUSE_TIMERS0/* 省一个任务的栈 */#defineconfigQUEUE_REGISTRY_SIZE0#defineconfigUSE_TRACE_FACILITY0#defineconfigCHECK_FOR_STACK_OVERFLOW2/* 调试期开 */#defineconfigUSE_MALLOC_FAILED_HOOK1#defineconfigSUPPORT_DYNAMIC_ALLOCATION1/* 用 heap_1 */#defineconfigSUPPORT_STATIC_ALLOCATION0七、小结
三个要点:
1. 默认配置是给 64KB+ 芯片的,16KB 必须逐项裁剪。
2.configTOTAL_HEAP_SIZE控制在 8~10KB,heap_1优先,避免碎片。
3. 任务栈用uxTaskGetStackHighWaterMark实测,别拍脑袋;中断优先级必须核对configMAX_SYSCALL_INTERRUPT_PRIORITY。
下一讲(第 4 讲),我们把"不用 malloc"这个原则真正落地:用定长内存池管理所有业务缓冲,并实现跨任务的零拷贝数据传递。这是让固件"确定性"的关键一步。