设备树不是魔法:从零读懂DTS文件的真正写法
你有没有遇到过这样的场景?
调试一块新板子,内核启动日志里反复报错:“No matching device found for 'my-sensor'”,翻遍驱动代码也没看出问题。最后发现,只是设备树里的compatible字符串拼错了——少了个逗号,或者厂商名写成了“rockchip”而不是“rockchip,”。
这正是设备树(Device Tree)最真实的一面:它不复杂,但极其讲究细节;它解放了内核代码,却把硬件描述的责任交给了开发者。而这份责任,藏在每一个节点、每一条属性、每一组地址单元之中。
今天,我们不讲概念堆砌,也不复制手册。我们要像拆电路板一样,一层层揭开.dts文件的真实结构,搞清楚——为什么这么写?不这么写会怎样?
一、设备树到底解决了什么问题?
在 ARM Linux 还没有统一标准的年代,每个开发板都要维护一套独立的 C 语言板级文件(BSP),里面塞满了类似这样的代码:
static struct platform_device uart0_device = { .name = "serial8250", .resource = { [0] = { .start = 0x3f8, .end = 0x3ff, .flags = IORESOURCE_MEM, }, [1] = { .start = 4, .end = 4, .flags = IORESOURCE_IRQ, }, } };成百上千行这种代码,重复出现在不同板子上。改个引脚?重编内核。加个外设?还得进内核源码改。移植一次等于重做一遍。
于是社区决定:把硬件信息拎出来,用一种通用格式描述,让内核去读它,而不是硬编码进去。
这就是设备树的本质——一份给内核看的“硬件说明书”。
二、DTS 文件长什么样?从一个最小系统说起
来看一段真实的 DTS 片段,别急着看语法,先感受它的逻辑结构:
/dts-v1/; /include/ "skeleton.dtsi" / { model = "My Embedded Board"; compatible = "mycompany,myboard"; chosen { bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2"; }; memory@80000000 { device_type = "memory"; reg = <0x80000000 0x40000000>; /* 1GB */ }; soc { #address-cells = <1>; #size-cells = <1>; compatible = "simple-bus"; ranges; uart0: serial@3f8 { compatible = "ns8250"; reg = <0x3f8 0x8>; interrupts = <4>; clock-frequency = <1843200>; status = "okay"; }; }; };这段代码干了四件事:
- 声明这是一个 v1 格式的设备树;
- 包含了一个通用骨架文件;
- 描述了机器型号、内存位置和启动参数;
- 在 SoC 内部定义了一个串口控制器。
注意,这里没有任何函数调用或初始化逻辑。它是纯粹的数据描述。
那么,这些“数据”是如何变成内核能识别的设备的?
流程很清晰:
- 编写 DTS→
- dtc 编译成 DTB(二进制 Blob)→
- U-Boot 把 DTB 放到内存并传给内核→
- 内核解析 DTB,创建
platform_device或amba_device→ - 驱动通过
of_match_table匹配设备,开始工作
整个过程就像:厨师(内核)拿到一张菜单(DTB),照着上面写的食材清单准备饭菜,不需要提前知道今天吃什么。
三、节点与属性:谁是主角?
节点(Node)——代表一个物理实体
格式为:
node-name@unit-address { // 属性和子节点 };比如:
i2c@7e804000 { ... };这里的i2c是类型名,7e804000是它的寄存器基地址。如果有多个 I2C 控制器,就靠这个地址区分。
⚠️ 小贴士:即使两个设备都是 I2C,只要地址不同,就是不同的节点。这是实现多实例的基础。
你可以给节点起个别名(label),方便后续引用:
i2c1: i2c@7e804000 { ... };之后就可以用&i2c1来修改或添加内容,不用再写完整路径。
属性(Property)——描述节点特征
属性是键值对,比如:
compatible = "brcm,bcm2835-i2c"; reg = <0x7e804000 0x1000>; interrupts = <1>; status = "okay";它们不是随便写的,而是有明确语义的“关键词”。下面我们挑几个最关键的深入聊聊。
四、compatible:驱动匹配的灵魂
当你注册一个平台驱动时,通常会写这样一段代码:
static const struct of_device_id my_driver_of_match[] = { { .compatible = "fsl,imx6ul-enet" }, { /* sentinel */ } };内核启动时,会遍历所有设备树节点,查看哪个节点的compatible和你的驱动列表匹配。一旦命中,就会调用.probe()函数。
所以,compatible不是给人看的注释,是给内核做决策的依据。
而且它支持回退机制:
compatible = "fsl,imx6ul-enet", "fsl,imx6q-enet";意思是从左到右依次尝试匹配。如果imx6ul-enet驱动不存在,就试试imx6q-enet。这是一种兼容老驱动的设计技巧。
✅ 最佳实践:第一个字符串尽量具体(
vendor,model),第二个可以更宽泛(vendor,family)
五、reg与地址映射:怎么算出“占了多大地方”?
很多人第一次看到reg = <0x3f8 8>;都会疑惑:这两个数什么意思?
答案取决于它的父节点有没有定义#address-cells和#size-cells。
例如:
soc { #address-cells = <1>; #size-cells = <1>; serial@3f8 { reg = <0x3f8 0x8>; }; };这意味着:
- 地址部分占 1 个 cell(32位)
- 大小部分也占 1 个 cell
所以<0x3f8 0x8>表示:从地址0x3f8开始,占用 8 字节。
但如果换成:
#address-cells = <2>; #size-cells = <1>; reg = <0x0 0x3f8 0x8>;这就表示使用 64 位地址空间,前两个数字组成地址(高32 + 低32),第三个是长度。
💡 实际案例:某些 PCIe 控制器就需要双 cell 地址来支持大于 4GB 的映射空间。
六、中断系统是怎么连起来的?
中断是最容易出错的部分之一,因为它涉及两级结构:设备 → 中断控制器。
举个例子:
gpio_keys { compatible = "gpio-keys"; interrupt-parent = <&gpio1>; interrupts = <21 IRQ_TYPE_EDGE_RISING>; };这里的关键点在于:
-interrupt-parent指定了中断信号接到哪个控制器(&gpio1)
-interrupts给出了具体的中断号和触发方式
而 GPIO 控制器本身必须声明自己是一个中断控制器:
gpio1: gpio@10000000 { compatible = "foo,gpio"; interrupt-controller; #interrupt-cells = <2>; };其中#interrupt-cells = <2>表示每个中断需要两个参数(比如 pin 号 + 触发类型)。这决定了你在interrupts里要写几个值。
❗常见坑点:忘记设置
interrupt-controller或者#interrupt-cells数量不对,会导致中断无法注册。
七、标签(Label)和引用:别再写冗长路径了!
想象你要配置一个 USB PHY,它属于某个 USB 控制器:
&usbdrd_dwc3 { dr_mode = "host"; status = "okay"; extcon = <&usbdrd_iddig>; };这里的&usbdrd_iddig就是引用另一个带 label 的节点:
usbdrd_iddig: usb-id-dig { compatible = "linux,extcon-usb-gpio"; id-gpio = <&gpiob 12 GPIO_ACTIVE_HIGH>; };如果没有 label,你就得写成:
extcon = "/soc/usb-dr-device/usb-id-dig";不仅难读,还容易拼错。
✅ 强烈建议:所有会被引用的节点都加上 label!
八、Overlay:让设备树也能“热插拔”
你知道树莓派的 HAT 扩展板是怎么自动识别的吗?靠的就是设备树 Overlay。
传统设备树是静态的,编译好就不能改。但 Overlay 允许你在运行时动态加载补丁,修改主设备树。
比如你想在运行中启用一个 I2C 上的 EEPROM:
// i2c-eeprom-overlay.dts /dts-v1/; /plugin/; / { fragment@0 { target = <&i2c1>; __overlay__ { status = "okay"; eeprom@50 { compatible = "atmel,24c02"; reg = <0x50>; }; }; }; };编译后得到.dtbo文件,然后:
echo i2c-eeprom-overlay > /sys/kernel/config/device-tree/overlays/内核就会把这个节点合并进主树,并尝试绑定驱动。
🎯 应用场景:USB 转 CAN 卡、FPGA 子卡、传感器扩展模块等即插即用需求。
九、实战经验:我在项目中踩过的坑
坑1:status = "disabled"写成了"disable"
结果设备根本没被扫描,日志里一句话都没有。因为内核只认"okay"和"disabled",其他都是无效值。
✅ 解决方案:永远用双引号包裹状态值,且只使用标准值。
坑2:.dtsi文件包含顺序错误
我曾经在一个项目中同时包含了imx6dl.dtsi和imx6q.dtsi,结果 CPU 被识别成了 Quad-core,实际却是 DualLite。
✅ 正确做法:确保只有一个 SoC 级
.dtsi被包含,板级.dts只继承一个基础文件。
坑3:Pinmux 配置没生效
明明写了 pad 设置,但 I2C 就是不通。后来才发现,pin control 节点没有被任何设备引用!
正确的做法是在设备节点中显式指定 pinctrl:
&i2c1 { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_i2c1>; status = "okay"; };否则,即使你定义了 pin group,也不会被应用。
十、设计建议:如何写出可维护的 DTS?
1. 分层管理.dts和.dtsi
soc.dtsi:SoC 共享部分(CPU、内存、控制器框架)board.dts:板级差异(外设、电源、GPIO分配)module.dtso:模块化 overlay(可选功能)
这样升级 SoC 支持时,只需更新.dtsi,不影响板级配置。
2. 使用有意义的 label
不要写:
&LCD_CTRL { ... }而应写:
&lcdif1 { lcd-display { ... }; }越具体越好,避免缩写歧义。
3. 合理使用__force和W=1编译检查
在编译时加上:
make ARCH=arm dtbs W=1可以暴露未声明的属性、拼写错误等问题。很多看似“运行正常”的 DTS,其实藏着潜在风险。
4. 文档化你的compatible字符串
如果你写了新的驱动,一定要在文档中说明支持哪些compatible值。最好提交到 Devicetree Binding 文档 。
否则别人根本不知道该怎么写设备树来匹配你的设备。
写在最后:设备树是桥梁,不是终点
掌握设备树的意义,不只是会写.dts文件。
它代表着一种思维方式的转变:硬件不再是代码的一部分,而是一种可配置的资源。
未来,随着 RISC-V 生态的发展、Zephyr OS 对 DT 的全面采纳,以及 ACPI 在嵌入式领域的渗透,设备树的角色还会继续演化。
但对于现在的我们来说,理解它的语法细节、明白每一行背后的机制、避开那些隐蔽的坑——才是真正的基本功。
下次当你面对一片黑屏的日志时,不妨静下心来看看设备树:也许答案,早就写在那里了。
如果你在实际项目中遇到过离谱的设备树 bug,欢迎留言分享——我们一起排雷。