第一章:设备树的前世今生
1.1 为什么要有设备树?
先问大家一个问题:你写嵌入式Linux驱动时,最烦什么?
我个人最烦的是——换一块板子,代码就得大改。尤其是那些描述硬件资源的部分,比如GPIO几号、中断挂在哪、时钟频率多少。这些信息以前都硬编码在C文件里,我们管它叫“板级文件”。
说白了,板级文件就是一堆结构体,里面塞满了硬件地址、中断号、寄存器偏移。每次换平台,就得改这些结构体。改完还得重新编译内核。你想想看,如果只是换了个LED引脚,就得重新编译整个内核,这效率能高吗?
我在项目中遇到过最夸张的一次:客户换了颗Flash芯片,只是引脚重映射了一下,结果我们花了三天改板级文件,还引入了两个新bug。从那以后我就意识到——硬件描述和驱动代码必须解耦。
这就是设备树的由来。
1.2 从板级文件到设备树的演进史
早期Linux内核(2.6时代)是怎么处理硬件信息的?
- 每个板子都有一个
board-xxx.c文件 - 里面定义
platform_device、resource、platform_data - 驱动通过
platform_get_resource()获取硬件信息
举个例子,以前我们写一个LED驱动,板级文件里大概长这样:
static struct resource led_resources[] = {
{
.start = GPIO_BASE + 0x10,
.end = GPIO_BASE + 0x10 + 3,
.flags = IORESOURCE_MEM,
},
};
static struct platform_device led_device = {
.name = "my_led",
.id = 0,
.num_resources = ARRAY_SIZE(led_resources),
.resource = led_resources,
};
然后把这个设备注册到内核里。驱动那边再通过名字匹配,拿到资源。这套机制本身没问题,但问题是——每个板子都得写一份。ARM架构下,光是arch/arm/mach-xxx目录里,就有成百上千个这样的文件。
2011年左右,Linus Torvalds发飙了。他公开吐槽ARM社区的内核代码就是一坨“shit”,因为每次合并窗口,ARM那边提交的板级文件比新功能还多。他要求ARM必须像PowerPC那样,用设备树来描述硬件。
嗯,这里要注意:设备树并不是Linux发明的。它最早来自Open Firmware,PowerPC和SPARC平台一直在用。Linux只是把它移植到了ARM上。
从Linux 3.x开始,ARM架构逐步淘汰板级文件。到了4.x时代,新板子基本都强制使用设备树。现在你去看arch/arm/mach-xxx目录,里面干净多了。
1.3 设备树在Linux内核中的角色
设备树到底是什么?我习惯把它理解成“硬件的JSON文件”。
它用树形结构描述硬件:
- 根节点是“/”,代表整个板子
- 子节点是各种总线(如I2C、SPI、GPIO)
- 叶子节点是具体设备(如温度传感器、LED、按键)
每个节点里,用属性(property)来描述硬件参数。比如:
/ {
model = "MyBoard v1.0";
compatible = "vendor,myboard";
leds {
compatible = "gpio-leds";
led0 {
gpios = &gpio1 15 GPIO_ACTIVE_HIGH;
label = "heartbeat";
};
};
};
这段描述告诉内核:板子上有一个LED,挂在GPIO1的第15号引脚上,高电平有效。驱动不需要知道具体引脚号,它只需要读设备树就行。
设备树在内核启动流程中的角色是这样的:
- Bootloader(如U-Boot)加载设备树二进制文件(.dtb)到内存
- 内核启动时,解析dtb,构建设备树结构
- 驱动通过OF(Open Firmware)API读取设备树节点
- 内核根据设备树信息,自动创建设备、分配资源
我曾经踩过一个坑:设备树里中断号写错了,结果驱动注册时一直拿不到中断。查了两天才发现,是设备树里 interrupts = <0 31 4> 的第二个参数写成了31,实际应该是29。从那以后,我每次改设备树都会用 dtc -I dtb -O dts 反编译确认一遍。
核心要点:设备树把硬件描述从驱动代码中剥离出来。驱动只关心“怎么操作硬件”,不关心“硬件接在哪”。换板子时,只需要改设备树,不需要改驱动代码。
1.4 设备树的核心数据结构
设备树在内核里是怎么存的?其实就是一个大结构体——struct device_node。
| 字段 | 说明 |
|---|---|
| name | 节点名称,如"led0" |
| type | 节点类型,如"device" |
| compatible | 兼容性字符串,驱动匹配用 |
| phandle | 节点句柄,用于引用 |
| properties | 属性链表,存所有键值对 |
| parent | 父节点指针 |
| child | 子节点指针 |
| sibling | 兄弟节点指针 |
内核启动时,会把dtb解析成一颗由 device_node 组成的树。驱动通过 of_find_node_by_name()、of_get_property() 等API来遍历和读取。
避坑指南:我曾经在调试时发现设备树节点读不到,后来发现是设备树里节点名写错了大小写。设备树是大小写敏感的,LED0 和 led0 是两个不同的节点。写设备树时,建议统一用小写加数字。
1.5 设备树与驱动匹配机制
驱动怎么找到对应的设备树节点?靠的是 compatible 属性。
驱动里会定义一个 of_device_id 表:
static const struct of_device_id led_of_match[] = {
{ .compatible = "vendor,my-led" },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, led_of_match);
设备树里写:
led0 {
compatible = "vendor,my-led";
...
};
内核在注册设备时,会遍历所有驱动的 of_device_id 表,找到匹配的 compatible 字符串,然后调用驱动的probe函数。
这里有个细节:compatible 字符串的命名规范是 "厂商,设备名"。比如 "ti,omap-gpio"、"fsl,imx-uart"。这样能避免不同厂商的同名设备冲突。
注意:设备树里的 compatible 字符串必须和驱动里的完全一致,包括大小写和标点。我见过有人把逗号写成了句号,结果驱动死活匹配不上。这种bug很难查,因为内核不会报错,只会静默跳过。
1.6 设备树的编译与加载
设备树源文件(.dts)不能直接被内核使用,需要编译成二进制(.dtb)。
编译命令:
dtc -I dts -O dtb -o myboard.dtb myboard.dts
反编译命令:
dtc -I dtb -O dts -o myboard.dts myboard.dtb
在U-Boot里,加载设备树的典型流程:
tftp 0x80000000 zImage
tftp 0x83000000 myboard.dtb
bootz 0x80000000 - 0x83000000
内核启动时,会从 0x83000000 地址读取dtb,解析后构建设备树。
我个人习惯在调试阶段,把设备树放在内核的 arch/arm/boot/dts/ 目录下,和内核一起编译。这样方便修改和测试。但产品阶段,建议把设备树放在bootloader里,这样更换板子时不需要重新编译内核。
1.7 本章小结
设备树解决了嵌入式Linux开发中的一个核心痛点:硬件描述与驱动代码的耦合。它让驱动变得通用,让板级适配变得简单。
从板级文件到设备树,本质上是“把硬件信息从代码里抽出来,放到数据里”。这个思路在很多地方都适用——你想想看,配置文件和硬编码的区别,不就是这个道理吗?
下一章,我们会深入设备树的语法细节,教你如何手写一个完整的设备树文件。到时候我会分享一些我在项目中总结的“设备树避坑指南”,保证实用。