第4章:启动汇编代码实战——编写Reset_Handler、SystemInit函数调用、跳转到main函数前的准备工作
说实话,很多做MCU开发的朋友,写了几年C语言应用层代码,却从来没认真看过启动文件。我刚开始带项目时也是这样,直到有一次板子死活跑不起来,debug大半天才发现是中断向量表没对齐——嗯,从那以后我再也不敢跳过启动汇编了。
这一章,咱们就手把手把启动流程走一遍。你跟着我写,写完就能理解芯片上电后到底干了些什么。
4.1 启动流程全景图
先看整体脉络。MCU上电后,硬件自动做三件事:
- 从0x00000000取栈顶指针(MSP初始值)
- 从0x00000004取复位中断向量(Reset_Handler地址)
- 跳转到Reset_Handler执行
剩下的,全是软件的事了。我画了张图,把整个启动链路串起来:
4.2 编写Reset_Handler
Reset_Handler是整个软件世界的入口。说白了,芯片复位后第一条指令就在这里。我个人习惯把它放在启动文件最前面,紧跟在中断向量表之后。
来看一个典型的实现:
.section .text.Reset_Handler
.type Reset_Handler, %function
Reset_Handler:
/* 设置栈指针 */
ldr sp, =_estack
/* 调用系统初始化 */
bl SystemInit
/* 搬运.data段 */
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =_sidata
movs r3, #0
b LoopCopyDataInit
CopyDataInit:
ldr r4, [r2, r3]
str r4, [r0, r3]
adds r3, r3, #4
LoopCopyDataInit:
adds r4, r0, r3
cmp r4, r1
bcc CopyDataInit
/* 清零.bss段 */
ldr r0, =_sbss
ldr r1, =_ebss
movs r2, #0
b LoopFillZerobss
FillZerobss:
str r2, [r0]
adds r0, r0, #4
LoopFillZerobss:
cmp r0, r1
bcc FillZerobss
/* 跳转到main */
bl main
/* 如果main返回,死循环 */
LoopForever:
b LoopForever
关键点:ldr sp, =_estack 这条指令必须在最前面。我在项目中遇到过因为栈指针没初始化就调用函数,结果压栈把数据写到了随机地址,整个系统直接跑飞。记住:栈指针是命根子。
4.3 SystemInit函数调用时机
为什么要在搬运.data之前调用SystemInit?你想想看,如果系统时钟还是默认的慢速内部振荡器,搬运几十KB的数据得等到猴年马月。SystemInit先把PLL配好,把主频提上去,后面的搬运就快多了。
SystemInit通常做这几件事:
| 功能模块 | 具体操作 | 为什么重要 |
|---|---|---|
| 时钟树配置 | 使能HSE/HSI,配置PLL,切换系统时钟 | 决定CPU主频和总线速度 |
| Flash预取/等待周期 | 根据主频设置Flash延迟 | 高主频下必须配,否则取指令出错 |
| 电压调节器 | 配置核心电压范围 | 低功耗和高性能模式切换 |
| 外设时钟门控 | 关闭未使用的外设时钟 | 降低功耗,防止外设误触发 |
我的经验:SystemInit里不要做太复杂的事情。我曾经见过有人把外设初始化全塞进去,结果启动时间长了200ms,Bootloader升级时差点超时。SystemInit只做时钟和必要的基础配置,其他交给main。
4.4 数据段搬运与BSS清零
这里有个概念要搞清楚。.data段是已初始化的全局变量,编译后放在Flash里(叫_sidata),运行时需要搬到RAM里(_sdata到_edata)。.bss段是未初始化的全局变量,运行时必须清零。
为什么不能直接在Flash里用?因为C语言要求全局变量可读写,Flash只能读不能写。所以必须搬到RAM。
搬运的汇编逻辑其实很简单:
/* .data搬运:从Flash复制到RAM */
CopyDataInit:
ldr r4, [r2, r3] /* 从Flash读4字节 */
str r4, [r0, r3] /* 写到RAM */
adds r3, r3, #4 /* 地址+4 */
cmp r4, r1 /* 检查是否搬完 */
bcc CopyDataInit
/* .bss清零 */
FillZerobss:
str r2, [r0] /* 写0 */
adds r0, r0, #4 /* 地址+4 */
cmp r0, r1 /* 检查是否清完 */
bcc FillZerobss
注意:链接脚本里的_sdata、_edata这些符号必须和汇编里的一致。我踩过最大的坑就是链接脚本改了符号名,汇编没同步,结果搬运地址错位,全局变量全是乱值。排查了整整一天。
4.5 跳转到main前的最后检查
在bl main之前,我建议做一次快速自检。这不是必须的,但能帮你省很多debug时间。我个人习惯加这么几行:
/* 跳转前检查 */
/* 1. 检查栈指针是否在RAM范围内 */
ldr r0, =_estack
ldr r1, =_sstack
cmp r0, r1
bls StackError
/* 2. 检查.data搬运是否完整 */
ldr r0, =_sdata
ldr r1, =_edata
cmp r0, r1
bhi DataError
/* 一切正常,跳转main */
bl main
StackError:
DataError:
/* 错误处理:可以点亮LED或进入Bootloader恢复模式 */
b ErrorHandler
这些检查在开发阶段特别有用。等产品稳定了,可以去掉以节省几十个指令周期。
4.6 常见踩坑与避坑指南
- 中断向量表对齐问题:Cortex-M要求向量表按512字节对齐。我遇到过把向量表放在非对齐地址,结果第一个中断就进不了。
- 栈大小估算不足:默认栈大小通常只有1KB,如果函数调用层级深或者有大量局部变量,栈溢出是必然的。我建议至少给2KB,RTOS环境下给4KB以上。
- SystemInit里调用printf:千万别。这时候串口还没初始化,printf直接卡死。我见过有人这么干,debug时一脸懵。
- 搬运顺序搞反:先清.bss再搬.data?不行。因为.data和.bss在RAM里可能是相邻的,先清零会把刚搬好的.data也清了。
一句话总结:启动汇编就像盖房子的地基,看不见摸不着,但出了问题整栋楼都得塌。花半小时把这段代码吃透,后面所有调试都会顺畅很多。