内存管理¶
FreeRTOS 运行在资源受限的 MCU 上(如 STM32F103 只有 20KB SRAM),内存管理直接影响系统的稳定性。理解堆内存分配方案、合理配置栈大小、检测内存问题,是保障 RTOS 程序可靠运行的基础。
FreeRTOS 内存分区¶
RTOS 系统的 SRAM 被划分为以下几个区域:
graph TB
subgraph "SRAM 内存布局(从低地址到高地址)"
G["全局/静态变量<br>.data + .bss"]
H["FreeRTOS 堆<br>(TOTAL_HEAP_SIZE)<br>任务栈、TCB、队列、信号量<br>都从这里分配"]
MS["主栈<br>(MSP Stack)<br>main()、中断处理用"]
end
G --- H --- MS
| 区域 | 来源 | 用途 |
|---|---|---|
| 全局/静态变量 | .data + .bss 段 |
全局变量、static 变量 |
| FreeRTOS 堆 | configTOTAL_HEAP_SIZE |
任务栈、TCB、队列、信号量、定时器等 |
| 主栈 | 链接脚本定义 | main() 函数和所有中断处理程序 |
FreeRTOS 堆 ≠ C 标准库堆
FreeRTOS 有自己的内存管理,不使用 C 标准库的 malloc/free(除了 heap_3 方案)。pvPortMalloc() 和 vPortFree() 是 FreeRTOS 的内存分配/释放函数。
五种堆管理方案¶
FreeRTOS 提供了 5 种堆管理实现(heap_1 ~ heap_5),CubeMX 默认使用 heap_4。
heap_1:只分配不释放¶
分配前:|████████████████████████████████| 完整堆空间
分配后:|■■■TCB■■|■■栈■■■|■■TCB■■|■■栈■■■|████| 已用 | 剩余
↑ 只能往后分配
| 特点 | 说明 |
|---|---|
| 分配 | ✅ 简单高效 |
| 释放 | ❌ 不支持 |
| 碎片 | 无(因为不释放) |
| 适用场景 | 所有任务/队列在初始化时创建,运行期间不动态创建删除 |
heap_2:可释放但不合并¶
| 特点 | 说明 |
|---|---|
| 分配 | ✅ 支持 |
| 释放 | ✅ 支持 |
| 碎片 | ⚠️ 会产生(不合并相邻空闲块) |
| 适用场景 | 分配/释放的块大小固定 |
heap_3:封装 malloc/free¶
直接调用 C 标准库的 malloc() 和 free(),加上线程安全保护。
| 特点 | 说明 |
|---|---|
| 使用的堆 | C标准库堆(不是 configTOTAL_HEAP_SIZE) |
| 碎片 | 取决于标准库实现 |
| 适用场景 | 需要兼容已有代码、使用标准库内存管理 |
heap_4(默认推荐):可释放 + 合并碎片¶
| 特点 | 说明 |
|---|---|
| 分配 | ✅ 支持 |
| 释放 | ✅ 支持 |
| 碎片 | ✅ 自动合并相邻空闲块,大幅减少碎片 |
| 适用场景 | 绝大多数项目(CubeMX 默认选择) |
为什么推荐 heap_4?
- 支持动态创建/删除任务、队列等
- 自动合并碎片,内存利用率高
- 实现简单高效,适合 MCU
- CubeMX 默认方案,无需额外配置
heap_5:多内存区域¶
heap_4 的升级版,支持将不连续的内存区域合并为一个堆使用。
| 特点 | 说明 |
|---|---|
| 特殊能力 | 可以使用多块不连续的 SRAM(如 STM32F4 的 CCM SRAM) |
| 适用场景 | 芯片有多个 SRAM 区域,想全部用于 FreeRTOS 堆 |
方案对比总结¶
| 方案 | 分配 | 释放 | 碎片合并 | 多区域 | 推荐度 |
|---|---|---|---|---|---|
| heap_1 | ✅ | ❌ | - | ❌ | 极简系统 |
| heap_2 | ✅ | ✅ | ❌ | ❌ | 固定大小分配 |
| heap_3 | ✅ | ✅ | 取决于库 | ❌ | 兼容需求 |
| heap_4 | ✅ | ✅ | ✅ | ❌ | ⭐ 默认首选 |
| heap_5 | ✅ | ✅ | ✅ | ✅ | 多 SRAM 芯片 |
CubeMX 配置内存参数¶
关键配置项¶
在 Middleware → FREERTOS → Config parameters 中:
| 参数 | 默认值 | 说明 |
|---|---|---|
MEMORY_ALLOCATION |
Dynamic | 动态分配(使用堆) |
TOTAL_HEAP_SIZE |
15360 | FreeRTOS 堆大小(字节) |
Memory Management scheme |
heap_4 | 堆管理方案 |
CHECK_FOR_STACK_OVERFLOW |
Option1 或 Option2 | 栈溢出检测方式 |
INCLUDE_uxTaskGetStackHighWaterMark |
1 | 启用栈水位检测 |
堆大小怎么算?¶
FreeRTOS 堆需要容纳所有动态分配的对象:
| 对象 | 占用内存 | 计算公式 |
|---|---|---|
| 每个任务 | TCB + 栈 | 约 100 + StackSize×4 字节 |
| 每个队列 | 控制块 + 缓冲区 | 约 80 + QueueSize×ItemSize 字节 |
| 每个信号量 | 控制块 | 约 88 字节 |
| 每个互斥量 | 控制块 | 约 88 字节 |
| 每个定时器 | 控制块 | 约 48 字节 |
估算示例:
4个任务(栈各 256 Words)= 4 × (100 + 256×4) = 4496 字节
2个队列(大小10,每个4字节)= 2 × (80 + 10×4) = 240 字节
3个信号量 = 3 × 88 = 264 字节
1个互斥量 = 88 字节
1个定时器 = 48 字节
定时器守护任务 = 100 + 256×4 = 1124 字节
空闲任务 = 100 + 128×4 = 612 字节
─────────────────────────────────────
合计约:6872 字节 + 安全余量 ≈ 8~10KB
实际调优方法
- 先给一个较大的值(如 15360)
- 编译运行后调用
xPortGetFreeHeapSize()查看剩余堆 - 根据剩余量适当缩小,留 1~2KB 余量
- 同时用
xPortGetMinimumEverFreeHeapSize()查看历史最小剩余
栈空间管理¶
任务栈 vs 主栈¶
| 栈 | 用途 | 大小配置 |
|---|---|---|
| 任务栈 | 每个任务的局部变量、函数调用 | CubeMX 中每个任务单独配置(Stack Size) |
| 主栈(MSP) | main() 函数 + 所有中断处理 |
链接脚本中 _Min_Stack_Size(默认 0x400 = 1KB) |
中断也用主栈!
所有中断处理函数(HAL 回调)都使用主栈,不是任务栈。如果中断中使用大数组或调用复杂函数,需要增大主栈。
在 CubeMX 中修改:Project Manager → Linker Settings → Minimum Stack Size
栈溢出的后果¶
当任务栈空间不够用时:
正常: |──────任务栈空间──────|
[局部变量][调用链][...] [空闲]
溢出: |──────任务栈空间──────|
[局部变量][调用链][...][溢出数据→] → 写入其他内存区域!
↑ 可能破坏TCB、其他任务的栈、全局变量
后果:
- HardFault:最常见,系统直接崩溃
- 数据错误:破坏其他变量,症状随机且难以定位
- 看门狗复位:系统无响应,被看门狗重启
栈溢出检测¶
CubeMX 中 CHECK_FOR_STACK_OVERFLOW 提供两种检测方法:
每次任务切换时检查栈指针是否超出边界。
- ✅ 开销极小
- ❌ 如果溢出后又恢复(写了又被覆盖),可能检测不到
在栈底写入特殊标记值(0xA5A5A5A5),每次切换检查标记是否被破坏。
- ✅ 检测更可靠
- ❌ 开销稍大,但通常可以忽略
检测到溢出后会调用钩子函数:
void vApplicationStackOverflowHook(xTaskHandle xTask,
signed char *pcTaskName)
{
/* 打印出问题的任务名 */
printf("STACK OVERFLOW: %s\r\n", pcTaskName);
/* 方案1:死循环,方便调试器定位 */
while (1);
/* 方案2:记录错误并重启 */
// HAL_NVIC_SystemReset();
}
栈水位检测¶
主动检查任务栈的使用情况:
/* 获取任务栈历史最小剩余量(Words) */
void Monitor_Task(void *argument)
{
for (;;)
{
UBaseType_t wm;
wm = uxTaskGetStackHighWaterMark(LED_TaskHandle);
printf("LED_Task stack watermark: %lu words\r\n", wm);
wm = uxTaskGetStackHighWaterMark(UART_TaskHandle);
printf("UART_Task stack watermark: %lu words\r\n", wm);
wm = uxTaskGetStackHighWaterMark(ADC_TaskHandle);
printf("ADC_Task stack watermark: %lu words\r\n", wm);
osDelay(5000); // 每5秒检查一次
}
}
输出示例:
LED_Task stack watermark: 98 words ← 剩余充足,可缩小栈
UART_Task stack watermark: 32 words ← 还行
ADC_Task stack watermark: 8 words ← ⚠️ 危险!马上要溢出了!
安全余量建议
栈水位 ≥ 30 Words(120 字节)才算安全。如果接近 0,必须立即增大栈。
堆使用监控¶
查看堆内存状态¶
/* 在运行时查看堆内存使用情况 */
void PrintHeapInfo(void)
{
size_t free_heap = xPortGetFreeHeapSize();
size_t min_free = xPortGetMinimumEverFreeHeapSize();
printf("当前剩余堆: %u bytes\r\n", free_heap);
printf("历史最小剩余: %u bytes\r\n", min_free);
printf("已使用堆: %u bytes\r\n",
configTOTAL_HEAP_SIZE - free_heap);
printf("堆利用率: %.1f%%\r\n",
(1.0f - (float)free_heap / configTOTAL_HEAP_SIZE) * 100);
}
输出示例:
内存分配失败处理¶
当 pvPortMalloc() 返回 NULL 时,默认会调用 vApplicationMallocFailedHook():
void vApplicationMallocFailedHook(void)
{
/* 内存分配失败!通常是 TOTAL_HEAP_SIZE 不够 */
printf("Malloc failed! Free heap: %u\r\n",
xPortGetFreeHeapSize());
while (1);
}
在 CubeMX 中启用
Config parameters → USE_MALLOC_FAILED_HOOK = 1
动态分配 vs 静态分配¶
动态分配(默认)¶
任务、队列等从 FreeRTOS 堆中动态分配:
/* 动态分配(CubeMX 默认) */
osThreadId_t handle = osThreadNew(MyTask, NULL, &task_attr);
// 内存从 FreeRTOS 堆中分配
静态分配¶
预先在全局区定义好内存,不使用堆:
/* 静态分配:自己提供栈和TCB内存 */
StaticTask_t taskTCB;
uint32_t taskStack[256];
const osThreadAttr_t task_attr = {
.name = "StaticTask",
.cb_mem = &taskTCB, // 控制块内存
.cb_size = sizeof(taskTCB),
.stack_mem = taskStack, // 栈内存
.stack_size = sizeof(taskStack),
.priority = (osPriority_t) osPriorityNormal,
};
osThreadNew(MyTask, NULL, &task_attr);
| 对比 | 动态分配 | 静态分配 |
|---|---|---|
| 内存来源 | FreeRTOS 堆 | 全局变量/静态变量 |
| 灵活性 | 高(运行时创建/销毁) | 低(编译时确定) |
| 碎片风险 | 有 | 无 |
| 适用场景 | 一般项目 | 安全关键系统、内存紧张 |
| CubeMX 支持 | ✅ 默认 | ✅ Allocation 选 Static |
CubeMX 中切换分配方式
创建任务/队列时,Allocation 参数选择 Static 即可使用静态分配。CubeMX 会自动生成静态缓冲区。
内存优化技巧¶
1. 精确设置栈大小¶
不要给每个任务都分配 512 Words,按实际需求分配:
| 任务类型 | 推荐栈大小 |
|---|---|
| 简单 GPIO/LED | 128 Words(512B) |
| 串口通信 | 256 Words(1KB) |
| 使用 printf | 384+ Words(1.5KB+) |
| 浮点运算 | 256+ Words |
| 嵌套函数调用深 | 根据嵌套深度增加 |
2. 减少不必要的全局变量¶
// ❌ 浪费:大数组常驻内存
uint8_t big_buffer[1024]; // 即使只偶尔使用
// ✅ 改进:用 pvPortMalloc 按需分配
void SomeTask(void *argument)
{
for (;;)
{
if (need_big_buffer)
{
uint8_t *buf = pvPortMalloc(1024);
if (buf != NULL)
{
use_buffer(buf);
vPortFree(buf); // 用完释放
}
}
osDelay(1000);
}
}
3. 避免在栈上分配大数组¶
void BadTask(void *argument)
{
for (;;)
{
char buf[512]; // ❌ 占用大量栈空间
snprintf(buf, sizeof(buf), "...");
osDelay(100);
}
}
void GoodTask(void *argument)
{
static char buf[512]; // ✅ 改为 static,不占用栈
for (;;)
{
snprintf(buf, sizeof(buf), "...");
osDelay(100);
}
}
4. 合理设置 TOTAL_HEAP_SIZE¶
// 在初始化完成后打印堆信息,据此调整
void SystemInitDone(void)
{
printf("初始化完成,剩余堆: %u / %u bytes (%.1f%% free)\r\n",
xPortGetFreeHeapSize(),
configTOTAL_HEAP_SIZE,
(float)xPortGetFreeHeapSize() / configTOTAL_HEAP_SIZE * 100);
}
内存调试实战¶
完整的内存监控任务¶
/* 内存监控任务:定期输出系统资源状态 */
void Monitor_Task(void *argument)
{
char task_list[512];
for (;;)
{
printf("\r\n===== System Monitor =====\r\n");
/* 1. 堆内存状态 */
printf("[Heap] Free: %u B, Min: %u B, Used: %u B\r\n",
xPortGetFreeHeapSize(),
xPortGetMinimumEverFreeHeapSize(),
configTOTAL_HEAP_SIZE - xPortGetFreeHeapSize());
/* 2. 各任务栈水位 */
printf("[Stack Watermark]\r\n");
printf(" LED_Task: %lu words\r\n",
uxTaskGetStackHighWaterMark(LED_TaskHandle));
printf(" UART_Task: %lu words\r\n",
uxTaskGetStackHighWaterMark(UART_TaskHandle));
printf(" ADC_Task: %lu words\r\n",
uxTaskGetStackHighWaterMark(ADC_TaskHandle));
/* 3. 任务列表(需启用 configUSE_TRACE_FACILITY) */
vTaskList(task_list);
printf("[Task List]\r\n"
"Name State Prio Stack Num\r\n"
"%s\r\n", task_list);
/* 4. 运行时间统计(需启用 configGENERATE_RUN_TIME_STATS) */
// vTaskGetRunTimeStats(task_list);
// printf("[Runtime Stats]\r\n%s\r\n", task_list);
osDelay(10000); // 每10秒输出一次
}
}
常见问题¶
程序一启动就进 HardFault?
最常见原因:
- TOTAL_HEAP_SIZE 太大,超过了芯片实际 SRAM 大小。STM32F103C8T6 只有 20KB SRAM,去掉全局变量和主栈后,堆最多可能只剩 10~15KB
- 主栈太小,链接脚本中
_Min_Stack_Size不够 - 检查方法:在调试器中查看 SP(栈指针)是否超出范围
pvPortMalloc 返回 NULL 但堆还有空间?
这是内存碎片导致的。堆中虽然总剩余空间够,但没有连续的大块空间可用。
解决方案:
- 使用 heap_4(自动合并碎片)
- 统一分配大小,减少碎片
- 改用静态分配
如何确定芯片还剩多少 SRAM 可用?
编译后查看 .map 文件或编译输出:
RW-data + ZI-data = 已用的 SRAM(不含栈和堆)。用芯片总 SRAM 减去这个值,就是栈+堆可用的空间。
任务越多越好吗?
不是。每个任务需要:
- TCB:约 100 字节
- 栈:至少 128×4 = 512 字节
- 上下文切换开销
在 20KB SRAM 的芯片上,4~6 个任务是较合理的数量。