Chapter 08 · FreeRTOS 实时操作系统

内存管理

理解 FreeRTOS 五种堆方案的取舍,动态与静态分配的选择,内存碎片与栈溢出这两大隐形杀手,以及如何监控剩余内存。

课程进度67%

关键概念

FreeRTOS 堆 (Heap)
FreeRTOS 自带的内存分配器,独立于 C 库的 malloc。创建任务、队列、信号量等内核对象时,内核调用 pvPortMalloc() 从这块堆里分配。堆的实现由你选择的 heap_x.c 决定,总大小由 configTOTAL_HEAP_SIZE 配置。用它而非标准 malloc,是为了确定性和可移植性。
pvPortMalloc / vPortFree
FreeRTOS 的内存分配/释放函数,对应标准库的 malloc/free。所有内核对象内部都通过它们分配。用户也可直接调用它们分配应用数据,好处是线程安全(内部加了临界区/互斥),且能被 xPortGetFreeHeapSize 统计到。
heap_1(只分配不释放)
最简单:从一个静态数组里线性切分,不支持 free。绝无碎片、行为完全确定、代码最小。适合"启动时一次性创建所有对象、运行期永不删除"的安全关键系统。缺点是无法回收内存。
heap_2(支持释放但不合并)
支持 free,用最佳匹配(best-fit)算法分配,但不合并相邻空闲块,因此频繁分配释放不同大小的块会产生碎片。已被 heap_4 取代,新项目基本不用。
heap_3(包装标准 malloc)
简单包一层标准 C 库的 malloc/free,并加上 FreeRTOS 临界区保护使其线程安全。堆大小由链接脚本决定(configTOTAL_HEAP_SIZE 无效)。适合已依赖 libc 堆的场景,但失去了确定性。
heap_4(首次适配 + 合并)★推荐
最常用方案:首次适配(first-fit)分配,并合并相邻空闲块,有效抑制碎片。支持任意分配/释放,适合绝大多数需要动态创建/删除对象的应用。CubeMX 默认选它。
heap_5(多内存区 + 合并)
在 heap_4 基础上支持把多块不连续的内存区(如内部 SRAM + 外部 SDRAM)合成一个堆,用 vPortDefineHeapRegions() 初始化。适合内存分散、需要大堆的高端 MCU。
内存碎片 (Fragmentation)
反复分配释放不同大小的块后,空闲内存被切成许多小片,虽总量足够却没有一块连续区能满足大分配请求,导致 malloc 失败。嵌入式设备运行数月不重启,碎片是长期稳定性的头号敌人。对策:用 heap_4/5、尽量静态分配、用固定大小内存池、避免运行期频繁 malloc/free。
栈溢出 (Stack Overflow)
每个任务有独立且固定大小的栈,若函数调用太深、局部数组太大、或递归失控,栈指针越过栈底覆盖相邻内存,引发难以复现的诡异崩溃。FreeRTOS 提供 configCHECK_FOR_STACK_OVERFLOW(方法 1/2)在切换任务时检测,触发 vApplicationStackOverflowHook

五种堆方案速查

方案可释放合并空闲块碎片风险适用场景
heap_1启动全创建、永不删除;安全关键
heap_2已弃用,勿用于新项目
heap_3由 libc 决定取决于 libc复用标准 malloc
heap_4通用首选,动态增删对象
heap_5多块不连续内存合成大堆
选型口诀

不确定就选 heap_4。只在启动时创建、运行期绝不删除对象且追求极致确定性 → heap_1。需要跨内部 SRAM + 外部 SDRAM 的大堆 → heap_5。已深度依赖 C 库堆 → heap_3。heap_2 除维护老代码外不要用。把选中的那个 heap_x.c 加入工程即可,同时只能有一个。

内存碎片是怎么产生的

内存碎片示意(不合并空闲块时) ═══════════════════════════════════════════════════════════ 初始: ┌──────────────────────────────────────┐ │ 全部空闲 (16KB) │ └──────────────────────────────────────┘ 分配 A/B/C: ┌─────┬────────┬─────┬──────────────────┐ │ A │ B │ C │ 空闲 │ └─────┴────────┴─────┴──────────────────┘ 释放 A、C: ┌─────┬────────┬─────┬──────────────────┐ │空闲A│ B │空闲C│ 空闲 │ └─────┴────────┴─────┴──────────────────┘ ↑ 总空闲很多,但被 B 隔断成不连续小块 heap_4 合并:相邻空闲块自动合并,右侧仍是大连续块 heap_2 不合并:想分配一个比"空闲A/空闲C"都大的块 → 失败

动态分配:xTaskCreate + heap

/* FreeRTOSConfig.h */
#define configSUPPORT_DYNAMIC_ALLOCATION  1
#define configTOTAL_HEAP_SIZE   ((size_t)(20 * 1024))  /* 20KB 堆 */

/* 动态创建:TCB 和栈从 FreeRTOS 堆分配 */
TaskHandle_t h;
if (xTaskCreate(MyTask, "T", 256, NULL, 2, &h) != pdPASS) {
  /* 返回失败 = 堆不够,检查 configTOTAL_HEAP_SIZE */
}

