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 搬进去运行。
启动流程大致是这样:
- ROM Code(芯片出厂固化的)从 Boot 设备读取 TPL(如果有)或 SPL
- TPL 初始化最小硬件(通常是时钟和一小块 SRAM),加载 SPL
- SPL 初始化 DDR 和存储控制器,加载完整的 U-Boot
- 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 启动流程,从芯片上电到加载内核,每个阶段做什么一目了然。
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、存储,最后看到内核跑起来——那种成就感,嗯,值得。