第4章 U-Boot启动流程详解:第一阶段(汇编)与第二阶段(C语言)

U-Boot的启动流程,说白了就是一段从汇编到C语言的“接力跑”。很多初学者一上来就被start.S吓住了,觉得汇编代码又长又乱。其实你把它拆成两个阶段来看,思路就清晰了。

我个人习惯把第一阶段叫做“硬件初始化接力棒”,第二阶段叫做“板级配置大本营”。今天我们就顺着这条线,把U-Boot怎么从CPU上电一路跑到命令行交互的完整路径走一遍。

核心要点: U-Boot启动 = 汇编阶段(硬件最小系统) → C语言阶段(板级初始化) → 命令行循环(等待用户输入)
U-Boot启动三阶段总览 第一阶段(汇编) start.S / lowlevel_init.S 设置异常向量表 初始化CPU模式/时钟 初始化DDR/串口 重定位U-Boot到DDR 跳转 第二阶段(C语言) board_init_f / board_init_r 初始化外设(GPIO/I2C/MMC) 初始化环境变量 初始化网络/存储设备 进入main_loop 循环 命令行循环 main_loop() 等待串口输入 解析命令 执行命令 bootm启动内核 💡 汇编阶段负责“让板子能动”,C语言阶段负责“让板子好用”,main_loop负责“让用户能操作”

4.1 第一阶段(汇编):从复位向量到DDR就绪

CPU上电后,PC指针直接跳到复位向量地址。对于ARM芯片来说,这个地址通常是0x00000000或者0xFFFF0000。U-Boot的start.S就在这里等着。

我记得第一次移植U-Boot到一块新板子时,板子死活不跑。折腾了两天,最后发现是异常向量表没对齐——ARM要求向量表必须4KB对齐,我偏偏少写了一个.align指令。嗯,从那以后我每次看start.S都会先检查对齐。

4.1.1 第一阶段核心任务清单

步骤 代码位置 做什么 为什么重要
1 arch/arm/cpu/armv7/start.S 设置异常向量表 CPU复位后第一条指令入口
2 arch/arm/cpu/armv7/start.S 切换到SVC模式,关中断 确保CPU处于特权模式,不受干扰
3 arch/arm/cpu/armv7/lowlevel_init.S 初始化PLL、时钟树 CPU和总线必须跑在正确频率
4 board/vendor/board/lowlevel_init.S 初始化DDR控制器 没有DDR,代码无处安放
5 arch/arm/lib/crt0.S 初始化串口(调试用) 没有串口,你连板子在干啥都不知道
6 arch/arm/lib/crt0.S 重定位U-Boot自身到DDR 从Flash/ROM拷贝到DDR,提升执行速度
7 arch/arm/lib/crt0.S 设置堆栈指针SP 为跳转到C语言做准备
⚠️ 我曾经踩过的坑: DDR初始化参数写错,导致U-Boot重定位后跑飞。调试时串口打印到一半就卡死,最后用示波器量DDR时钟才发现频率配高了50MHz。建议你在lowlevel_init里先配一个保守频率,确认DDR能读写后再提频。

4.1.2 关键代码片段解析

我们来看start.S里最核心的一段——设置异常向量表并跳转到lowlevel_init:

/*
 * 异常向量表 —— 必须放在起始地址,4KB对齐
 */
_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            @ IRQ中断
    ldr     pc, _fiq            @ FIQ快速中断

reset:
    /* 设置CPU为SVC模式,关掉所有中断 */
    mrs     r0, cpsr
    bic     r0, r0, #0x1f
    orr     r0, r0, #0xd3
    msr     cpsr, r0

    /* 关闭MMU和Cache —— 刚上电时地址空间还没映射好 */
    mrc     p15, 0, r0, c1, c0, 0
    bic     r0, r0, #0x0005      @ 关MMU和DCache
    bic     r0, r0, #0x1000      @ 关ICache
    mcr     p15, 0, r0, c1, c0, 0

    /* 跳转到lowlevel_init,初始化时钟和DDR */
    bl      lowlevel_init

    /* 重定位U-Boot到DDR */
    bl      _main

你注意看,这里有个细节:bl lowlevel_init 用的是相对跳转,所以不管U-Boot当前在Flash还是SRAM里,这条指令都能正确执行。我见过有人把这里改成绝对跳转,结果板子直接变砖——因为Flash地址和DDR地址根本不在一个段上。

4.2 第二阶段(C语言):board_init_f 到 board_init_r

汇编阶段把DDR、时钟、串口这些“基础设施”搭好后,就该C语言登场了。入口函数是 board_init_f(),它在DDR里运行,负责初始化板级外设。

你想想看,为什么要有 board_init_f 和 board_init_r 两个函数?其实这是U-Boot的一个设计智慧:board_init_f 在“临时环境”下运行(可能还在SRAM里),只做最必要的初始化;board_init_r 在“完整环境”下运行(DDR已经就绪,堆栈也够大),可以做所有事情。

4.2.1 board_init_f 做了什么

  • 初始化串口 —— 让你能看到打印信息
  • 初始化定时器 —— 为延时和超时提供基础
  • 初始化GPIO —— 控制板上的LED、复位等
  • 初始化I2C/SPI —— 访问板上的EEPROM、传感器
  • 初始化MMC/SD卡 —— 为加载内核做准备
  • 设置环境变量存储位置 —— 决定env存在Flash还是EEPROM
