3. U-Boot 启动第一阶段:汇编级初始化(CPU、DDR、时钟)

好,咱们直接切入正题。U-Boot 的第一阶段,说白了就是那段汇编代码。很多同学觉得汇编难,不愿意看。但我跟你说,这部分恰恰是调试启动问题的核心。你想想看,CPU 一上电,C 环境都还没建立,能跑什么?只能跑汇编。

这一阶段的目标很纯粹:把 CPU 初始化好,把时钟配好,把 DDR 搞起来,然后跳转到 C 代码。就这么四件事。但每一件都藏着坑。

3.1 入口点与异常向量表

CPU 上电后,第一件事是去固定的地址取指令。对于 ARM 芯片,通常是 0x00000000 或者 0xFFFF0000。U-Boot 的入口点就在 arch/arm/cpu/armv7/start.S 里。

核心要点: 入口点必须与链接脚本(.lds)中指定的地址一致。否则,你烧进去的代码根本跑不到。

异常向量表紧跟在入口点后面。我见过一个项目,工程师改了中断处理函数,但忘了更新向量表里的跳转地址。结果一触发中断,系统直接飞了。嗯,查了两天才找到原因。

// start.S 中的典型入口
.globl _start
_start:
    b       reset            // 复位向量
    ldr     pc, _undefined_instruction
    ldr     pc, _software_interrupt
    ldr     pc, _prefetch_abort
    ldr     pc, _data_abort
    ldr     pc, _not_used
    ldr     pc, _irq           // 中断
    ldr     pc, _fiq           // 快速中断

注意看,复位向量用的是 b reset,是相对跳转。其他向量用的是 ldr pc, ...,是绝对跳转。为什么?因为复位后 MMU 还没开,地址空间是平坦的,相对跳转没问题。但其他异常可能发生在任何模式下,用绝对跳转更安全。

3.2 CPU 模式与状态初始化

进入 reset 标号后,第一件事是设置 CPU 为 SVC(超级用户)模式。为什么要这么做?因为其他模式(比如 IRQ、FIQ)的栈还没配,万一进来个中断,连压栈的地方都没有。

reset:
    /* 设置 CPU 为 SVC 模式,关闭 IRQ 和 FIQ */
    mrs     r0, cpsr
    bic     r0, r0, #0x1f       // 清除模式位
    orr     r0, r0, #0xd3       // SVC 模式 + IRQ/FIQ 禁止
    msr     cpsr, r0

这里有个细节:0xd3 这个值,二进制是 1101 0011。低 5 位 10011 是 SVC 模式,第 6、7 位是 1,表示关中断。我刚开始学的时候,总觉得这些数字是魔法数。后来习惯了,其实就那么几种模式,背下来就好。

模式 CPSR 模式位 用途
USR 10000 用户模式,权限最低
FIQ 10001 快速中断,有专用寄存器
IRQ 10010 普通中断
SVC 10011 超级用户,启动时使用
MON 10110 安全监控模式

我的习惯: 在设置完 CPU 模式后,立刻把各个模式的栈指针(SP)初始化。虽然有些模式暂时用不到,但万一调试时误入了,不至于立刻崩溃。

3.3 关闭看门狗与中断

这一步很多人会忽略。看门狗(WDT)在芯片出厂时默认是开启的。如果你不关它,代码跑着跑着,看门狗超时了,啪,复位了。你还在那纳闷:怎么代码跑一会就重启?

/* 关闭看门狗 —— 以 S5PV210 为例 */
ldr     r0, =0xE2700000        // WDT 控制寄存器地址
mov     r1, #0x0
str     r1, [r0]               // 写 0 关闭

不同芯片的看门狗地址不一样。我建议你拿到开发板后,第一件事就是查芯片手册,找到 WDT 的基地址。我曾经在一个项目里,因为看门狗没关,调试了整整一个下午。后来发现,嗯,就是少了一行代码。

中断也要全部屏蔽。虽然前面已经关了 IRQ 和 FIQ,但有些芯片还有别的中断源。保险起见,把中断控制器里的所有中断都 mask 掉。

3.4 时钟系统初始化

时钟是芯片的心脏。CPU 能跑多快,DDR 能跑多快,全靠时钟。U-Boot 第一阶段通常会把时钟配到一个较高的频率,这样后面的代码执行更快。

时钟配置的流程一般是:

  1. 选择时钟源:外部晶振还是内部振荡器?
  2. 配置 PLL:锁相环,把低频时钟倍频到高频。
  3. 设置分频器:给 CPU、总线、外设分配不同的时钟。
  4. 切换时钟源:从低速时钟切换到 PLL 输出的高速时钟。

注意: 切换时钟源时,一定要先确认 PLL 已经锁定(Locked)。PLL 锁定需要时间,通常是微秒级。如果没锁定就切换,CPU 可能直接跑飞。

/* 以 i.MX6UL 为例的时钟初始化片段 */
ldr     r0, =CCM_BASE_ADDR

