第三讲:Bootloader第二阶段(U-Boot)——加载内核与设备树,传递启动参数
好,我们接着往下走。上一讲我们聊了Bootloader的第一阶段,说白了就是汇编那一套,把最基础的硬件初始化搞定。现在,控制权交到了C语言手里,也就是U-Boot的第二阶段。
这一阶段的核心任务,用一句话就能概括:把Linux内核和设备树从存储介质里读出来,放到内存里,然后跳过去执行。听起来简单?嗯,细节可不少。我在项目里见过太多次,内核明明编译对了,就是起不来,最后发现是U-Boot传的参数有问题。
U-Boot第二阶段到底在干什么?
我个人习惯把第二阶段拆成三个步骤:
- 初始化更复杂的硬件——比如网卡、Flash控制器、文件系统驱动。
- 加载内核映像和设备树——从eMMC、SD卡、网络(TFTP)或者NAND Flash里读数据。
- 设置启动参数并跳转——把内核需要的所有信息打包好,然后一条指令跳过去。
你想想看,U-Boot其实就是一个微型操作系统。它有自己的命令行、文件系统支持、网络协议栈。第二阶段就是让这些功能全部就绪,为加载内核做准备。
设备树(Device Tree)——为什么需要它?
以前的老内核,硬件信息是硬编码在代码里的。换一个板子,就得重新编译内核。这太痛苦了。后来社区搞出了设备树,说白了就是一个描述硬件拓扑的文本文件。
U-Boot会把设备树(.dtb文件)加载到内存,然后把它的地址告诉内核。内核启动时,自己去解析这个树,就知道“哦,我这块板子上有个UART,地址是0x...,中断号是...”。
核心要点:设备树让内核与板级硬件解耦。换板子?换一个.dtb文件就行,内核不用重新编译。
我曾经踩过一个坑:设备树里GPIO的复用配置写错了,结果内核启动到一半,屏幕死活不亮。查了两天才发现,是.dts文件里一个pinmux的编号不对。嗯,从那以后,我每次改设备树都会反复核对芯片手册。
启动参数(bootargs)——内核的“命令行”
U-Boot通过一个叫bootargs的环境变量,把启动参数传给内核。这些参数包括:
- 根文件系统在哪里(比如
root=/dev/mmcblk0p2) - 控制台用哪个串口(比如
console=ttyS0,115200) - 内存大小、DMA区域、init进程路径等等
内核启动时,会解析这些参数,然后按照你的指示去挂载根文件系统、启动第一个用户态进程。
小技巧:调试时可以在bootargs里加上init=/bin/sh,这样内核启动后会直接给你一个shell,方便排查文件系统的问题。我经常这么干。
加载内核的典型流程(代码级)
我们来看看U-Boot里实际是怎么做的。假设你用的是ARM64平台,U-Boot的环境变量大概是这样:
setenv bootargs 'console=ttyS0,115200 root=/dev/mmcblk0p2 rw'
setenv loadaddr '0x80000000'
setenv fdtaddr '0x88000000'
load mmc 0:1 ${loadaddr} /boot/Image
load mmc 0:1 ${fdtaddr} /boot/board.dtb
booti ${loadaddr} - ${fdtaddr}
解释一下:
load mmc 0:1表示从MMC设备0的分区1加载文件。${loadaddr}是内核映像要放到的内存地址。${fdtaddr}是设备树要放到的内存地址。booti是ARM64架构下启动内核的命令,它会把设备树地址作为第三个参数传给内核。
为什么用booti而不是bootm?booti专门用于启动未压缩的Image格式内核,而bootm用于uImage格式(带U-Boot头)。现在主流平台都直接用booti了,省去一层解包。
传递参数给内核的底层机制
U-Boot跳转到内核之前,会在一个约定的寄存器里放设备树的地址(ARM64是x0寄存器)。内核启动时,第一个动作就是从这个寄存器里读地址,然后解析设备树。
同时,U-Boot还会在内存中构造一个ATAG列表(老方式)或者直接使用设备树的chosen节点来传递bootargs。现在基本都用设备树方式了,ATAG已经很少见了。
注意:设备树地址必须与内核解压后的地址不重叠。我见过有人把设备树放在0x80000000,内核也解压到0x80000000,结果设备树被覆盖,内核启动到一半就挂了。建议把设备树放在内核地址之后,比如内核在0x80000000,设备树在0x88000000,留出足够空间。
进入内核——最后一跳
当U-Boot执行booti或bootm时,它会做以下几件事:
- 关闭中断(防止内核启动时被打扰)
- 设置CPU为SVC模式(超级管理员模式)
- 清空指令缓存和数据缓存
- 跳转到内核入口地址
从这一刻起,U-Boot的生命周期就结束了。内核接管了所有硬件控制权。U-Boot占用的内存,如果没被保留,也可能被内核覆盖。
我记得第一次调这个跳转时,内核死活没反应。最后发现是U-Boot没有关闭MMU,内核启动时发现自己跑在虚拟地址上,直接懵了。嗯,这种细节,手册上不会写,只能靠经验积累。
避坑指南
- 设备树编译版本要匹配:内核版本和dtc编译器的版本不一致,可能导致解析失败。我建议用内核源码里的dtc来编译设备树。
- bootargs不要写死:尽量通过U-Boot的环境变量传递,方便调试时修改。
- 内存地址别冲突:加载内核、设备树、ramdisk时,三个地址要算清楚,别重叠。
- 串口参数要一致:U-Boot里用的串口波特率,和bootargs里设置的console参数必须一致,否则你啥也看不到。
好了,这一讲就到这里。U-Boot把内核加载好、参数传好、一跳,内核就开始运行了。下一讲,我们会进入内核的世界,看看Linux内核自身是如何初始化的。