4、Bootloader阶段(下):从Bootloader到内核的跳转、内核启动参数(cmdline)的传递、我在项目中遇到的Bootloader坑

好,我们接着聊。上一节我们把Bootloader的加载和初始化讲完了,这一节重点说说跳转那一下——说白了,就是Bootloader怎么把控制权交给内核的。这个动作看起来就一行代码,但背后藏着不少门道。

4.1 从Bootloader到内核的跳转

Bootloader做完硬件初始化、内存映射、设备树加载之后,最后一步就是跳转到内核入口。这个入口地址,通常是内核解压后的起始地址。比如在ARM64平台上,这个地址往往是0x80080000

跳转的方式,其实就是一条指令:

// 伪代码示意
void boot_linux(void *kernel_entry, void *dtb_addr, char *cmdline)
{
    // 关闭中断、MMU
    // 设置CPU为SVC模式
    // 跳转到内核入口
    ((void (*)(unsigned long, unsigned long, unsigned long))kernel_entry)(
        0,      // r0: 通常为0
        machid, // r1: 机器ID(旧方式)
        dtb_addr // r2: DTB地址(新方式)
    );
}

嗯,这里要注意。不同架构的传参方式不一样。ARM32时代,r0、r1、r2三个寄存器分别传参。到了ARM64,传参方式变了,用的是x0、x1、x2。但核心逻辑没变——把设备树地址和命令行参数告诉内核。

我个人习惯在跳转前做一次完整性检查。比如检查DTB地址是否在有效内存范围内,检查内核入口地址是否可执行。别小看这一步,我在项目中遇到过跳转到无效地址,结果系统直接挂死的惨案。

4.2 内核启动参数(cmdline)的传递

cmdline,全称是kernel command line。说白了,就是内核启动时的一串配置参数。你想想看,内核怎么知道根文件系统在哪个分区?怎么知道console用哪个串口?这些信息都靠cmdline传递。

典型的cmdline长这样:

console=ttyS0,115200 root=/dev/mmcblk0p2 rw rootfstype=ext4 init=/sbin/init

每个参数的含义:

参数 含义
console 指定控制台设备,比如串口或HDMI
root 根文件系统所在设备
rw 以读写方式挂载根文件系统
rootfstype 根文件系统类型
init 第一个用户态进程路径

cmdline的传递方式,主要有两种:

  • 通过设备树(DTB)传递:在设备树的chosen节点中设置bootargs属性。这是目前主流的方式。
  • 通过固定内存地址传递:Bootloader把cmdline写到某个约定好的内存地址,内核去读。这种方式比较老,现在用得少了。

我建议你优先用设备树方式。为什么?因为灵活。你可以在不同产品上使用同一个内核,只需要改设备树里的bootargs就行。我曾经在一个项目里,因为用了固定地址传递,结果内存布局一改,cmdline就读不到了,排查了半天。

重要提醒:cmdline的长度有限制。Linux内核默认最大是2048字节。如果你传的参数太多,会被截断。我见过有人把整个分区表都写在cmdline里,结果内核只认了一半,系统起不来。

4.3 我在项目中遇到的Bootloader坑

做Bootloader开发,说白了就是和各种坑打交道。我踩过的坑,随便说几个给你听听。

坑一:DTB地址对齐问题

有一次,我调试一个新平台,内核总是启动到一半就崩溃。查了很久,最后发现是DTB地址没有做64字节对齐。ARM64的规范要求DTB地址必须64字节对齐,否则内核在解析时会触发异常。

解决办法很简单:

// 确保DTB地址对齐
dtb_addr = (unsigned long)dtb_buffer;
if (dtb_addr & 0x3F) { // 检查是否64字节对齐
    dtb_addr = (dtb_addr + 0x3F) & ~0x3F;
    memcpy((void *)dtb_addr, dtb_buffer, dtb_size);
}

坑二:缓存一致性问题

这个坑比较隐蔽。Bootloader在跳转前,通常会把MMU和缓存都关掉。但有些Bootloader实现得比较粗糙,关缓存之前没有做clean操作。结果就是,内核启动时读到的指令和数据是脏的。

我曾经在一个项目上,Bootloader跳转后内核总是随机崩溃。后来发现是L2缓存没刷干净。加上一行flush_cache_all()就解决了。

警告:跳转前一定要确保所有缓存都刷回内存。特别是DMA缓冲区,如果DMA还在传输,你跳转了,数据就丢了。我建议在跳转前加一个等待DMA完成的循环。

坑三:cmdline中的特殊字符

cmdline里如果有空格、引号、分号这些特殊字符,解析时会出问题。比如你想传一个带空格的参数:

# 错误的写法
console=ttyS0,115200 root=/dev/mmcblk0p2 init="/sbin/init with args"

内核解析器会把withargs当成两个独立的参数。正确的做法是用引号包裹,或者用转义字符。但说实话,我建议你尽量避免在cmdline里传复杂参数,能放到设备树里的就放设备树里。

坑四:机器ID(machid)的兼容性

ARM32时代,内核通过机器ID来匹配设备。如果你用的Bootloader传了一个错误的machid,内核可能找不到对应的设备初始化代码。我记得有一次,我把一个开发板的machid写成了另一个板子的值,结果GPIO全部映射错了,屏幕死活不亮。

现在ARM64已经废弃了machid,统一用设备树。但如果你还在维护老项目,记得检查machid是否匹配。

小技巧:调试Bootloader时,可以在跳转前加一个延时,比如5秒。这样如果内核起不来,你还能通过串口看到Bootloader的最后一屏输出。我习惯在延时期间打印出即将传给内核的cmdline和DTB地址,方便核对。

4.4 总结一下

从Bootloader到内核的跳转,看起来简单,但细节决定成败。我总结了几点:

  • 跳转前关闭中断和MMU,刷干净缓存
  • 确保DTB地址对齐(ARM64要求64字节对齐)
  • cmdline长度不要超过2048字节,避免特殊字符
  • 跳转前做一次完整性检查,别省这一步

下一节我们会讲内核的早期初始化,也就是内核怎么从汇编阶段过渡到C语言阶段。到时候再聊。