嵌入式 Linux 启动:设备树的加载、传递和解析全流程分析

关于设备树,我们需要知道:.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.scrboot.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 阶段干了什么:
存在




不存在
板子上电
执行 boot.scr
检查 /uEnv/uEnv.txt

是否存在
加载环境变量

到 env_addr_r
设置 bootargs

root=/dev/mmcblk0p3
加载 initrd

到 ramdisk_addr_r
加载内核镜像

到 kernel_addr_r
加载基础设备树

rk-kernel.dtb 到 fdt_addr_r
检测 I2C 总线
enable_gsdt_auto_load

是否使能?
读取屏幕 EEPROM
是否检测到屏幕?
加载屏幕设备树插件
fdt apply 叠加设备树
booti 启动内核
启动失败

这个流程图总结了整个 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之后进行解析,最终组装成系统中的设备树。

最后,如果本篇文章真的帮助到你了,恳请点赞加关注支持一下,这也算是我漫长、枯燥学习生活中的一种动力。


本文结束。

相关推荐
kebidaixu2 小时前
VS Code安装 Remote - SSH 扩展
linux·服务器·ssh
somi72 小时前
51单片机-01-在8位数码管上动态滚动显示数字
单片机·嵌入式硬件·51单片机
AI+程序员在路上2 小时前
瑞芯微 RV1126B ADB 调试命令完全指南
linux·adb
爱倒腾的老唐2 小时前
05、STM32 开发基础知识
stm32·单片机·嵌入式硬件
小茗的嵌入式学习日记2 小时前
基于IMX6ULL的车载中控系统
linux·c语言·qt
sc_爬坑之路2 小时前
Linux 部署 Redis:一主一从 + Sentinel 完整实战
linux·redis·sentinel
香水5只用六神2 小时前
【RTOS快速入门】05_动态_静态创建任务(2)
c语言·stm32·单片机·嵌入式硬件·freertos·rtos·嵌入式软件
k11845917682 小时前
原理图选器件→PCB 逐个摆放 操作步骤
嵌入式硬件
香水5只用六神2 小时前
【RTOS快速入门】06_任务状态理论讲解(1)
c语言·stm32·单片机·嵌入式硬件·freertos·rtos·嵌入式软件