3. 内存布局核心概念

好,咱们今天聊聊U-Boot的启动流程。说实话,很多刚接触嵌入式Linux的工程师,一上来就被各种启动阶段搞懵了——ROM Code、SPL、U-Boot proper,到底谁先谁后?各自干了什么?

我当年第一次调一块i.MX6的板子,看着串口只打印出一个'U'字就卡住了,折腾了两天才发现是SPL阶段DDR初始化没搞好。从那以后,我就养成了一个习惯:先把启动流程吃透,再动手调板子。

3.1 为什么需要多阶段启动?

你可能会问:为什么不能像PC的BIOS那样,一个固件搞定所有事情?

原因其实很简单——资源限制。嵌入式SoC内部的SRAM通常只有几十KB到几百KB。以我常用的AM335x为例,内部SRAM才64KB。这点空间,连个完整的U-Boot都放不下,更别说内核了。

所以,业界普遍采用多阶段启动方案:

  • 第一阶段(ROM Code):固化在SoC内部,不可修改。负责最基本的硬件初始化和加载下一阶段。
  • 第二阶段(SPL):即Secondary Program Loader。由ROM Code加载到SRAM中执行,负责初始化DDR、时钟等关键外设,然后加载U-Boot proper。
  • 第三阶段(U-Boot proper):完整的U-Boot,运行在DDR中。提供命令行、网络、文件系统支持,最终加载内核。

核心观点:SPL的本质是一个"瘦身版"的U-Boot。它只做两件事——初始化硬件、加载下一阶段镜像。

3.2 ROM Code:芯片出厂就有的"第一口饭"

ROM Code是芯片制造商固化在SoC内部的一段只读代码。你改不了它,也删不掉它。它的任务很明确:

  1. 最基本的CPU初始化:设置栈指针、关闭看门狗、配置系统时钟(通常用内部振荡器)。
  2. 启动介质检测:按照预设的优先级顺序,轮询各个启动设备——比如先从SD卡读,读不到再试eMMC,再试NAND Flash,最后试UART或USB下载。
  3. 加载SPL到SRAM:从检测到的启动介质中读取固定偏移位置的SPL镜像,加载到SRAM中,然后跳转执行。

这里有个关键点:ROM Code对硬件的认知非常有限。它不知道DDR怎么配置,不知道复杂的时钟树怎么设。它只敢用内部SRAM——因为SRAM不需要初始化,上电就能用。

经验之谈:我在调试一块新板子时,如果串口完全没有输出,我会先检查启动介质选择引脚的电平是否正确。很多时候,板子压根没走到SPL阶段,就是因为ROM Code没找到合法的启动设备。

3.3 SPL:承上启下的"小管家"

SPL的全称是Secondary Program Loader,也叫MLO(在TI平台上)或boot1(在Rockchip平台上)。它由ROM Code加载到SRAM中执行,大小通常被限制在几十KB以内。

SPL的主要职责包括:

职责 说明 常见坑点
时钟初始化 配置PLL,将CPU和总线频率提升到目标值 频率设太高可能导致DDR不稳定
DDR初始化 配置DDR控制器和PHY,完成内存训练 时序参数不对,板子直接死掉
存储设备驱动 初始化MMC、NAND、SPI Flash等控制器 驱动时序与ROM Code不一致
加载U-Boot proper 从存储设备读取U-Boot镜像到DDR 偏移地址或大小写错
跳转执行 清理自身痕迹,跳转到U-Boot入口 栈指针未恢复导致崩溃

说白了,SPL就是给U-Boot proper铺路的。它把最难搞的DDR初始化搞定,把U-Boot镜像从慢速存储搬到快速DDR里,然后功成身退。

3.4 SPL的初始化流程(逐层拆解)

咱们以U-Boot 2023.04为例,看看SPL的初始化流程到底长什么样。代码入口在 arch/arm/lib/spl.c 里的 board_init_f() 函数。

