4、U-Boot启动流程分析:从reset向量到main_loop的完整链路

U-Boot的启动流程,说白了就是一段从汇编到C、从板级初始化到命令行交互的完整旅程。很多初学者一上来就看start.S,结果被各种宏和条件编译绕晕了。我刚开始做移植那会儿也是这样,后来花了整整两天,一行一行地跟代码,才把这条链路理清楚。

今天我们就沿着这条主线走一遍。你不需要记住每一行代码,但要把关键节点和跳转逻辑刻在脑子里。

核心认知:U-Boot启动本质上是三段式——汇编阶段做最底层的CPU/内存初始化,C语言阶段做板级外设初始化,最后进入主循环等待用户命令。

4.1 第一站:reset向量——CPU的第一口气

CPU上电或复位后,PC指针会跳到一个固定的地址。对于ARM芯片,通常是0x00000000或0xFFFF0000。U-Boot的链接脚本会把start.S中的_start标签放在这个位置。

/* arch/arm/lib/vectors.S 或 start.S */
_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, =reset。为什么?因为此时CPU可能还没初始化DDR,只能在SRAM或ROM里跑。相对跳转指令不依赖绝对地址,更安全。我在一个项目中遇到过有人把这里改成绝对跳转,结果板子直接变砖——嗯,从那以后我再也不敢乱改向量表了。

4.2 第二站:reset函数——从异常模式到SVC模式

跳转到reset标签后,U-Boot做的第一件事是切换处理器模式。ARM在复位后默认处于SVC模式,但有些调试器或BootROM可能会把它留在其他模式。所以代码里会强制设置CPSR寄存器:

reset:
    /* 设置SVC模式,关闭IRQ/FIQ */
    mrs     r0, cpsr
    bic     r0, r0, #0x1f
    orr     r0, r0, #0xd3
    msr     cpsr, r0

紧接着是关闭MMU和Cache。为什么?因为此时页表还没建立,打开MMU会直接触发异常。我见过有人为了加速启动,在SPL里提前开了Cache,结果数据不一致导致校验失败——折腾了两天才找到原因。

寄存器/功能 初始值 说明
CPSR 0xD3 SVC模式,IRQ/FIQ关闭
SCTLR MMU=0, D-Cache=0, I-Cache=0 关闭内存管理单元和缓存
SP 指向SRAM顶部 临时栈,DDR未初始化

4.3 第三站:lowlevel_init——板级最底层的初始化

设置好基本运行环境后,U-Boot会调用lowlevel_init。这个函数通常由板级代码实现,负责初始化PLL、时钟、DDR控制器等。说白了,就是让CPU能跑起来、让内存能用。

ENTRY(lowlevel_init)
    /* 初始化系统时钟 */
    bl      system_clock_init
    /* 初始化DDR控制器 */
    bl      ddr_init
    /* 返回 */
    mov     pc, lr
ENDPROC(lowlevel_init)

我个人习惯在这里加一个LED闪烁函数,用来确认代码是否跑到了这个位置。调试裸机程序时,串口可能还没初始化,LED就是唯一的“printf”。

调试技巧:在lowlevel_init前后各放一个GPIO翻转操作,用示波器量一下波形,就能知道这段代码的执行时间。我曾经用这个方法发现DDR初始化耗时过长,优化后启动时间缩短了200ms。

4.4 第四站:_main——从汇编到C的桥梁

完成底层初始化后,U-Boot会跳转到_main。这个函数是汇编到C的过渡,它做了三件关键的事:

  1. 设置C运行环境:初始化栈指针、BSS段清零
  2. 调用board_init_f:在SRAM中完成早期板级初始化
  3. 重定位:将U-Boot自身从SRAM拷贝到DDR
ENTRY(_main)
    /* 设置初始栈 */
    ldr     sp, =(CONFIG_SYS_INIT_SP_ADDR)
    bic     sp, sp, #7          /* 8字节对齐 */

    /* 清零BSS段 */
    ldr     r0, =__bss_start
    ldr     r1, =__bss_end
    mov     r2, #0
1:  cmp     r0, r1
    strlo   r2, [r0], #4
    blo     1b

    /* 调用board_init_f */
    bl      board_init_f
    /* ... 后续重定位逻辑 ... */
ENDPROC(_main)

你想想看,为什么BSS清零这么重要?因为C语言中未初始化的全局变量默认是0,如果BSS段没清零,这些变量的值就是随机的。我遇到过一块板子,每次启动网络都不正常,最后发现是BSS段里的MAC地址缓存没清零,导致随机值被当成有效地址。

4.5 第五站:board_init_f——板级早期初始化

board_init_f是一个函数指针数组的遍历过程。U-Boot定义了一个init_sequence_f数组,里面按顺序放了几十个初始化函数:

static init_fnc_t init_sequence_f[] = {
    setup_machine,        /* 设置机器类型 */
    env_init,             /* 环境变量初始化 */
    init_baud_rate,       /* 设置波特率 */
    serial_init,          /* 串口初始化 */
    console_init_f,       /* 控制台早期初始化 */
    dram_init,            /* 检测DDR大小 */
    setup_dest_addr,      /* 计算重定位地址 */
    /* ... 更多初始化项 ... */
    NULL,
};

