4. Second Stage Bootloader (SSBL) 与 OS 加载:U-Boot/GRUB 的启动流程与优化
SSBL 这层,说白了就是承上启下的关键环节。它要把硬件从「能用」变成「好用」,然后稳稳地把控制权交给操作系统。我见过太多项目,前面安全校验做得滴水不漏,结果在 SSBL 这层卡了 3 秒多,用户早就不耐烦了。
这一章我们聚焦两个主流选手:嵌入式领域的 U-Boot,和桌面/服务器领域的 GRUB。它们的优化思路有共通之处,但具体手法差异很大。
4.1 U-Boot 的启动流程与瓶颈分析
U-Boot 的启动大致分三个阶段:SPL(Secondary Program Loader)、U-Boot proper、以及最后的 bootm/go 命令跳转。我习惯把 SPL 看作「微缩版」的 bootloader,它只做最必要的事——初始化 DDR、时钟、串口,然后加载 U-Boot proper。
真正的耗时大户在 U-Boot proper 阶段。你想想看,它要枚举所有外设、解析设备树、加载驱动、检查环境变量、执行 bootcmd……每一步都可能藏着性能陷阱。
| 阶段 | 主要工作 | 典型耗时占比 | 优化空间 |
|---|---|---|---|
| SPL | DDR 初始化、时钟、串口、加载 U-Boot | 10-20% | 中等(DDR 训练可优化) |
| U-Boot proper 初始化 | 设备树解析、驱动模型(DM)初始化、环境变量加载 | 30-40% | 高(驱动加载顺序是关键) |
| bootcmd 执行 | 网络/存储设备探测、加载内核、校验、跳转 | 40-50% | 高(并行化、延迟探测) |
核心观点:U-Boot 的优化重点不在 SPL,而在 proper 阶段的驱动模型初始化和 bootcmd 执行。我曾经在一个车机项目里,光是把 DM 初始化顺序调优,就省下了 800ms。
4.2 优化 SSBL 的驱动加载顺序
U-Boot 的驱动模型(Driver Model)允许我们控制设备的探测顺序。默认情况下,它按照设备树中的出现顺序来初始化。但很多设备其实不需要在启动阶段就准备好——比如音频编解码器、显示控制器(如果不用 splash screen)、USB 主机控制器等。
我建议的做法是:
- 先做依赖分析——哪些设备是内核加载必须的?存储控制器(MMC/SD/NAND)、网络接口(如果从网络加载内核)、串口(调试用)。其他设备一律延迟。
- 调整设备树中的顺序——把关键设备放在前面,非关键设备放在后面。U-Boot 的 DM 会按照设备树节点顺序进行探测。
- 使用 u-boot,dm-pre-reloc 标志——只有标记了这个属性的设备才会在重定位前初始化。我习惯只给存储和串口加上这个标志。
小技巧:在 U-Boot 命令行下执行 dm tree 可以查看当前设备的探测顺序和状态。这是我每次调优的第一步——先看清楚现状,再动手改。
对于 GRUB,情况稍有不同。GRUB 的驱动加载主要发生在访问文件系统时。默认它会探测所有可用的磁盘和分区,这在有大量磁盘的服务器上会非常慢。
我曾经在一个存储服务器上遇到过 GRUB 启动耗时 12 秒的问题。排查后发现,GRUB 在逐个扫描 24 块硬盘上的所有分区。解决方案很简单:在 grub.cfg 中显式指定 root 设备,并禁用不必要的模块加载。
# 优化前的 grub.cfg(默认行为)
set root=(hd0,msdos1)
linux /vmlinuz root=/dev/sda1
initrd /initrd.img
# 优化后的 grub.cfg
set root=(hd0,msdos1)
insmod part_msdos
insmod ext2
# 显式指定不扫描其他设备
set pager=1
set timeout=1
linux /vmlinuz root=/dev/sda1 quiet
initrd /initrd.img
4.3 并行化硬件初始化
这是 SSBL 优化中最立竿见影的手段。U-Boot 默认是串行初始化设备的——一个一个来。但很多设备的初始化是独立的,完全可以并行。
U-Boot 从 2020 年开始引入了 CONFIG_DM_PARALLEL 选项,允许在支持多核的平台上并行初始化设备。启用后,DM 会为每个设备创建一个独立的初始化任务,利用多核加速。
注意:并行初始化不是银弹。设备之间有依赖关系(比如 I2C 控制器必须在 I2C 设备之前初始化),必须通过设备树中的 depends-on 属性显式声明依赖。我曾经在一个项目里盲目开启并行初始化,结果因为 I2C 控制器和触摸屏的初始化顺序错乱,导致系统反复重启。
对于单核平台,我们可以通过另一种方式实现「伪并行」:把耗时的硬件操作(如等待 PHY 自协商、等待 DDR 训练完成)放到后台,先去做其他不需要等待的事情。
具体做法是:
- 在设备驱动中,把
probe函数拆成两部分:probe_init(启动硬件操作)和probe_finish(检查操作结果)。 - U-Boot 的 DM 框架会先调用所有设备的
probe_init,然后再逐个调用probe_finish。 - 这样,硬件操作(比如 PHY 自协商,通常需要 2-3 秒)可以和其他设备的初始化重叠进行。
// 伪代码示例:拆分 probe 函数
static int eth_phy_probe_init(struct udevice *dev) {
// 启动 PHY 自协商,不等待结果
phy_start_autoneg(dev);
return 0;
}
static int eth_phy_probe_finish(struct udevice *dev) {
// 等待自协商完成
while (!phy_autoneg_done(dev)) {
// 可以在这里做其他事情,或者 yield
mdelay(10);
}
return 0;
}
实战效果:我在一个 ARM64 平台上应用了这种拆分 probe 的方法,把以太网 PHY 初始化的等待时间从 2.8 秒降到了 0.3 秒(实际等待时间被其他设备的初始化覆盖了)。整体启动时间从 5.2 秒降到了 2.7 秒。
4.4 SSBL 启动流程与优化点全景图
下面这张图展示了 SSBL 从启动到加载 OS 的完整流程,以及每个阶段对应的优化手段。我把它放在这里,方便你对照自己的项目做分析。
4.5 避坑指南与实战经验
我在多个项目中踩过 SSBL 优化的坑,这里分享几个最常见的:
坑 1:盲目裁剪驱动
我曾经为了追求启动速度,在 U-Boot 中移除了所有「看起来没用」的驱动。结果内核启动时因为缺少某个 GPIO 驱动,导致外设无法正常工作。记住:U-Boot 的驱动裁剪要基于内核的实际需求,而不是「我觉得用不上」。
坑 2:忽略环境变量加载时间
U-Boot 的环境变量默认存储在 SPI Flash 或 EMMC 中。如果环境变量很大(比如存了很多 bootargs 和脚本),读取时间会非常可观。我建议把环境变量大小控制在 4KB 以内,并把常用的 bootargs 硬编码到配置中。
实用工具推荐:
bootgraph——U-Boot 的启动时间分析工具,可以生成每个阶段的耗时分布图。dm tree——查看设备探测顺序和状态。time命令——在 U-Boot 命令行下测量单个命令的执行时间。
嗯,SSBL 优化这块内容不少,但核心思路其实就两条:减少不必要的等待和让等待与其他工作重叠。你只要抓住这两条主线,再结合具体的平台特性,就能把启动时间压到理想范围。