4.5 内存与资源管理
在资源紧凑的微控制器上开发多任务固件,内存往往是最脆弱的防线。在 STM32F103C8 这类片上 SRAM 仅有 20 KB 的芯片上,许多看似诡异的硬件故障背后,本质上都是内存越界的影子:新增一个任务后芯片开始偶发性复位重启;将通信接收缓冲略微扩大一倍,某个风马牛不相及的业务状态便被悄然篡改;又或是偶发的通信断流,耗费数天排查底层驱动后才发现是某个任务的私有栈悄然侵蚀了相邻任务的上下文。FreeRTOS 默认将各个任务的私有栈、TCB 控制块以及消息队列全部挤压在同一片物理 SRAM 中动态切分,任何一处未经审慎度量的分配,都在将系统推向溢出的边缘。
要在紧凑的硬件空间中维系固件的长期稳健,必须做到对每一字节 RAM 的去向心明眼亮。从静态编译期的符号排布剖析,到运行时动态堆分配器(heap_1 至 heap_5)的碎片代价;从基于编译期调用链与运行时高水位线的精准定栈,到杜绝碎片的固定块内存池设计,都是嵌入式架构师必须掌握的内功。本节将深入拆解 FreeRTOS 的内存空间布局与各类分配范式,并结合 STM32F103 缺乏硬件 MPU 隔离的物理现实,建立一套兼顾确定性与容错力的资源管理体系。
1. RAM 去哪了
STM32F103C8 有 20 KB SRAM,起始于 0x2000_0000。编译出的固件在 RAM 里占四类东西:有初值的全局对象、零初值的全局对象、主栈、FreeRTOS 堆。链接脚本(code/嵌入式/common/stm32f103c8.ld)把它们分别放进 .data、.bss 和 .noinit,主栈与堆的预留由链接脚本末尾的 ._user_heap_stack 段保证。
先看一个真实工程的分布。ram_map.c 是这样的固件:一个静态创建的采样任务(128 字栈)、一个动态创建的通信任务(256 字栈)、一个 256 字节的串口双缓冲区,外加一张 const 的滤波系数表(编译后留在 Flash,不进 RAM)。用 arm-none-eabi-gcc 编译链接之后:
$ arm-none-eabi-size ram_map.elf
text data bss dec hex filename
3784 20 11484 15288 3bb8 ram_map.elf2
3
data 的 20 字节是 16 字节的 g_accel_bias 加上内核自己的 uxCriticalNesting,bss 的 11484 字节才是大头。两个命令给出的 bss 不一样(11484 与 9944),因为简写的 size 把 .noinit 与 ._user_heap_stack 也算进了 bss 一栏:9944 + 0 + 1540 = 11484。size 只给总数,要看清每一项得用 size -A 与 nm --size-sort:
$ arm-none-eabi-size -A ram_map.elf
section size addr
.isr_vector 236 134217728
.text 3528 134217964
.rodata 20 134221492
.data 20 536870912
.bss 9944 536870932
.noinit 0 536880876
._user_heap_stack 1540 536880876
$ arm-none-eabi-nm --size-sort -S ram_map.elf | grep -E " [bB] " | tail -8
20000610 00000014 b xTasksWaitingTermination
20000014 0000005c b g_sensor_tcb
20000570 0000005c b xIdleTaskTCB.1
20000668 00000064 b pxReadyTasksLists
20000270 00000100 B g_uart_rx_buf
20000070 00000200 b g_sensor_stack
20000370 00000200 b uxIdleTaskStack.0
200006ec 00002000 b ucHeap2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
nm 的 -S 给出符号大小(十六进制),按地址从高到低排。ucHeap 是 FreeRTOS 的堆,这里是 0x2000 即 8 KB,也就是 configTOTAL_HEAP_SIZE 的值——它本身就是一个位于 .bss 的数组,不在任何“堆区”里,而是被链接器数进了 .bss。动态创建的任务栈与 TCB 都从 ucHeap 里切出来,所以它们不出现在 nm 的符号表里;静态创建的 g_sensor_stack(0x200 = 512 字节)与 g_sensor_tcb(0x5c = 92 字节)则以独立符号出现。
RAM 的分布可以画成这样:
| 区段 | 大小 | 内容 | 谁分配的 |
|---|---|---|---|
.data | 20 B | 有非零初值的全局对象(g_accel_bias 16 B、uxCriticalNesting 4 B) | 编译器与链接器 |
.bss 中的普通对象 | 约 1.7 KB | 零初值的全局对象、静态任务栈与 TCB、内核控制块 | 链接器 |
.bss 中的 ucHeap | 8 KB | FreeRTOS 堆(ucHeap 数组),动态任务栈、TCB、队列都在里面 | 链接器预留,内核切分 |
._user_heap_stack | 1540 B | 主栈 1 KB 加最小堆预留 0.5 KB | 链接脚本按 _Min_Stack_Size 与 _Min_Heap_Size 算出 |
主栈(MSP)是中断与调度器启动前使用的栈。复位时硬件从向量表首项装入 _estack,也就是 RAM 末尾;主栈从那里向下生长。._user_heap_stack 不存放任何内容,只把地址计数器往前推 1540 字节,让链接器在 RAM 放不下时报错:.data、.bss、.noinit 之后剩下的空间必须容得下 _Min_Stack_Size 加 _Min_Heap_Size。FreeRTOS 工程里这个最小堆用不到(堆是 ucHeap),但脚本不区分。调度器启动时 prvPortStartFirstTask() 会重新把 MSP 装回 _estack,main() 的栈帧随之作废——这一点在下一节展开。中断嵌套的深度决定主栈要用多大,1 KB 是链接脚本给的默认值,中断里调用函数多、局部对象大时要往上调。
先看 .map 再改代码
arm-none-eabi-size 给的是各类总数,-Wl,-Map= 生成的 .map 文件给出每个目标文件贡献了多少。RAM 不够时先查 map:是哪个模块的对象占了大头,是哪个库函数的常量表被拖了进来。凭感觉删代码往往删不到点子上。
1.1 主栈上的局部对象不能交给任务
main() 里的局部对象活在主栈上。调度器启动时 prvPortStartFirstTask() 把 MSP 重新装成 _estack,main() 的栈帧立刻失效,随后的中断又会从栈顶向下覆盖同一片存储;任务再通过指针访问那块地址,读到的已经是别人的数据。main_stack_bug.c 用 -DSHOW_BUG 编译出错误版本:
int main(void) {
app_cfg_t cfg = {115200u, 20u, 0x01u};
xTaskCreate(worker_task, "worker", 128, &cfg, 2, NULL);
vTaskStartScheduler();
for (;;) {
}
}2
3
4
5
6
7
int main(void) {
static const app_cfg_t cfg = {115200u, 20u, 0x01u};
xTaskCreate(worker_task, "worker", 128, (void *)&cfg, 2, NULL);
vTaskStartScheduler();
for (;;) {
}
}2
3
4
5
6
7
两段都能编译,运行时行为完全不同。错误版本里 cfg 在调度器启动后失效,任务读到的 baudrate 可能是任意值;正确版本里 cfg 位于 .data,整个程序运行期都有效。同一类错误还有把 main() 里的数组交给任务、把局部对象的地址存进全局指针等等,根源都是同一个:调度器启动前后,主栈已经被换掉了。
2. 堆方案
FreeRTOS 不直接调用 C 库的 malloc(),而是自带一份实现,放在 portable/MemMang/ 下,按文件名分成五种方案。构建时只编进其中一个,pvPortMalloc()/vPortFree() 就指向它。
| 文件 | 能否释放 | 会不会合并相邻空闲块 | 时间 | 适用 |
|---|---|---|---|---|
heap_1.c | 不能 | 不适用 | 分配是常数时间 | 初始化后不再创建/删除对象的系统 |
heap_2.c | 能 | 不合并 | 分配时间与块数有关 | 已不推荐,用 heap_4 代替 |
heap_3.c | 能 | 由 C 库决定 | 取决于 malloc | 需要 C 库的堆管理器,或要接上调试器观察 |
heap_4.c | 能 | 会合并 | 分配时间与块数有关 | 运行中会创建、删除任务的系统 |
heap_5.c | 能 | 会合并,可跨多段内存 | 同 heap_4 | RAM 分成几块不连续区域的芯片 |
五种方案的演化逻辑清晰对应着不同的工程权衡:
heap_1 遵循单向递增策略,仅支持分配而彻底不支持内存释放。其底层机制极为简明:每次申请内存时只需将堆内部的分配游标按需向前推进并完成对齐,因而具备常数级别的极致执行效率与最小的代码尺寸。由于分配出的内存永不回收,它天然免疫了动态内存碎片化,非常适合那些在系统初始化阶段完成全部任务、队列与信号量静态化创建后便维持拓扑不变的高可靠性产品。
heap_2 引入了空闲链表以支持释放操作,但它采用的是简单的最佳适应(Best Fit)算法且完全不合并相邻的连续空闲块。在经历交替分配与释放不同尺寸的对象后,堆空间会被切割为大量零散孤立的微小碎片,极易引发“总空闲内存充裕却无法满足单次大块分配”的尴尬局势。该方案目前仅作为历史遗产保留,现代工程已由更完善的方案全面取代。
heap_3 本质上是对底层 C 运行时库 malloc() 与 free() 的线程安全包装,它在进入与退出分配逻辑时临时挂起调度器以实现同步互斥。该方案通常用于需要借助特定平台内存分析或防越界检错工具介入调试的特殊场景。
heap_4 采用了带相邻空闲块就地合并(Coalescing)机制的首次适应(First Fit)算法,是当系统在生命周期内确实需要动态创建与销毁任务、队列时最稳健的通用方案。然而即便具备物理块合并机制,外部碎片依然是长周期运行中无法根除的潜在威胁:当长生命周期的小型对象交错嵌在不同区域时,大段连续的内存空间依然会被碎片化切断。
heap_frag_sim.c 把这个过程演示出来。程序交替申请 128 字节与 64 字节的块,一直占到堆满;然后释放全部 128 字节块,再申请一块 600 字节的连续内存:
[1. 初始:demo 任务已创建] 当前空闲 3888 B,历史最低空闲 3888 B
交替占满堆:共 17 对 (128 B + 64 B)
[2. 堆已占满] 当前空闲 80 B,历史最低空闲 0 B
[3. 释放全部 128 B 块] 当前空闲 2528 B,历史最低空闲 0 B
-> 申请 600 B 连续块: 失败(返回 NULL)
[4. 释放全部 64 B 块,相邻空闲块合并] 当前空闲 3888 B,历史最低空闲 0 B
-> 再次申请 600 B 连续块: 成功2
3
4
5
6
7
第 3 步之后空闲总量是 2528 字节,远大于 600,但剩下的空闲区都被 64 字节块隔成了一段段,最大的连续空闲段只有 128 字节出头(每次释放的 128 字节块,加上 heap_4 合并后的块头)。第 4 步释放长命块之后,heap_4 把相邻空闲块合并回一整块,同样的申请立刻成功。xPortGetMinimumEverFreeHeapSize() 在第 2 步跌到 0 之后一直保持 0:它记录的是历史最低点,不会随释放回升——这正是判断堆是否够用时该看的数,峰值需求看它,瞬时余量看 xPortGetFreeHeapSize()。
这个实验在 64 位 Windows 模拟器上跑,块头与 TCB 的尺寸都和 Cortex-M3 不同,所以绝对数字不能照搬到目标板上;要观察的是“空闲总量够、连续块不够”这个现象本身。
堆方案怎么选
初始化后不再创建删除对象的,用 heap_1 或干脆全静态;运行中确实要创建删除的,用 heap_4;RAM 分成几段不连续区域(比如片内 SRAM 加外部 SRAM)的,用 heap_5。无论选哪个,不要让 newlib 的 malloc() 与 FreeRTOS 的 pvPortMalloc() 同时管理同一片内存——两套分配器互相看不见对方占用的块,迟早撞车。
configTOTAL_HEAP_SIZE 决定 ucHeap 数组的大小。可以在启动后打印 xPortGetFreeHeapSize() 看还剩多少,用 xPortGetMinimumEverFreeHeapSize() 看历史最低点;configUSE_MALLOC_FAILED_HOOK 打开以后,pvPortMalloc() 返回 NULL 时会调用 vApplicationMallocFailedHook(),可以在那里点亮一个 LED 或记录一次故障(记录方式在 6.2 节)。
3. 静态分配
FreeRTOS 的另一种做法是根本不碰堆:任务栈、TCB、队列存储全部由应用程序声明成普通全局对象,创建函数只接收这些指针。打开 configSUPPORT_STATIC_ALLOCATION 之后,xTaskCreateStatic() 与 xQueueCreateStatic() 就可以用了。
/* 静态队列控制块与存储区 */
static StaticQueue_t g_sample_q_struct;
static uint8_t g_sample_q_storage[SAMPLE_QUEUE_LEN * sizeof(sample_t)];
/* 两个任务的静态栈与静态 TCB */
static StackType_t g_producer_stack[128];
static StaticTask_t g_producer_tcb;
static StackType_t g_consumer_stack[128];
static StaticTask_t g_consumer_tcb;2
3
4
5
6
7
8
9
10
创建时把这些对象交给内核:
g_sample_q = xQueueCreateStatic(SAMPLE_QUEUE_LEN, sizeof(sample_t),
g_sample_q_storage, &g_sample_q_struct);
xTaskCreateStatic(producer_task, "prod", 128, NULL, 3,
g_producer_stack, &g_producer_tcb);2
3
4
全静态之后,所有 RAM 开销在链接阶段就确定了,链接器能判断 20 KB 够不够,链接失败比运行时随机重启好得多。static_tasks.c 用 -DUSE_DYNAMIC_ALLOC=1 切换动态版,两个版本的目标构建对比:
$ arm-none-eabi-size static_tasks.elf
text data bss dec hex filename
4872 4 3812 8688 21f0 static_tasks.elf
$ arm-none-eabi-size static_tasks_dyn.elf
text data bss dec hex filename
5212 4 10628 15844 3de4 static_tasks_dyn.elf2
3
4
5
6
7
动态版的 .bss 多出 6816 字节,因为 configTOTAL_HEAP_SIZE 是 8 KB,整块 ucHeap 都算在 .bss 里;静态版没有 ucHeap,任务栈、TCB、队列存储各是独立的符号。两者的实际占用差多少要看 heap_4 的块头开销:动态版 8 KB 堆里真正被用掉的只是两个任务栈、两个 TCB 和一个队列,其余是空闲空间。全静态的意义不在于省绝对字节数,而在于把这些字节交给链接器核对。
空任务(idle task)与定时器服务任务(如果 configUSE_TIMERS 为 1)也需要内存。不给它们提供内存时,应用必须实现 vApplicationGetIdleTaskMemory() 与 vApplicationGetTimerTaskMemory();V11.3.0 提供了一个省事的开关 configKERNEL_PROVIDED_STATIC_MEMORY,设为 1 时由内核自己声明这两块静态存储:
void vApplicationGetIdleTaskMemory( StaticTask_t ** ppxIdleTaskTCBBuffer,
StackType_t ** ppxIdleTaskStackBuffer,
configSTACK_DEPTH_TYPE * puxIdleTaskStackSize )
{
static StaticTask_t xIdleTaskTCB;
static StackType_t uxIdleTaskStack[ configMINIMAL_STACK_SIZE ];
*ppxIdleTaskTCBBuffer = &( xIdleTaskTCB );
*ppxIdleTaskStackBuffer = &( uxIdleTaskStack[ 0 ] );
*puxIdleTaskStackSize = configMINIMAL_STACK_SIZE;
}2
3
4
5
6
7
8
9
10
11
这段来自 FreeRTOS-Kernel V11.3.0 的 tasks.c。打开这个开关以后,xIdleTaskTCB 与 uxIdleTaskStack 会以独立符号出现在 nm 的输出里,前文 ram_map 的输出中就有 xIdleTaskTCB.1 与 uxIdleTaskStack.0 两项,大小分别是 0x5c 与 0x200。
全静态的另一个好处是每个对象都有名字。栈溢出、越界写破坏到别人的内存时,nm 给出的地址能直接对上符号,不用猜是哪一块。
4. 任务栈多大
xTaskCreate() 的第三个参数是栈深度,单位是字(StackType_t 的个数),不是字节。Cortex-M3 上一个字是 4 字节,传 128 就是 512 字节。这个单位是初学 FreeRTOS 最容易记错的地方,按字节填的后果是栈缩水到四分之一。
栈深度以字为单位
xTaskCreate()、xTaskCreateStatic()、xQueueCreate() 的长度参数、configMINIMAL_STACK_SIZE,全部以 StackType_t 为单位。Cortex-M3 上 StackType_t 是 uint32_t,所以 128 表示 128 个字即 512 字节。uxTaskGetStackHighWaterMark() 返回的也是字。
在 Cortex-M3 上调用 xTaskCreate(task, "t", 256, NULL, 1, NULL),这个任务分到多大的栈?
栈要多大,静态分析加运行验证最可靠。第一步用 GCC 的 -fstack-usage 让编译器给出每个函数的栈帧大小:
$ arm-none-eabi-gcc -std=c11 -mcpu=cortex-m3 -mthumb -O2 -Wall -Wextra -fstack-usage -c stack_usage.c
$ cat stack_usage.su
stack_usage.c:20:9:sensor_read_filtered 16 static
stack_usage.c:37:6:control_task 32 static
stack_usage.c:50:6:telemetry_task 176 static2
3
4
5
.su 文件每行是“源文件:行:列:函数名 栈帧字节数 类型”。static 表示帧大小在编译时确定,dynamic 表示有变长数组或 alloca(),那种函数不能进中断也不能用在栈紧张的任务里。这里三个函数分别是 16、32、176 字节,176 的那个在栈上开了 128 字节的行缓冲并调用 snprintf()。
第二步是沿调用链求和。telemetry_task 自己的 176 字节里已经包含了它在栈上的 line[128],但它调用 sensor_read_filtered()(16 字节)与 snprintf()(newlib 里可能几百字节)。简单的估法是“自己的帧 + 最深那条调用链上所有被调函数的帧”,snprintf() 这类库函数必须算进去,它们的栈用量比应用代码大得多。
stack_usage.su 的完整内容
stack_usage.c:20:9:sensor_read_filtered 16 static
stack_usage.c:37:6:control_task 32 static
stack_usage.c:50:6:telemetry_task 176 static2
3
三行分别对应滤波函数、一个只做整数运算的控制任务、一个调用 snprintf() 的遥测任务。前两者加起来不到 50 字节,后者一个函数就 176 字节,加上它调用的库函数,栈需求会到几百字节。栈的大小取决于最深调用链,而不是任务里最大的那个局部数组。
telemetry_task 的完整代码如下,读者可以对照 .su 的输出看每一层是怎么累加的:
/* 任务 B:在栈上开 128 字节缓冲区并调用 snprintf 格式化报文 */
void telemetry_task(void *arg) {
(void)arg;
kalman1d_t kf = {0, 100, 4, 25};
uint32_t seq = 0;
for (;;) {
int32_t val = sensor_read_filtered(&kf);
char line[128];
int n = snprintf(line, sizeof line,
"{\"seq\":%lu,\"az\":%ld}\r\n",
(unsigned long)seq++, (long)val);
if (n > 0) {
uart_write_bytes(line, (size_t)n);
}
task_delay_ms(100);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
176 字节大致由这几项构成:kalman1d_t 的 16 字节、line[128] 的 128 字节,以及保存寄存器与对齐所需的二十来字节。line 是最大的一项,编译器把它放在栈帧的低地址处,sensor_read_filtered() 的 16 字节帧直接叠在它上面。
静态估算只给下限,实际用量要在目标板上验证。uxTaskGetStackHighWaterMark() 返回任务运行至今栈上剩余的最小字数,也就是“栈高水位线”:任务刚创建、还没运行过时它接近栈深度,运行一段时间后它给出历史最低点。任务跑过了最坏情况之后读这个值,就能判断栈是太大还是不够。
static void monitor_task(void *param) {
(void)param;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(5000));
UBaseType_t left = uxTaskGetStackHighWaterMark(g_telemetry_handle);
if (left < 32u) {
/* 余量不到 128 字节,记录一次告警(记录方式见 6.2) */
g_stack_low_count++;
}
}
}2
3
4
5
6
7
8
9
10
11
高水位线观测的有效性建立在一个关键前提之上:任务必须在真实或全负载工况下经历过最坏情况的极限压测,刚完成编写或浅层冒烟测试所得到的数字并不具备代表性。对于留取多少安全裕量,工程上通常遵循分级准则:对于纯整数运算、调用链平浅的控制任务,建议预留 20%~25% 的栈深作为弹性缓冲;而对于涉及多层中断嵌套触发、包含大量复杂格式化或第三方协议栈调用的任务,则建议将余量提升至 40%~50%,以吸收难以预估的栈帧峰值。
模拟器上的栈数字没有意义
Windows 移植层里每个“任务”实际运行在一个 Windows 线程上,栈由 Windows 分配,大小是默认的 MB 级。FreeRTOS 记录的那个栈缓冲区只用来放线程状态结构;uxTaskGetStackHighWaterMark() 与栈溢出检测因此测到的都不是真实用量。栈的结论只能来自目标构建的静态分析或板上运行。
要确定一个任务的栈大小,哪些做法能得到有效结论?
中断使用主栈,不是任务栈。中断里调用的函数有多深,主栈就要多深,这部分在链接脚本的 _Min_Stack_Size 里定(1.8 复位、启动与链接)。中断里调用 snprintf() 这种库函数是常见的主栈溢出原因。
5. 栈溢出检测
FreeRTOS 能检查任务栈是否溢出,打开 configCHECK_FOR_STACK_OVERFLOW 即可,取值 1 或 2:
- 方法 1:任务切换时检查栈指针是否越出任务栈的范围。开销最小,只能在切换那一刻发现已经越界。
- 方法 2:任务创建时把栈底填上固定的图案
0xA5,切换时检查最后 16 字节是否仍为这个图案。能更早发现溢出,代价是创建任务时多写一遍栈。
检测到溢出时调用 vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)。这个钩子的调用点在任务切换的路径上,不能阻塞;要打印可能又碰上栈已经坏了,能做的只是记录任务名、停机或者复位:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
(void)xTask;
/* 只记下名字与现场,记录方式在 6.2 节 */
g_overflow_task_name = pcTaskName;
for (;;) {
}
}2
3
4
5
6
7
检测不是保护。栈溢出发生的那一刻,被压坏的是相邻的另一块内存——可能是别的任务的栈,可能是队列的存储区,可能是 ucHeap 的块头。等切换时检查发现图案被改,破坏早已发生,程序后续的行为完全不可信。栈溢出检测的价值在于“至少能在下一次切换时停下来”,而不是“能在溢出前拦住”。
在模拟器上,这两项检测都没有意义,原因与高水位线相同:任务实际跑在 Windows 线程的栈上。配置里可以在 #ifdef __ARM_ARCH 里打开检测、在模拟器里关掉,code/嵌入式/4_RTOS与固件架构/4_5_内存与资源管理/FreeRTOSConfig_mem.h 就是这么写的。
6. 固定块内存池
即使选对了堆方案,pvPortMalloc() 与 vPortFree() 仍然有两个问题:分配时间随空闲块数量变化,以及碎片。实时系统里这两样都不想要。替代方案是固定块内存池(fixed-size block pool):初始化时把一段存储切成同样大小的块,分配与释放都只是链表操作,常数时间,永远不会碎片化。
本节实现一个约 80 行的 mem_pool。设计上有一个小技巧:空闲块串成单链表,但链表指针就存在块自己的头一个字里,所以除了块存储区以外不占任何管理内存。
struct mem_pool {
uint8_t *storage;
size_t block_size;
size_t block_count;
size_t free_count;
size_t free_head;
void (*lock)(void);
void (*unlock)(void);
};2
3
4
5
6
7
8
9
free_head 是链表头的块序号,MEM_POOL_NIL 表示链表结束。初始化时从后往前串:
void mem_pool_init(struct mem_pool *pool, void *storage, size_t block_size,
size_t block_count, void (*lock)(void), void (*unlock)(void)) {
size_t aligned = ALIGN_UP(block_size, sizeof(void *));
pool->storage = storage;
pool->block_size = aligned;
pool->block_count = block_count;
pool->lock = lock;
pool->unlock = unlock;
/* 从后往前串:第 0 块在表头,分配时先给出地址最低的块,便于观察 */
for (size_t i = block_count; i > 0u; --i) {
size_t *next = (size_t *)block_at(pool, i - 1u);
*next = (i == block_count) ? MEM_POOL_NIL : i;
}
pool->free_head = 0u;
pool->free_count = block_count;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
分配是取下表头,释放是插回表头:
void *mem_pool_alloc(struct mem_pool *pool) {
void *result = NULL;
if (pool->lock != NULL) {
pool->lock();
}
if (pool->free_head != MEM_POOL_NIL) {
size_t index = pool->free_head;
size_t *next = (size_t *)block_at(pool, index);
pool->free_head = *next;
pool->free_count--;
*next = MEM_POOL_NIL;
result = next;
}
if (pool->unlock != NULL) {
pool->unlock();
}
return result;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
lock/unlock 由调用者提供:RTOS 里传 taskENTER_CRITICAL 与 taskEXIT_CRITICAL(4.1 节讲的 BASEPRI 临界区),主机单元测试传 NULL。这样内存池本身不依赖 FreeRTOS,可以在 PC 上单独测。
mem_pool_free() 先检查指针是否属于本池、是否正好落在一个块的开头,不属于就返回 -1;定义 MEM_POOL_DEBUG 时再沿空闲链表查一遍,重复归还也返回 -1。这两项检查在正式版里可以关掉,换取更短的执行时间。
主机单元测试 test_mem_pool.c 覆盖了四种情况:
test_alloc_exhaust: 通过
test_free_then_alloc: 通过
test_bad_free: 通过
test_write_through: 通过
全部测试通过2
3
4
5
四个用例分别是:分配到耗尽(包括再取一次必须返回 NULL)、释放后再分配拿到同一块、拒绝池外指针与重复释放、写满一块不影响另一块。这类测试在 PC 上跑,与目标板的编译、执行环境无关,可以进 CI。
在 FreeRTOS 里使用的完整例子是 pool_sim.c:采样任务从池里取一块填数据,把指针放进队列;消费者任务取走指针、处理完再把块归还。发送方分配、接收方释放,所有权随消息转移,这条约定要写清楚,否则两边都以为自己该释放,或者都不释放。
[sampler] 分配块并发送 seq=0,池中剩余 3 块
[sampler] 分配块并发送 seq=1,池中剩余 2 块
[sampler] 分配块并发送 seq=2,池中剩余 1 块
[sampler] 分配块并发送 seq=3,池中剩余 0 块
[consumer] 收到 seq=0 az=998,已释放回池,剩余 1 块
[sampler] 分配块并发送 seq=4,池中剩余 0 块
[sampler] 池已空,丢弃本次采样
[consumer] 收到 seq=1 az=1006,已释放回池,剩余 1 块
[consumer] 收到 seq=2 az=1002,已释放回池,剩余 2 块
[consumer] 收到 seq=3 az=994,已释放回池,剩余 3 块
[consumer] 收到 seq=4 az=1010,已释放回池,剩余 4 块
[consumer] 结束:收到 5 帧,池中尚余 4 块2
3
4
5
6
7
8
9
10
11
12
消费者故意比生产者慢,池被耗尽时采样任务拿到 NULL,于是丢弃这一次采样。这比在队列里堆满指针、内存无限增长要好:上限由池的块数决定,运行时不会有“内存慢慢漏光”的现象。
void *payload = pvPortMalloc(len);
if (payload != NULL) {
xQueueSend(g_queue, &payload, portMAX_DELAY);
}
/* 何时释放、由谁释放,都靠约定 */2
3
4
5
frame_t *frame = mem_pool_alloc(&g_pool);
if (frame == NULL) {
/* 池满:丢弃本次采样或等待,行为明确 */
return;
}
frame->seq = seq;
xQueueSend(g_queue, &frame, portMAX_DELAY);2
3
4
5
6
7
左边的问题不在代码本身,而在于运行时行为不确定:分配可能失败也可能成功,耗时随碎片变化,最坏情况无法估计。右边把上限放在编译期就确定的位置,失败分支的含义也清楚。
池的块数按“生产速率 × 消费者最长停顿时间”定
块数少了会频繁丢数据,块数多了白占 RAM。估算方法是让最慢的消费者算一遍:它最长可能停顿多久(等 I2C 总线、等 Flash 擦除),这期间生产者能产出多少个样本,再加上几个余量。这个数与 4.3 节队列长度的估算方法是同一个。
7. 其他资源
RAM 之外,RTOS 里还有几类资源需要有人管:
- 外设与总线。串口、I²C 总线、SPI 总线都只能有一个使用者在同一时刻操作。用 4.3 的互斥量包住一次完整事务,或者设一个守门任务(gatekeeper task)独占外设、其他任务通过队列提请求。守门任务的做法在 4.3 节讲过,好处是去掉锁之后不会发生优先级反转(4.6 节展开)。
- C 库的可重入性。标准 C 运行时库(如 newlib)内置的
printf()与malloc()内部维护着全局共享的格式化缓冲区与堆元数据,多任务并发调用时极易相互踩踏。配置项configUSE_NEWLIB_REENTRANT虽可在任务切换时为各个任务分派私有的struct _reent结构体以化解冲突,但代价是每个任务都会额外背负数百字节的静态 RAM 负担,且浮点打印本身所需的栈空间极其惊人。在资源紧凑的嵌入式固件中,成熟的工程准则是:在应用任务中杜绝调用未经裁剪的浮点printf()与常规malloc();若需文本拼接,应使用snprintf()写入当前任务栈内预先划拨的短缓冲区;若确实存在动态数据块的流转需求,则应全额转向固定块内存池。 - 任务的删除。
vTaskDelete()只回收任务自身的栈与 TCB,不会归还任务持有的互斥量与内存池块。嵌入式系统里很少删除任务,一旦删了,就要先确认它手里的资源都已经放下。vTaskDelete(NULL)删除自己时尤其要小心:调用点之后的代码都不会执行,包括那些清理。
8. 没有存储器保护时
STM32F103C8 硬件上未集成存储器保护单元(MPU)。在通用移植层配置下,所有应用任务均以特权级执行,共享扁平的 4 GB 物理编址空间。任何一个任务内部偶发的指针越界写操作,都会直接涂改相邻任务的私有栈帧、覆盖内核的就绪链表节点、乃至篡改外设控制寄存器,而硬件层面不会产生任何访存违例异常中断。前述讨论的栈溢出、野指针与内存泄露,其破坏后果都会波及整个物理芯片。
配备 MPU 的高级微控制器(如部分高性能 Cortex-M4、Cortex-M7 或基于 Armv8-M 架构的 Cortex-M33)可以借助 FreeRTOS-MPU 移植层,将不同任务隔离在独立的内存保护域中,越界访存将立刻触发硬件级 MemManage 故障中断并终止违规任务。但在 F103 这类芯片上,系统缺乏硬件级的隔离屏障,工程防御的核心聚焦于尽早暴露与限制扩散:
- 推进全静态分配,将所有核心资源推移至编译链接期由链接器裁定,杜绝运行时的意外容量击穿;
- 常态化开启栈溢出检测机制,确保越界行为至少能够在相邻的上下文切换点被捕获;
- 在关键数据结构与缓冲区边界植入金丝雀校验字(Canary),在出入栈与出入池时校验完整性(具体实现见 6.2 节故障追踪);
- 部署独立硬件看门狗,在逻辑死锁或总线锁死时强制硬件复位重启(4.9 RTOS 固件架构、跟踪与测试 深入探讨监视任务与喂狗协同);
- 严格遵循基于值拷贝的队列消息通信,收敛跨任务共享裸指针的边界。
这套纵深防御思路的本质,是在缺乏硬件沙箱支撑的物理环境下,通过严密的静态约束与动态探针,把隐蔽的内存破坏转化为可感知的故障现场。
9. 习题
9.1 巩固
某固件用 arm-none-eabi-size 得到:
text data bss dec hex filename
5120 36 10300 15456 3c60 app.elf2
arm-none-eabi-size -A app.elf 又给出 .bss 为 8760、.noinit 为 0、._user_heap_stack 为 1540。
(1) 解释为什么简写的 size 把 bss 报成 10300,而 -A 报 8760。
(2) nm --size-sort -S 里 ucHeap 是 0x1000。这片内存在 -A 的哪一行里?它属于谁管理?
(3) 用 -A 的数字算:.data、.bss、.noinit、._user_heap_stack 从 0x2000_0000 起依次排开,20 KB SRAM 还剩多少字节没用?如果用简写 size 的 bss 一栏直接加,会算出什么错误结果?
(4) 若把 configTOTAL_HEAP_SIZE 从 4 KB 改到 6 KB,text、data、bss、剩余空间各怎么变?
在 Cortex-M3 上,某工程这样创建对象:
static StackType_t prod_stack[192];
static StaticTask_t prod_tcb;
static uint8_t q_storage[10 * sizeof(sample_t)]; /* sizeof(sample_t) == 12 */
static StaticQueue_t q_struct;
xTaskCreateStatic(producer_task, "prod", 192, NULL, 3, prod_stack, &prod_tcb);
g_q = xQueueCreateStatic(10, sizeof(sample_t), q_storage, &q_struct);2
3
4
5
6
7
(1) 这个任务的任务栈实际多少字节?队列存储区多少字节?
(2) 把这些对象改写成动态创建(xTaskCreate() 与 xQueueCreate()),写出调用;说明它们从哪片内存里切出来,以及为什么会多出块头开销。
(3) xQueueCreate(10, 4) 与 xQueueCreate(4, 10) 的区别是什么?uxQueueMessagesWaiting() 与 uxQueueSpacesAvailable() 的单位各是什么?
(4) configMINIMAL_STACK_SIZE 的单位是什么?在 Cortex-M3 上它的默认值通常够不够一个调用 printf() 的任务?
main_stack_bug.c 的错误版本把 main() 里的局部 cfg 交给任务。下面这个变体把局部对象藏在辅助函数里,看起来更隐蔽:
static app_cfg_t *make_cfg(void) {
app_cfg_t cfg = {115200u, 20u, 0x01u};
return &cfg;
}
int main(void) {
app_cfg_t *p = make_cfg();
xTaskCreate(worker_task, "worker", 128, p, 2, NULL);
vTaskStartScheduler();
for (;;) {
}
}2
3
4
5
6
7
8
9
10
11
12
(1) 这段代码用 -Wall -Wextra 编译会得到什么警告?如果只开 -Wno-return-local-addr 把它压掉,运行时 worker_task 读到的是什么?
(2) 说明“返回局部对象地址”和“把 main() 局部对象地址交给任务”这两个错误在生命周期上的差别,以及为什么本例比正文的错误版本更糟。
(3) 给出两种正确改法(static 对象、把配置复制进任务栈或 pvPortMalloc 的块),比较它们的 RAM 开销与适用场合。
(4) 另找一个同类错误:任务里把局部数组的地址通过队列发给别的任务。写出错误代码与改正版本。
9.2 编程
stack_usage.c 用 -fstack-usage 给出了三个函数的栈帧。自己写一个任务,回答“栈到底要开多大”。
(1) 定义一个含 int32_t ax, ay, az; uint32_t seq; 的 status_t。写 format_status(char *out, size_t cap, const status_t *s),内部用一个 char tmp[96] 和 snprintf();再写 read_sensor() 调用它。用 arm-none-eabi-gcc -fstack-usage 得到这三个函数的 .su 行。
(2) 按“最深调用链求和”的方法算出任务栈的下限,把 snprintf() 的栈用量按 300 字节计。写出算式。
(3) 取 25% 余量,把结果换算成 xTaskCreate() 的栈深度(字),写出调用。
(4) 在模拟器上跑这个任务,读 uxTaskGetStackHighWaterMark()。把结果与 (2) 的估算对比,说明这里的数字为什么不能作为定栈依据。
验收:提交 .su 文件内容、算式、xTaskCreate() 调用,以及一段说明估算值与模拟器数值差距来源的文字。
给 mem_pool 补测试。现有 test_mem_pool.c 覆盖了耗尽、释放后重分配、拒绝坏指针、块间隔离四种情况。
(1) 加一个用例 test_lifo_order:初始化 4 块的池,按顺序分配 A、B、C,释放 B 再释放 A,然后连续分配两次,验证拿到的顺序是 A、B(而不是 B、A)。说明这个顺序由 mem_pool_free() 的哪一行决定。
(2) 加一个用例 test_alignment:用 block_size = 13 初始化(存储区用 static size_t storage[...] 或 _Alignas 声明,保证起始地址对齐),分配一块,验证返回的指针按 sizeof(void *) 对齐、池报告的 block_size 是向上对齐后的值,且 mem_pool_free_count() 与块数一致。
(3) 加一个用例 test_all_free_after_stress:随机分配与释放 10000 次(用固定种子的 rand()),最后全部释放,验证 free_count == block_count。再跑一次同样的序列,验证结果可复现。
(4) 用 -DMEM_POOL_DEBUG 编一遍,重复 (3),说明调试版的耗时为什么明显更长,以及什么场合值得付这个代价。
验收:四个用例都打印“通过”,非调试版与调试版都能编译运行。
pool_sim.c 里采样任务分配块、消费者释放块。现有的发送用的是 portMAX_DELAY,队列永远不会满,所以“分配成功但送不出去”的路径被掩盖了。
(1) 把队列深度从 8 改成 2,把 xQueueSend() 的超时改成 0,去掉采样任务里 10 ms 的延时、把消费者延时长到 80 ms。运行程序,观察 mem_pool_free_count() 是否回到 4。找到“池块泄漏”的路径:mem_pool_alloc() 成功之后、xQueueSend() 返回 errQUEUE_FULL 时,块既不进队列也不归还。
(2) 修正发送侧:队列满时把块还回池并计入 g_dropped,保证程序结束时 mem_pool_free_count(&g_pool) == 4。
(3) 在消费者侧加一项检查:收到指针后先验证它属于本池。mem_pool_free() 不能用来看家(它会把块插回链表),自己写一个 bool mem_pool_owns(const struct mem_pool *pool, const void *block),复用 mem_pool.c 里 index_of() 的判断。说明为什么接收方需要这个检查。
(4) 把“谁分配谁释放”改成“谁接收谁释放”的所有权约定,写进代码注释,并说明不写清约定的两种后果(双方都释放、双方都不释放)分别会在第几次运行后暴露。
验收:改造后的 pool_sim 在第 (1) 问的设置下(队列深度 2、发送超时为 0、消费者 80 ms 取一次)运行到采样任务结束:结束时池中剩余 4 块,g_dropped 非零,没有一次双重释放。
configTOTAL_HEAP_SIZE 设得过大浪费 RAM,过小运行时随机失败。写一个工具判定期末值。
(1) 在模拟器上跑一个“回放”程序:按真实固件的顺序创建任务、队列、信号量,然后在 main() 里打印 xPortGetFreeHeapSize() 与 xPortGetMinimumEverFreeHeapSize()。
(2) 逐步减小 configTOTAL_HEAP_SIZE,记录“创建失败”之前的最后一个能工作的值,与 (1) 的最低空闲量比较。说明二者为什么相差一个块头加对齐的量级。
(3) 打开 configUSE_MALLOC_FAILED_HOOK,实现 vApplicationMallocFailedHook(),在其中记录失败次数并打印当前空闲量;验证它确实被调用。
(4) 模拟器是 64 位 Windows 构建,块头里的指针与对齐都是 8 字节,TaskHandle_t、TCB 与 size_t 也都比 Cortex-M3 上的宽;目标板是 32 位,对齐是 4 字节。逐项列出这两套尺寸的差别,说明为什么不能直接把模拟器上的数字当作目标板的 configTOTAL_HEAP_SIZE。
验收:给出从大到小二分搜索得到的最小可用值、对应的最低空闲量,以及两者之差。
第 7 节说 vTaskDelete() 只回收任务自己的栈与 TCB,不归还它持有的资源。用实验把这个漏掉的部分量出来。
(1) 写一个任务 job_task:启动时从 g_pool 取一块、拿一次 g_mutex、往 g_queue 里放一条消息,然后调用 vTaskDelete(NULL) 删除自己。主任务等它被删除后打印 mem_pool_free_count()、uxQueueMessagesWaiting() 与 xSemaphoreGetMutexHolder()(需要在配置里打开 INCLUDE_xSemaphoreGetMutexHolder)。
(2) 三个数各说明什么?其中哪一个能被别的任务观察到、哪两个完全查不出来?
(3) 在删除前加一个 job_cleanup(),按“消息、锁、块”的逆序归还,重跑并确认三个数恢复。说明清理函数为什么要放在 vTaskDelete(NULL) 之前、且不能写在它之后。
(4) 打开 configUSE_NEWLIB_REENTRANT,在模拟器上用 nm 或 sizeof 估算每个任务因此多出的 RAM;把这个开销与 job_task 的栈(128 字)比较,判断在 F103 的 20 KB 里值不值得开。
(5) printf() 与 malloc() 在多任务下的问题,各用一个具体现象描述;给出你在本页示例里会采用的替代写法。
验收:三个计数器在 (1) 与 (3) 两次运行中给出对照数值;(4) 给出字节数与占 20 KB 的百分比。
9.3 综合与挑战
把一个“动态 + 静态混合”的固件改成全静态。以 code/嵌入式/4_RTOS与固件架构/4_5_内存与资源管理/ram_map.c 为起点。
(1) 把其中的动态任务(栈与 TCB)和队列全部改成 xTaskCreateStatic() 与 xQueueCreateStatic(),用 -DUSE_DYNAMIC_ALLOC=0 之类的编译开关保留两版。
(2) 实现 vApplicationGetIdleTaskMemory() 与 vApplicationGetTimerTaskMemory()(后者在 configUSE_TIMERS == 1 时需要),或者打开 configKERNEL_PROVIDED_STATIC_MEMORY,两种做法各编一遍,用 nm 对照多出哪些符号。
(3) 用 arm-none-eabi-size -A 对比两版的 .bss,解释差值为什么不是简单的“动态版用掉的字节数”。
(4) 把链接脚本的 RAM LENGTH 临时改成 16K,验证全静态版链接失败的位置(哪个段溢出)与动态版失败的位置有何不同,说明哪一种失败更容易在开发阶段发现。
验收:两版都能编译链接;给出 size -A 与 nm 的对照,并指出全静态版里每个任务栈与 TCB 的符号名。
STM32F103 没有 MPU,栈溢出检测只能“事后发现”。设计一套在 F103 上可实施的内存破坏早发现方案。
(1) 开关 configCHECK_FOR_STACK_OVERFLOW = 2,解释它填的图案 0xA5 被写在栈的哪一端、任务切换时检查多少字节。
(2) 在 mem_pool 的每个块尾部加一个 4 字节校验字 0xC0DEC0DE,分配时写入、释放时校验。写一个越界写一字节的用例,验证 mem_pool_free() 能报出破坏。说明校验字为什么放在尾部而不是头部。
(3) 把校验字的做法推广到一个环形缓冲区与一个共享的结构体,写出通用的 canary_check() 与 canary_stamp(),并说明检查失败后能做什么、不能做什么。
(4) 给出一份“内存破坏排查顺序”:从栈高水位线、canary、nm 地址对照到 HardFault 现场,按先易后难排列,说明每一步能排除什么。
(5) 说明这套方案在什么情况下完全失效(破坏发生在检查之前且没有触发任何 canary),以及有 MPU 的芯片上应换成什么机制。
验收:canary 用例在 PC 上运行并通过;给出排查顺序表;答案里用 code/嵌入式/4_RTOS与固件架构/4_5_内存与资源管理/ 下的文件名指出你参考的示例。
10. 参考资料
- FreeRTOS, Mastering the FreeRTOS Real Time Kernel, ch03 Heap Memory Management、ch13 Troubleshooting:https://github.com/FreeRTOS/FreeRTOS-Kernel-Book
- FreeRTOS, Memory management:https://www.freertos.org/Documentation/02-Kernel/02-Kernel-features/09-Memory-management/01-Memory-management
- FreeRTOS, Stack usage and stack overflow checking:https://www.freertos.org/Documentation/02-Kernel/02-Kernel-features/09-Memory-management/02-Stack-usage-and-stack-overflow-checking
- FreeRTOS-Kernel V11.3.0,
include/task.h(xTaskCreateStatic、uxTaskGetStackHighWaterMark)、include/FreeRTOS.h(configKERNEL_PROVIDED_STATIC_MEMORY)、tasks.c:https://github.com/FreeRTOS/FreeRTOS-Kernel/tree/V11.3.0 - FreeRTOS-Kernel V11.3.0,
portable/MemMang/heap_1.c到heap_5.c:https://github.com/FreeRTOS/FreeRTOS-Kernel/tree/V11.3.0/portable/MemMang - FreeRTOS WIN32-MingW 演示
main.c(模拟器不检测栈溢出的说明):https://github.com/FreeRTOS/FreeRTOS/blob/main/FreeRTOS/Demo/WIN32-MingW/main.c - GCC, Developer Options(
-fstack-usage):https://gcc.gnu.org/onlinedocs/gcc/Developer-Options.html - ST, RM0008 Rev 21(存储器组织)与 DS5319 Rev 20(STM32F103x8/xB 存储器容量)