这个数组的执行顺序非常讲究。比如serial_init必须在console_init_f之前,否则printf打不出字。我建议你在调试时,可以在每个函数入口加一个打印标记,这样能精确定位是哪个初始化步骤卡住了。

注意:board_init_f阶段仍然在SRAM中运行,栈空间非常有限(通常只有4KB~16KB)。不要在回调函数里定义大数组或递归调用,否则栈溢出会导致莫名其妙的死机。

4.6 第六站:重定位——从SRAM搬到DDR

当board_init_f执行完毕后,U-Boot已经知道了DDR的大小和地址。接下来就是重定位——把U-Boot自身从SRAM拷贝到DDR的高地址区域。

/* relocate_code 是汇编实现的 */
ENTRY(relocate_code)
    ldr     r1, =__image_copy_start   /* 源地址:SRAM */
    ldr     r2, =__image_copy_end     /* 结束地址 */
    ldr     r3, =CONFIG_SYS_TEXT_BASE /* 目标地址:DDR */
1:  ldmia   r1!, {r10-r11}           /* 从SRAM加载 */
    stmia   r3!, {r10-r11}           /* 写入DDR */
    cmp     r1, r2
    blo     1b
    /* 修复GOT表 */
    bl      relocate_vectors
    /* 跳转到DDR中的board_init_r */
    ldr     pc, =board_init_r
ENDPROC(relocate_code)

这里有个关键点:重定位后必须修复GOT表(全局偏移表),否则函数调用会跳到错误的地址。我记得第一次移植U-Boot时,重定位后板子直接死机,查了两天才发现是GOT表没更新——从那以后,我每次改链接脚本都会仔细检查GOT段。

4.7 第七站:board_init_r——完整的板级初始化

跳转到DDR中的board_init_r后,U-Boot才算真正“活”过来了。这个函数同样遍历一个初始化数组,但这次做的事情更全面:

  • 完整的外设初始化:网卡、Flash、I2C、SPI等
  • 环境变量加载:从存储设备读取env
  • 设备驱动绑定:DM框架的设备树解析
  • 命令行初始化:注册所有命令
static init_fnc_t init_sequence_r[] = {
    initr_reloc,          /* 重定位完成标记 */
    initr_caches,         /* 开启Cache */
    initr_env,            /* 环境变量初始化 */
    initr_dm,             /* 驱动模型初始化 */
    initr_net,            /* 网络初始化 */
    /* ... */
    NULL,
};
性能提示:board_init_r阶段会开启Cache和MMU。如果你发现启动后某些外设工作异常,可以尝试在对应的init函数前关闭Cache,确认是否是缓存一致性问题。

4.8 终点站:main_loop——命令行的世界

所有初始化完成后,U-Boot进入main_loop。这是启动流程的终点,也是用户交互的起点。

void main_loop(void)
{
    const char *s;

    /* 检查启动延迟 */
    bootdelay_process();
    /* 处理预置命令 */
    s = getenv("bootcmd");
    if (s != NULL)
        run_command_list(s, -1, 0);

    /* 进入命令行循环 */
    for (;;) {
        len = readline(CONFIG_SYS_PROMPT);
        if (len > 0)
            run_command(line, 0);
    }
}

这里有个常见的坑:bootdelay_process会等待用户按键中断自动启动。如果串口线接触不良,或者终端软件没有正确发送回车,U-Boot会一直卡在等待状态。我建议在产品发布时,把CONFIG_BOOTDELAY设为0,或者直接让bootcmd自动执行,避免生产测试时卡住。

4.9 完整链路总览

为了让你更直观地理解整个流程,我画了一张流程图。你可以把它当作地图,以后看代码时随时对照。

U-Boot启动流程完整链路 U-Boot启动流程完整链路 1. reset向量 CPU上电,跳转_start 2. reset函数 SVC模式,关闭MMU/Cache 3. lowlevel_init PLL/时钟/DDR初始化 4. _main 设置栈/BSS清零/调用board_init_f 5. board_init_f SRAM中执行早期初始化 6. 重定位 SRAM → DDR,修复GOT表 7. board_init_r DDR中完整初始化外设 8. main_loop 命令行交互 / 自动启动 💡 关键记忆点: • 前5步在SRAM中运行,栈空间有限 • 重定位是分水岭,之后代码在DDR中执行 • main_loop是终点,也是用户交互的起点 • 每个阶段都可以加调试代码定位问题

4.10 避坑总结

最后,我把这些年踩过的坑整理成一张表,你移植时可以直接对照排查:

阶段 常见问题 排查方法
reset向量 链接地址与实际加载地址不一致 检查链接脚本和CONFIG_SYS_TEXT_BASE
lowlevel_init DDR参数配置错误导致死机 用示波器量DDR时钟和片选信号
board_init_f 栈溢出导致函数返回异常 在回调函数入口打印SP值
重定位 GOT表未修复,函数调用跑飞 反汇编检查跳转地址是否正确
board_init_r Cache一致性导致外设数据错误 临时关闭Cache确认问题
main_loop 串口无输出或无法输入 检查波特率、引脚复用、终端软件

这条链路,我前前后后走了不下五十遍。每次移植新板子,我都会对照这个流程过一遍。你只要把每个阶段的核心任务记住,遇到问题就知道该去哪个文件里找答案。