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。它是一段极精简的代码,只做三件事:

  1. 初始化最基本的硬件:时钟、串口、DDR控制器。
  2. 从存储介质加载U-Boot proper到DDR。
  3. 跳转到DDR里的U-Boot proper入口。
我的经验:调试SPL是最痛苦的,因为它没有完整的驱动栈。我习惯在SPL里加串口打印,用GPIO点灯来定位问题。别指望JTAG——很多量产板子根本没留JTAG接口。

2.2.2 第二阶段:U-Boot proper(俗称「大Boot」)

U-Boot proper运行在DDR里,空间充裕。它的启动流程大致如下:

步骤动作说明
1board_init_f()初始化板级外设:定时器、GPIO、I2C、环境变量存储区
2relocate_code()将U-Boot自身重定位到DDR的高地址区域
3board_init_r()初始化完整驱动:网卡、存储、USB、显示
4run_main_loop()进入主循环,等待用户输入或自动启动内核

这里有个关键点:重定位。U-Boot一开始在低地址运行,但低地址通常要留给内核。所以它把自己搬到高地址,然后继续执行。如果这一步出问题,整个系统就挂了。我曾经在一个新板子上遇到重定位后串口打印乱码,查了半天发现是DDR时序参数没配对。

2.3 设备树传递:U-Boot和内核之间的「契约」

设备树(Device Tree)是U-Boot传递给内核的核心数据结构。它描述了硬件拓扑:CPU有几个核、外设挂在哪条总线上、中断号是多少、寄存器地址范围……

U-Boot传递设备树的方式很简单:把设备树在内存中的物理地址放在寄存器r2(ARM架构)里,然后跳转到内核。内核启动时,直接从r2读取设备树地址,解析硬件信息。

关键命令:在U-Boot命令行里,用fdt addr设置设备树地址,用fdt list查看设备树内容。调试时这两个命令最常用。

我见过最典型的错误是:设备树地址和内核加载地址重叠。U-Boot把内核解压到0x80000000,设备树也放在0x80000000附近,结果内核解压时直接把设备树覆盖了。内核启动到一半,访问外设寄存器时崩溃——因为它拿到的设备树已经被撕碎了。

正确的做法是:给设备树留出独立的内存区域。比如内核放在0x80000000,设备树放在0x88000000。中间留够空间。

2.3.1 设备树在U-Boot中的生命周期

设备树在U-Boot里会经历三个阶段:

  1. 加载:从存储介质读到内存。命令:load mmc 0:1 0x88000000 /boot/xxx.dtb
  2. 修改:U-Boot可以根据环境变量动态修改设备树。比如设置bootargs、使能某个节点。命令:fdt set /chosen bootargs "console=ttyS0,115200"
  3. 传递:跳转内核前,把设备树地址写入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。
避坑指南:我曾经在生产环境中遇到一个问题——bootargs里忘了加rw(读写挂载)。结果根文件系统以只读方式挂载,应用程序写日志时全部失败。排查了很久才发现是少了一个参数。所以我的习惯是:量产前把bootargs逐字检查三遍。

2.5 整体流程可视化

下面这张图总结了U-Boot从复位到启动内核的完整流程。我建议你把它存下来,调试时对照着看。

U-Boot 启动流程与内核引导 芯片上电 / 复位 SPL:初始化时钟、DDR、串口 从存储介质加载 U-Boot proper board_init_f():板级初始化 relocate_code():重定位到高地址 board_init_r():完整驱动初始化 加载内核镜像 + 设备树到内存 设置 bootargs → 跳转到内核

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启动的核心。你把这些吃透了,后面调试驱动、优化启动时间,都会顺手很多。