2. 嵌入式启动流程基础
设备树(Device Tree),圈里人常叫它 DT。这东西刚出来的时候,我其实挺抵触的——又得学一套新语法。但后来在几个项目里被坑过几次,才真正体会到它存在的意义。
说白了,设备树就是一套描述硬件的数据结构。它告诉内核:你的板子上有哪些外设、它们挂在哪条总线上、中断号是多少、寄存器地址在哪。没有它,内核就得靠一堆硬编码的板级文件来认硬件。
1. 设备树的起源:为什么要有它?
在 Linux 早期,每换一款板子,就得改 arch/arm/mach-xxx 下的 board 文件。那时候我还在做 PPC 平台的移植,光是维护不同厂商的板级文件就够头疼的。后来 PowerPC 社区先受不了了,搞出了设备树这个概念。ARM 社区跟进得更晚,我记得是 2011 年左右才开始大规模推广。
设备树的核心目的就两个:
- 硬件描述与内核代码解耦——换板子不用改内核,只换 DTB 文件就行
- 统一硬件描述语言——不管你是 ARM、RISC-V 还是 x86,都用同一套语法描述硬件
核心思想:设备树把硬件信息从内核源码中抽离出来,变成一份独立的数据文件。Bootloader 负责把这个文件传给内核,内核解析后就知道怎么初始化硬件了。
2. 四种文件格式:DTS / DTC / DTB / DTBO
这四种格式的关系,其实就是一个编译链。我刚开始接触的时候也搞混过,后来画了个图才理清楚。
3. 各文件格式详解
3.1 DTS(Device Tree Source)
DTS 是文本格式的源文件。你写设备树,就是在写 .dts 文件。它的语法有点像 JSON 和 C 的混合体——用花括号表示层级,用分号结尾。
看个最简单的例子:
/dts-v1/;
/ {
model = "TI AM335x BeagleBone Black";
compatible = "ti,am335x-bone-black", "ti,am335x-bone", "ti,am33xx";
cpus {
cpu@0 {
compatible = "arm,cortex-a8";
device_type = "cpu";
reg = <0>;
};
};
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x10000000>; /* 256 MB */
};
uart0: serial@44e09000 {
compatible = "ti,am3352-uart", "ti,omap3-uart";
reg = <0x44e09000 0x2000>;
interrupts = <72>;
status = "okay";
};
};
个人习惯:我写 DTS 时,喜欢把 compatible 属性放在每个节点的第一行。这样一眼就能看出这个节点对应哪个驱动,调试时省不少事。
3.2 DTSI(Device Tree Source Include)
DTSI 就是头文件,专门放公共定义。比如 SoC 厂商会提供一个通用的 .dtsi,里面定义好所有内置外设。板级厂商再写自己的 .dts,用 #include 把它引进来,然后只改自己板子特有的部分。
我曾经见过一个项目,三块板子共用同一个 SoC,但外设配置完全不同。如果没有 DTSI,就得写三份几乎一样的 DTS,维护起来简直是噩梦。
3.3 DTC(Device Tree Compiler)
DTC 是编译工具,把 .dts 编译成 .dtb。用法很简单:
dtc -I dts -O dtb -o am335x-boneblack.dtb am335x-boneblack.dts
反过来也能反编译:
dtc -I dtb -O dts -o am335x-boneblack.dts am335x-boneblack.dtb
避坑指南:我曾经在反编译 DTB 时发现节点顺序和源文件不一样。DTC 会按节点地址排序,这不是 bug,是特性。反编译出来的 DTS 只适合查看,不适合直接改回去重新编译。
3.4 DTB(Device Tree Blob)
DTB 是二进制格式,Bootloader 直接加载它,然后传给内核。它的大小通常只有几 KB 到几十 KB,非常轻量。
3.5 DTBO(Device Tree Blob Overlay)
DTBO 是叠加层,用于运行时修改设备树。比如你有一个 FPGA 动态加载的外设,不想把它的描述写死在主 DTB 里,就可以用 DTBO 在系统运行时动态添加。
编译 DTBO 的命令:
dtc -I dts -O dtb -@ -o my-overlay.dtbo my-overlay.dts
注意那个 -@ 选项,它会在 DTB 中生成符号表,让叠加层能找到挂载点。
4. 设备树在 Bootloader 与 Kernel 之间的传递
这个过程,说白了就是 Bootloader 把 DTB 的物理地址告诉内核。不同架构的传递方式不一样:
| 架构 | 传递方式 | 说明 |
|---|---|---|
| ARM32 | r2 寄存器 | 内核启动时,r2 存放 DTB 的物理地址 |
| ARM64 | x0 寄存器 | 与 ARM32 类似,使用 x0 寄存器 |
| RISC-V | a1 寄存器 | a1 存放 DTB 地址,a0 存放 hart ID |
| x86 | EFI 配置表 | 通过 EFI 系统表传递 DTB 指针 |
在 U-Boot 里,传递 DTB 的典型流程是这样的:
- 加载 DTB——从存储介质(eMMC、NAND、网络)读到内存
- 修改 DTB——U-Boot 会根据实际情况修改一些属性,比如内存大小、MAC 地址
- 传递地址——用
bootm或booti命令启动内核时,把 DTB 地址作为参数传入
U-Boot 里常用的命令:
# 从内存地址加载 DTB
fdt addr 0x88000000
# 查看 DTB 内容
fdt list
# 修改属性值
fdt set /serial@44e09000 status "disabled"
# 启动内核并传递 DTB
bootz 0x80000000 - 0x88000000
关键点:DTB 在内存中的存放位置不能和内核解压地址重叠。我遇到过几次启动失败,最后发现是 DTB 被内核解压时覆盖了。建议把 DTB 放在内核地址之后,留出足够的间隔。
5. 内核如何解析 DTB
内核收到 DTB 地址后,会做这几件事:
- 早期解析——在 setup_arch() 里,内核先读取 DTB 中的
chosen节点,拿到 cmdline、initrd 地址等启动参数 - 内存初始化——从
memory节点获取内存布局,建立页表 - 设备枚举——遍历所有节点,为每个 compatible 匹配的节点创建 platform_device
- 驱动绑定——驱动注册时,通过 compatible 匹配对应的设备节点
内核里有个叫 of_ 开头的函数族,专门用来操作设备树。比如:
// 获取节点
struct device_node *np = of_find_compatible_node(NULL, NULL, "ti,am3352-uart");
// 读取属性
const __be32 *reg = of_get_property(np, "reg", &size);
// 读取中断号
int irq = irq_of_parse_and_map(np, 0);
调试技巧:如果内核启动时设备树解析有问题,可以在内核 cmdline 里加 earlycon 和 dtoc 参数。这样内核会在早期串口输出设备树解析的详细信息,方便定位问题。
6. 实战中的常见问题
做 Bootloader 移植时,设备树相关的问题我见得最多的是这几类:
- DTB 地址对齐——DTB 必须 4 字节对齐,否则内核解析时会报错
- DTB 大小超限——有些 Bootloader 对 DTB 大小有限制,超过 64KB 可能加载失败
- compatible 不匹配——驱动里的 compatible 和设备树里的不一致,导致驱动不加载
- 中断映射错误——interrupt-parent 和 interrupts 属性配置不对,外设中断不工作
嗯,设备树这东西,刚接触时觉得繁琐,用久了就会发现它确实是个好设计。至少现在换板子,不用再改内核源码了,改个 DTS 重新编译一下就行。