2. Bootloader 核心原理:U-Boot 架构、启动阶段(SPL/TPL)与硬件初始化流程

讲 Bootloader 移植,绕不开 U-Boot。说实话,U-Boot 这玩意儿刚接触时确实有点吓人——代码量大,宏定义满天飞,启动流程绕来绕去。但摸透了它的骨架,你会发现其实就三板斧:架构分层、两阶段启动、硬件初始化。

我个人习惯把 U-Boot 比作一个「微型操作系统」。它有自己的驱动框架、内存管理、命令行解析,甚至还能跑网络协议栈。但它的核心使命只有一个:把内核从存储介质里捞出来,塞进内存,然后跳过去执行。

核心认知:U-Boot 不是简单的「引导程序」,它是一个完整的板级支持包(BSP)框架。你移植的不是代码,而是让这个框架认识你的板子。

2.1 U-Boot 架构分层

U-Boot 的源码目录结构,说白了就是按功能拆开的。我刚开始看的时候也懵,后来总结了一个规律:架构相关代码在 arch/,板级相关在 board/,通用驱动在 drivers/。

目录 作用 我常关注的点
arch/ CPU 架构相关(ARM、RISC-V、x86 等) 启动汇编、异常向量、MMU 配置
board/ 板级初始化代码 DDR 参数、时钟配置、GPIO 复用
drivers/ 外设驱动(串口、网卡、MMC、NAND 等) 驱动模型(DM)适配
include/configs/ 板级配置头文件 宏定义开关,坑最多的地方
dts/ 设备树源文件 硬件描述,与内核共用
common/ 通用功能(命令行、环境变量、HUSH shell) 一般不用动

这里有个经验:移植新板子时,80% 的工作量在 board/ 和 include/configs/。剩下的 20% 是调设备树和驱动。

2.2 SPL/TPL:为什么需要两阶段启动?

你想想看,早期的 Bootloader 就一个阶段,直接从 ROM 加载到内存运行。但现在的 SoC 越来越复杂,内部 SRAM 可能只有几十 KB,根本放不下完整的 U-Boot。

所以就有了 SPL(Secondary Program Loader)和 TPL(Tertiary Program Loader)。

一句话理解:SPL 是「瘦身版 U-Boot」,只做最核心的事——初始化 DDR、时钟、存储控制器,然后把完整的 U-Boot 从 Flash 搬到 DDR 里。

TPL 更极端,它比 SPL 还小。有些 SoC 的 ROM Code 只认特定格式的镜像,或者内部 SRAM 小到连 SPL 都放不下,那就再加一层 TPL。我在一个项目里遇到过,某款低端 MCU 的 SRAM 只有 16KB,SPL 编译出来 18KB,硬是塞不进去。最后加了 TPL,TPL 只做一件事:初始化外部 SPI SRAM,再把 SPL 搬进去运行。

启动流程大致是这样:

  1. ROM Code(芯片出厂固化的)从 Boot 设备读取 TPL(如果有)或 SPL
  2. TPL 初始化最小硬件(通常是时钟和一小块 SRAM),加载 SPL
  3. SPL 初始化 DDR 和存储控制器,加载完整的 U-Boot
  4. U-Boot proper 初始化所有外设,加载内核

注意:不是所有 SoC 都需要 TPL。ARM Cortex-A 系列大多只用 SPL,而一些 Cortex-M 或低端 RISC-V 芯片才需要 TPL。具体看芯片手册的 Boot ROM 章节。

2.3 硬件初始化流程详解

U-Boot 的硬件初始化,我习惯把它拆成三个阶段:汇编阶段、SPL 阶段、U-Boot proper 阶段。每个阶段干的活不一样,代码位置也不同。

2.3.1 汇编阶段(start.S)

这是 U-Boot 的第一行代码,在 arch/arm/cpu/armv7/start.S(ARMv7 为例)。它做的事非常底层:

  • 设置 CPU 为 SVC 模式(超级用户模式)
  • 关闭中断(IRQ/FIQ)
  • 设置异常向量表
  • 初始化 CP15 协处理器(关闭 MMU 和 Cache)
  • 设置栈指针(SP)
  • 清零 BSS 段
  • 跳转到 C 语言入口 board_init_f()

我记得第一次调 U-Boot 时,板子死活不跑。用 JTAG 单步跟踪,发现是 BSS 段清零的地址算错了——链接脚本里 __bss_start 和 __bss_end 的符号没对齐。从那以后,我每次移植都会先确认链接脚本的段布局。

2.3.2 SPL 阶段(board_init_f / board_init_r)

进入 C 语言后,SPL 的执行流程很清晰:

// SPL 主流程(简化版)
void board_init_f(ulong boot_flags)
{
    // 1. 初始化串口(为了能看到打印信息)
    preloader_console_init();

    // 2. 初始化时钟和 PLL
    clock_init();

    // 3. 初始化 DDR 控制器
    dram_init();

    // 4. 初始化存储设备(MMC/NAND/SPI Flash)
    spl_init();

    // 5. 跳转到 board_init_r,准备加载 U-Boot
    board_init_r(NULL, 0);
}

void board_init_r(gd_t *new_gd, ulong dest_addr)
{
    // 1. 从存储设备读取 U-Boot 镜像
    spl_load_image();

    // 2. 跳转到 U-Boot proper 的入口地址
    spl_jump_to_image();
}

关键点:SPL 阶段不能使用全局变量(因为 BSS 可能还没准备好),所有数据都放在栈上或通过寄存器传递。这也是为什么 SPL 代码里到处都是 gd_t(全局数据结构指针)——它指向一个在栈上分配的结构体。

2.3.3 U-Boot proper 阶段

U-Boot proper 的初始化比 SPL 复杂得多。它调用 init_sequence_f[] 和 init_sequence_r[] 两个函数数组,里面排了上百个初始化函数。

我挑几个重要的说:

初始化函数 作用 常见坑
initf_malloc() 初始化堆内存分配器 堆大小不够会导致驱动加载失败
initf_bootstage() 启动计时器 用于性能分析,可关闭
board_early_init_f() 板级早期初始化 GPIO 复用、电源管理芯片配置
timer_init() 初始化系统定时器 定时器频率不对会导致延时不准
env_init() 初始化环境变量 环境变量存储位置(Flash/MMC/EEPROM)
initr_dm() 初始化驱动模型 设备树节点必须与驱动匹配
initr_net() 初始化网络子系统 MAC 地址、PHY 配置

这里有个经验:如果 U-Boot 在某个初始化函数卡住,先看串口打印的最后一条消息。U-Boot 的 init_sequence 每个函数执行前后都有调试打印,打开 CONFIG_BOOTSTAGE 可以看到每个阶段的耗时。

2.4 启动流程全景图

下面这张图是我自己总结的 U-Boot 启动流程,从芯片上电到加载内核,每个阶段做什么一目了然。

U-Boot 启动流程全景图 阶段1:ROM Code(芯片固化) 阶段2:TPL(可选,极小加载器) 初始化最小硬件,加载 SPL 阶段3:SPL(Secondary Program Loader) 初始化 DDR、时钟、存储控制器 从 Flash/MMC 加载 U-Boot proper 到 DDR 阶段4:U-Boot proper(完整引导程序) 初始化所有外设(网卡、USB、存储、显示) 解析设备树、加载环境变量、进入命令行或自动启动 阶段5:加载并启动内核 从存储/网络加载内核镜像和设备树 阶段6:内核启动(Linux Kernel) U-Boot 使命完成,交出控制权 关键说明 • TPL 仅在 SRAM 极小的 SoC 上使用 • SPL 必须包含 DDR 初始化 • U-Boot proper 可裁剪 调试建议 • 打开 CONFIG_BOOTSTAGE • 使用 early_printf • JTAG 单步跟踪 start.S • 检查链接脚本段布局

2.5 避坑指南:我踩过的几个坑

做 U-Boot 移植这些年,踩过的坑不少。挑几个典型的说说:

坑1:DDR 参数配置错误

我曾经在一个 i.MX6 项目上,DDR 时序参数抄错了数据手册的一个值。结果板子能启动,但运行几分钟就随机死机。查了三天,最后用 DDR 压力测试工具才发现是时序问题。建议:移植时先用芯片厂商提供的 DDR 初始化代码,确认稳定后再优化。

坑2:设备树与驱动不匹配

U-Boot 的驱动模型(DM)依赖设备树。有一次我改了设备树里一个 GPIO 控制器的 compatible 字符串,忘了更新驱动里的匹配表。结果 U-Boot 启动时 GPIO 驱动没绑定,导致 LCD 初始化失败。建议:改设备树后,一定要检查驱动是否匹配。

坑3:环境变量存储位置冲突

默认环境变量存在 Flash 里,但有些板子的 Flash 驱动在 SPL 阶段还没初始化。结果 U-Boot proper 读环境变量时直接卡死。我的做法:在 SPL 阶段用默认环境变量,等 U-Boot proper 初始化 Flash 后再加载。

2.6 小结

U-Boot 的架构和启动流程,说白了就是「分层 + 分阶段」。架构分层让你知道代码该放哪,分阶段启动解决了资源受限的问题。硬件初始化流程虽然长,但每个阶段都有明确的职责。

我个人觉得,学 U-Boot 最好的方法就是动手移植一块板子。不用太复杂,一块 STM32MP1 或 i.MX6ULL 就够了。从配置头文件开始,一步步调通串口、DDR、存储,最后看到内核跑起来——那种成就感,嗯,值得。