Bootloader 与启动参数:GRUB、U-Boot 与传递机制
Bootloader 这玩意儿,说白了就是硬件与操作系统之间的「二传手」。它干的事很纯粹:初始化硬件、加载内核、传递启动参数。但就是这最后一步——参数传递,坑最多,也最值得深挖。
我个人习惯把启动参数传递分成三个时代:ATAG 时代、设备树时代、EFI 时代。正好对应三种主流 Bootloader:U-Boot、GRUB 和现代 UEFI。咱们一个一个聊。
GRUB 配置解析:从 menuentry 到 linux 命令
GRUB 是 x86 平台的老大哥。它的配置文件 /boot/grub/grub.cfg 里,藏着启动参数的源头。
看一个典型的配置片段:
menuentry 'Linux 6.1' {
set root=(hd0,1)
linux /vmlinuz-6.1 root=/dev/sda2 ro quiet splash
initrd /initrd.img-6.1
}
这里 linux 行后面的所有内容,就是传给内核的命令行参数。内核启动时会逐词解析这些字符串。你想想看,root=/dev/sda2 指定根文件系统,ro 表示只读挂载,quiet 抑制内核日志输出——每个参数背后都对应内核里的一段处理逻辑。
关键点:GRUB 本身不解析这些参数,它只是把字符串原封不动地塞到内核约定的内存位置。内核在 start_kernel() 里通过 parse_args() 函数逐项处理。
我曾经在调试一个内核 panic 时,发现是 GRUB 配置里多了一个换行符,导致参数被截断。嗯,这种问题排查起来特别恶心——你根本想不到是 Bootloader 在传参时动了手脚。
U-Boot 环境变量:嵌入式世界的灵活与陷阱
U-Boot 是嵌入式 Linux 的事实标准。它的参数传递方式比 GRUB 灵活得多,但也更复杂。
U-Boot 使用环境变量(environment variables)来管理启动参数。这些变量存储在 Flash 或 EEPROM 中,通过 printenv、setenv、saveenv 命令操作。
一个典型的 U-Boot 启动流程:
setenv bootargs 'console=ttyS0,115200 root=/dev/mmcblk0p2 rw'
setenv bootcmd 'mmc read 0x42000000 0x800 0x4000; bootm 0x42000000'
saveenv
boot
这里 bootargs 就是传给内核的命令行参数。bootcmd 是自动启动时执行的命令序列。U-Boot 会把 bootargs 的内容放到内存的某个固定地址,然后跳转到内核入口。
| 环境变量 | 作用 | 典型值 |
|---|---|---|
bootargs |
内核命令行参数 | console=ttyS0,115200 root=/dev/mmcblk0p2 |
bootcmd |
自动启动命令 | mmc read ...; bootm ... |
fdt_addr |
设备树加载地址 | 0x48000000 |
loadaddr |
内核加载地址 | 0x42000000 |
避坑指南:我曾经遇到过一块板子,每次重启后 bootargs 都会丢失。查了半天发现是 Flash 坏块导致 saveenv 写入失败。U-Boot 的环境变量存储区没有坏块管理,这是嵌入式开发中常见的坑。
启动参数传递机制:ATAG、设备树、EFI
Bootloader 把参数传给内核,总得有个约定好的协议。历史上出现过三种主流方案。
ATAG:老派但可靠
ATAG(ARM Tags)是 ARM 平台早期的参数传递方式。Bootloader 在内存中构建一个标签列表,每个标签包含一个类型标识和对应的数据。内核启动时从约定地址读取这些标签。
ATAG 列表的结构:
struct tag {
struct tag_header hdr;
union {
struct tag_core core;
struct tag_mem32 mem;
struct tag_cmdline cmdline;
/* ... 其他类型 */
} u;
};
struct tag_header {
u32 size; // 标签大小(以字为单位)
u32 tag; // 标签类型
};
内核在 setup_arch() 中遍历这个链表,解析内存布局、命令行参数等信息。这种方式简单直接,但扩展性差——每增加一个新参数,就得定义一个新的标签类型。
设备树(Device Tree):现代 ARM 的标准
设备树解决了 ATAG 的扩展性问题。它用一棵树形结构来描述硬件信息,Bootloader 把编译好的 .dtb 文件加载到内存,把地址传给内核。
设备树的传递流程:
- U-Boot 从 Flash 或文件系统读取
.dtb文件 - 加载到内存(如
0x48000000) - 通过
bootm或bootz命令把设备树地址传给内核 - 内核解析设备树,初始化所有外设
设备树里可以包含 chosen 节点,用来传递内核命令行参数:
/ {
chosen {
bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2";
};
};
个人经验:我建议在设备树里只放硬件描述,不要硬编码 bootargs。把命令行参数放在 U-Boot 的环境变量里,这样调试时修改更方便,不用重新编译设备树。
EFI:UEFI 时代的统一方案
EFI(Extensible Firmware Interface)是 x86 和 ARM 服务器领域的新标准。它使用 EFI_LOADED_IMAGE_PROTOCOL 和 EFI_DEVICE_PATH_PROTOCOL 来传递启动参数。
UEFI 启动时,固件会创建一个系统表(System Table),里面包含了启动参数、内存映射、运行时服务等信息。内核通过 efi_init() 读取这些数据。
EFI 的参数传递比 ATAG 和设备树更复杂,但也更强大:
- 支持 Unicode 字符串
- 可以传递二进制数据块
- 支持安全启动验证
- 提供运行时服务接口
三种传递机制的对比
| 特性 | ATAG | 设备树 | EFI |
|---|---|---|---|
| 平台 | ARM(旧) | ARM、RISC-V | x86、ARM64 |
| 扩展性 | 差(需定义新标签) | 好(树形结构) | 好(Protocol 机制) |
| 二进制支持 | 有限 | 好 | 好 |
| 安全启动 | 不支持 | 需额外实现 | 原生支持 |
| 内核解析入口 | parse_tags() |
unflatten_device_tree() |
efi_init() |
核心流程图:Bootloader 到内核的参数传递路径
下面这张图展示了三种传递机制的完整路径。从 Bootloader 读取参数,到内核最终解析使用,每一步都有明确的约定。
实际调试中的经验
我在项目中遇到过最典型的问题,是设备树和 U-Boot 环境变量之间的优先级冲突。U-Boot 的 bootargs 会覆盖设备树 chosen 节点里的 bootargs,但很多人不知道这个规则。结果就是:改了设备树,重启后参数没变。
为什么会这样?因为 U-Boot 在启动内核前,会用环境变量里的 bootargs 覆盖设备树中的对应节点。这是 U-Boot 的设计决策——让用户可以在不重新编译设备树的情况下修改启动参数。
避坑指南:我曾经调试一个网络驱动加载失败的问题,查了两天才发现是 bootargs 里少了 ip=dhcp 参数。内核在解析网络配置时,如果没有这个参数,会直接跳过 DHCP 初始化。这种问题在文档里根本找不到,只能靠经验积累。
嗯,说到参数传递,还有一个容易被忽略的点:参数长度限制。ATAG 方式下,命令行参数长度通常限制在 256 字节以内。设备树没有这个限制,但 U-Boot 的环境变量存储区有大小限制。如果你需要传递很长的参数(比如多个 console= 或复杂的 cma 配置),建议用设备树方式。
最后说一句:不管你用哪种方式,参数传递的本质是 Bootloader 和内核之间的契约。理解了这个契约,你就能在系统启动失败时快速定位问题——是参数没传过去,还是内核没解析对。