2、Bootloader深度解析与移植(上):U-Boot源码结构分析、启动流程详解、板级配置与设备树基础
好,咱们开始聊Bootloader。说实话,很多做嵌入式Linux的朋友,对U-Boot的态度就是「能用就行」。编译一下,烧进去,能启动内核就完事了。但真到了要移植新板子、或者排查启动异常的时候,才发现自己对U-Boot的理解其实很模糊。
我个人习惯,做系统裁剪之前,一定要先把Bootloader吃透。为什么?因为它是硬件和内核之间的桥梁。桥没搭好,后面全是坑。这一章,咱们就深入U-Boot内部,看看它的源码怎么组织、启动流程到底干了什么、板级配置怎么玩、设备树又是怎么回事。
2.1 U-Boot源码结构:别被目录吓到
第一次解压U-Boot源码,看到一堆目录,确实容易懵。我刚开始接触的时候,也是对着ls输出发愣。其实它的结构很清晰,咱们拆开看。
| 目录/文件 | 作用 | 我的经验 |
|---|---|---|
arch/ |
架构相关代码,ARM、x86、RISC-V都在这里 | 移植ARM板子,90%的时间泡在arch/arm/里 |
board/ |
板级特定代码,厂商或具体板子的目录 | 这里放的是「这块板子独有的东西」 |
common/ |
通用功能,比如命令行、环境变量、HUSH解析器 | 别乱改,除非你知道后果 |
drivers/ |
各种驱动:网卡、串口、MMC、NAND、USB | 裁剪时,这里是大头,用不到的驱动全删掉 |
include/ |
头文件,特别是configs/下的板级配置头文件 |
移植第一步,就是在这里新建一个头文件 |
dts/ |
设备树源文件(Device Tree Source) | 新板子必须有的东西,后面细说 |
tools/ |
主机端工具,比如mkimage |
打包内核镜像时常用 |
你看,核心就这几个。我个人习惯,拿到一个新板子,先看board/和include/configs/,这两个目录决定了板子能不能跑起来。
include/configs/头文件,复制一份,改名字。这是最稳妥的起步方式。
2.2 启动流程详解:从复位到命令行
U-Boot的启动流程,说白了就是一段「从汇编到C」的旅程。咱们以ARM64为例,捋一遍关键步骤。
- 复位向量(Reset Vector):CPU上电后,从
0x00000000或0xFFFF0000开始执行。U-Boot在这里放了一条跳转指令,跳到_start。 - 汇编阶段(
arch/arm/cpu/armv8/start.S):设置CPU为SVC模式、关中断、初始化异常向量表、设置栈指针。嗯,这里要注意,栈指针必须指向一段可用的RAM,否则后面的C代码一调用就崩。 - 低级别初始化(
lowlevel_init):初始化时钟、DDR控制器。这一步很关键,DDR没配好,后面所有操作都是空中楼阁。我在项目中遇到过,DDR时序参数配错,板子死活跑不起来,最后用示波器一根一根量地址线才找到问题。 - 重定位(Relocation):U-Boot把自己从Flash或ROM复制到DDR中。为什么要这么做?因为DDR里运行速度快,而且可以修改代码段(比如打补丁)。
- 进入C世界(
board_init_f->board_init_r):初始化串口、定时器、I2C、网卡等外设。然后进入主循环,等待用户输入命令。
为什么会分成board_init_f和board_init_r?说白了,f阶段是在Flash里跑的,资源受限;r阶段是在DDR里跑的,可以放开手脚。这个设计很巧妙,我每次给别人讲的时候,都会强调这个分界线。
2.3 板级配置:从头文件到Kconfig
U-Boot的板级配置,经历过一次「文艺复兴」。早期版本全靠头文件宏定义,现在逐步迁移到Kconfig + 设备树。但很多老牌芯片厂商,还是喜欢用头文件方式。咱们两种都得会。
2.3.1 传统方式:include/configs/头文件
打开一个典型的板级配置头文件,你会看到一堆#define。比如:
/* include/configs/myboard.h */
#define CONFIG_SYS_TEXT_BASE 0x80000000
#define CONFIG_NR_DRAM_BANKS 1
#define CONFIG_SYS_SDRAM_BASE 0x80000000
#define CONFIG_SYS_SDRAM_SIZE 0x20000000 /* 512 MB */
#define CONFIG_CONS_INDEX 1
#define CONFIG_BAUDRATE 115200
#define CONFIG_BOOTCOMMAND "fatload mmc 0:1 0x82000000 kernel.img; bootm 0x82000000"
这些宏,定义了板子的内存布局、串口参数、启动命令。我刚开始移植时,最头疼的就是CONFIG_SYS_TEXT_BASE。这个值必须和链接脚本里的地址一致,否则U-Boot重定位时会跑飞。
CONFIG_SYS_TEXT_BASE设错了,结果U-Boot启动到一半就死机。查了两天,最后发现是链接地址和实际加载地址不匹配。记住:这个值必须等于你的U-Boot在DDR中的加载地址。
2.3.2 现代方式:Kconfig + 设备树
新版本的U-Boot,推荐用Kconfig来配置。你运行make menuconfig,选好芯片型号、板子型号、驱动选项,系统会自动生成.config文件。这种方式更直观,也更容易管理。
举个例子,选择ARM64架构的QEMU虚拟板:
$ make qemu_arm64_defconfig
$ make menuconfig
# 在这里可以开关各种功能
$ make -j4
我个人更喜欢Kconfig方式,因为不用记那么多宏名字。但说实话,很多工业级产品还在用老方式,因为稳定。你想想看,一个产品量产了好几年,谁会为了「配置方式更现代」去升级Bootloader?
2.4 设备树基础:让U-Boot认识你的硬件
设备树(Device Tree),是ARM Linux生态里绕不开的东西。它的作用,说白了就是「用数据结构描述硬件」。U-Boot从2014年开始,逐步引入设备树支持。现在,U-Boot本身也使用设备树来配置板级信息。
2.4.1 设备树文件结构
一个典型的设备树文件(.dts)长这样:
/* arch/arm/dts/myboard.dts */
/dts-v1/;
#include "imx6ul.dtsi"
/ {
model = "My Custom i.MX6UL Board";
compatible = "mycompany,myboard", "fsl,imx6ul";
chosen {
stdout-path = &uart1;
};
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x20000000>; /* 512 MB */
};
&uart1 {
pinctrl-names = "default";
pinctrl-0 = <&pinctrl_uart1>;
status = "okay";
};
};
看到compatible属性了吗?它就是U-Boot和内核用来匹配驱动的「身份证」。U-Boot在启动时,会读取设备树,根据compatible找到对应的驱动初始化函数。
2.4.2 U-Boot中的设备树使用
U-Boot使用设备树,主要有两个场景:
- U-Boot自身使用设备树:U-Boot编译时,会把设备树二进制(
.dtb)附加到U-Boot镜像末尾。启动时,U-Boot解析这个.dtb,获取内存大小、串口地址、时钟频率等信息。 - 传递给内核:U-Boot启动内核时,会把
.dtb的地址通过寄存器(ARM64是x0)传递给内核。内核解析设备树,枚举所有硬件设备。
我在项目中遇到过一个问题:U-Boot里设备树的内存节点和实际硬件不一致,导致内核只识别了部分内存。排查方法很简单,在U-Boot命令行里输入fdt list /memory,看看reg属性对不对。
fdt命令,可以查看和修改设备树。比如fdt addr $fdt_addr设置设备树地址,fdt list /列出根节点。这些命令在调试时非常有用。
2.5 本章知识结构图
下面这张图,把U-Boot源码结构、启动流程、板级配置和设备树的关系串起来了。你可以把它当作本章的「导航地图」。
这张图里,我把四个核心模块放在同一层,用箭头表示它们之间的关系。源码结构是基础,启动流程是主线,板级配置是定制手段,设备树是数据载体。你移植一个新板子,其实就是在这四个模块里填内容。
2.6 本章小结
这一章,咱们把U-Boot的源码结构、启动流程、板级配置和设备树基础过了一遍。这些东西,是后续做系统裁剪和移植的「内功」。你想想看,如果连Bootloader怎么启动、配置在哪改、设备树怎么用都不清楚,后面裁剪内核、优化驱动,根本无从下手。