2. Bootloader原理与U-Boot移植
Bootloader,说白了就是嵌入式系统的「第一口饭」。CPU上电后,它得先跑起来,把硬件初始化好,再把内核请进内存。我刚开始做嵌入式那会儿,总觉得Bootloader就是个跳板,后来踩了几次坑才明白——这玩意儿要是没弄对,后面全是白搭。
2.1 Bootloader启动流程
嵌入式系统的启动,一般分这么几步:
- ROM代码:芯片内部固化的启动代码,负责从指定介质加载SPL。
- SPL(Secondary Program Loader):初始化DDR、时钟等,加载完整的U-Boot。
- U-Boot:完整的Bootloader,加载内核和设备树。
- 内核:解压并运行,挂载根文件系统。
你想想看,为什么要有SPL?因为很多SoC的片上SRAM只有几十KB,装不下完整的U-Boot。SPL就是那个「瘦身版」的加载器,干完活就交棒。
关键点:SPL和U-Boot的分工,是嵌入式Bootloader设计的核心思想。SPL只做最少的事,把复杂逻辑留给U-Boot。
我在项目中遇到过一块板子,SPL死活加载不了U-Boot。查了半天,发现是DDR初始化时序配错了。嗯,这种问题最头疼——因为SPL阶段连串口都还没完全配好,调试全靠点灯。
2.2 U-Boot源码结构
U-Boot的源码目录,我建议你重点关注这几个:
| 目录 | 作用 |
|---|---|
arch/ |
架构相关代码,比如ARM、RISC-V、x86 |
board/ |
板级代码,每个板子一个子目录 |
drivers/ |
驱动,包括串口、网卡、MMC等 |
include/ |
头文件,特别是configs/下的板级配置 |
common/ |
通用功能,比如命令行解析、环境变量 |
cmd/ |
命令实现,每个命令一个文件 |
我个人习惯,拿到一个新板子,先看board/和include/configs/。这两个目录基本决定了板子的「性格」。
小技巧:U-Boot的配置系统借鉴了Linux内核的Kconfig。你可以在源码根目录执行make menuconfig,图形化地开关功能。
2.3 U-Boot配置与编译
配置和编译U-Boot,流程其实很固定:
# 清理
make distclean
# 配置(以qemu-arm为例)
make qemu_arm_defconfig
# 图形化微调(可选)
make menuconfig
# 编译
make -j4
# 编译SPL(如果板子支持)
make SPL
为什么先distclean?因为U-Boot的配置缓存很敏感,换板子不清理,编译会出各种怪问题。我曾经因为偷懒没clean,结果编译出来的U-Boot在板子上跑飞了,查了两天才发现是配置残留。
避坑指南:交叉编译时,一定要设置CROSS_COMPILE环境变量。比如export CROSS_COMPILE=arm-linux-gnueabihf-。忘了这个,编译出来的东西在目标板上跑不了。
编译完成后,你会得到u-boot.bin(完整版)和u-boot-spl.bin(SPL版)。有些SoC还需要mkimage工具把U-Boot打包成特定格式。
2.4 U-Boot常用命令
U-Boot的命令行,是调试嵌入式系统的利器。我列几个最常用的:
| 命令 | 功能 | 示例 |
|---|---|---|
printenv |
打印环境变量 | printenv bootargs |
setenv |
设置环境变量 | setenv bootargs 'console=ttyS0,115200' |
saveenv |
保存环境变量到存储 | saveenv |
tftp |
通过TFTP下载文件 | tftp 0x42000000 uImage |
mmc |
MMC/SD卡操作 | mmc read 0x42000000 0x800 0x1000 |
bootm |
启动内核 | bootm 0x42000000 |
bootz |
启动zImage内核 | bootz 0x42000000 |
fdt |
设备树操作 | fdt addr 0x43000000 |
我个人最常用的是tftp和bootm的组合。开发阶段,把内核和设备树放在TFTP服务器上,U-Boot通过网络加载,省去了反复烧写存储的麻烦。
核心流程:tftp 0x42000000 uImage → tftp 0x43000000 board.dtb → bootm 0x42000000 - 0x43000000
2.5 设备树基础
设备树(Device Tree),是描述硬件信息的结构化数据。U-Boot和内核通过它来了解板子上有什么外设、怎么访问。
一个简单的设备树节点长这样:
/dts-v1/;
/ {
model = "MyBoard";
compatible = "vendor,myboard";
chosen {
stdout-path = &uart0;
};
uart0: serial@10000000 {
compatible = "ns16550";
reg = <0x10000000 0x1000>;
clock-frequency = <24000000>;
};
};
你想想看,为什么要有设备树?以前的做法是把硬件信息硬编码在代码里,换个板子就得改代码。设备树把硬件描述和驱动代码解耦了——换板子,换一个.dtb文件就行。
我在项目中遇到过一个问题:U-Boot启动内核后,串口没输出。查了半天,发现是设备树里chosen节点的stdout-path配错了。嗯,这种问题很隐蔽,因为U-Boot阶段串口是好的,内核阶段才出问题。
调试技巧:在U-Boot命令行下,可以用fdt list查看设备树内容,用fdt set修改节点属性。这样不用重新编译,就能快速验证硬件配置。
设备树的编译也很简单:
# 编译设备树源文件(.dts)为二进制(.dtb)
dtc -I dts -O dtb -o board.dtb board.dts
# 反编译查看
dtc -I dtb -O dts -o board.dts board.dtb
下面这张图,展示了U-Boot启动过程中,设备树是如何被传递和使用的:
这张图把启动流程和设备树的关系画清楚了。U-Boot从存储介质加载.dtb文件,可以修改chosen节点(比如设置启动参数),然后通过寄存器或内存地址把设备树指针传给内核。
避坑指南:设备树和内核版本要匹配。我曾经把新内核配旧设备树,结果网卡驱动死活不认。后来发现是设备树里compatible字符串和驱动对不上。记住,设备树是「契约」,双方都得遵守。