/* 用户数据也可用 FreeRTOS 分配器(线程安全、可统计) */
uint8_t *buf = pvPortMalloc(128);
if (buf) {
  /* ... 使用 buf ... */
  vPortFree(buf);   /* 用完释放(heap_4/5 支持) */
}

静态分配:零堆、编译期确定

/* FreeRTOSConfig.h */
#define configSUPPORT_STATIC_ALLOCATION  1

/* 用户提供栈和 TCB 内存,运行期不碰堆 */
#define ST_WORDS 256
static StackType_t  s_stack[ST_WORDS];
static StaticTask_t s_tcb;

void setup_static(void) {
  TaskHandle_t h = xTaskCreateStatic(
      MyTask, "T", ST_WORDS, NULL, 2, s_stack, &s_tcb);
  /* 静态创建绝不会因堆不足失败,句柄非空 */
}

/* 启用静态分配时,还必须提供空闲任务和 Timer 任务的内存 */
void vApplicationGetIdleTaskMemory(StaticTask_t **ppxTCB,
        StackType_t **ppxStack, uint32_t *pulDepth) {
  static StaticTask_t idleTcb;
  static StackType_t  idleStack[configMINIMAL_STACK_SIZE];
  *ppxTCB   = &idleTcb;
  *ppxStack = idleStack;
  *pulDepth = configMINIMAL_STACK_SIZE;
}
动态 vs 静态怎么选

动态:灵活,可运行期增删对象,代码简洁,是大多数应用的默认;风险是堆碎片和 malloc 失败。静态:内存布局编译期完全确定,绝不会因堆耗尽失败,通过 MISRA/功能安全认证更容易;代价是要手写内存缓冲、不够灵活。安全关键(汽车、医疗)常强制静态;普通消费电子多用动态 + heap_4。两者可混用。

栈溢出检测

/* FreeRTOSConfig.h:方法 2 检测更可靠(检查栈末尾填充字节) */
#define configCHECK_FOR_STACK_OVERFLOW  2

/* 触发时内核回调此钩子;用于定位是哪个任务栈爆了 */
void vApplicationStackOverflowHook(TaskHandle_t xTask,
                                   char *pcTaskName) {
  (void) xTask;
  printf("STACK OVERFLOW in task: %s\r\n", pcTaskName);
  taskDISABLE_INTERRUPTS();
  for (;;) { }   /* 停在此处,配合调试器抓现场 */
}

/* 运行期查询任务栈"还剩多少字",用于调优栈大小 */
void check_stack(TaskHandle_t h) {
  UBaseType_t hw = uxTaskGetStackHighWaterMark(h);
  printf("min free stack = %lu words\r\n", (unsigned long)hw);
}

监控堆使用

/* 当前剩余堆(字节) */
size_t freeNow = xPortGetFreeHeapSize();

/* 历史最低剩余(水位线),判断最坏情况余量 */
size_t everMin = xPortGetMinimumEverFreeHeapSize();

printf("heap free=%u, ever-min=%u\r\n",
       (unsigned)freeNow, (unsigned)everMin);

/* malloc 失败钩子:需 configUSE_MALLOC_FAILED_HOOK = 1 */
void vApplicationMallocFailedHook(void) {
  printf("pvPortMalloc FAILED — heap exhausted!\r\n");
  for (;;) { }
}
栈深度单位再次提醒:字,不是字节

xTaskCreate 的栈深度和 uxTaskGetStackHighWaterMark 返回值单位都是字(word)。Cortex-M 上 1 字 = 4 字节,所以 256 是 1024 字节。若任务里用了 printf、浮点、或大局部数组,很容易吃掉几百字节,务必用高水位线实测后再留 20%~30% 余量,不要拍脑袋。

本章小结

FreeRTOS 自带独立于 libc 的堆分配器,创建内核对象时通过 pvPortMallocconfigTOTAL_HEAP_SIZE 大小的堆分配。五种方案:heap_1 只分配不释放、无碎片、最确定;heap_2 可释放但不合并(已弃用);heap_3 包装 libc malloc;heap_4 首次适配 + 合并空闲块、碎片低,是通用首选(CubeMX 默认);heap_5 支持多块不连续内存合成大堆。内存碎片是长期稳定性头号敌人,用 heap_4/5、静态分配、内存池、少运行期 malloc 来对抗。动态分配灵活但有碎片/失败风险;静态分配(configSUPPORT_STATIC_ALLOCATION + xTaskCreateStatic)编译期确定、绝不失败,安全关键领域首选。栈溢出configCHECK_FOR_STACK_OVERFLOW=2 检测并回调 vApplicationStackOverflowHook,用 uxTaskGetStackHighWaterMark 实测栈余量。用 xPortGetFreeHeapSize/xPortGetMinimumEverFreeHeapSize 监控堆,vApplicationMallocFailedHook 捕获耗尽。栈深度单位是字(×4 才是字节)。