3. Bootloader深度解析:U-Boot启动流程、命令与脚本、驱动模型、内核跳转

Bootloader,说白了就是嵌入式系统的“第一口饭”。上电后CPU第一个跑的就是它。我这些年调过的板子,十有八九的问题都出在U-Boot阶段——要么起不来,要么起错了。今天咱们就把U-Boot从里到外扒一遍。

3.1 U-Boot启动流程:从汇编到C的世界

U-Boot的启动分两个阶段。第一阶段用汇编,第二阶段用C。为什么这么设计?因为刚上电时,C语言运行环境还没准备好——栈指针没设、BSS段没清、全局变量没法用。你想想看,这时候只能用汇编干粗活。

第一阶段:SPL(Secondary Program Loader)

现在的U-Boot普遍采用SPL机制。SPL是个迷你版U-Boot,体积控制在几十KB以内。它负责最基础的硬件初始化,然后加载完整的U-Boot到DDR里。

SPL的核心任务:

  • 设置CPU为SVC模式,关中断
  • 初始化时钟、PLL、DDR控制器
  • 设置栈指针(SP),为C语言铺路
  • 从启动介质(SD卡、eMMC、NAND Flash)读取U-Boot主体
  • 跳转到U-Boot的入口地址

我在项目中遇到过一块板子,SPL加载U-Boot时总是校验失败。查了两天,发现是DDR时序参数配错了——SPL初始化DDR时用了保守参数,但U-Boot主体加载后重新配置了DDR,导致数据错位。嗯,这里要注意:SPL和U-Boot对DDR的配置必须一致。

第二阶段:U-Boot主体

U-Boot主体从SPL接手后,会做更完整的初始化。流程大致如下:

  1. board_init_f():初始化串口、定时器、GPIO等基础外设
  2. relocate_code():把自己从Flash搬到DDR里运行
  3. board_init_r():初始化完整的外设驱动、文件系统、网络协议栈
  4. 进入主循环:等待用户输入命令,或自动执行bootcmd

我个人习惯在board_init_r()里加调试打印,打印出当前DDR地址和栈指针。这招帮我抓过好几次内存越界问题。

3.2 U-Boot命令与脚本:不只是敲命令

U-Boot的命令行看着简单,但用好它,调试效率能翻倍。我见过不少工程师只会用printenv和boot,其实U-Boot的命令体系很强大。

常用命令分类

类别 命令 用途
内存操作 md mw mm 查看、写入、修改内存
存储设备 mmc sf fatload 操作SD卡、SPI Flash、文件系统
网络 tftp ping dhcp 网络下载、连通性测试
环境变量 setenv saveenv printenv 配置持久化
调试 go bootm bootz 跳转到指定地址执行

调试技巧:用md.b 0x80000000 100可以dump内存前256字节。配合cmp.b命令,能快速比对两段内存是否一致。我曾经用这个命令定位过DMA传输的数据错位问题。

U-Boot脚本:自动化你的调试流程

U-Boot支持脚本化执行。环境变量里可以写多行命令,用分号或换行分隔。举个例子:

setenv myboot 'tftp 0x82000000 kernel.itb; tftp 0x83000000 dtb; bootm 0x82000000 - 0x83000000'
saveenv
run myboot

我个人习惯把常用的调试流程写成脚本,比如“从网络加载内核并启动”、“从SD卡加载备用系统”、“进入工厂测试模式”。这样量产时,产线工人只需要按电源键,U-Boot自动跑脚本。

注意:U-Boot脚本里不要写死内存地址。不同板子的DDR基地址可能不同。我建议用${loadaddr}、${fdt_addr}这类预定义变量,或者通过getenv动态获取。

3.3 U-Boot驱动模型:DM(Driver Model)

U-Boot的驱动模型(DM)是2014年后引入的。说白了,就是把Linux的设备树和驱动框架搬到了U-Boot里。为什么要这么做?因为传统U-Boot的驱动代码太乱了——每个板子一套初始化函数,复用性极差。

DM的核心概念

  • udevice:代表一个设备实例,由设备树节点创建
  • driver:驱动结构体,包含probe、remove、ops等回调
  • uclass:设备类,比如所有GPIO控制器归为GPIO uclass
  • phandle:设备树中的引用关系,驱动通过它找到依赖设备

举个例子,一个串口驱动的probe流程:

static int serial_s3c_probe(struct udevice *dev) {
    struct s3c_serial_plat *plat = dev_get_plat(dev);
    // 从设备树获取寄存器基地址
    plat->base = dev_read_addr(dev);
    // 初始化硬件
    s3c_serial_init(plat);
    return 0;
}

U_BOOT_DRIVER(serial_s3c) = {
    .name = "s3c_serial",
    .id = UCLASS_SERIAL,
    .probe = serial_s3c_probe,
    .plat_auto = sizeof(struct s3c_serial_plat),
};

