3. U-Boot 源码结构

U-Boot 启动,说白了就是一段从死板硬件到灵活交互的蜕变过程。芯片上电那一刻,CPU 还懵懵懂懂,只知道去固定地址取指令。而我们要做的,就是引导它一步步初始化硬件、搬运代码、加载系统,最后把控制权交给你——通过命令行。

我个人习惯把启动流程拆成三个阶段:汇编阶段、板级初始化阶段、命令行交互阶段。下面我们顺着这条线,把每个关键节点掰开揉碎了讲。

核心观点:U-Boot 启动的本质是「从固定入口到动态交互」的渐进式初始化。理解这条链路,你才能精准定位移植中的死机、串口无输出、无法加载内核等问题。

3.1 第一站:reset 向量——CPU 醒来后的第一件事

CPU 上电或复位后,会从 reset vector(复位向量)开始执行。这个地址通常是 0x00000000(ARM 架构)或 0xFFFF0000(某些 Cortex-M),具体取决于 SoC 设计。

在 U-Boot 中,reset 向量定义在 arch/arm/cpu/armv7/start.S(以 ARMv7 为例)。代码长这样:

_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 直接跳转到 reset 标签。为什么不用 ldr pc, =reset?因为此时 CPU 可能还在 ROM 或 SRAM 里运行,地址空间还没完全初始化,用相对跳转最安全。

我的经验:移植时如果发现上电后完全没反应,先检查 reset 向量是否被正确链接到 SoC 要求的起始地址。我曾经在一个 i.MX6 项目上,因为链接脚本里 .text 段偏移算错了一个字节,CPU 直接飞了——串口连个 'U' 都没吐出来。

3.2 汇编阶段:从异常模式到 C 环境

进入 reset 标签后,U-Boot 会做几件关键的事。我按顺序列出来:

  1. 设置 CPU 为 SVC32 模式——关中断、清 CPSR,确保后续操作不受异常干扰。
  2. 关闭 MMU 和 Cache——此时地址映射还没建立,开 MMU 等于找死。
  3. 设置栈指针——为调用 C 函数做准备。栈通常放在当前可用的 SRAM 顶部。
  4. 清零 BSS 段——全局变量和静态变量必须初始化为 0。
  5. 调用 board_init_f()——这是第一个 C 函数,负责早期板级初始化。

嗯,这里要注意:board_init_f() 是在 SPL(Secondary Program Loader)或 Tiny U-Boot 阶段执行的。如果你的 SoC 支持 SPL,那么 reset 向量其实属于 SPL,而不是完整的 U-Boot。

阶段 入口 主要任务 运行位置
SPL reset 向量 初始化时钟、DDR、加载完整 U-Boot 内部 SRAM
完整 U-Boot _main(SPL 跳转过来) 板级初始化、设备树解析、命令行 DDR

为什么要有 SPL?说白了就是「鸡生蛋蛋生鸡」的问题——DDR 初始化需要代码运行,但代码又需要 DDR 才能跑起来。SPL 体积小,放在内部 SRAM 里,先把 DDR 初始化好,再把完整 U-Boot 从 Flash 搬到 DDR 里执行。

3.3 board_init_f():板级早期初始化

这个函数在 common/board_f.c 里,是一个巨大的初始化函数数组。我挑几个关键步骤说说:

  • init_sequence_f[] 数组里注册了所有早期初始化函数,按顺序执行。
  • timer_init()——初始化系统定时器,为延时和超时提供基础。
  • env_init()——环境变量区域的早期定位,此时还没完整解析。
  • init_baud_rate()——从环境变量或默认值获取串口波特率。
  • serial_init()——初始化串口控制器。这一步做完,你就能看到串口输出第一个字符了。
  • dram_init()——检测并初始化 DDR 控制器,获取内存大小。

避坑指南:我曾经在移植时,serial_init() 返回了成功,但串口就是没输出。查了两天才发现是 GPIO 复用配置没做——UART 的 TX/RX 引脚被默认配成了 GPIO 功能。所以移植时一定要确认 SoC 的引脚复用寄存器。

