一、STM32 启动流程与 FreeRTOS 内存管理机制在嵌入式系统中程序的运行并不只是简单地从main()函数开始执行。对于 STM32 这类 Cortex-M 内核的微控制器而言程序从上电复位到进入用户代码需要经历启动地址映射、中断向量表读取、MSP 和 PC 初始化、.data段复制、.bss段清零等过程。同时程序运行过程中使用的全局变量、局部变量、堆、栈以及外设寄存器也分别位于不同的地址区域中。因此在学习 FreeRTOS 任务创建、任务栈、动态内存分配和上下文切换之前必须先理解 STM32 的基本内存划分和启动流程。本章首先介绍 STM32 的地址空间组成说明 Flash、RAM、System memory、启动别名区和外设地址区的作用随后分析裸机程序中的堆和栈解释局部变量、函数调用、中断现场和动态内存申请在 RAM 中的存放方式接着结合 MSP 和 PC 说明 STM32 上电复位后的启动流程最后引入 FreeRTOS 的内存管理机制重点说明 FreeRTOS heap、任务栈、TCB、动态创建任务和静态创建任务之间的关系。通过本章内容可以为后续理解 FreeRTOS 任务调度、任务切换、PendSV 上下文保存与恢复等机制打下基础。1.1 STM32的内存划分堆和栈是FreeRTOS中的重点在讲堆栈之前需要先了解一下STM32的内存划分。上图为STM32的内存划分示意图从图中可以看出STM32 的地址空间并不是一整块连续的普通内存而是由多个不同用途的地址区域组成包括启动别名区、Flash、System memory、RAM 以及外设地址区。Flash 主要用于存放程序运行前就已经确定且掉电不丢失的内容包括中断向量表、.text代码段、.rodata只读常量段以及.data段的初始值副本。其中.text段保存 CPU 执行的机器指令.rodata段保存字符串常量和只读数据.data初始值副本用于系统启动时初始化 RAM 中的.data段。Flash 中未被程序占用的部分则表现为未使用 Flash 空间。RAM 用于程序运行期间的可读写数据。系统启动时启动代码会将 Flash 中的.data初始值副本复制到 RAM 的.data段中使有初始值的全局变量和静态变量能够在运行时被修改同时启动代码会将.bss段清零用于存放未初始化或初始化为 0 的全局变量和静态变量。RAM 中剩余的空间可作为动态区使用。图中的 System memory 是 STM32 芯片内部厂家固化的系统存储区通常存放 ST 官方 BootLoader可用于串口、USB、CAN 等方式下载程序。外设地址区则用于映射 GPIO、USART、TIM、CAN、SPI、ADC 等外设寄存器CPU 通过读写这些固定地址来控制外设。整体来看Flash 主要负责保存程序和初始数据RAM 主要负责保存运行时变量、堆栈和 FreeRTOS 动态对象而外设地址区则用于通过内存映射方式访问硬件外设。1.2 裸机程序中的堆栈裸机程序在RAM中划分了两块区域 heap 堆空间和 stack 栈空间。堆主要用于动态内存申请栈主要用于函数调用、局部变量、返回地址以及中断现场保存。下面给出堆和栈的具体定义栈Stack是一种由系统自动管理的内存区域主要用于函数调用过程中的临时数据保存。程序每调用一次函数CPU 都会在栈中保存函数参数、局部变量、返回地址以及部分寄存器现场当函数执行结束后这部分栈空间会被自动释放。因此栈具有“自动分配、自动释放、访问速度快”的特点。对于 STM32 裸机程序而言栈通常位于 RAM 的高地址区域并向低地址方向增长。如果函数调用层级过深、局部数组过大或者中断嵌套过多就可能导致栈空间越界产生栈溢出进而引发 HardFault、程序跑飞或异常复位等问题。堆Heap是一种用于动态内存分配的内存区域主要用于程序运行过程中根据需要申请和释放内存。例如在 C 语言中调用malloc()时系统会从堆中分配一块指定大小的空间使用完成后需要通过free()主动释放。与栈不同堆的生命周期由程序员控制适合存放运行时大小不确定、生命周期较长的数据。但堆也存在明显风险例如内存申请后忘记释放会造成内存泄漏频繁申请和释放不同大小的内存可能产生内存碎片从而导致系统虽然仍有剩余 RAM但无法分配出一块足够大的连续空间。对于裸机程序而言heap 和 stack 一般位于 RAM 的剩余区域中。通常情况下heap 从低地址向高地址增长stack 从高地址向低地址增长。当二者不断扩展并发生重叠时就说明 RAM 空间已经不足此时可能发生堆栈溢出。因此在 STM32 裸机开发中应尽量避免在函数内部定义过大的局部数组也应谨慎使用malloc/free尤其是在实时性要求较高的控制系统中。1.3 STM32的启动流程在理解 Flash、RAM、堆和栈的关系后可以进一步说明 STM32 的启动流程。STM32 上电或复位后Cortex-M 内核并不是直接执行main()函数而是先根据中断向量表初始化两个非常关键的寄存器MSP和PC。其中MSP是 Main Stack Pointer即主栈指针用于指向主栈位置PC是 Program Counter即程序计数器用于指向 CPU 当前要执行的指令地址。在正常从 Flash 启动时STM32 会将0x00000000启动别名区映射到 Flash 的实际起始地址0x08000000。因此CPU 复位后访问0x00000000实际读取的是 Flash 中0x08000000处的内容。Flash 起始位置存放的是中断向量表其中前两个表项非常重要0x08000000初始 MSP 值0x08000004Reset_Handler 地址也就是说复位后 CPU 会先从向量表中读取第一个 32 位数据将其装载到 MSP 中作为系统启动时的主栈栈顶然后读取第二个 32 位数据将其装载到 PC 中使 CPU 跳转到Reset_Handler开始执行启动代码。Reset_Handler是 STM32 程序真正开始运行的入口它主要完成运行环境初始化。首先它会根据链接脚本中定义的地址将 Flash 中保存的.data初始值副本复制到 RAM 中的.data段。这样带初始值的全局变量和静态变量才能在运行时正常使用。接着Reset_Handler会将 RAM 中的.bss段清零。.bss段用于存放未初始化或初始化为 0 的全局变量和静态变量。这些变量在 C 语言标准中默认初始值为 0因此启动代码需要在进入main()前将.bss区域全部清零。完成.data复制和.bss清零后启动代码还会进行一些系统初始化例如设置系统时钟、初始化 FPU、调用 C 库初始化函数等。随后程序才会跳转到用户编写的main()函数。1.4 FreeRTOS的内存管理在引入 FreeRTOS 后堆和栈的概念会更加重要。FreeRTOS 中的栈不再只有裸机程序中的一个主栈而是每个任务都会拥有自己独立的任务栈。例如创建MotorTask、CanTask和UartTask后每个任务都会有自己的栈空间用于保存该任务的局部变量、函数调用信息以及任务切换时的上下文现场。任务运行时通常使用 PSP 作为任务栈指针而中断和异常仍然使用 MSP 主栈。因此FreeRTOS 中需要区分两类栈一类是 MSP 主栈主要用于中断、异常和系统启动阶段另一类是每个任务独立的 task stack用于任务自身运行。FreeRTOS 中的 heap 则主要用于动态创建内核对象。当使用xTaskCreate()动态创建任务时FreeRTOS 会从 FreeRTOS heap 中分配任务控制块 TCB 和任务栈当使用xQueueCreate()、xSemaphoreCreateBinary()、xEventGroupCreate()等接口时也会从 FreeRTOS heap 中分配队列、信号量、事件组等内核对象。因此FreeRTOS heap 本质上是 RAM 中由 FreeRTOS 管理的一块动态内存池并不是额外独立的硬件内存。FreeRTOS有五种内存管理方式以常见的heap_1.c、heap_2.c和heap_4.c为例FreeRTOS 默认使用一个名为ucHeap[configTOTAL_HEAP_SIZE]的全局静态数组作为内存池。由于该数组通常未显式初始化因此一般位于.bss段中。动态创建任务时任务的 TCB 和 task stack 会从这个ucHeap[]中分配出来。不同 heap 实现文件的区别在于内存管理策略不同heap_1.c只支持申请内存不支持释放heap_2.c支持释放但不会合并相邻空闲块容易产生碎片heap_4.c支持申请和释放并能合并相邻空闲块因此在 STM32 FreeRTOS 工程中较为常用。heap_3.c则直接调用 C 标准库的malloc/free使用的是 C heapheap_5.c可以通过vPortDefineHeapRegions()指定一块或多块 RAM 区域作为 FreeRTOS heap适用于芯片存在多块 SRAM 或外部 SDRAM 的情况。需要注意的是如果采用静态创建任务的方式例如xTaskCreateStatic()则任务栈和 TCB 不再从 FreeRTOS heap 中动态申请而是由用户提前定义。例如用户可以定义一个StaticTask_t结构体作为任务控制块再定义一个StackType_t数组作为任务栈然后将它们传入xTaskCreateStatic()。这种方式可以减少动态内存分配带来的不确定性更适合对安全性、可靠性和实时性要求较高的嵌入式系统。二、STM32的两类异常机制与FreeRTOS的上下文保存Cortex-M 的异常大体可以分为两类内核异常 / 系统异常、外部中断 / 外设中断。这两类异常都会使用中断向量表也都会经历“自动压栈、跳转 Handler、异常返回”的过程。但它们的来源和用途不同。2.1 内核异常内核异常是 Cortex-M 内核自身提供的异常机制不是由普通外设直接产生的。常见的内核异常包括Reset、NMI、HardFault、MemManage、BusFault、UsageFault、SVC、DebugMonitor、 PendSV、SysTick。其中不同 Cortex-M 内核支持的异常种类略有差异。例如 Cortex-M0/M0 的异常功能比 Cortex-M3/M4/M7 少一些部分 Fault 异常可能不存在或被合并到 HardFault 中。在 FreeRTOS 中最重要的系统异常主要有三个SysTick、PendSV、SVC。下面介绍一些比较重要的内核异常。Reset 异常Reset 是系统复位异常。STM32 上电或复位后CPU 首先从中断向量表读取两个值初始化 MSP 和 PC 值。然后CPU 开始执行Reset_Handler完成.data段复制、.bss段清零、系统时钟初始化等操作最后进入main()函数。和上一章描述的一样。HardFault 异常HardFault 是常见的错误异常。当程序出现严重错误时CPU 会进入HardFault_Handler()。常见触发原因包括访问非法地址、栈溢出、执行非法指令、中断向量表配置错误、函数指针跑飞、访问未对齐地址、外设寄存器访问错误。在 STM32 调试中如果程序莫名其妙卡死经常会发现最终进入了 HardFault。此时需要结合 PC、LR、栈内容和 Fault 状态寄存器分析错误来源。SVC异常全称是 Supervisor Call即系统服务调用异常。它可以由软件主动触发例如执行svc 0CPU 会进入SVC_Handler()。在 FreeRTOS 中SVC 常用于启动第一个任务。因为在调用vTaskStartScheduler()之前系统还处于main()的裸机执行流程中尚未真正进入任务调度状态。FreeRTOS 需要通过 SVC 进入异常处理流程设置 PSP并从第一个任务的栈中恢复现场使 CPU 开始执行第一个任务。SVC把系统从 main() 的裸机执行流带入 FreeRTOS 任务执行流。FreeRTOS 依靠 SVC 实现从裸机到操作系统内核的切换SysTick 异常SysTick 是 Cortex-M 内核自带的系统定时器异常依赖与系统的时钟。FreeRTOS 通常使用 SysTick 作为系统节拍来源。下列代码表示系统每 1 ms 产生一次 SysTick 中断。#define configTICK_RATE_HZ 1000SysTick 发生后FreeRTOS 会tick 计数加 1、检查延时任务是否到期、把到期任务从 Delayed 链表移回 Ready 链表、如果需要切换任务则设置 PendSV pending。SysTick 本身通常不直接完成任务切换而是请求 PendSV 执行任务切换。PendSV异常PendSV 是 FreeRTOS 上下文切换的核心。它的特点是可以被挂起并且通常被设置为最低优先级。当 SysTick 或外设中断发现需要进行任务切换时FreeRTOS 会设置 PendSV 的 pending 位。等当前中断执行完成并且没有更高优先级中断需要处理时CPU 才进入PendSV_Handler()。在进入 PendSV 或进入触发 PendSV 的异常时Cortex-M 硬件会自动将当前任务的R0-R3、R12、LR、PC、xPSR压入当前任务栈中。随后FreeRTOS 在 PendSV 中手动保存当前任务的R4-R11并将当前 PSP 保存到当前任务的TCB-pxTopOfStack中。接着调度器选择下一个要运行的任务并从新任务的TCB-pxTopOfStack中取出栈顶地址恢复新任务的R4-R11。最后在 PendSV 异常返回时硬件会根据新的 PSP从新任务栈中自动恢复R0-R3、R12、LR、PC、xPSRCPU 便从新任务之前被切出的地方继续执行。FreeRTOS 依靠 SysTick 提供系统节拍依靠 PendSV 完成任务上下文保存与切换2.2外部中断第二类异常是外部中断也可以称为外设中断。它们来自 STM32 的外设模块例如 GPIO、USART、TIM、CAN、ADC、DMA 等。外设中断的来源通常是具体的硬件事件例如 USART 接收到一个字节、定时器计数溢出、ADC 转换完成或 DMA 传输完成。以 USART 接收中断为例当 USART 接收到数据后外设内部会置位相应的接收标志位如果 USART 外设内部的接收中断使能同时 NVIC 中对应的 USART 中断通道也已使能USART 就会向 NVIC 发出中断请求。NVIC 会根据中断使能状态、挂起状态以及优先级判断 CPU 是否响应该中断。当 CPU 响应中断时Cortex-M 硬件会自动将当前上下文中的部分寄存器压入当前栈中然后根据中断向量表找到USARTx_IRQHandler的入口地址并跳转到该中断服务函数执行。在中断服务函数中程序通常需要读取接收数据寄存器并按要求清除相关中断标志。中断处理完成后CPU 执行异常返回硬件自动从栈中恢复之前保存的寄存器随后继续执行被中断打断前的程序流程。外部中断的调度和优先级管理主要由 NVIC 完成而具体中断事件的产生、状态标志和清除方式则由对应外设模块决定。2.3 异常优先级Cortex-M 支持异常优先级。优先级数字越小优先级越高。当一个低优先级中断正在执行时如果更高优先级中断到来高优先级中断可以打断低优先级中断这就是中断嵌套。在 FreeRTOS 中PendSV 通常被设置为最低优先级原因是任务切换不应该打断更紧急的外设中断。正确的顺序应该是先处理紧急外设中断、再统一进行任务切换。在FreeRTOS 中中断先执行任务切换后执行。无论是内核异常还是外部中断它们都会通过中断向量表找到对应的 Handler并在进入异常时由硬件自动保存部分现场在异常返回时自动恢复现场。FreeRTOS 正是利用这一机制实现任务调度SysTick 提供系统节拍SVC 用于启动第一个任务PendSV 用于执行上下文切换而外设中断则可以通过队列、信号量或任务通知唤醒对应任务。