读懂一份设备树:U-Boot 怎样把 DTB 交给 Linux?
从一份简化的 DTS 逐行认路,再跟着 U-Boot 把内核、设备树和启动参数放进内存,交到 Linux 手里。
上一篇我们知道了设备树为什么会出现:不同开发板把自己的硬件说明交给 Linux,内核就不必靠猜来认识机器。这次我们把说明书摊开,看看 DTS 里每一行在说什么;然后跟着 U-Boot,看它怎样把编译后的 DTB 和内核一起交给 Linux。
下面的板子是为了讲解而虚构的,地址和名字都是示意,不能拿去启动真实开发板。真实设备树必须照着具体芯片、板卡手册和 Linux 设备树规则填写。
示意 DTS(虚构开发板)/dts-v1/;
/ {
model = "Doge 示例开发板";
compatible = "example,doge-board";
#address-cells = <1>;
#size-cells = <1>;
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x10000000>;
};
chosen {
bootargs = "console=ttyS0,115200";
};
soc {
compatible = "simple-bus";
#address-cells = <1>;
#size-cells = <1>;
ranges;
uart0: serial@10000000 {
compatible = "example,uart-v1";
reg = <0x10000000 0x1000>;
status = "okay";
};
};
};先看最外面:斜杠是整棵树的根
DTS 里最外层的 / { ... }; 表示根节点。它像地图的总标题,里面可以有处理器、内存、总线和设备等分支。每一对大括号表示一个节点的内容,节点里面还能继续放属性或子节点。
model 是给人看的板子名称;compatible 是让内核代码识别机器家族的名字,通常采用“厂商,型号”这样的写法。它不是随便好听就行:内核会拿它和已知的机器描述或设备驱动作匹配,真实项目必须使用硬件对应的正式名称。
这里的 example,doge-board 只是虚构名字。它告诉读者这个字符串长什么样,不代表 Linux 内核真的认识一块叫 Doge 示例开发板的机器。
数字怎样告诉内核内存在哪里?
根节点里的 #address-cells 和 #size-cells 是在说明:它下面的 reg 属性要用几个数字表示地址、再用几个数字表示大小。示例把两项都设成 1,因此内存的 reg 用两个数字:第一个是起始位置,第二个是长度。
memory@80000000 是一个内存节点。@ 后面的数字是节点的单元地址,通常应该和 reg 里的起始地址相呼应。device_type = "memory" 告诉内核这描述的是系统内存;示例中的 0x80000000 表示起点,0x10000000 表示长度,也就是 256 MiB。
这仍然只是算术示范。真实开发板可能有多段内存、保留区域,或特殊的内存映射;数字写错了,内核就可能把不存在的地方当作可用内存。地址规则由父节点和具体平台共同决定,不能只看 @ 后面的名字。
chosen 是开机时交给内核的小纸条
chosen 节点通常放和这次启动有关的信息,而不是一件固定焊在电路板上的设备。示例里的 bootargs 是内核命令行,意思是告诉 Linux 把早期启动信息送到 ttyS0 这个串口,波特率设为 115200。
设备树还可以在 chosen 中记录 initramfs 在内存里的起止位置。启动程序可能会根据实际加载位置填写或更新这些信息。这样内核就知道临时系统放在哪里,后面可以用它寻找真正的根文件系统。
Linux 命令行也可能由启动程序用其他约定传入;放进 chosen 的 bootargs 是设备树启动中常见的一种方式。设备树在这里既写了硬件地图,也带上少量与本次开机有关的信息。
soc 是芯片内部的设备集合
很多开发板把串口、I²C、SPI、定时器等控制器集成在同一颗 SoC(片上系统)芯片里。示例的 soc 节点就是这些片上设备的家。compatible = "simple-bus" 表示这是一个简单的内存映射总线;ranges 属性用于说明子设备地址怎样对应到父级地址空间。这里写成空的 ranges,表示在这个简化例子里地址不需要转换。
实际芯片可能有好几层总线,地址还要经过转换;ranges 也可能不是空的。读真实 DTS 时,需要从上一级节点往下看,不能假设所有 reg 地址都是直接的物理地址。
serial@10000000:找到串口控制器
serial@10000000 是一个串口设备节点。serial 是设备类别的名字,@10000000 表示它的单元地址。uart0: 是一个标签,像给节点贴上的书签;其他地方可以写 &uart0 来引用这个节点,不必复制它的路径。
compatible = "example,uart-v1" 描述串口控制器的型号家族。内核里对应的 UART 驱动如果认识这个 compatible,就可以尝试绑定设备。硬件描述和驱动要说同一种“型号语言”,设备才容易被正确配对。
reg = <0x10000000 0x1000> 表示这颗控制器占用一段寄存器地址:起点是 0x10000000,长度是 0x1000。寄存器可以想成设备内部一排控制旋钮和状态窗口,驱动通过读写这些位置来设置波特率、发送数据或检查状态。
status = "okay" 表示设备可以启用。很多设备树规则把没有写 status 的节点也视为可用;写成 disabled 则表示先不要让内核启用它。板级 DTS 常用这种方式,决定某个 SoC 上已有的控制器在具体这块板子上有没有接出来、是否应该打开。
为什么很多真实 DTS 不从头写起?
一块板子上的处理器和 SoC 通常和同系列其他板子相同。内核会把共同部分放进 .dtsi 文件,再由板子自己的 .dts 文件 include 进来,补上该板独有的内存、接口和外围芯片。这样相同的芯片说明不用在几十块板子里重复抄写。
一个 DTS 也可以用标签引用公共节点,再改动少量属性。例如芯片公共文件里先定义了 uart0,板级文件可以写 &uart0 { status = "okay"; }; 来启用它。如果某块板把该串口接到蓝牙模块而不是调试插座,还要按真实连线补充其他信息。标签引用让不同层次的说明能拼在一起。
内核为设备树属性制定的规则叫 binding(绑定规范)。它说明某类设备要有哪些字段、字段怎么写、哪些值有效。设备树不是随手画一张图:DTS 写法、compatible 名称、地址和时钟中断字段都要与芯片资料和绑定规范对得上。
DTS 怎么变成 DTB?
DTS 是给工程师阅读和修改的文本;编译后得到的 DTB 是适合在启动时快速读取的二进制文件。设备树编译器 dtc 会处理节点和属性,把文字打包成一种有明确结构的 FDT 数据。真实内核构建还会检查绑定规则和依赖,光能编译通过并不代表硬件数字一定正确。
最后,DTB 可以和 Linux 内核、initramfs 分开存放,也可以被打包进 FIT 镜像或其他固件映像。重要的不是文件放在哪一种介质,而是启动时要选择适合这块板子的那份设备树,并让 U-Boot 把它放到内存里。
U-Boot 会准备三样东西
在常见的 ARM64 开发板上,U-Boot 通常要找到 Linux 内核镜像、DTB 设备树和可选的 initramfs。它把它们从 SD 卡、闪存或网络读进内存。每样东西都要放到互不覆盖的区域:如果 DTB 刚好被内核文件压住,交接时内核拿到的就不是一张完整地图。
U-Boot 的环境变量常用来记这些内存位置,例如 kernel_addr_r、fdt_addr_r 和 ramdisk_addr_r。变量名字里的 addr 是 address(地址)的缩写。不同板子可用内存区域不同,地址也不同,所以网上看到的具体十六进制数字不能盲目照抄。
U-Boot 命令示意(ARM64,省略路径差异)load mmc 0:1 ${kernel_addr_r} Image
load mmc 0:1 ${fdt_addr_r} board.dtb
load mmc 0:1 ${ramdisk_addr_r} initramfs.cpio.gz
booti ${kernel_addr_r} ${ramdisk_addr_r}:${filesize} ${fdt_addr_r}第一行从 MMC 存储的第一个分区加载内核,第二行加载设备树,第三行加载 initramfs,最后一行调用 booti 启动 ARM64 Linux。命令里的 ${...} 是从 U-Boot 环境读取地址;真实启动脚本还要照顾文件路径、压缩格式、内存地址和 initramfs 大小。
如果没有 initramfs,booti 的第二个参数位置可以用短横线占位;如果内核是 32 位 ARM 的 zImage,常见启动命令是 bootz;如果内核和设备树被打成 FIT 镜像,常见入口可能是 bootm。具体命令由内核格式和 U-Boot 配置决定。
跳进 Linux 前,U-Boot 还会补好地图
U-Boot 可能会在交接前修改 working FDT(准备交给操作系统的那份设备树):更新启动命令行、initramfs 地址,或按平台需要补充内存和保留区信息。它有时还会组合设备树覆盖片段。完成这些准备后,再按对应处理器架构的启动约定把 DTB 的位置交给内核。
在 ARM64 的启动约定中,启动程序把 DTB 在物理内存中的地址放进处理器的 x0 寄存器;32 位 ARM 使用不同的寄存器约定。寄存器就像处理器手里几张很小的便签,双方约好把重要地址写在某个位置。然后 U-Boot 跳到 Linux 内核的入口,固件和启动程序的这轮工作就暂时结束了。
这里的 control FDT 和 working FDT 要分清:control FDT 主要供 U-Boot 自己识别设备、运行驱动;working FDT 才是准备传给 Linux 的那份。它们可能有共同来源,但 U-Boot 文档提醒,两者可能承担不同工作,修改给 Linux 的树不一定会改变 U-Boot 自己的设备配置。
Linux 拿到 DTB 后做什么?
内核入口开始运行后,会先解析启动约定和设备树中的早期信息,确认处理器平台、可用内存、保留区域、启动命令行和 initramfs 位置。之后内核根据 compatible 等信息查找驱动,并把设备逐步加入自己的设备模型。
你可以把这一步想成维修师傅先看平面图,再按图找配电箱和水阀;但如果图上写着不认识的设备型号,或者箱子里没有适配工具,他仍然没法操作。设备树是交接的线索和约定,内核里的驱动才是实际干活的工具。
等设备和存储驱动准备得差不多,内核会找到真正的根文件系统。有的系统先借助 initramfs 里的 /init 查找磁盘、解锁或挂载系统;接着正式系统里的 init 程序成为 PID 1,拉起服务并让人登录。
DTS 是写给人看的板子说明,DTB 是装进内存的二进制说明;U-Boot 负责挑选、放好并把它交到内核手中。
下一篇,我们就从这份交接继续往前:内核如何找到根文件系统,initramfs 里的 /init 怎样整理临时工具箱,再怎样把 PID 1 的工作交给正式系统的 init(常见的是 systemd)。到那一步,开机接力就从“内核认硬件”走到“系统服务陆续醒来”。