3. Bootloader移植与实战

Bootloader 是嵌入式系统上电后运行的第一段代码。它的任务说白了就两件:初始化硬件,然后加载操作系统内核。在 Linux 世界里,U-Boot 几乎是事实标准。我这些年经手的项目,从 ARM9 到 Cortex-A72,清一色用的都是它。

这一章我们就把 U-Boot 扒开来看。从启动流程到源码结构,从配置编译到设备树支持,一条线走通。

3.1 U-Boot 启动流程分析

U-Boot 的启动分两个阶段。为什么分两段?因为早期芯片内部 SRAM 太小,放不下完整的 Bootloader。第一阶段用汇编搞定最基础的初始化,第二阶段用 C 语言完成复杂逻辑。

第一阶段:SPL(或叫 Stage 1)

SPL 是 Secondary Program Loader 的缩写。它体积小,通常只有 16KB 到 64KB。我见过不少新手直接拿完整的 U-Boot 去烧写,结果发现芯片内部 RAM 根本装不下——这就是没搞懂 SPL 的作用。

SPL 的核心工作:

  • 设置 CPU 为 SVC 模式,关中断
  • 初始化时钟、DDR 控制器
  • 从启动介质(SD卡、NAND Flash、eMMC)加载第二阶段代码到 DDR
  • 跳转到第二阶段入口

代码入口通常在 arch/arm/cpu/armv7/start.S(以 ARMv7 为例)。你打开这个文件,第一眼看到的就是 reset 向量。

/* arch/arm/cpu/armv7/start.S 片段 */
reset:
    /* 设置 SVC 模式,关 IRQ/FIQ */
    mrs r0, cpsr
    bic r0, r0, #0x1f
    orr r0, r0, #0xd3
    msr cpsr, r0

    /* 关闭 MMU 和 Cache */
    mrc p15, 0, r0, c1, c0, 0
    bic r0, r0, #0x0005
    mcr p15, 0, r0, c1, c0, 0

    /* 初始化栈指针 */
    ldr sp, =CONFIG_SPL_STACK
    /* 跳转到 C 语言入口 */
    bl board_init_f
注意:SPL 阶段的栈空间非常有限。我曾在某款 i.MX6 平台上踩过坑——SPL 里多声明了一个 4KB 的局部数组,直接导致栈溢出,系统反复重启。排查了整整两天才找到原因。

第二阶段:U-Boot Proper

第二阶段就是完整的 U-Boot。它运行在 DDR 里,空间充裕。入口函数是 board_init_r,主要做这些事:

  1. 初始化剩余外设(网卡、USB、存储控制器)
  2. 设置环境变量(bootargs、bootcmd)
  3. 解析设备树(FDT)
  4. 进入命令行界面(CLI)或自动启动内核

整个流程可以用下面这张图来概括:

U-Boot 启动流程 第一阶段:SPL 入口 (start.S) 初始化时钟、DDR、栈 从启动介质加载 U-Boot Proper 第二阶段:board_init_r 初始化外设、环境变量 解析设备树 (FDT) 启动内核 / 进入 CLI SPL 阶段代码量极小,通常用汇编 + 少量 C 实现 第二阶段才有完整的驱动模型和设备树支持

3.2 U-Boot 源码结构

U-Boot 的源码目录组织得相当清晰。你拿到源码后,第一件事就是看顶层目录。我个人的习惯是先看 board/ 和 configs/,这两个目录直接决定了你的板子能不能跑起来。

目录 作用 我常用的关注点
arch/ CPU 架构相关代码(arm、riscv、x86 等) start.S、lowlevel_init.S
board/ 板级初始化代码 board.c、dram.c
configs/ 默认配置文件(defconfig) xxx_defconfig
drivers/ 驱动代码(串口、网卡、MMC 等) serial、net、mmc
dts/ 设备树源文件(.dts、.dtsi) 对应板子的 dts 文件
include/ 头文件和配置宏 configs/xxx.h
common/ 通用功能(命令行、环境变量) main.c、cli.c
net/ 网络协议栈 tftp、nfs 支持
小技巧:移植新板子时,我通常找一个硬件相近的参考板,把它的 board/ 和 configs/ 目录复制一份再改。这比从零开始写快得多。

3.3 U-Boot 配置与编译

