关于设备树,我们需要知道:.dts 文件是人类可读的文本文件,也是我们通常添加设备树节点的地方;DTC 是编译工具;.dtb 文件是 .dts 文件编译后生成的二进制文件,解析速度快,适合在系统启动早期被搬运。
1. u-Boot如何将dtb放到内存指定位置
对于我个人而言,我通常会把编译好的dtb文件拷贝到板子的/boot/dtb目录下,然后重启板子,新添加的节点或者新的修改就就更新好了。但这只是我们站在人类的层面看到的现象,其底层还是有着比较深奥的学问。重启板子时,系统在启动之前,U-Boot 会先将我们拷过去的 dtb 文件从 EMMC 读取到内存中,然后才启动内核。
下面详细分析。
1.1 /boot目录分析
为了更进一步分析,我们得先看看/boot目录下的文件都有什么作用,这样才能把整个流程串起来。
下面是我的板子的/boot目录:

我们挑其中重要的几个说一下:
Image-4.19.232是 Linux 内核镜像,它后面要被 U-Boot 加载到内存里面。rk-kernel.dtb是一个软链接,它指向 U-Boot 真正会加载到内存里面的dtb文件,前面我说过,我通常会把编译好的dtb文件拷贝到板子的/boot/dtb目录下,众所周知同一目录下不能出现两个文件名相同的文件,所以这个新编译好的dtb文件就会覆盖掉旧的dtb文件,从而在启动阶段被 U-Boot 加载到内存。dtb/是存放二进制设备树文件的目录,包含多个板型的设备树文件,根据rk-kernel.dtb这个软链接的指向,决定 U-Boot 会加载哪个dtb文件。这也是这里软链接设计的精妙之处,有了这个软链接,我们完全可以将rk-kernel.dtb这个软链接名硬编码进 U-Boot ,这样只需要改变这个软链接的指向就可以切换 U-Boot 加载的设备树,而不需要修改 U-Boot 并编译那么麻烦。boot.cmd是 U-Boot 脚本源文件,也就是启动流程的源代码,我们后面会分析它。boot.scr是boot.cmd编译后的文件,U-Boot 实际加载的就是它。initrd-4.19.232是初始内存文件系统,是一个临时的根文件系统。
目录介绍就到这里。
1.2 分析boot.cmd
下面是boot.cmd文件的全部内容,其中 echo 是在系统启动时 向串口打印信息。我会为这段代码中与本文内容有关的部分附上详细的注释,并在后面进行总结,以概括整个 U-Boot 阶段到底干了什么。
bash
echo [boot.cmd] run boot.cmd scripts ...;
#检查 /uEnv/uEnv.txt 配置文件是否存在,uEnv.txt 存放用户自定义的启动参数
if test -e ${devtype} ${devnum}:${distro_bootpart} /uEnv/uEnv.txt; then
echo [boot.cmd] load ${devtype} ${devnum}:${distro_bootpart} ${env_addr_r} /uEnv/uEnv.txt ...;
#加载用户配置文件到内存地址 ${env_addr_r}
load ${devtype} ${devnum}:${distro_bootpart} ${env_addr_r} /uEnv/uEnv.txt;
echo [boot.cmd] Importing environment from ${devtype} ...
#将文件内容导入为 U-Boot 环境变量
env import -t ${env_addr_r} 0x8000
#构建内核启动参数,指定根文件系统在第三个分区
setenv bootargs ${bootargs} root=/dev/mmcblk${devnum}p3 ${cmdline}
printenv bootargs
echo [boot.cmd] load ${devtype} ${devnum}:${distro_bootpart} ${ramdisk_addr_r} /initrd-${uname_r} ...
#关键1:加载临时根文件系统
load ${devtype} ${devnum}:${distro_bootpart} ${ramdisk_addr_r} /initrd-${uname_r}
echo [boot.cmd] loading ${devtype} ${devnum}:${distro_bootpart} ${kernel_addr_r} /Image-${uname_r} ...
#关键2:加载内核镜像
load ${devtype} ${devnum}:${distro_bootpart} ${kernel_addr_r} /Image-${uname_r}
echo [boot.cmd] loading default rk-kernel.dtb
#关键3:加载设备树,一定要注意这里加载的就是软链接,顺着软链接就能找到真正的dtb文件
load ${devtype} ${devnum}:${distro_bootpart} ${fdt_addr_r} /rk-kernel.dtb
echo [boot.cmd] check the I2C bus
#i2c检测与屏幕自适应的相关代码
i2c bus
if test $? -eq 0; then
setenv i2c_exist 1
if test "${enable_gsdt_auto_load}" = "1" && test -n "${i2c_devs}"; then
for dev in ${i2c_devs}; do
i2c dev ${dev}
done
fi
else
setenv i2c_exist 0
fi
#指定当前操作的设备树地址
fdt addr ${fdt_addr_r}
#在 DTB 的 /chosen 节点设置 bootargs
fdt set /chosen bootargs
echo [boot.cmd] dtoverlay from /uEnv/uEnv.txt
#设备树overlay加载代码
setenv dev_bootpart ${devnum}:${distro_bootpart}
dtfile ${fdt_addr_r} ${fdt_over_addr} /uEnv/uEnv.txt ${env_addr_r}
if test "${enable_gsdt_auto_load}" = "1" && test -n "${i2c_devs}" && test "${i2c_exist}" = "1"; then
setenv i2c_idx 0
echo [boot.cmd] screen eeprom reading test...
for dev in ${i2c_devs}; do
i2c dev ${dev}
#检测eeprom,根据检测结果加载对应的overlay
i2c md "${eeprom_addr}" 0 1
if test $? -eq 0; then
if test "${i2c_idx}" = "0"; then
setenv plugin_file "${gsdt_plugin0}"
elif test "${i2c_idx}" = "1"; then
setenv plugin_file "${gsdt_plugin1}"
fi
if test -n "${plugin_file}"; then
echo [boot.cmd] load the generic screen plugin:${plugin_file}
load ${devtype} ${devnum}:${distro_bootpart} ${fdt_over_addr} ${plugin_file}
if test $? -eq 0; then
fdt apply ${fdt_over_addr}
fi
fi
fi
if test "${i2c_idx}" = "0"; then
setenv i2c_idx 1
fi
done
fi
echo [boot.cmd] [${devtype} ${devnum}:${distro_bootpart}] ...
echo [boot.cmd] [booti] ...
#启动内核并传递dtb地址
booti ${kernel_addr_r} ${ramdisk_addr_r} ${fdt_addr_r}
fi
echo [boot.cmd] run boot.cmd scripts failed ...;
# Recompile with:
# mkimage -C none -A arm -T script -d /boot/boot.cmd /boot/boot.scr
先简要介绍一下代码中出现的一些重要的环境变量:
devtype表示设备类型,决定了 U-Boot 调用哪种驱动读取磁盘,是 mmc 还是 usb 等等。devnum表示设备编号,区分板子上不同的接口。distro_bootpart是启动分区号。kernel_addr_r是内核镜像在内存中的存放位置,booti命令会从这个地址读取内核并运行。fdt_addr_r是设备树二进制文件在内存中的首地址,内核启动后会根据 U-Boot 传给它的这个地址来解析硬件信息。ramdisk_addr_r是虚拟文件系统initrd的内存地址,内核启动早期需要用它过渡。env_addr_r临时存放uEnv.txt文本内容的内存地址,读取后会被env import命令解析。fdt_over_addr是设备树插件的临时存放地址。bootargs是内核启动参数,告诉内核在启动之后去哪个分区找根文件系统。uname_r是内核版本标识。
下面我通过一个流程图总结一下整个 U-Boot 阶段干了什么:

