平时编写 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_sp |
0x08000004 | 复位入口Reset_Handler的地址 |
| 后续地址 | 其他异常和中断处理函数的地址 |
每项占 4 字节,因此第二项的地址比第一项多 4。
STM32F407 从 Flash 启动时,会把 Flash 映射到从0x00000000开始的启动地址空间。因此,CPU 复位时读取0x00000000和0x00000004,就能读到 Flash 中的这两项。
地址映射不会把 Flash 内容复制一遍,只是让同一块存储器可以通过另一个地址访问。
一个具体例子
假设编译后的内容如下,地址仅用于说明:
| 存放位置 | 内容 | 含义 |
|---|---|---|
0x08000000 | 0x20001000 | 初始栈顶在 RAM 的0x20001000 |
0x08000004 | 0x08000201 | 复位入口,最低位 1 表示 Thumb 状态 |
0x08000200 | 启动代码的机器指令 | Reset_Handler的实际指令位置 |
复位后,CPU 自动完成:
读取第一项 → MSP = 0x20001000 读取第二项 → 根据 0x08000201 设置执行状态 从 0x08000200 开始执行指令Cortex-M4 使用 Thumb 指令集,因此入口地址值的最低位需要为 1。
三、启动文件中的栈配置
启动文件中常见以下代码:
Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp这段代码描述了一块大小为 1 KB 的栈区域。
| 写法 | 含义 |
|---|---|
EQU | 定义常量,作用类似 C 的#define |
Stack_Size | 栈大小,0x400 = 1024字节 |
AREA STACK | 定义名为STACK的段 |
NOINIT | 该区域不要求启动时初始化内容 |
READWRITE | 该区域可读可写 |
ALIGN=3 | 按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的地址加载到 R0 |
BLX 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随后分配给不同总线:
| 时钟 | 当前配置 |
|---|---|
| SYSCLK | 168 MHz |
| AHB / HCLK | 168 MHz |
| APB1 / PCLK1 | 42 MHz |
| APB2 / PCLK2 | 84 MHz |
这里列的是总线时钟。部分外设还会使用单独的分频或倍频规则,例如定时器时钟不能始终直接等同于 APB 时钟。
25 MHz 是软件设置出来的吗?
板上晶振的标称频率由硬件决定。软件使能振荡电路,并配置后续 PLL 和分频器。
例如:
#define HSE_VALUE 25000000这个宏告诉软件外部时钟频率是多少。修改它不会改变晶振本身的振荡频率。
上述 168 MHz 配置对应当前标准外设库工程。其他工程可能在不同位置配置时钟,例如某些 HAL 工程会在
main()中调用SystemClock_Config()。