board_init_f() 执行完后,U-Boot 会做一次 relocation(重定位),把自己从 Flash 或 SRAM 搬到 DDR 里。这是通过 relocate_code() 汇编函数完成的。搬完后,代码在 DDR 里跑,速度更快,空间也更大。

3.4 board_init_r():板级后期初始化

重定位完成后,调用 board_init_r()(在 common/board_r.c)。这个阶段的任务更丰富:

  • board_init()——板级特定初始化,比如设置 LED、配置 I2C 总线、初始化以太网 PHY。
  • stdio_init()——初始化标准输入输出设备(串口、LCD、USB 键盘等)。
  • initr_env()——完整加载和解析环境变量,从存储设备(Flash、EMMC、NFS)读取。
  • initr_net()——初始化网络子系统,为 TFTP 下载内核做准备。
  • initr_mmc()——初始化 MMC/SD 卡控制器。

这个阶段做完,U-Boot 已经具备了加载内核的能力。但还没完——它需要进入命令行,等待你的指令。

3.5 main_loop():命令行的诞生

最后一步,调用 main_loop()(在 common/main.c)。这个函数的核心逻辑是:

  1. 检查环境变量 bootdelay,决定等待用户输入的时间。
  2. 如果在倒计时内按下任意键,进入命令行交互模式(shell)。
  3. 如果超时,执行 bootcmd 环境变量里的命令(通常是加载并启动内核)。

main_loop() 的伪代码大致如下:

void main_loop(void)
{
    char *s;
    int bootdelay = getenv_ulong("bootdelay", 10);

    s = getenv("bootcmd");

    if (bootdelay >= 0 && s && !abortboot(bootdelay)) {
        // 倒计时结束,自动执行 bootcmd
        run_command_list(s, -1, 0);
    }

    // 进入命令行循环
    for (;;) {
        len = readline("U-Boot> ");
        if (len > 0)
            run_command(line, 0);
    }
}

看到没?abortboot() 函数负责检测按键。如果用户在倒计时内按了任意键,返回 1,跳过自动启动,进入命令行。否则执行 bootcmd。

我的习惯:调试阶段我会把 bootdelay 设成 0,这样上电直接进命令行,省得每次按回车。量产时再改回 3 秒,给用户一个中断的机会。

3.6 完整链路图

下面这张 SVG 图把整个流程串起来了。你可以把它当作调试时的路线图:

U-Boot 启动完整链路图 U-Boot 启动完整链路图 reset 向量 汇编阶段 设置SVC32 / 关MMU / 设栈 / 清BSS board_init_f() 时钟 / 串口 / DDR / 定时器 重定位到 DDR board_init_r() 网络 / MMC / 环境变量 / 设备树 main_loop() 倒计时内按任意键 → 进入命令行 倒计时结束 → 执行 bootcmd

3.7 常见问题与排查思路

移植过程中,启动流程的每个阶段都可能出问题。我总结了几种典型场景:

现象 可能原因 排查方向
上电后完全无输出 reset 向量地址错误 / 时钟未初始化 / 串口引脚复用不对 检查链接脚本、SoC 启动模式、GPIO 配置
输出到某个字符后卡死 DDR 初始化失败 / 重定位地址冲突 检查 DDR 时序参数、重定位基地址
能进命令行但无法加载内核 网络 / MMC 驱动未正确初始化 检查 board_init_r() 中的设备初始化顺序
倒计时不等待直接启动 bootdelay 环境变量未设置或为 0 检查默认环境变量配置

我曾经踩过的坑:有一次移植到新板子,串口输出了 "U-Boot 2023.04" 后就卡死了。用 JTAG 单步跟踪发现,board_init_f() 里的 dram_init() 返回了 0。原因是 DDR 芯片的型号和代码里配置的参数不匹配——CAS 延迟差了 1 个 cycle。改完参数后,启动就正常了。