嵌入式 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 阶段干了什么:

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

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


本文结束。

相关推荐
不做超级小白1 小时前
Git大小写陷阱:当README.md遇上readme.md
linux·windows·git
敷衍一下X1 小时前
Linux综合监控工具——nmon
linux·运维·服务器
Survivor0011 小时前
VMware虚拟机网络技术
linux·服务器·网络
程序员库里1 小时前
TipTap简介
前端·javascript·面试
i建模2 小时前
Ubuntu Node.js 升级方案
linux·运维·ubuntu·node.js
小涛不学习2 小时前
WebSocket 技术详解(原理 + 使用 + 面试总结)
websocket·网络协议·面试
顶点多余2 小时前
进程:计算机世界的执行单元
linux·运维·服务器·进程
素心如月桠2 小时前
IT-如何连接共享打印机
linux·服务器·网络
张毫洁2 小时前
解决虚拟机ip不见的问题
linux·服务器·网络