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"
内核解析器会把with和args当成两个独立的参数。正确的做法是用引号包裹,或者用转义字符。但说实话,我建议你尽量避免在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语言阶段。到时候再聊。