第 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 */
怎么确定合适的大小? 不是拍脑袋,而是:
- 先按任务数量估算(下一节讲栈的算法);
- 编译后看 map 文件里
.bss是否还有余量; - 运行时用
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 或栈,破坏了内核数据结构。
排查步骤:
- 打开
configCHECK_FOR_STACK_OVERFLOW = 2,看是否命中钩子; - 打开调试版本的栈监视任务,打印每个任务的
uxTaskGetStackHighWaterMark; - 重点怀疑局部大数组、递归、深层函数调用(如 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"这个原则真正落地:用定长内存池管理所有业务缓冲,并实现跨任务的零拷贝数据传递。这是让固件"确定性"的关键一步。