我在项目中遇到过一个问题:某个外设在U-Boot下能工作,但进内核后就挂了。查到最后,发现U-Boot的DM驱动在probe时没有正确复位设备,导致内核驱动拿到的硬件状态是脏的。所以,我建议在驱动的probe函数里,一定要做硬件复位。

DM的启动流程

U-Boot启动时,DM的初始化顺序是固定的:

  1. 解析设备树,创建所有udevice实例
  2. 按依赖关系排序(父设备先于子设备)
  3. 依次调用每个设备的probe函数
  4. probe失败时,标记设备为“不可用”,不影响其他设备

调试DM问题:在U-Boot命令行输入dm tree,可以查看所有设备的probe状态。如果某个设备显示not probed,说明它的probe函数没被调用。这时候检查设备树里的status属性,或者看驱动有没有注册到对应的uclass。

3.4 从U-Boot到内核的跳转

这是Bootloader的终极使命——把控制权交给内核。跳转过程看似简单,但细节决定成败。

跳转前的准备工作

U-Boot在跳转前,必须完成以下几件事:

  • 关闭中断:所有外设中断必须关掉,否则内核启动时会被打断
  • 设置内核启动参数:通过设备树(DTB)或ATAGS传递内存布局、命令行参数
  • 设置CPU寄存器:ARM架构下,r0=0,r1=机器类型ID,r2=DTB地址
  • 刷新Cache和TLB:确保数据一致性,避免内核读到脏数据

我曾经踩过的坑:有一块板子,U-Boot跳转内核后总是卡在“Starting kernel...”不动。查了两天,发现是U-Boot在跳转前没有关闭MMU。内核启动时,MMU配置和U-Boot不一致,导致指令预取异常。解决方案:在跳转前调用cleanup_before_linux(),这个函数会关MMU、关Cache、刷TLB。

跳转命令详解

U-Boot支持三种跳转命令:

命令 适用场景 参数格式
bootm 传统uImage格式 bootm [kernel_addr] [ramdisk_addr] [dtb_addr]
bootz zImage格式(ARM) bootz [kernel_addr] [ramdisk_addr] [dtb_addr]
booti Image格式(ARM64) booti [kernel_addr] [ramdisk_addr] [dtb_addr]

我个人习惯用bootm配合FIT Image(Flattened Image Tree)。FIT Image把内核、设备树、ramdisk打包成一个文件,用签名校验确保完整性。量产时,这个功能能防止固件被篡改。

跳转后的状态

U-Boot跳转后,CPU的控制权完全交给内核。U-Boot不再运行,它的代码段和数据段可以被内核覆盖。所以,U-Boot在跳转前要把所有需要保留的信息(比如环境变量)写回存储介质。

调试跳转问题:如果内核启动卡住,可以在U-Boot里用bootargs加上earlyprintk参数。这样内核在早期初始化阶段就会打印调试信息。另外,检查DTB地址是否正确——我见过有人把DTB加载到DDR的末尾,结果被内核的初始页表覆盖了。

3.5 U-Boot启动全流程SVG图

下面这张图总结了U-Boot从上电到跳转内核的完整流程:

U-Boot启动全流程 U-Boot启动全流程 第一阶段:SPL(汇编 + 精简C) CPU初始化(SVC模式) 时钟/PLL/DDR初始化 设置栈指针(SP) 加载U-Boot到DDR 第二阶段:U-Boot主体(完整C环境) board_init_f() relocate_code() board_init_r() 进入主循环 跳转前准备 关中断、关MMU 设置启动参数(DTB) 设置CPU寄存器 刷Cache/TLB Linux Kernel 启动 关键点:SPL与U-Boot的DDR配置必须一致;跳转前必须关MMU和中断;DTB地址不能与内核重叠

3.6 实战经验总结

最后,分享几个我这些年积累的U-Boot调试经验:

  • SPL起不来:先检查DDR初始化。用示波器量DDR的时钟和使能信号,看时序对不对。我遇到过DDR的ODT配置不对,导致信号反射严重。
  • U-Boot加载内核失败:检查存储介质的读写速度。有些SD卡在高速模式下会丢数据,降速到25MHz就稳定了。
  • 内核启动卡住:在U-Boot里用bootargs加earlyprintk和debug参数。如果还不行,检查设备树里的内存节点——我见过有人把内存大小写错了,内核只认到一半内存。
  • 环境变量丢失:检查存储介质的坏块。NAND Flash有坏块是常态,U-Boot的环境变量驱动必须支持坏块管理。

一句话总结:U-Boot调试,本质上是“硬件初始化 + 数据搬运 + 状态传递”三个环节的排查。从SPL到U-Boot,再到内核,每一步都要确认前一步的结果正确。别跳步,别假设,用打印和示波器说话。