Skip to content

一个裸机程序到底是怎么占用内存的

上一节我们从系统架构出发,认识了 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 Overflow

3. 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

     MSP

FreeRTOS 开始调度任务以后,典型情况则是:

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 Stack

TCB:

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 / .bss

FreeRTOS:

text
多条Task执行流

每个Task独立Task Stack

TCB

FreeRTOS自己的动态内存管理

Queue / Semaphore / EventGroup等内核对象

所以:

RTOS 并没有创造一种新的物理内存。

它使用的依然是 STM32 原本拥有的:

text
Flash

SRAM

FreeRTOS 所做的事情,是在这些物理内存之上建立自己的:

text
任务管理

栈管理

动态内存管理

内核对象管理

这句话非常重要。

FreeRTOS Heap最终还是:

text
SRAM

Task Stack最终还是:

text
SRAM

TCB最终还是:

text
SRAM

RTOS只是改变了:

这些 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 = 0

CPU 又是怎么知道:

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 程序的。

FreeRTOS 内核与工程实践