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
第二阶段:U-Boot Proper
第二阶段就是完整的 U-Boot。它运行在 DDR 里,空间充裕。入口函数是 board_init_r,主要做这些事:
- 初始化剩余外设(网卡、USB、存储控制器)
- 设置环境变量(bootargs、bootcmd)
- 解析设备树(FDT)
- 进入命令行界面(CLI)或自动启动内核
整个流程可以用下面这张图来概括:
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,那这套东西你上手会非常快。
配置流程
- 选择默认配置:
make xxx_defconfig - 微调配置:
make menuconfig(可选) - 编译:
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 的配置分三层,我画了个简图帮你理解:
这三层是叠加关系。底层的默认值会被板级宏覆盖,板级宏又会被 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 // 我自己的板子
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
.dts 重新编译,直接 tftp 加载测试,不用反复烧写 eMMC。开发效率能提升不少。