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 文件加载到内存,把地址传给内核。

设备树的传递流程:

  1. U-Boot 从 Flash 或文件系统读取 .dtb 文件
  2. 加载到内存(如 0x48000000)
  3. 通过 bootm 或 bootz 命令把设备树地址传给内核
  4. 内核解析设备树,初始化所有外设

设备树里可以包含 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 读取参数,到内核最终解析使用,每一步都有明确的约定。

Bootloader 到内核的参数传递路径 GRUB / U-Boot / UEFI 读取配置 / 环境变量 / 固件表 ATAG 列表 内存约定地址(0x100) 设备树(.dtb) fdt_addr 指定地址 EFI 系统表 Boot Services / Runtime 内核启动入口 start_kernel() → setup_arch() 参数解析 parse_args() / of_scan() / efi_parse() 三种方式最终都汇聚到内核的 setup_arch(),由各自对应的解析函数处理

实际调试中的经验

我在项目中遇到过最典型的问题,是设备树和 U-Boot 环境变量之间的优先级冲突。U-Boot 的 bootargs 会覆盖设备树 chosen 节点里的 bootargs,但很多人不知道这个规则。结果就是:改了设备树,重启后参数没变。

为什么会这样?因为 U-Boot 在启动内核前,会用环境变量里的 bootargs 覆盖设备树中的对应节点。这是 U-Boot 的设计决策——让用户可以在不重新编译设备树的情况下修改启动参数。

避坑指南:我曾经调试一个网络驱动加载失败的问题,查了两天才发现是 bootargs 里少了 ip=dhcp 参数。内核在解析网络配置时,如果没有这个参数,会直接跳过 DHCP 初始化。这种问题在文档里根本找不到,只能靠经验积累。

嗯,说到参数传递,还有一个容易被忽略的点:参数长度限制。ATAG 方式下,命令行参数长度通常限制在 256 字节以内。设备树没有这个限制,但 U-Boot 的环境变量存储区有大小限制。如果你需要传递很长的参数(比如多个 console= 或复杂的 cma 配置),建议用设备树方式。

最后说一句:不管你用哪种方式,参数传递的本质是 Bootloader 和内核之间的契约。理解了这个契约,你就能在系统启动失败时快速定位问题——是参数没传过去,还是内核没解析对。