U-Boot 的配置系统从 2014 年之后全面转向了 Kconfig。如果你用过 Linux 内核的 make menuconfig,那这套东西你上手会非常快。

配置流程

  1. 选择默认配置:make xxx_defconfig
  2. 微调配置:make menuconfig(可选)
  3. 编译:make

举个例子,我手头有块 STM32MP157 的开发板:

# 第一步:选择默认配置
make stm32mp15_defconfig

# 第二步(可选):打开图形界面调整
make menuconfig

# 第三步:编译
make -j4

# 编译产物
# u-boot.bin        —— 完整的 U-Boot 镜像
# u-boot-spl.bin    —— SPL 镜像
# u-boot.img        —— 带 U-Boot 头部的镜像

关键点:编译前一定要确认交叉编译器。我习惯在 Makefile 里直接指定:

export CROSS_COMPILE=arm-linux-gnueabihf-

或者编译时带上:

make CROSS_COMPILE=arm-linux-gnueabihf- -j4

配置文件层级

U-Boot 的配置分三层,我画了个简图帮你理解:

U-Boot 配置层级 Kconfig(defconfig + menuconfig) include/configs/xxx.h(板级宏定义) include/config_defaults.h(默认值)

这三层是叠加关系。底层的默认值会被板级宏覆盖,板级宏又会被 Kconfig 覆盖。我曾经在调试一块板子时,明明在 menuconfig 里打开了某个功能,编译出来却没有——后来发现是 include/configs/xxx.h 里有个 #undef 把它关掉了。嗯,这种问题排查起来确实有点绕。

3.4 U-Boot 设备树支持

设备树(Device Tree)是 U-Boot 和 Linux 内核之间传递硬件信息的桥梁。U-Boot 在启动内核前,会把设备树在内存中的地址传给内核。内核根据设备树来知道:我有哪些外设?它们的地址是多少?中断号是多少?

U-Boot 中的设备树处理

U-Boot 对设备树的支持分两个层面:

  • 编译时:把 .dts 编译成 .dtb,打包进 U-Boot 镜像
  • 运行时:U-Boot 可以修改设备树(比如修改 chosen 节点设置 bootargs)

设备树源文件放在 dts/ 目录下。以我最近调的一块 i.MX8M 板子为例:

dts/
├── arch/
│   └── arm/
│       └── dts/
│           ├── imx8mm.dtsi          // SoC 级通用定义
│           ├── imx8mm-evk.dts       // 评估板定义
│           └── my-custom-board.dts  // 我自己的板子
避坑指南:我曾经在移植 U-Boot 时,设备树里把 UART 的时钟频率写错了。结果 U-Boot 启动时串口输出全是乱码。排查了三个小时才发现是 clock-frequency 属性值不对。所以移植新板子时,第一件事就是确认串口节点的时钟和引脚复用配置。

U-Boot 如何传递设备树给内核

启动内核时,U-Boot 通过 bootm 或 booti 命令加载内核和设备树。典型的启动命令:

# 从网络加载
tftp 0x42000000 zImage
tftp 0x43000000 my-board.dtb
bootz 0x42000000 - 0x43000000

# 从 eMMC 加载
load mmc 1:1 0x42000000 /boot/zImage
load mmc 1:1 0x43000000 /boot/my-board.dtb
bootz 0x42000000 - 0x43000000

这里 0x42000000 是内核加载地址,0x43000000 是设备树加载地址。U-Boot 在跳转到内核前,会把设备树地址放在 r2 寄存器里(ARM 架构约定)。

U-Boot 修改设备树的常用操作

有时候我们需要在 U-Boot 命令行里动态修改设备树。比如临时改一下内核启动参数:

# 设置 bootargs
setenv bootargs 'console=ttymxc0,115200 root=/dev/mmcblk1p2 rootwait'

# 把 bootargs 写入设备树的 chosen 节点
fdt addr 0x43000000
fdt set /chosen bootargs "${bootargs}"

# 启动内核
bootz 0x42000000 - 0x43000000
经验之谈:调试阶段我习惯把设备树单独放在 TFTP 服务器上,这样改完 .dts 重新编译,直接 tftp 加载测试,不用反复烧写 eMMC。开发效率能提升不少。

3.5 小结