// 简化后的SPL初始化流程
void board_init_f(ulong boot_flags)
{
    // 1. 架构级初始化
    arch_cpu_init();        // CPU架构相关,如设置异常向量表
    timer_init();           // 定时器初始化,用于延时

    // 2. 板级初始化
    board_early_init_f();   // 板级早期初始化,如GPIO、LED

    // 3. 时钟与DDR
    dram_init();            // 获取DDR大小信息
    sdram_init();           // 实际初始化DDR控制器

    // 4. 控制台初始化(此时DDR已可用)
    console_init_f();       // 串口输出初始化

    // 5. 存储设备初始化
    spl_init();             // SPL框架初始化
    board_init_r(NULL, 0);  // 跳转到第二阶段初始化
}

注意看,console_init_f() 被放在了DDR初始化之后。这意味着——如果DDR初始化失败,你连任何调试信息都看不到。这就是为什么我前面说,串口只打印一个'U'字就卡住,大概率是DDR的问题。

避坑指南:我曾经在一块新板子上,DDR初始化总是失败。查了两天,最后发现是DDR的VREF电压没配好。建议大家在调试SPL时,先确保串口能在DDR初始化之前输出信息——有些SoC支持用SRAM里的串口驱动做早期输出,这个功能一定要打开。

3.5 SPL镜像的生成与布局

SPL镜像怎么来的?其实它就是从U-Boot源码编译出来的一个独立二进制文件。编译时通过配置 CONFIG_SPL=y 来开启。

生成SPL的关键配置项:

配置项 作用 典型值
CONFIG_SPL_TEXT_BASE SPL在SRAM中的加载地址 0x402F0400 (AM335x)
CONFIG_SPL_MAX_SIZE SPL镜像的最大尺寸 0x8000 (32KB) 或 0x10000 (64KB)
CONFIG_SPL_BSS_START_ADDR BSS段起始地址 SRAM末尾区域
CONFIG_SPL_BSS_MAX_SIZE BSS段最大尺寸 0x2000 (8KB)

编译完成后,你会得到 spl/u-boot-spl.bin。这个文件需要加上头部信息(比如TI的GP header),才能被ROM Code识别。加头部的工具通常是 mkimage。

3.6 一张图看懂启动流程

下面这张图,是我自己画的一个启动流程概览。它把ROM Code、SPL、U-Boot proper的关系和关键节点都标出来了。

U-Boot多阶段启动流程 ROM Code 固化在SoC内部 初始化CPU、检测启动介质 SPL 运行在SRAM中 初始化DDR、时钟、存储 U-Boot proper 运行在DDR中 命令行、网络、加载内核 加载SPL 加载U-Boot 存储介质(启动设备) SD/eMMC 偏移: 0x0 / 0x200 NAND Flash 偏移: 0x0 / 块0 SPI NOR Flash 偏移: 0x0 UART/USB 串口/USB下载 各阶段内存布局 ROM Code:固化在SoC内部ROM,地址固定(如0x00000000) SPL:加载到SRAM(如0x402F0400),大小受限(通常≤64KB) U-Boot proper:加载到DDR(如0x80000000),大小灵活(通常1~2MB) 环境变量:通常存储在DDR末尾或独立分区

从这张图可以看得很清楚:ROM Code是起点,SPL是桥梁,U-Boot proper是终点。每一阶段都依赖前一阶段完成特定的初始化工作。

3.7 调试SPL的实用技巧

最后,分享几个我调试SPL时常用的技巧:

  • 开启早期串口输出:在 board_early_init_f() 里初始化UART,这样DDR初始化之前就能看到打印信息。配置 CONFIG_SPL_SERIAL_SUPPORT=y 和 CONFIG_SPL_EARLY_PRINTK=y。
  • 使用LED作为调试信号:如果串口还没通,可以用GPIO控制LED闪烁,判断代码执行到了哪一步。
  • 检查SPL镜像大小:编译完成后,用 ls -l spl/u-boot-spl.bin 查看大小。如果超过了SRAM容量,ROM Code根本不会加载它。
  • 验证加载地址:用 objdump -d spl/u-boot-spl 查看反汇编,确认第一条指令的地址是否与 CONFIG_SPL_TEXT_BASE 一致。

个人习惯:我每次拿到一块新板子,第一件事就是先让SPL在串口输出一个"Hello from SPL"。这一步通了,后面的DDR和U-Boot proper调试才有意义。