4. 第一阶段启动(汇编部分):初始化CPU模式、关闭中断、设置栈指针、初始化关键硬件
好,咱们进入Bootloader最底层的部分——汇编启动阶段。说实话,很多做上层应用开发的工程师,一辈子可能都不会碰这段代码。但如果你要做底层系统、搞安全启动、或者优化启动时间,这部分就是绕不过去的坎。
我个人习惯把这一阶段叫做“从零到一”的过程。CPU上电时,寄存器全是随机值,内存还没初始化,连栈都没有。你想想看,这时候能跑C语言吗?跑不了。所以必须用汇编先把“生存环境”搭建好。
核心目标一句话:让CPU从“原始状态”进入一个“可执行C代码”的稳定状态。
4.1 初始化CPU模式:先搞清楚你在哪
CPU上电后默认处于什么模式?这取决于架构。ARM Cortex-M系列直接就是线程模式+主栈,比较简单。但如果是ARM Cortex-A系列,比如我在项目中用过的i.MX6ULL,上电后默认是SVC模式(超级管理员模式)。
为什么要关心这个?因为不同模式有独立的栈指针、独立的权限级别。你总不能在用户模式下去配置系统寄存器吧?那会被硬件直接拒绝。
@ 示例:ARM Cortex-A 设置SVC模式
MRS r0, cpsr @ 读取当前程序状态寄存器
BIC r0, r0, #0x1F @ 清除模式位
ORR r0, r0, #0x13 @ 设置为SVC模式 (0b10011)
MSR cpsr_c, r0 @ 写回
这段代码我几乎每个项目都要写一遍。嗯,这里要注意:cpsr_c 中的 _c 表示只修改控制域,别把条件标志位给冲了。
4.2 关闭中断:别在收拾屋子的时候有人敲门
这个道理很朴素——你正在初始化关键寄存器,突然来了个中断,中断服务程序跑起来发现栈指针还没设、内存控制器还没配,直接崩给你看。
我早期犯过一个错误:在关闭中断之前先配了时钟,结果某个外设时钟一开,中断就涌进来了。从那以后,我的启动代码第一件事永远是关总中断。
@ ARM 架构关中断
MRS r0, cpsr
ORR r0, r0, #0xC0 @ 设置IRQ和FIQ掩码位
MSR cpsr_c, r0
注意:不同架构关中断的方式不同。x86用CLI指令,RISC-V用mstatus的MIE位清零。别拿ARM的写法往RISC-V上套,我见过有人这么干,仿真器直接报非法指令。
4.3 设置栈指针:C语言的生命线
栈指针(SP)有多重要?这么说吧,没有栈,你连函数调用都做不了——BL指令把返回地址压哪去?局部变量放哪?
我个人习惯给每种CPU模式都分配独立的栈空间。为什么?因为异常处理可能在任意模式下触发,如果共用栈,一旦嵌套就互相踩踏。
| CPU模式 | 栈大小(典型值) | 用途 |
|---|---|---|
| SVC(超级管理员) | 4KB | 主程序、系统调用 |
| IRQ(普通中断) | 1KB | 中断服务程序 |
| FIQ(快速中断) | 512B | 高速中断处理 |
| ABT(中止) | 256B | 数据/指令中止处理 |
| UND(未定义) | 256B | 未定义指令陷阱 |
@ 设置各模式栈指针(以IMX6ULL为例)
ldr r0, =__stack_end @ 栈顶地址(链接脚本定义)
/* 切换到IRQ模式设置栈 */
MRS r1, cpsr
BIC r1, r1, #0x1F
ORR r1, r1, #0x12 @ IRQ模式
MSR cpsr_c, r1
ldr sp, =__irq_stack_top
/* 切换到SVC模式设置栈 */
MRS r1, cpsr
BIC r1, r1, #0x1F
ORR r1, r1, #0x13 @ SVC模式
MSR cpsr_c, r1
ldr sp, =__svc_stack_top
小技巧:栈地址建议8字节对齐。ARM架构下,未对齐的栈访问会触发异常。我习惯在链接脚本里用 ALIGN(8) 来保证。
4.4 初始化关键硬件:时钟和内存控制器
这两样是启动的“卡脖子”环节。时钟不配好,CPU跑在默认的慢速时钟上,后面所有操作都慢如蜗牛。内存控制器不配好,你连全局变量都放不了。
4.4.1 时钟初始化:从慢到快
大多数SoC上电后默认使用内部低速振荡器,比如24MHz或者32.768KHz。你要做的是:
- 等待外部晶振稳定(一般需要几毫秒)
- 配置PLL倍频到目标频率(比如从24MHz倍频到528MHz)
- 切换系统时钟源到PLL输出
- 配置外设时钟分频
@ 伪代码:时钟初始化流程
bl wait_osc_stable @ 等待晶振稳定
bl config_pll @ 配置PLL倍频
bl switch_sysclk @ 切换系统时钟
bl config_periph_clk @ 配置外设时钟
我曾经在一个项目里遇到过:PLL锁定时间不够,导致时钟频率跳变,整个系统跑起来时序全乱。后来加了超时重试机制才解决。
4.4.2 内存控制器初始化:最危险的一步
DDR初始化是Bootloader里最复杂的硬件操作之一。为什么说危险?因为一旦配错,CPU直接挂死,没有任何报错信息给你。
关键参数包括:
- 时序参数:tRCD、tCL、tRP、tRAS等,必须严格匹配DDR颗粒的datasheet
- 驱动强度:ODT(片上端接)配置,信号完整性全靠它
- 刷新率:DDR需要定期刷新,否则数据丢失
- 地址映射:行列地址线如何映射到物理地址空间
@ DDR初始化示例(简化)
ldr r0, =DDR_PHY_BASE
ldr r1, [r0, #DDR_PHY_OFFSET]
orr r1, r1, #DDR_RESET_N @ 释放DDR复位
str r1, [r0, #DDR_PHY_OFFSET]
bl ddr_delay_200us @ 等待复位完成
ldr r0, =DDR_CTRL_BASE
ldr r1, =0x01000000 @ tRCD=10, tCL=10, tRP=10
str r1, [r0, #DDR_TIMING]
ldr r1, =0x00010001 @ 使能自刷新
str r1, [r0, #DDR_CONFIG]
bl ddr_calibration @ 执行DDR训练校准
血的教训:DDR初始化必须在关闭所有中断的情况下进行。我曾经在调试时忘了关中断,结果DDR训练过程中被中断打断,训练数据全乱,板子直接变砖。后来只能通过JTAG擦除整个Flash才救回来。
4.5 整体流程:一张图看懂
说了这么多,咱们用一张SVG图把整个流程串起来。这样你脑子里就有个清晰的脉络了。
这张图把整个流程串起来了。你注意看,时钟和内存初始化是并行的——实际上它们有依赖关系吗?有的。DDR控制器本身需要时钟才能工作,所以必须先配时钟再配DDR。但图中为了展示“两个关键硬件”的并列关系,用了分叉画法,实际代码里时钟初始化必须在DDR之前。
4.6 避坑总结
最后,我把自己这些年踩过的坑整理一下,你写代码时对照着检查:
- 栈大小别抠门:我见过有人给IRQ栈只分配128字节,结果中断嵌套一深就栈溢出。建议至少1KB起步。
- DDR时序参数别照搬:不同PCB布局、不同频率下,最佳时序参数不一样。量产前一定要做DDR训练校准。
- 关中断要彻底:不只是IRQ,FIQ也要关。有些SoC还有NMI(不可屏蔽中断),启动阶段最好也屏蔽掉。
- 链接脚本要对齐:栈顶地址、堆地址的8字节对齐,少写一个ALIGN(8)就可能出诡异问题。
我的习惯:每次写完汇编启动代码,我会在跳转到main之前加一段栈测试——往栈空间写一个固定模式(比如0xDEADBEEF),然后在main里检查栈底有没有被踩。这样能早期发现栈溢出问题。
好了,汇编启动阶段就这些。代码量不大,但每一行都关乎系统能不能正常跑起来。写的时候慢一点,多检查几遍,比出问题后再调试省时间得多。