)
平时编写 STM32 程序通常从main()开始。但芯片上电后需要先设置栈指针、找到程序入口、配置系统时钟并准备好 C 程序的运行环境才能执行main()。本文以STM32F407、Keil MDK 和标准外设库工程为例整理启动文件中的主要知识点。一、从上电到 main() 的完整过程在选择从内部 Flash 启动的情况下基本过程如下芯片上电或复位 ↓ CPU 通过启动地址映射读取向量表 ↓ 将向量表第一项加载到主栈指针 MSP ↓ 根据向量表第二项进入 Reset_Handler ↓ 调用 SystemInit()配置系统时钟等 ↓ 进入 C 运行库 __main准备 C 运行环境 ↓ 调用用户编写的 main()其中读取初始栈顶和复位入口是 CPU 复位硬件自动完成的。随后CPU 才开始执行Reset_Handler中的指令。二、CPU 为什么能找到启动代码启动文件经过汇编、链接后会和其他代码一起写入 Flash。CPU 运行时使用的是机器指令和数据源文件名只用于开发阶段。CPU 能找到启动代码是因为硬件与链接布局遵循同一个约定启动向量表的前两个 32 位数据分别保存初始栈顶和复位入口。假设程序从 Flash 起始地址0x08000000开始存放Flash 地址保存的内容0x08000000初始栈顶地址__initial_sp0x08000004复位入口Reset_Handler的地址后续地址其他异常和中断处理函数的地址每项占 4 字节因此第二项的地址比第一项多 4。STM32F407 从 Flash 启动时会把 Flash 映射到从0x00000000开始的启动地址空间。因此CPU 复位时读取0x00000000和0x00000004就能读到 Flash 中的这两项。地址映射不会把 Flash 内容复制一遍只是让同一块存储器可以通过另一个地址访问。一个具体例子假设编译后的内容如下地址仅用于说明存放位置内容含义0x080000000x20001000初始栈顶在 RAM 的0x200010000x080000040x08000201复位入口最低位 1 表示 Thumb 状态0x08000200启动代码的机器指令Reset_Handler的实际指令位置复位后CPU 自动完成读取第一项 → MSP 0x20001000 读取第二项 → 根据 0x08000201 设置执行状态 从 0x08000200 开始执行指令Cortex-M4 使用 Thumb 指令集因此入口地址值的最低位需要为 1。三、启动文件中的栈配置启动文件中常见以下代码Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp这段代码描述了一块大小为 1 KB 的栈区域。写法含义EQU定义常量作用类似 C 的#defineStack_Size栈大小0x400 1024字节AREA STACK定义名为STACK的段NOINIT该区域不要求启动时初始化内容READWRITE该区域可读可写ALIGN3按2³ 8字节对齐SPACE Stack_Size在该段中保留指定字节数Stack_Mem栈区域起点的标号__initial_sp栈区域末尾的标号用作初始栈顶这些是汇编器和链接器使用的定义。链接器会根据工程配置为这块区域安排 RAM 地址。栈在 RAM 中是什么样的假设这 1 KB 栈从0x20000000开始高地址 0x20000400 ← __initial_sp初始 MSP 0x200003FF ← 栈区域最后一个字节 ... 0x20000000 ← Stack_Mem栈区域第一个字节 低地址实际保留的范围是0x20000000 0x200003FF初始栈指针指向这块区域上边界的下一个地址0x20000400。STM32F407 使用向低地址增长的栈入栈时栈指针减小出栈时栈指针增大。栈有什么用栈主要用于保存函数调用时需要保留的寄存器和返回信息。需要放入内存的局部变量。部分函数参数。中断发生时的 CPU 现场。局部变量和函数参数是否使用栈还取决于编译器。有些值可以直接保存在寄存器中。例如一次普通中断进入时CPU 的基本异常栈帧包含 8 个寄存器值共 32 字节R0、R1、R2、R3、R12、LR、PC、xPSR假设没有额外对齐填充和浮点上下文中断前MSP 0x20000400 保存 32 字节 MSP 0x20000400 - 0x20 0x200003E0 中断返回恢复现场后 MSP 0x20000400中断处理函数自身还可能继续使用栈因此一次中断的总栈开销可能超过 32 字节。栈空间不够会怎样如果函数调用层次较深、局部数组较大或者中断嵌套较多栈就可能超过预留范围覆盖其他 RAM 数据造成异常结果或 HardFault。例如void test(void) { volatile unsigned char buffer[2048]; buffer[0] 1; buffer[2047] 2; }如果编译器为这个局部数组分配栈空间它本身就需要 2 KB已经超过示例中的 1 KB 栈。HardFault 有很多原因栈溢出只是其中一种。四、中断向量表是什么中断向量表可以理解为一张“入口地址表”。CPU 根据异常或中断编号从表中找到对应的处理函数。启动文件中的部分定义如下__Vectors DCD __initial_sp DCD Reset_Handler DCD NMI_Handler DCD HardFault_HandlerDCD用于定义 32 位数据。这里保存的是地址值。需要特别注意第一项保存的是初始栈顶地址。从第二项开始保存异常或中断处理函数的入口地址。这张表通常在编译、链接时就已生成并随程序烧录到 Flash。例如向量表中有一项DCD EXTI0_IRQHandler配置并使能相应的外部中断后CPU 响应 EXTI0 中断时就会通过该项找到处理函数。void EXTI0_IRQHandler(void) { /* 判断并清除中断标志然后处理事件 */ }向量表只提供入口地址。要让中断正常发生还需要配置外设、中断线路和 NVIC。五、Reset_Handler 做了什么当前工程的复位处理代码核心如下Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP执行顺序是Reset_Handler ↓ SystemInit() ↓ __main几个关键写法的含义写法含义IMPORT声明一个在其他地方定义的符号EXPORT允许其他模块引用该符号LDR R0, SystemInit把SystemInit的地址加载到 R0BLX R0调用 R0 指向的函数并保存返回地址BX R0跳转到 R0 指向的入口[WEAK]弱定义可以被同名的强定义覆盖这里的SystemInit()返回后会继续执行跳转到__main的代码。六、SystemInit() 怎样配置系统时钟STM32F407 复位后先使用内部HSI 16 MHz时钟运行因此 CPU 可以执行启动代码和SystemInit()。在当前标准外设库工程中SystemInit()会调用SetSysClock()主要完成使能外部高速振荡器 HSE。等待外部晶振起振并稳定。配置 PLL 的分频和倍频参数。使能 PLL并等待锁定。配置总线分频、Flash 等待周期等。将系统时钟切换到 PLL 输出。25 MHz 晶振如何得到 168 MHz当前板子的外部晶振为25 MHz工程配置为#define PLL_M 25 #define PLL_N 336 #define PLL_P 2对应计算25 MHz ÷ 25 1 MHz 1 MHz × 336 336 MHz 336 MHz ÷ 2 168 MHz即系统时钟 晶振频率 ÷ PLL_M × PLL_N ÷ PLL_P 25 MHz ÷ 25 × 336 ÷ 2 168 MHz随后分配给不同总线时钟当前配置SYSCLK168 MHzAHB / HCLK168 MHzAPB1 / PCLK142 MHzAPB2 / PCLK284 MHz这里列的是总线时钟。部分外设还会使用单独的分频或倍频规则例如定时器时钟不能始终直接等同于 APB 时钟。25 MHz 是软件设置出来的吗板上晶振的标称频率由硬件决定。软件使能振荡电路并配置后续 PLL 和分频器。例如#define HSE_VALUE 25000000这个宏告诉软件外部时钟频率是多少。修改它不会改变晶振本身的振荡频率。上述 168 MHz 配置对应当前标准外设库工程。其他工程可能在不同位置配置时钟例如某些 HAL 工程会在main()中调用SystemClock_Config()。