4. 设备树(Device Tree):从硬件描述到Bootloader解析
设备树,英文叫 Device Tree。说白了,它就是一份硬件配置清单。
你想想看,一个嵌入式系统里有多少外设?UART、I2C、SPI、GPIO、中断控制器、时钟...每个外设的地址、中断号、时钟频率都不一样。以前的做法是把这些信息硬编码在代码里,换一个板子就要改代码,非常痛苦。
设备树就是来解决这个问题的。它把硬件信息从代码里抽离出来,变成一份独立的数据结构。Bootloader 启动时,读这份数据,然后把信息传给内核。这样,同一份内核镜像就能适配不同的硬件平台。
4.1 设备树的作用与结构
设备树的作用,我总结为三点:
- 描述硬件: CPU、内存、总线、外设的地址、中断、时钟、DMA 等信息。
- 传递参数: 内核启动参数、内存大小、MAC 地址等运行时信息。
- 解耦硬件: 同一份内核镜像,通过不同的设备树文件,适配不同的板卡。
它的结构是一棵树。根节点是 /,下面挂 CPU、内存、总线,总线下面再挂外设。每个节点都有属性,属性就是键值对,用来描述硬件特征。
我举个例子,一个简单的设备树片段:
/ {
model = "TI AM335x BeagleBone Black";
compatible = "ti,am335x-bone-black", "ti,am335x-bone", "ti,am33xx";
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
compatible = "arm,cortex-a8";
device_type = "cpu";
reg = <0x0>;
};
};
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x10000000>; /* 512 MB */
};
uart0: serial@44e09000 {
compatible = "ti,am3352-uart", "ti,omap3-uart";
reg = <0x44e09000 0x2000>;
interrupts = <72>;
clock-frequency = <48000000>;
};
};
看到没?uart0 节点描述了 UART 的基地址、中断号、时钟频率。内核驱动读到这些信息,就知道怎么初始化这个串口了。
4.2 DTS / DTC / DTB 文件格式
这三个缩写,你肯定见过。它们的关系很简单:
| 缩写 | 全称 | 说明 |
|---|---|---|
| DTS | Device Tree Source | 文本格式的源文件,人类可读,后缀 .dts |
| DTC | Device Tree Compiler | 编译器,把 DTS 编译成 DTB |
| DTB | Device Tree Blob | 二进制格式,机器可读,Bootloader 直接加载 |
工作流程是这样的:
- 工程师写
.dts文件。 - 用
dtc工具编译成.dtb文件。 - Bootloader 把
.dtb加载到内存,解析后传给内核。
编译命令很简单:
dtc -I dts -O dtb -o myboard.dtb myboard.dts
反过来,反编译 DTB 到 DTS 也常用:
dtc -I dtb -O dts -o myboard.dts myboard.dtb
另外,还有 .dtsi 文件,它是公共头文件。比如 am33xx.dtsi 定义了 AM335x 系列芯片的公共外设,不同板卡只需要 #include 它,然后覆盖或添加自己的节点。这样能减少重复代码。
4.3 如何在Bootloader中解析设备树
Bootloader 解析设备树,主要做三件事:
- 加载 DTB: 从存储介质(Flash、SD卡、网络)读到内存。
- 解析 DTB: 遍历节点,读取属性,获取硬件信息。
- 传递 DTB: 把 DTB 在内存中的地址告诉内核。
以 U-Boot 为例,它内置了 fdt 命令,可以直接操作设备树。
常用的 U-Boot 命令:
# 加载 DTB 到内存地址 0x88000000
tftp 0x88000000 myboard.dtb
# 查看设备树内容
fdt list
# 修改某个节点的属性
fdt set /uart0 clock-frequency <50000000>
# 启动内核时,传递 DTB 地址
bootm 0x80000000 - 0x88000000
在代码层面,U-Boot 使用 libfdt 库来解析 DTB。核心 API 有:
| 函数 | 作用 |
|---|---|
fdt_check_header() |
检查 DTB 头部是否合法 |
fdt_path_offset() |
根据路径找到节点偏移量 |
fdt_getprop() |
读取节点的某个属性值 |
fdt_first_subnode() |
遍历子节点 |
我写一个简单的解析示例:
#include <libfdt.h>
void parse_dtb(void *fdt_blob)
{
int offset;
const void *prop;
int len;
// 1. 检查头部
if (fdt_check_header(fdt_blob) != 0) {
printf("Invalid DTB header!\n");
return;
}
// 2. 找到 uart0 节点
offset = fdt_path_offset(fdt_blob, "/uart0");
if (offset < 0) {
printf("Cannot find /uart0\n");
return;
}
// 3. 读取 clock-frequency 属性
prop = fdt_getprop(fdt_blob, offset, "clock-frequency", &len);
if (prop) {
uint32_t freq = fdt32_to_cpu(*(uint32_t *)prop);
printf("UART clock frequency: %d Hz\n", freq);
}
// 4. 读取 interrupts 属性
prop = fdt_getprop(fdt_blob, offset, "interrupts", &len);
if (prop) {
uint32_t irq = fdt32_to_cpu(*(uint32_t *)prop);
printf("UART IRQ number: %d\n", irq);
}
}
这段代码,我在好几个项目里都用过。核心逻辑就是:找节点 → 读属性 → 用属性值初始化硬件。
fdt32_to_cpu() 转换。我曾经因为这个,读出来的时钟频率是 0,查了半天才发现是字节序问题。
下面这张图,展示了设备树从编写到解析的完整流程:
最后,说一个我个人的经验。调试设备树时,最常用的手段就是:
- 在 Bootloader 里用
fdt list查看 DTB 内容,确认节点和属性是否正确。 - 如果内核启动时报设备树相关错误,用
dtc -I dtb -O dts反编译 DTB,对比原始 DTS,看是不是编译或传输过程中出了问题。 - 实在不行,在 Bootloader 代码里加打印,把关键属性值打出来,一步步排查。
fdt 命令最大支持 256KB 的 DTB。如果你的设备树很复杂,超过了这个限制,需要修改 U-Boot 的配置。我遇到过一块 FPGA 加 ARM 的板子,设备树写了 300 多 KB,结果加载失败,后来改了 CONFIG_FDT_MAX_SIZE 才解决。