切换主题
一个裸机程序到底是怎么占用内存的
上一节我们从系统架构出发,认识了 CPU 如何通过总线访问 Flash、SRAM 和外设,也初步理解了 Memory Map。
那么新的问题来了:
我们每天写下来的 C 程序,编译以后到底被放到了哪里?为什么有些东西在 Flash,有些东西在 SRAM?到了 FreeRTOS 以后,内存结构又发生了什么变化?
这一节我们先建立一个整体模型,不急着钻 FreeRTOS 内核源码。先把裸机和 RTOS 的内存关系搞清楚,后面再深入任务调度和上下文切换。
1. 从裸机程序的内存布局开始
我们先来看一个非常普通的程序:
c
uint32_t g_a = 100;
uint32_t g_b;
const uint32_t table[] = {1, 2, 3};
int main(void)
{
uint32_t temp = 10;
while (1)
{
}
}以前我们可能只是笼统地记:
text
Flash → 放程序 SRAM → 放变量这个理解没有错,但还远远不够。
一个典型裸机程序编译、链接完成以后,可以先建立下面这个模型。
Flash 中通常保存
text
Flash
│
├── 中断向量表
│
├── .text
│ └── 程序机器指令
│
├── .rodata
│ └── 只读常量
│
└── .data 的初始值镜像例如:
c
uint32_t g_a = 100;g_a 在程序真正运行的时候需要能够被修改,所以它最终运行在 SRAM。
但是 SRAM 断电后内容会丢失。
那么问题来了:
text
g_a第一次上电时为什么知道自己应该等于100?因此,100 这个初始值还需要在 Flash 中保存一份。
我们暂时把它理解成:
text
Flash
↓
保存 .data 的初始值镜像
上电初始化
↓
SRAM
↓
真正运行时的 .data至于这个复制过程到底是谁完成的,我们放到下一章专门研究。
SRAM 中通常保存
text
SRAM
│
├── .data
│ └── 已初始化的全局变量、static变量
│
├── .bss
│ └── 未显式初始化或初始化为0的全局/static变量
│
├── Heap
│ └── 动态申请的内存
│
└── Stack
└── 函数调用、局部变量、运行现场等例如刚才:
c
uint32_t g_a = 100;
uint32_t g_b;程序运行以后可以先理解成:
text
.data
└── g_a = 100
.bss
└── g_b = 0而:
c
uint32_t temp = 10;是普通自动局部变量。
从入门模型上看,它通常由:Stack / CPU寄存器 承担。
需要注意,实际编译结果会受到编译器优化影响,例如某些局部变量可能直接被放在寄存器里,甚至被优化掉。但是现阶段我们先不深入编译器优化。
2. Stack 到底是什么
Stack,也就是栈,是程序运行过程中非常重要的一块内存区域。
它通常承担:
text
函数调用产生的栈帧
局部变量
部分寄存器保存
函数调用现场
返回相关信息对于 Cortex-M 来说,栈通常向低地址方向增长。
可以简单想象:
text
高地址
┌────────────────────┐
│ │
│ Stack │
│ │
│ 函数A调用现场 │
│ 函数B调用现场 │
│ 局部变量 │
│ │
├────────────────────┤
│ │
└────────────────────┘
低地址
↑
SP其中:
text
SP
=
Stack Pointer
=
栈指针用于记录当前栈使用到了哪里。
栈的典型特点是:
text
由编译器/处理器按照调用规则自动使用
函数进入时可能消耗栈空间
函数返回后相应空间可以重新利用
访问速度快
容量有限
使用过深可能导致 Stack Overflow3. Heap 又是什么
Heap,也就是堆,主要用于:
运行时动态申请内存。
例如标准 C 语言中的:
c
malloc();
free();就是典型的堆操作。
与 Stack 不同,Heap 中某块内存什么时候申请、什么时候释放,由程序主动控制。
例如:
text
申请100 Byte
↓
使用
↓
释放动态内存虽然灵活,但工程中也需要考虑:
text
申请会不会失败?
执行时间是否足够确定?
长期申请、释放会不会产生碎片?
多线程环境下分配器是否线程安全?
有没有发生内存泄漏?所以不能简单说:
“动态内存绝对不能用于实时系统。”
更加准确的是:
通用 C 库 malloc/free 的实时性、碎片和线程安全行为不一定满足嵌入式实时系统的要求,因此 RTOS 往往会提供自己的内存管理方案。
这也正是 FreeRTOS 提供 heap_1.c ~ heap_5.c 的原因之一。
4. 从裸机进入 RTOS,最大的变化是什么
裸机程序通常只有一条主要执行流。
例如:
c
while (1)
{
Led_Task();
Key_Task();
Motor_Task();
}这里名字虽然叫:
text
Led_Task
Key_Task但从操作系统角度来说,它们还不是真正意义上的 Task。
本质上只是:
text
普通函数它们仍然运行在同一条执行流中。
到了 FreeRTOS:
text
LedTask
KeyTask
MotorTask才真正成为相对独立的任务。
CPU 会在这些任务之间切换执行。
这就出现了一个非常重要的问题:
LedTask 运行到一半被切走,过一段时间回来以后,怎么继续从之前的位置运行?
因此:
每个 Task 都需要自己的 Task Stack
它不仅承担:
text
任务函数的局部变量
任务内部的函数调用
任务运行现场还为任务切换时保存该任务的运行状态提供重要的内存基础。
我们现阶段先这样理解:
每个任务都是一条相对独立的执行流,因此必须拥有自己独立的运行栈,否则多个任务的函数调用和运行现场会互相干扰。
至于:
text
具体保存哪些寄存器?
PendSV怎么切换?
PSP怎么恢复?
TCB怎样保存栈顶?这些属于后面的 FreeRTOS 调度与上下文切换章节,这里暂时不展开。
5. Main Stack 和 Task Stack
这里再补一个 Cortex-M 的基础知识。
Cortex-M3 和 Cortex-M4 都具有两个栈指针:
text
MSP
Main Stack Pointer
PSP
Process Stack Pointer注意:
不是 M3 有 PSP、M4 没有。
STM32F1 常见的 Cortex-M3 和 STM32F4 使用的 Cortex-M4 都拥有 MSP 和 PSP。
在普通裸机程序中:
text
Reset_Handler
SystemInit
main()
普通函数调用默认情况下通常主要使用:
text
MSP指向 Main Stack。
因此可以先把裸机理解成:
text
程序主要执行流
↓
Main Stack
↑
MSPFreeRTOS 开始调度任务以后,典型情况则是:
text
Main Stack
↑
MSP主要承担:
text
启动阶段
异常
中断 Handler而:
text
当前运行Task
↓
Task Stack
↑
PSP因此这里一定要避免一个误区:
MSP 和 PSP 并不代表系统里只有两个栈。
FreeRTOS 系统实际上可能存在:
text
1 个 Main Stack
+
Task1 Stack
Task2 Stack
Task3 Stack
Task4 Stack
...只是 CPU 内核提供:
text
MSP
PSP两个栈指针。
PSP 在不同任务运行时,可以指向不同任务自己的 Task Stack。
现阶段理解到这里就够了。
6. Task 创建为什么需要内存
现在重新看:
c
xTaskCreate(...)就不能只把它理解成:
“创建一个函数。”
一个真正的 FreeRTOS Task 至少需要两类非常重要的数据:
text
TCB
+
Task StackTCB:
text
Task Control Block
任务控制块用于保存 FreeRTOS 管理这个任务所需要的信息。
例如:
text
优先级
任务状态相关信息
任务栈位置
任务管理数据具体内部结构后面再深入。
Task Stack:
text
这个任务自己的运行栈所以从内存角度看:
text
创建一个Task
↓
准备TCB
+
准备Task Stack这才是 Task 创建的基本代价。
7. FreeRTOS Heap 到底是什么
这里非常容易和:
text
C Runtime Heap混淆。
FreeRTOS 可以拥有自己独立的动态内存管理区域。
以常见的 heap_4.c 为例,FreeRTOS 内部可以定义类似:
c
static uint8_t ucHeap[configTOTAL_HEAP_SIZE];先看最重要的一点:
ucHeap[] 不是 Stack。
它是一个具有静态存储期的大数组。
通常:
text
ucHeap[]
↓
位于 .bss
↓
物理上占用 SRAM可以先建立:
text
SRAM
├── .data
│
├── .bss
│ ├── 普通全局/static变量
│ │
│ └── ucHeap[]
│
├── Main Stack
│
└── 其他区域那么为什么:
c
static uint8_t ucHeap[];明明是一个静态数组,却叫:
text
FreeRTOS Heap原因并不是:
“数组本身属于堆。”
而是:
FreeRTOS把这整块提前保留好的数组当作自己的动态内存池,在里面进行申请、释放和重新利用。
例如:
text
ucHeap[]
┌──────────────────────────┐
│ Task1 TCB │
├──────────────────────────┤
│ Task1 Stack │
├──────────────────────────┤
│ Queue │
├──────────────────────────┤
│ Free Block │
├──────────────────────────┤
│ Task2 TCB │
├──────────────────────────┤
│ Task2 Stack │
├──────────────────────────┤
│ Free Block │
└──────────────────────────┘所以应该这样理解:
text
ucHeap[]本体
↓
静态分配
↓
通常位于.bss
ucHeap[]内部空间
↓
由FreeRTOS动态管理
↓
表现为Heap这和 C 库自己的 Heap 不是同一个概念。
8. C Heap、FreeRTOS Heap、Stack 不要混
到这里一定要把三个东西彻底分开:
text
SRAM
┌─────────┼─────────┐
↓ ↓ ↓
C Heap FreeRTOS Stack
Heap
malloc() ucHeap[] 函数调用
free() 管理区域 局部变量
运行现场尤其注意:
text
static数组
≠
Stack例如:
c
static uint8_t ucHeap[10000];因为它具有静态存储期,所以通常进入:
text
.bss而不是函数运行时的 Stack。
9. FreeRTOS 的 heap_1 ~ heap_5
FreeRTOS 官方提供了五种典型内存管理实现:
text
heap_1.c
heap_2.c
heap_3.c
heap_4.c
heap_5.c它们不是五块不同的内存。
而是:
五种不同的内存分配实现策略。
heap_1.c
特点:
text
只允许申请
不支持释放实现简单、行为非常确定。
适合:
系统启动阶段一次性创建完所有对象,以后再也不删除的场景。
heap_2.c
支持:
text
申请
释放但是对空闲块的合并能力有限。
长期频繁申请释放时更容易产生碎片问题。
现在新项目通常不会优先选择它。
heap_3.c
这是比较特殊的一种。
它并不自己维护 ucHeap[],而是直接包装:
c
malloc()
free()也就是说:
text
FreeRTOS申请
↓
最终调用C库Heap所以它的行为会受到:
text
C库malloc/free实现
线程安全
碎片
执行时间等因素影响。
因此在很多嵌入式实时项目中不会作为首选。
heap_4.c
这是非常常用的一种。
它通常使用:
c
ucHeap[]作为 FreeRTOS 自己的内存池。
支持:
text
pvPortMalloc()
vPortFree()并且能够:
合并相邻的空闲内存块。
因此相比 heap_2,可以降低长期运行过程中产生严重外部碎片的风险。
我们后续主要以:
text
heap_4.c作为学习对象。
heap_5.c
heap_5 和 heap_4 的分配算法思想比较接近。
最大的不同是:
heap_5 可以管理多块不连续的内存区域。
例如某些 MCU:
text
SRAM1
SRAM2
外部RAM之间不是连续地址。
heap_5 可以通过配置多个区域,把这些内存共同交给 FreeRTOS 管理。
这一部分后续再深入。
10. 动态创建 Task
例如:
c
xTaskCreate(
Task1,
"Task1",
128,
NULL,
1,
&Task1Handle
);如果使用 FreeRTOS 动态分配方式:
text
xTaskCreate()
↓
从FreeRTOS Heap申请内存
↓
Task TCB
+
Task Stack也就是说:
text
FreeRTOS Heap
├── Task1 TCB
└── Task1 Stack都是运行时由内核申请的。
动态创建的优点:
text
使用方便
运行时可以灵活创建和删除对象
不需要用户提前准备每一块内存但是需要考虑:
text
Heap是否足够?
申请是否失败?
长期运行是否存在碎片风险?
动态申请行为是否满足系统实时性要求?11. 静态创建 Task
FreeRTOS 同样支持:
c
xTaskCreateStatic();这种方式不是让 FreeRTOS 自己从 Heap 找:
text
TCB
Task Stack而是:
用户提前把需要的内存准备好。
例如:
c
static StaticTask_t Task1_TCB;
static StackType_t Task1_Stack[128];
TaskHandle_t Task1Handle;
Task1Handle = xTaskCreateStatic(
Task1,
"Task1",
128,
NULL,
1,
Task1_Stack,
&Task1_TCB
);这里:
text
Task1_TCB
Task1_Stack[]都是提前静态分配好的。
因此创建 Task 时:
text
不需要再从FreeRTOS Heap中为TCB和Task Stack申请空间优点:
text
所需内存提前确定
不会因为FreeRTOS Heap不足导致任务创建失败
内存占用更容易分析
适合对确定性和可靠性要求较高的系统代价则是:
text
用户需要自己准备和管理这些内存对象
灵活性比动态创建低12. 动态创建和静态创建真正的区别
不要把区别理解成:
text
动态 = 有Task Stack
静态 = 没Task Stack两种方式都必须有:
text
TCB
Task Stack真正区别只是:
这块内存由谁提供
动态创建:
text
FreeRTOS Heap
↓
内核自己申请静态创建:
text
用户定义的静态变量
↓
直接提供给FreeRTOS所以:
text
动态创建
=
内核帮你找内存
静态创建
=
你提前把内存准备好13. FreeRTOS Heap 中到底可能有什么
如果对象采用动态创建,那么 FreeRTOS Heap 中可能存在:
text
Task TCB
Task Stack
Queue控制信息
Queue数据存储区
Semaphore相关控制结构
EventGroup控制结构
Software Timer相关结构
其他动态创建的内核对象但是这里要特别注意:
不是这些对象永远都在 FreeRTOS Heap。
如果使用对应的:
text
Static API例如:
c
xTaskCreateStatic()
xQueueCreateStatic()那么相关内存可能由用户提前提供,而不是从 FreeRTOS Heap 动态申请。
所以更加准确地说:
FreeRTOS Heap 存放的是那些通过 FreeRTOS 动态内存接口创建出来的对象所占用的内存。
14. 任务删除以后,内存会发生什么
例如:
c
vTaskDelete(Task1Handle);如果 Task1 是通过:
c
xTaskCreate()动态创建的,那么它的:
text
TCB
Task Stack最终需要归还给 FreeRTOS Heap。
但是这里有一个很重要的细节:
被删除任务的动态内存清理,可能需要 Idle Task 完成。
因此如果系统中存在一个高优先级任务:
c
while (1)
{
// 永远运行
// 不delay
// 不阻塞
// 不主动让出CPU
}并且它长期阻止 Idle Task 获得运行机会,就可能影响删除任务资源的及时回收。
所以不要写成:
“高优先级任务不能有死循环。”
因为绝大多数 RTOS Task 本身就是:
c
while (1)
{
}真正的问题是:
高优先级 Task 不能在无限循环中始终保持 Ready/Running,而完全没有阻塞、延时或等待事件的机会。
例如更加合理:
c
while (1)
{
WaitForMessage();
ProcessMessage();
}或者:
c
while (1)
{
DoSomething();
vTaskDelay(...);
}15. Heap 不足怎么办
动态创建对象需要 FreeRTOS Heap。
例如:
c
xTaskCreate();如果无法得到足够的内存,就可能创建失败。
常见原因:
text
configTOTAL_HEAP_SIZE太小
某些Task Stack设置过大
动态创建Task过多
Queue等对象创建过多
存在内存泄漏
长期运行造成内存利用效率下降可以使用:
c
xPortGetFreeHeapSize();查看当前剩余 FreeRTOS Heap。
还可以使用:
c
xPortGetMinimumEverFreeHeapSize();观察:
系统运行以来,FreeRTOS Heap 曾经最少还剩多少。
这个值对于评估:
text
configTOTAL_HEAP_SIZE到底配置得合不合理非常有帮助。
16. FreeRTOS 内存申请失败 Hook
如果:
c
pvPortMalloc()申请 FreeRTOS 内存失败,并且配置:
c
configUSE_MALLOC_FAILED_HOOK = 1那么内核可以调用:
c
vApplicationMallocFailedHook();我们可以自己实现这个 Hook。
例如:
c
void vApplicationMallocFailedHook(void)
{
// 记录日志
// 点亮错误LED
// 进入断言
}这样系统发生:
text
FreeRTOS Heap不足时,我们能够第一时间定位。
注意:
这里主要针对 FreeRTOS 的动态内存申请机制,并不能简单理解成“所有普通 C malloc 失败都会自动进这个 Hook”。
17. Task Stack 不够又会怎样
这和 Heap 不足是两种不同的问题。
Heap不足:
text
FreeRTOS没有足够空间创建对象Task Stack溢出:
text
任务已经创建成功
但运行过程中自己的Task Stack不够用了例如某个 Task:
text
局部数组非常大
函数调用层级很深
使用printf等高栈消耗函数
算法临时变量过多就可能导致:
text
Task Stack Overflow可以使用:
c
uxTaskGetStackHighWaterMark();观察任务历史运行过程中:
Task Stack 曾经最少还剩多少空间。
因此:
text
xPortGetFreeHeapSize()关注的是:
FreeRTOS 整体动态内存池。
而:
text
uxTaskGetStackHighWaterMark()关注的是:
某一个具体 Task 自己的栈空间。
两者不要混淆。
18. 到这里,我们应该形成什么内存模型
先忽略具体地址,建立逻辑模型:
text
STM32 SRAM
┌────────────────────────────────┐
│ .data │
│ 已初始化全局/static变量 │
├────────────────────────────────┤
│ .bss │
│ 未初始化全局/static变量 │
│ │
│ ucHeap[] 可能也在这里 │
├────────────────────────────────┤
│ │
│ FreeRTOS ucHeap[]内部 │
│ │
│ Task1 TCB │
│ Task1 Stack │
│ │
│ Task2 TCB │
│ Task2 Stack │
│ │
│ Queue / Semaphore / Event... │
│ │
│ Free Block │
├────────────────────────────────┤
│ │
│ Main Stack │
│ ↑ │
│ MSP │
│ │
└────────────────────────────────┘其中:
text
PSP在 FreeRTOS 任务运行时,可以指向:
text
Task1 Stack
或者
Task2 Stack
或者
其他当前正在运行Task的Stack需要再次强调:
上面只是逻辑模型,真实 SRAM 中各区域的具体地址和排列由链接脚本、编译器和运行时内存分配共同决定,不能把这张图理解成固定物理顺序。
19. 裸机和 FreeRTOS 到底发生了什么变化
现在我们可以把这一节最重要的变化浓缩出来。
裸机:
text
一条主要执行流
Main Stack
普通C Heap
.data / .bssFreeRTOS:
text
多条Task执行流
每个Task独立Task Stack
TCB
FreeRTOS自己的动态内存管理
Queue / Semaphore / EventGroup等内核对象所以:
RTOS 并没有创造一种新的物理内存。
它使用的依然是 STM32 原本拥有的:
text
Flash
SRAMFreeRTOS 所做的事情,是在这些物理内存之上建立自己的:
text
任务管理
栈管理
动态内存管理
内核对象管理这句话非常重要。
FreeRTOS Heap最终还是:
text
SRAMTask Stack最终还是:
text
SRAMTCB最终还是:
text
SRAMRTOS只是改变了:
这些 SRAM 被如何组织和管理。
20. 下一章:这些内存是怎么“活起来”的
到这里还有一个非常关键的问题没有回答。
我们现在已经知道:
text
程序代码通常在Flash
.data运行在SRAM
.bss运行在SRAM
Stack位于SRAM
FreeRTOS Heap最终也位于SRAM但新的问题来了:
c
uint32_t g_a = 100;SRAM明明断电以后什么都留不住。
那么:
STM32刚刚上电时,SRAM中的 g_a 为什么会自动变成100?
还有:
c
uint32_t g_b;为什么进入 main() 以后:
text
g_b = 0CPU 又是怎么知道:
text
Stack从哪里开始?以及:
CPU上电以后为什么没有直接进入
main()?
这些问题已经不能只靠:
text
Flash存程序
SRAM存变量来解释了。
所以下一章我们会继续向下追:
从上电到 main():Flash、SRAM 与启动流程
我们会把:
text
Flash为什么能掉电保存数据
SRAM为什么速度快但掉电丢失
Vector Table
初始MSP
Reset_Handler
SystemInit
.data初始值镜像
Flash → SRAM复制
.bss清零
main()完整串成一条时间线。
到那时,我们才算真正理解:
一个编译完成、躺在 Flash 里的程序,是怎么在 STM32 上电以后一步一步变成一个正在 SRAM 中运行的 C 程序的。