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。你要做的是:

  1. 等待外部晶振稳定(一般需要几毫秒)
  2. 配置PLL倍频到目标频率(比如从24MHz倍频到528MHz)
  3. 切换系统时钟源到PLL输出
  4. 配置外设时钟分频
@ 伪代码:时钟初始化流程
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图把整个流程串起来。这样你脑子里就有个清晰的脉络了。

第一阶段启动(汇编部分)完整流程 第一阶段启动(汇编部分)完整流程 CPU上电 / 复位 初始化CPU模式(SVC) 关闭所有中断 设置各模式栈指针 初始化时钟系统 初始化DDR内存 跳转到C语言入口 (bl main) 关键原则 1. 从慢到快(时钟) 2. 从简单到复杂 3. 中断全程关闭

这张图把整个流程串起来了。你注意看,时钟和内存初始化是并行的——实际上它们有依赖关系吗?有的。DDR控制器本身需要时钟才能工作,所以必须先配时钟再配DDR。但图中为了展示“两个关键硬件”的并列关系,用了分叉画法,实际代码里时钟初始化必须在DDR之前。

4.6 避坑总结

最后,我把自己这些年踩过的坑整理一下,你写代码时对照着检查:

  • 栈大小别抠门:我见过有人给IRQ栈只分配128字节,结果中断嵌套一深就栈溢出。建议至少1KB起步。
  • DDR时序参数别照搬:不同PCB布局、不同频率下,最佳时序参数不一样。量产前一定要做DDR训练校准。
  • 关中断要彻底:不只是IRQ,FIQ也要关。有些SoC还有NMI(不可屏蔽中断),启动阶段最好也屏蔽掉。
  • 链接脚本要对齐:栈顶地址、堆地址的8字节对齐,少写一个ALIGN(8)就可能出诡异问题。

我的习惯:每次写完汇编启动代码,我会在跳转到main之前加一段栈测试——往栈空间写一个固定模式(比如0xDEADBEEF),然后在main里检查栈底有没有被踩。这样能早期发现栈溢出问题。

好了,汇编启动阶段就这些。代码量不大,但每一行都关乎系统能不能正常跑起来。写的时候慢一点,多检查几遍,比出问题后再调试省时间得多。