2. Bootloader与硬件初始化:U-Boot的启动流程、设备树传递、内核引导参数设置
说实话,很多做嵌入式Linux的朋友,对U-Boot的态度就是「能用就行」。烧进去能启动内核,就再也不管了。但我要说,Bootloader是整个系统的地基。地基没打好,上层再漂亮也是危房。
我早年在一个量产项目上吃过亏。U-Boot里设备树地址传错了,导致内核拿不到正确的内存布局,产品在高温下随机死机。查了整整两周,最后发现是Bootloader里一个宏定义写死了。从那以后,我对U-Boot的启动流程和参数传递,再也不敢「差不多就行」。
这一章,我们就把它彻底讲透。
2.1 U-Boot到底在干什么?
U-Boot的全称是Universal Boot Loader。它的核心任务就三件:
- 初始化硬件:把CPU、DDR、Flash、串口这些基础外设从「上电状态」带到「可用状态」。
- 加载内核镜像:从存储介质(NAND、eMMC、SD卡、网络)把内核和设备树读到内存。
- 传递控制权:设置好启动参数,跳转到内核入口。
你想想看,PC的BIOS/UEFI做完硬件初始化后,把控制权交给操作系统。U-Boot在嵌入式系统里,扮演的就是这个角色。只不过它更灵活,也更「裸」——你几乎可以定制一切。
2.2 U-Boot的启动流程:从复位向量到命令行
U-Boot的启动流程,我习惯把它分成两个阶段:SPL(Secondary Program Loader)和U-Boot proper。现在主流芯片都这么玩。
2.2.1 第一阶段:SPL(俗称「小Boot」)
芯片上电后,CPU从复位向量(通常是0x00000000或0xFFFF0000)开始执行。但这时候DDR还没初始化,代码只能在芯片内部的SRAM里跑。SRAM通常只有几十KB到几百KB,放不下完整的U-Boot。
所以就有了SPL。它是一段极精简的代码,只做三件事:
- 初始化最基本的硬件:时钟、串口、DDR控制器。
- 从存储介质加载U-Boot proper到DDR。
- 跳转到DDR里的U-Boot proper入口。
2.2.2 第二阶段:U-Boot proper(俗称「大Boot」)
U-Boot proper运行在DDR里,空间充裕。它的启动流程大致如下:
| 步骤 | 动作 | 说明 |
|---|---|---|
| 1 | board_init_f() | 初始化板级外设:定时器、GPIO、I2C、环境变量存储区 |
| 2 | relocate_code() | 将U-Boot自身重定位到DDR的高地址区域 |
| 3 | board_init_r() | 初始化完整驱动:网卡、存储、USB、显示 |
| 4 | run_main_loop() | 进入主循环,等待用户输入或自动启动内核 |
这里有个关键点:重定位。U-Boot一开始在低地址运行,但低地址通常要留给内核。所以它把自己搬到高地址,然后继续执行。如果这一步出问题,整个系统就挂了。我曾经在一个新板子上遇到重定位后串口打印乱码,查了半天发现是DDR时序参数没配对。
2.3 设备树传递:U-Boot和内核之间的「契约」
设备树(Device Tree)是U-Boot传递给内核的核心数据结构。它描述了硬件拓扑:CPU有几个核、外设挂在哪条总线上、中断号是多少、寄存器地址范围……
U-Boot传递设备树的方式很简单:把设备树在内存中的物理地址放在寄存器r2(ARM架构)里,然后跳转到内核。内核启动时,直接从r2读取设备树地址,解析硬件信息。
fdt addr设置设备树地址,用fdt list查看设备树内容。调试时这两个命令最常用。
我见过最典型的错误是:设备树地址和内核加载地址重叠。U-Boot把内核解压到0x80000000,设备树也放在0x80000000附近,结果内核解压时直接把设备树覆盖了。内核启动到一半,访问外设寄存器时崩溃——因为它拿到的设备树已经被撕碎了。
正确的做法是:给设备树留出独立的内存区域。比如内核放在0x80000000,设备树放在0x88000000。中间留够空间。
2.3.1 设备树在U-Boot中的生命周期
设备树在U-Boot里会经历三个阶段:
- 加载:从存储介质读到内存。命令:
load mmc 0:1 0x88000000 /boot/xxx.dtb - 修改:U-Boot可以根据环境变量动态修改设备树。比如设置
bootargs、使能某个节点。命令:fdt set /chosen bootargs "console=ttyS0,115200" - 传递:跳转内核前,把设备树地址写入r2。
这里有个小技巧:U-Boot的fdt命令支持fdt boardsetup,它会调用板级回调函数,自动修改设备树。比如根据硬件版本号,使能或禁用某些外设。我在一个多版本共存的板子上用过这个功能,省了不少事。
2.4 内核引导参数设置:bootargs的学问
内核引导参数(bootargs)是U-Boot传给内核的命令行字符串。它告诉内核:根文件系统在哪、控制台用哪个串口、内存大小、init进程路径……
一个典型的bootargs长这样:
console=ttyS0,115200 root=/dev/mmcblk0p2 rw rootfstype=ext4 init=/sbin/init
每个参数都有讲究:
- console:指定内核日志输出到哪里。调试阶段用串口,量产阶段可以改成
tty1(显示终端)。 - root:根文件系统设备。可以是
/dev/mmcblk0p2(eMMC分区)、/dev/nfs(网络文件系统)、root=/dev/ram0(initramfs)。 - rootfstype:文件系统类型。不指定的话内核会自动检测,但指定了能加快启动速度。
- init:第一个用户态进程。默认是
/sbin/init,但嵌入式系统常用/linuxrc或/bin/busybox。
rw(读写挂载)。结果根文件系统以只读方式挂载,应用程序写日志时全部失败。排查了很久才发现是少了一个参数。所以我的习惯是:量产前把bootargs逐字检查三遍。
2.5 整体流程可视化
下面这张图总结了U-Boot从复位到启动内核的完整流程。我建议你把它存下来,调试时对照着看。
2.6 实战:一个典型的自动启动脚本
在U-Boot的环境变量里,bootcmd定义了自动启动时要执行的命令。下面是我在一个i.MX6ULL项目上用的脚本:
setenv bootargs console=ttyS0,115200 root=/dev/mmcblk0p2 rw rootfstype=ext4
load mmc 0:1 0x80000000 /boot/zImage
load mmc 0:1 0x88000000 /boot/xxx.dtb
fdt addr 0x88000000
fdt boardsetup
bootz 0x80000000 - 0x88000000
逐行解释:
- 第1行:设置内核引导参数。
- 第2行:从SD卡第一个分区加载内核到0x80000000。
- 第3行:加载设备树到0x88000000。
- 第4行:告诉U-Boot设备树在哪个地址。
- 第5行:调用板级回调,动态修改设备树(比如根据硬件版本调整GPIO配置)。
- 第6行:启动内核。
bootz用于zImage格式,bootm用于uImage格式。
bootcmd里加一个echo打印当前加载的版本号。这样量产时如果出了问题,通过串口日志就能知道烧的是哪个版本的固件。别小看这个细节,它能帮你省下大量排查时间。
2.7 常见问题与排查思路
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 串口无输出 | SPL没跑起来,或串口配置错误 | 检查时钟配置、GPIO复用、波特率 |
| 内核启动到一半卡死 | 设备树地址错误,或设备树被覆盖 | 用fdt addr确认地址,检查内存布局 |
| 根文件系统挂载失败 | bootargs里root参数错误 | 检查分区号、文件系统类型、读写权限 |
| 网络启动超时 | PHY初始化失败,或MAC地址未设置 | 用mdio命令检查PHY状态 |
嗯,这一章的内容就到这里。U-Boot的启动流程、设备树传递、内核引导参数设置,这三块是嵌入式Linux启动的核心。你把这些吃透了,后面调试驱动、优化启动时间,都会顺手很多。