4. Reset_Handler详解(上):从复位向量跳转到C环境的准备工作
好,咱们接着往下聊。上一章我们把启动文件的开头部分过了一遍,今天要啃的这块骨头,是整个启动流程里最核心的一环——Reset_Handler。
说白了,芯片上电复位后,CPU第一件事就是去固定地址取栈指针,第二件事就是跳转到Reset_Handler。这个函数干的事,就是给C语言的世界铺好路。没有它,你的main函数根本跑不起来。
我个人习惯把Reset_Handler的工作分成三个阶段:三板斧。哪三板?搬数据、清BSS、调库。咱们一个一个说。
4.1 复位向量的真相
先看这张图,这是整个启动流程的骨架:
这张图我画得比较直白。你注意看,从复位向量到进入main,中间其实就三步。但每一步都有坑,我当年刚接触STM32时,就在BSS清零上栽过跟头。
4.2 第一板斧:栈指针的初始化
芯片上电后,CPU从0x00000000取栈顶地址,从0x00000004取复位向量地址。这是ARM架构定的规矩,咱们改不了。
在STM32的启动文件里,你会看到这样的代码:
; 向量表起始地址
__Vectors DCD __initial_sp ; 栈顶地址
DCD Reset_Handler ; 复位向量
DCD NMI_Handler
DCD HardFault_Handler
; ... 其他中断向量
这里有个细节——__initial_sp 的值是谁定的?是链接脚本。我习惯在链接脚本里这样写:
_estack = ORIGIN(RAM) + LENGTH(RAM); // 栈顶指向RAM最高地址
嗯,这里要注意:栈是向下生长的。所以栈顶要放在RAM的最高地址,这样栈向下扩展时不会踩到其他数据区。我在一个项目里见过有人把栈顶设在了RAM中间,结果一压栈就把全局变量给覆盖了——查了两天才找到原因。
4.3 第二板斧:数据段搬移
接下来是重头戏。C语言里的全局变量和静态变量,初始值都存放在Flash里。但变量是要在RAM里读写的,所以启动时得把初始值从Flash搬到RAM。
启动文件里通常是这样实现的:
; 复制 .data 段
ldr r1, =_sidata ; Flash中的初始值起始地址
ldr r2, =_sdata ; RAM中的.data起始地址
ldr r3, =_edata ; RAM中的.data结束地址
copy_loop:
cmp r2, r3
ittt lo
ldrlo r0, [r1], #4
strlo r0, [r2], #4
blo copy_loop
这段代码看着简单,但有个关键点——_sidata、_sdata、_edata 这三个符号是谁定义的?
对,链接脚本。我见过不少新手直接复制启动文件,却忘了在链接脚本里定义这些符号,结果编译能过,跑起来就死机。说白了,启动文件和链接脚本是夫妻档,缺一个都不行。
4.4 第二板斧(续):BSS段清零
搬完数据,接下来清BSS。BSS段里放的是未初始化的全局变量和静态变量。C标准规定,这些变量默认值为0。所以启动时要把它们全清零。
; 清零 .bss 段
ldr r2, =_sbss
ldr r3, =_ebss
movs r0, #0
bss_loop:
cmp r2, r3
itt lo
strlo r0, [r2], #4
blo bss_loop
你可能会问:为什么不能把BSS段也放在Flash里,像.data一样搬过来?
原因很简单——BSS段在Flash里不占空间。链接脚本里BSS段的LMA(加载地址)和VMA(运行地址)是同一个地址,都在RAM里。Flash里根本没有BSS的副本。所以清零比搬数据快得多。
4.5 第三板斧:调用SystemInit和main
数据准备好了,C环境基本就绪。但还差一步——时钟配置。
STM32上电后默认使用HSI(内部高速振荡器),频率只有8MHz(某些系列是16MHz)。你想跑72MHz?得先配置PLL。这个工作由SystemInit函数完成。
bl SystemInit ; 配置系统时钟
bl main ; 进入主函数
bx lr ; 如果main返回,这里会死循环
注意最后那个 bx lr。如果main函数真的返回了,程序会跳回调用main之前的地址。但那个地址是什么?其实是个未定义状态。所以正规的启动文件会在main后面加一个死循环:
bl main
LoopForever:
b LoopForever
我个人习惯在main函数最后加一个 while(1);,但启动文件里再加一层保护,双重保险。
4.6 一个完整的Reset_Handler示例
把上面三部分拼起来,就是一个完整的Reset_Handler:
Reset_Handler PROC
EXPORT Reset_Handler
; 1. 设置栈指针(其实硬件已经做了,但再设一次更保险)
ldr sp, =_estack
; 2. 搬移.data段
ldr r1, =_sidata
ldr r2, =_sdata
ldr r3, =_edata
copy_loop:
cmp r2, r3
ittt lo
ldrlo r0, [r1], #4
strlo r0, [r2], #4
blo copy_loop
; 3. 清零.bss段
ldr r2, =_sbss
ldr r3, =_ebss
movs r0, #0
bss_loop:
cmp r2, r3
itt lo
strlo r0, [r2], #4
blo bss_loop
; 4. 调用SystemInit和main
bl SystemInit
bl main
; 5. 死循环
dead_loop:
b dead_loop
ENDP
这段代码我用了很多年,基本没出过问题。唯一一次翻车是在一个低功耗项目里——我为了省电,在进入睡眠前把SystemInit又调了一次,结果PLL重新锁定花了几个毫秒,导致唤醒后第一帧数据采集超时。所以,SystemInit不是随便调用的,它只应该在启动时执行一次。
4.7 小结
Reset_Handler的三板斧,说白了就是三件事:
- 栈指针:给C语言的函数调用铺好路
- 数据搬移和BSS清零:让全局变量有正确的初始值
- 调用SystemInit和main:把控制权交给C语言
这三件事做完,C环境才算真正建立。你想想看,如果没有这些准备工作,main函数里随便访问一个全局变量,读到的可能是Flash里的初始值,也可能是RAM里的随机值——程序根本没法跑。
下一章我们会深入链接脚本,看看那些 _sdata、_ebss 之类的符号到底是怎么定义的。到时候你会发现,启动文件和链接脚本之间的配合,比你想的还要精妙。
公众号:蓝海资料掘金营,微信deep3321