这个流程图总结了整个 U-Boot 阶段干的所有事情,从中能清晰的看到 dtb 文件从加载,再到与overlay叠加,最后将地址传给内核的全过程。
2. 内核如何解析设备树
当 U-Boot 执行完最后一行代码时,CPU 的控制权就交给了 Linux 内核,此时,内核面对的dtb文件中还只是一堆二进制数据。
2.1 从寄存器中拿到地址
根据 ARM64 架构的规定:
- U-Boot 的最后将
dtb在内存中的物理首地址,也就是fdt_addr_r存入 CPU 的 X0 寄存器。 - 内核入口处的汇编代码第一件事就是读取 X0 寄存器,并将这个地址保存到内核变量
initial_boot_params中。
注意: 此时内核还没有开启 MMU,所以它直接通过物理地址访问这段数据。
2.2 解析
U-Boot 传给内核的设备树被称为 FDT ,全称为 Flattened Device Tree,翻译为 扁平设备树。它是一段连续的二进制流,适合传输但不方便查询。
内核启动后,会调用 unflatten_device_tree() 函数进行解析:
- 检查开头 4 字节是不是
0xd00dfeed,确认数据没有丢失。 - 内核扫描整个二进制块,并在内存中 动态 创建一套树状结构。
- 二进制中的每一个
{...}块都被转换成一个内核结构体struct device_node。
到这,原本的二进制数据就变成了内核可以随时查阅的设备树。
此外,在上面的boot.cmd文件中有这样一行代码:
bash
fdt set /chosen bootargs
内核在解析时会重点关注这个 /chosen 节点,它会提取出里面的 bootargs 字符串,并将其赋值给内核的全局变量 saved_command_line。
这就是为什么在 Linux 里运行 cat /proc/cmdline 就能看到 U-Boot 设置的参数的原因。
2.3 从节点到平台设备
在内核初始化的后期,会调用 of_platform_default_populate_init():
- 它会遍历所有的
device_node。 - 如果节点带有
compatible属性,且不是类似cpus这种基础系统节点,内核就会为它创建一个struct platform_device。 - 然后这个设备会被注册到内核的
platform_bus上。
当内核加载了我们编写的驱动程序后:
- 平台总线会拿驱动程序里的
of_match_table(里面存着compatible字符串)去和设备树生成的platform_device挨个对比。 - 一旦字符串完全一致,总线就会触发驱动的
probe()函数。 - 在
probe函数里,驱动通过platform_get_resource等接口,就能拿到设备树里预留的寄存器地址和中断号。
至此,本篇文章就接近尾声了。
整篇文章的内容总结成一句话,就是:U-Boot 负责将dtb搬运到内存中一个空闲的位置,并将这个位置的地址通过寄存器传递给内核,内核启动之后从这个地址拿到dtb之后进行解析,最终组装成系统中的设备树。
最后,如果本篇文章真的帮助到你了,恳请点赞加关注支持一下,这也算是我漫长、枯燥学习生活中的一种动力。
本文结束。