/* 1. 使能 PLL1 (ARM PLL) */
ldr     r1, [r0, #CCM_ANALOG_PLL_ARM]
orr     r1, r1, #(1 << 13)     // 使能 PLL
str     r1, [r0, #CCM_ANALOG_PLL_ARM]

/* 2. 等待 PLL 锁定 */
1:  ldr     r1, [r0, #CCM_ANALOG_PLL_ARM]
    tst     r1, #(1 << 31)      // 检查锁定位
    beq     1b                  // 没锁定就继续等

/* 3. 切换 CPU 时钟到 PLL1 */
ldr     r1, [r0, #CCM_CACRR]
bic     r1, r1, #0x7           // 清除分频位
str     r1, [r0, #CCM_CACRR]

这段代码里,那个 1: 和 beq 1b 是个循环等待。我见过有人忘了加这个等待,结果芯片工作不稳定,时好时坏。这种问题最难查,因为不是必现的。

3.5 DDR 初始化

DDR 初始化是整个第一阶段里最复杂、最容易出问题的部分。DDR 的时序参数非常多,而且每个芯片都不一样。U-Boot 里通常会把 DDR 的配置参数放在一个结构体里,然后调用一个通用的初始化函数。

DDR 初始化的关键步骤:

  • 配置 DDR 控制器:设置地址映射、突发长度、CAS 延迟等。
  • 配置 DDR PHY:物理层,负责信号的电气特性。
  • 发送 ZQ 校准命令:DDR3/DDR4 需要校准输出阻抗。
  • 发送 MRS 命令:模式寄存器设置,配置 DDR 芯片的工作模式。
  • 执行训练:读写训练,确保数据线、地址线、控制线的时序正确。

避坑指南: 我曾经在一个项目里,DDR 初始化总是失败。查了三天,最后发现是 DDR 的 VREF(参考电压)没配好。DDR3 的 VREF 一般是 VDDQ 的一半,但有些板子因为电源纹波大,需要软件调整。嗯,从那以后,我每次调 DDR 都会先量一下 VREF。

/* DDR 初始化伪代码 */
void ddr_init(struct ddr_params *params)
{
    /* 1. 复位 DDR 芯片 */
    ddr_reset(params->reset_pin);

    /* 2. 配置控制器 */
    ddr_ctrl_config(params->ctrl_cfg);

    /* 3. 配置 PHY */
    ddr_phy_config(params->phy_cfg);

    /* 4. 等待 ZQ 校准完成 */
    while (!(readl(params->base + DDR_STATUS) & ZQ_DONE));

    /* 5. 执行训练 */
    ddr_training(params->base);
}

这里要注意,DDR 初始化必须在时钟稳定之后进行。而且,DDR 的供电顺序也有要求:VDDQ 要先于 VDD 上电。这些在芯片手册里都有,但很多人不看。

3.6 重定位与栈设置

DDR 初始化完成后,内存就有了。这时候,U-Boot 会把自身从 Flash(或 ROM)复制到 DDR 中。这个过程叫重定位(Relocation)。

为什么要重定位?因为 Flash 的读取速度太慢了。把代码放到 DDR 里,执行速度能快几十倍。

/* 重定位代码示例 */
relocate:
    ldr     r0, =_start          // Flash 中的起始地址
    ldr     r1, =_TEXT_BASE      // DDR 中的目标地址
    ldr     r2, =_bss_start      // BSS 段起始地址
    sub     r2, r2, r0           // 计算代码长度

    /* 复制代码到 DDR */
    bl      copy_loop

    /* 设置栈指针 */
    ldr     sp, =_stack_end

    /* 跳转到 DDR 中的 C 代码入口 */
    ldr     pc, =board_init_f

重定位之后,栈也设置好了。这时候,C 环境才算真正建立。注意,跳转到 C 代码时,要用 ldr pc, =... 这种绝对跳转,因为代码已经在 DDR 里了,地址是固定的。

3.7 第一阶段流程图

下面这张图,把整个第一阶段的流程串起来了。你可以对照着看,每一步都做了什么。

U-Boot 第一阶段启动流程 U-Boot 第一阶段汇编初始化流程 1. 入口点 (_start) 2. 异常向量表设置 3. 设置 SVC 模式 4. 关闭看门狗/中断 5. 时钟系统初始化 6. DDR 初始化 7. 重定位到 DDR 8. 设置栈指针 (SP) 9. 跳转 board_init_f 说明:第 5 步(时钟)和第 6 步(DDR)是调试中最容易出问题的环节 建议在每一步之后添加 LED 或串口输出,方便定位故障点

3.8 调试要点总结

第一阶段全是汇编,没法用 printk 调试。那怎么调?我常用的方法有几种:

  • 点灯大法:在关键步骤后点亮不同的 LED。比如,时钟初始化成功后亮红灯,DDR 初始化成功后亮绿灯。这是最原始但最有效的方法。
  • 示波器量波形:量时钟输出引脚,看频率对不对。量 DDR 的 DQS 信号,看有没有读写动作。
  • JTAG 单步调试:用调试器(比如 J-Link)单步执行,看寄存器值对不对。这是最彻底的方法,但需要硬件支持。

我的建议: 在写第一阶段代码时,就预留好调试接口。比如,在某个 GPIO 上输出脉冲,用示波器看脉冲宽度就能知道代码执行到哪里了。别等到出了问题再想办法,那时候就晚了。

好了,第一阶段的内容就这些。说白了就是:配 CPU、配时钟、配 DDR、搬代码、设栈、跳转。每一步都有坑,但只要你理解了原理,调试起来就不难。