4. Bootloader的启动流程:从复位向量到main函数的执行路径
这一章,我们来聊聊Bootloader最核心的启动流程。说白了,就是芯片上电后,从哪条指令开始跑,怎么一步步走到我们的main函数。
很多刚入行的朋友觉得这步很简单——不就是跳转到main吗?其实不然。我见过不少项目,因为启动流程没理清楚,板子死活跑不起来。最后查出来,要么是堆栈指针没设对,要么是中断向量表地址配错了。
4.1 复位向量:芯片的第一口呼吸
芯片上电或复位后,CPU会去一个固定地址取第一条指令。这个地址,就叫复位向量。
对于ARM Cortex-M系列,复位向量在0x00000000。对于ARM7/9,通常在0x00000000或0xFFFF0000。不同的架构,规矩不一样。
关键点:复位向量里存的不是指令,而是地址。
- Cortex-M:
0x00000000存的是栈顶地址,0x00000004存的是复位中断服务函数地址。 - ARM7/9:
0x00000000直接放一条跳转指令,比如B Reset_Handler。
我个人习惯在链接脚本里,把复位向量段放在最前面。这样生成的bin文件,烧进去就能从正确位置启动。
/* 链接脚本示例:Cortex-M的向量表放置 */
SECTIONS
{
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector)) /* 中断向量表 */
. = ALIGN(4);
} > FLASH
.text :
{
/* 代码段 */
} > FLASH
}
4.2 堆栈指针(SP)的初始化
堆栈指针,就是CPU用来记录栈顶位置的寄存器。没有它,函数调用、局部变量、中断嵌套全得崩。
在Cortex-M上,SP的初始化是硬件自动完成的。复位后,CPU直接从0x00000000读取值,写入SP寄存器。所以,你必须在那个地址放一个合法的栈顶地址。
我的经验:栈顶地址怎么算?
假设你的RAM从0x20000000开始,大小64KB。栈顶就是0x20000000 + 0x10000 = 0x20010000。注意,栈是向下生长的,所以栈顶在最高地址。
对于ARM7/9这类架构,SP需要你在复位处理函数里手动初始化:
Reset_Handler:
LDR SP, =_stack_top /* 加载栈顶地址 */
BL main /* 跳转到main */
我曾经在一个项目里,因为栈顶地址算错了8字节,导致程序跑一会儿就莫名其妙地死机。查了两天才发现,是栈溢出把关键数据踩了。嗯,从那以后,我每次都会在链接脚本里显式定义栈顶符号,并在启动代码里做栈边界检查。
4.3 程序计数器(PC)的初始化
PC就是程序计数器,指向CPU当前正在执行的指令地址。复位后,PC会被设置为复位向量的值。
在Cortex-M上,CPU从0x00000004读取复位处理函数的地址,然后写入PC。注意,这个地址的最低位必须是1,表示Thumb模式。如果写成0,CPU会进入错误状态。
避坑指南:我曾经见过一个同事,把函数地址直接赋给PC,忘了置位最低位。结果CPU一启动就进HardFault。折腾了一下午才发现是这个问题。
对于ARM7/9,复位向量里放一条跳转指令,CPU执行这条指令后,PC就跳转到目标地址了。
; ARM7/9 复位向量示例
AREA RESET, CODE, READONLY
ENTRY
B Reset_Handler ; 复位向量
B Undefined_Handler ; 未定义指令
B SWI_Handler ; 软中断
B Prefetch_Handler ; 预取中止
B Data_Handler ; 数据中止
NOP ; 保留
B IRQ_Handler ; 中断
B FIQ_Handler ; 快速中断
4.4 从复位到main的完整路径
好了,我们把整个流程串起来。你想想看,芯片上电后到底发生了什么?
- 硬件复位:CPU内部寄存器复位到默认值。
- 读取复位向量:CPU去固定地址取栈顶地址和复位函数地址。
- 初始化SP:栈顶地址写入SP寄存器。
- 初始化PC:复位函数地址写入PC,开始执行。
- 执行复位处理函数:做必要的硬件初始化,比如时钟、内存、中断控制器。
- 跳转到main:最后调用main函数,进入主程序。
下面这张图,把整个流程画清楚了。
4.5 复位处理函数里该做什么
复位处理函数,是芯片启动后执行的第一段软件代码。它负责把硬件带到可运行状态。
我个人习惯,在Reset_Handler里至少做这几件事:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 初始化系统时钟 | 配置PLL、分频器,让CPU跑在目标频率 |
| 2 | 初始化内存控制器 | 如果使用SDRAM或外部Flash,需要先配好时序 |
| 3 | 复制数据段 | 将初始化数据从Flash复制到RAM |
| 4 | 清零BSS段 | 将未初始化全局变量区域清零 |
| 5 | 设置中断向量表 | 将向量表重定位到RAM(如果需要) |
| 6 | 调用main | 跳转到主程序 |
注意:在复制数据段和清零BSS段之前,绝对不能访问全局变量。因为此时它们还在Flash里,或者内容是随机的。我见过有人在这之前就调用了printf,结果打印出一堆乱码。
4.6 一个完整的启动代码示例
下面是一个Cortex-M的启动代码片段。我把它简化了,但核心逻辑都在。
; startup.s - Cortex-M启动代码
AREA RESET, CODE, READONLY
EXPORT __Vectors
EXPORT Reset_Handler
; 中断向量表
__Vectors
DCD _stack_top ; 栈顶地址
DCD Reset_Handler ; 复位处理函数
DCD NMI_Handler ; NMI
DCD HardFault_Handler ; HardFault
; ... 其他中断向量
; 复位处理函数
Reset_Handler
; 1. 初始化系统时钟
BL SystemInit
; 2. 复制数据段
LDR R0, =_sdata
LDR R1, =_edata
LDR R2, =_la_data
BL copy_data
; 3. 清零BSS段
LDR R0, =_sbss
LDR R1, =_ebss
BL zero_bss
; 4. 设置中断向量表(如果需要)
; ...
; 5. 跳转到main
BL main
; 死循环(main不应该返回)
B .
; 数据复制函数
copy_data
CMP R0, R1
IT LT
LDR R3, [R2], #4
STR R3, [R0], #4
BLT copy_data
BX LR
; BSS清零函数
zero_bss
MOV R3, #0
CMP R0, R1
IT LT
STR R3, [R0], #4
BLT zero_bss
BX LR
END
这段代码里,_stack_top、_sdata、_edata这些符号,都是在链接脚本里定义的。它们告诉启动代码,数据段从哪里开始、到哪里结束。
我的建议:链接脚本里的符号命名要规范。我习惯用_sdata表示数据段起始,_ebss表示BSS段结束。这样启动代码和链接脚本一一对应,不容易出错。
4.7 常见问题与避坑
做Bootloader启动这块,有几个坑我踩过,分享给你:
- 栈顶地址不对齐:Cortex-M要求栈顶地址8字节对齐。不对齐的话,某些指令会异常。
- 中断向量表放在RAM里忘了重定位:如果你在运行时修改了中断向量表,记得把VTOR寄存器指向新地址。
- 复位处理函数里用了全局变量:在BSS清零之前,全局变量是随机值。用了就出问题。
- 忘了使能FPU:如果芯片带浮点单元,复位后默认是禁用的。需要手动使能,否则浮点运算会进HardFault。