💡 我个人习惯: 在board_init_f里加一个“板子ID检测”函数。通过读取GPIO电平或ADC值来判断当前是哪个硬件版本,然后动态选择DDR参数。这样同一份U-Boot二进制就能兼容多个硬件版本,省去维护多份代码的麻烦。

4.2.2 board_init_r 的完整初始化流程

board_init_r 是U-Boot第二阶段的核心,它调用 init_sequence_r[] 数组里的所有初始化函数。这个数组定义在 common/board_r.c 中,我列几个关键项:

初始化函数 作用 依赖
initr_trace 初始化调试跟踪 无
initr_reloc 完成重定位后的收尾工作 DDR就绪
initr_malloc 初始化动态内存分配池 DDR就绪
initr_dm 初始化驱动模型(Driver Model) malloc就绪
initr_env 加载环境变量(从Flash/EEPROM) I2C/SPI就绪
initr_net 初始化网络协议栈 DM就绪
initr_cmd 注册所有命令 DM就绪
run_main_loop 进入命令行循环 以上全部完成

这里有个关键点:initr_dm 必须在 initr_env 之前执行。为什么?因为驱动模型初始化后,才能正确识别板上的设备树(Device Tree),而环境变量可能依赖设备树来定位存储位置。顺序错了,环境变量就加载失败。

⚠️ 我曾经踩过的坑: 在board_init_r里加了一个自定义初始化函数,放在initr_env之后、initr_net之前。结果这个函数里调用了malloc,但malloc池还没初始化——因为initr_malloc在更前面。U-Boot直接挂掉。教训是:不要随意打乱init_sequence_r[]的顺序,如果非要加,先搞清楚每个函数的依赖关系。

4.3 main_loop:命令行交互的“心脏”

所有初始化完成后,U-Boot进入 main_loop()。这个函数在 common/main.c 里,说白了就是一个死循环:等待用户输入、解析命令、执行命令、再等待。

main_loop的核心逻辑其实很简单:

void main_loop(void)
{
    const char *s;
    int bootdelay;

    /* 检查是否有自动启动命令(bootcmd) */
    s = env_get("bootcmd");
    bootdelay = env_get_ulong("bootdelay", 10, CONFIG_BOOTDELAY);

    if (s != NULL && bootdelay >= 0) {
        /* 倒计时,等待用户按键中断自动启动 */
        if (autoboot_command(s)) {
            /* 用户没按键,执行bootcmd启动内核 */
            run_command_list(s, -1, 0);
        }
    }

    /* 进入命令行循环 */
    for (;;) {
        /* 读取用户输入 */
        len = cli_readline(CONFIG_SYS_PROMPT);

        /* 解析并执行命令 */
        if (len > 0)
            run_command(lastcommand, 0);
    }
}

你注意看那个 autoboot_command() 函数。它会在倒计时期间不断检查串口是否有输入。如果用户按下任意键,倒计时中断,进入命令行模式;如果倒计时结束,自动执行 bootcmd 环境变量里定义的命令(通常是 bootm 启动内核)。

💡 调试小技巧: 如果你不想每次都等倒计时,可以在U-Boot启动时按住空格键。大多数U-Boot移植版都把空格键配置为“中断自动启动”的触发键。我习惯在板子上电前就把串口连好,手指放在空格键上——看到倒计时数字出现就按下去,百试百灵。

4.4 三个阶段的数据流关系

为了让你更直观地理解三个阶段怎么衔接,我画了一张数据流图:

U-Boot三阶段数据流与关键数据结构 汇编阶段 全局变量:gd_t (在SRAM中临时分配) 设置gd->fdt_blob 设置gd->relocaddr 设置gd->start_addr_sp gd指针传递 C语言阶段 board_init_f/r gd_t → bd_t 初始化外设驱动 加载设备树到内存 设置环境变量 环境变量+设备树 命令行循环 main_loop() 读取bootcmd bootm → 解析设备树 加载内核到内存 跳转到内核入口 🔗 gd_t(全局数据)贯穿三个阶段,是U-Boot启动过程中最重要的数据结构

4.5 实战经验:如何快速定位启动失败

在实际项目中,U-Boot启动失败是最常见的“劝退”场景。我总结了一套排查思路:

  1. 看串口有没有输出 —— 如果完全没有打印,问题大概率在汇编阶段:时钟没配好、DDR没初始化、或者串口引脚配置错了。
  2. 看输出到哪一行卡住 —— 如果打印了“U-Boot 2023.04”但卡在“DRAM:”后面,说明DDR初始化有问题。如果卡在“Net:”后面,说明网络驱动有问题。
  3. 看能不能进命令行 —— 如果能进命令行但执行命令报错,检查环境变量和设备树是否加载正确。
  4. 看bootm能不能启动内核 —— 如果bootm执行后卡死,检查内核镜像格式、设备树地址、以及内核入口地址是否匹配。
核心总结: U-Boot启动的三个阶段就像盖房子——汇编阶段打地基(CPU/DDR/时钟),C语言阶段砌墙(外设/驱动/环境变量),main_loop阶段装修(命令行/启动内核)。地基没打好,后面全白搭。所以调试时,先从汇编阶段查起,一步步往后推。