设备树节点与属性:从零开始的实战指南(新手也能懂)
你有没有遇到过这种情况:明明写好了驱动代码,烧录进开发板后却“纹丝不动”?串口没输出、GPIO 控制不了、I²C 传感器读不到数据……最后翻来覆去查了半天,问题竟然出在一个.dts文件里的一行配置?
在现代嵌入式 Linux 开发中,设备树(Device Tree)就是那个藏在幕后、掌控一切硬件命脉的关键角色。它不像 C 代码那样直接执行逻辑,但它决定了内核“知道有哪些外设”、“该加载哪个驱动”、“资源怎么分配”。可以说——不懂设备树,就别谈会写 Linux 驱动。
本文不玩虚的,也不堆术语。我们将以一个真实开发场景为线索,带你一步步看懂设备树的结构、理解它的规则,并最终学会如何手动添加一个外设节点。无论你是刚接触嵌入式的新人,还是已经摸过几块开发板但一直对.dts文件心存畏惧的老兵,这篇都能让你豁然开朗。
为什么我们需要设备树?一个故事讲明白
想象一下:你是一家芯片公司的工程师,负责设计一款叫MySoC-1000的处理器。这款芯片性能很强,支持 UART、SPI、I2C、PWM 等一堆外设控制器。
现在有两个客户买了你的芯片:
- 客户 A 做了一块评估板,上面接了个 SSD1306 OLED 屏;
- 客户 B 做了个工业网关,挂了两个温湿度传感器和一个 RS485 模块。
问题是:这两块板子用的是同一款 CPU,但外设完全不同。如果让 Linux 内核“内置”所有可能的硬件信息,那得编译多少个内核版本?每换一块板子就要改一次代码?显然不现实。
于是,设备树出现了。
设备树的本质,是一份描述“这块板子上到底连了啥”的说明书。
你可以把内核想象成一个通用大脑,而设备树就是告诉这个大脑:“嘿,你现在跑在一块带 OLED 和 TMP102 的板子上。”
不需要重新编译大脑,只要换个说明书就行。
这就是所谓的硬件抽象化 + 配置外部化—— 也是设备树最核心的价值。
设备树长什么样?先看一棵“树”
设备树本质上是一棵以/为根的树形结构,由节点和属性构成。
最基本的骨架
/ { model = "My Embedded Board"; compatible = "mycompany,myboard"; cpus { #address-cells = <1>; #size-cells = <0>; cpu@0 { compatible = "arm,cortex-a53"; reg = <0>; }; }; memory@80000000 { device_type = "memory"; reg = <0x80000000 0x40000000>; /* 1GB */ }; };这段代码虽然短,但包含了设备树最基本的元素:
/是根节点。model和compatible描述了整个开发板的信息。cpus和memory是标准节点,必须存在。- 节点内部可以嵌套子节点,形成层级关系。
- 每个键值对就是一个属性(property),比如
reg = <0x80000000 0x40000000>;表示内存起始地址和大小。
这棵树不是随便画的,它是对真实硬件拓扑的建模。就像电路图有主控、总线、外设一样,设备树也有对应的层次结构。
节点命名规则:别被@和&吓到
刚开始看.dts文件时,很多人会被这些符号搞晕:
spi@7e806000 { status = "okay"; }&spi0 { status = "okay"; }它们其实各有用途。
1. 正常定义一个新节点
格式是:
[label:] node-name[@unit-address]node-name:节点名,建议小写+连字符,如i2c,uart,gpio-leds@unit-address:单元地址,通常是该设备寄存器的物理基地址label:标签,用于后续引用(类似变量名)
举个例子:
uart1: serial@10000000 { compatible = "snps,dw-apb-uart"; reg = <0x10000000 0x100>; interrupts = <0 32 4>; clocks = <&clkc 13>; status = "okay"; };这里我们做了几件事:
- 给节点打了标签
uart1,后面可以用&uart1引用它; - 名称是
serial@10000000,说明这是一个串口控制器,位于地址0x10000000; reg明确指出寄存器范围:从0x10000000开始,占 256 字节;interrupts指定中断号(SPI 中断 32)、触发方式等;clocks引用了另一个叫clkc的时钟控制器节点的第 13 号输出。
注意:reg的格式是由父节点中的#address-cells和#size-cells决定的。常见组合如下:
| #address-cells | #size-cells | reg 示例 |
|---|---|---|
<1> | <1> | <0x10000000 0x100> |
<2> | <1> | <0x0 0x10000000 0x100>(高位+低位地址) |
一般情况下都是<1>, <1>,除非遇到 PCIe 这种复杂总线。
2. 修改已有节点:用&引用
有时候你不希望从头写一个节点,而是想修改 SoC 公共文件里已经定义好的内容。这时候就要用&。
比如,在rk3568.dtsi中已经有:
spi0: spi@fc004000 { reg = <0xfc004000 0x1000>; interrupts = <GIC_SPI 69 IRQ_TYPE_LEVEL_HIGH>; #address-cells = <1>; #size-cells = <0>; status = "disabled"; };但在你的板子上,你要启用 SPI 并挂一个 OLED 屏。你不需要复制整个节点,只需这样写:
&spi0 { status = "okay"; oled_display: oled@0 { compatible = "solomon,ssd1306fb-spi"; reg = <0>; spi-max-frequency = <1000000>; reset-gpios = <&gpio5 RK_PB7 GPIO_ACTIVE_LOW>; dc-gpios = <&gpio5 RK_PB6 GPIO_ACTIVE_LOW>; }; };这里的&spi0就是在“打补丁”,把原来的状态改成"okay",然后加上子设备。
这种机制极大提升了复用性:SoC 厂商提供.dtsi定义所有控制器,板级开发者只关心自己接了什么外设。
属性详解:哪些字段最关键?
每个节点都靠属性来“说话”。下面这几个是最常用、也最容易出错的。
✅compatible:决定谁能管你
这是最重要的属性,没有之一。
compatible = "vendor,device-model";例如:
compatible = "ti,tmp102"; compatible = "bosch,bme280-i2c"; compatible = "st,stmpe610";内核启动时会遍历所有平台设备,拿着这个字符串去匹配驱动注册表里的.of_match_table。只有匹配成功,才会调用probe()函数。
而且支持多级兼容:
compatible = "fsl,imx8mq-i2c", "fsl,imx35-i2c";意思是:“优先按 i.MX8MQ 的方式处理,不行就退化成 i.MX35 的老方法”,实现向后兼容。
⚠️ 提示:如果你新加了一个设备但驱动没加载,第一件事就是检查
compatible是否拼错或未被任何驱动支持。
✅reg:我在哪?有多大?
描述设备的寄存器地址空间。
reg = <地址 地址长度>;对于挂在 APB/AHB 总线上的外设,通常只有一个地址段:
uart@10000000 { reg = <0x10000000 0x100>; };如果是内存映射设备(如 framebuffer),可能是多个区域:
gpu@deadbeef { reg = <0xdeadbeef 0x1000>, <0xcafebabe 0x2000>; };记住:reg的 cell 数量由父节点的#address-cells和#size-cells控制!
✅interrupts和interrupt-parent
中断系统非常关键,尤其当你做按键、ADC、DMA 外设的时候。
interrupts = <类型 编号 触发方式>;ARM GIC 下常见格式:
interrupts = <GIC_SPI 32 IRQ_TYPE_LEVEL_HIGH>; // SPI 中断,编号 32,高电平触发或者简写为:
interrupts = <0 32 4>; // type=0(SPI), irq=32, flags=4(level-high)如果没有指定interrupt-parent,默认继承父节点。如果你想指定特定中断控制器,可以显式声明:
interrupt-parent = <&gpio_intc>;✅gpios/clocks/pinctrl:三大关联属性
这三个属性都不是本地定义,而是通过phandle引用其他节点。
GPIO 示例
reset-gpios = <&gpio5 7 GPIO_ACTIVE_HIGH>;分解一下:
&gpio5:指向名为gpio5的 GPIO 控制器节点;7:使用该控制器的第 7 号引脚;GPIO_ACTIVE_HIGH:表示高电平有效(宏定义为 0);
驱动中可以通过:
int gpio = of_get_named_gpio(np, "reset-gpio", 0);获取实际的 GPIO 编号并请求控制权。
Pinctrl 引脚复用配置
很多 SoC 引脚是多功能的,需要明确设置工作模式。
pinctrl-names = "default"; pinctrl-0 = <&pinctrl_oled_default>; &iomuxc { pinctrl_oled_default: oledgrp { fsl,pins = < MX6UL_PAD_GPIO1_IO02__GPIO1_IO02 0x10b0 MX6UL_PAD_UART1_TX_DATA__UART1_DCE_RX 0x17071 >; }; };pinctrl-0指向一组引脚配置,内核会在 probe 前自动应用。
Clock 引用
clocks = <&clkc IMX8MQ_CLK_I2C1>; clock-names = "i2c";告诉驱动:“请帮我打开 I2C1 的时钟”。
实战:手把手添加一个 I²C 温度传感器
假设你在自己的板子上焊接了一个 TI 的 TMP102 温度传感器,连接到 I2C1,地址是0x48。
我们要做的就是:让内核知道这个设备的存在,并加载正确的驱动。
第一步:找到 I2C 控制器节点
查看你的.dts或.dtsi文件,确认 I2C1 是否已启用:
&i2c1 { clock-frequency = <100000>; status = "okay"; }确保状态是"okay",否则不会初始化这条总线。
第二步:添加子设备节点
在&i2c1块内加入:
tmp102@48 { compatible = "ti,tmp102"; reg = <0x48>; };就这么简单!不需要写地址范围,因为 I2C 设备只有一个字节的设备地址。
保存后重新编译 dtb:
make ARCH=arm dtbs重启开发板,看看日志:
dmesg | grep tmp102你应该看到类似输出:
i2c i2c-1: new_device: client registered successfully thermal thermal_zone0: sensor 'tmp102'恭喜!你刚刚完成了一次完整的设备树配置。
常见坑点与调试技巧
设备树看似简单,但一不小心就会掉进坑里。以下是几个高频问题及应对方法。
❌ 问题1:驱动不加载,probe 不触发
排查步骤:
- 检查
compatible是否拼写正确; - 查看
dmesg是否提示 “no matching node found”; - 使用
of_node打印当前设备节点信息; - 确认
.dtb已更新并被 U-Boot 正确加载。
❌ 问题2:GPIO 引脚无效或行为异常
原因:
pinctrl没配置,引脚功能不对;gpios引用错误控制器或编号越界;- 极性设置反了(ACTIVE_HIGH vs ACTIVE_LOW)。
解决:
使用cat /sys/kernel/debug/pinctrl/*/pinmux-pins查看当前引脚状态。
❌ 问题3:I2C 设备扫描不到
i2cdetect -y -r 1如果显示UU,说明设备已被驱动占用;如果是--,说明没响应。
可能原因:
- 地址写错(7位 vs 8位);
- 上拉电阻缺失;
- 电源未供上;
- 设备树中
reg写成了十进制而非十六进制。
🛠️ 小技巧:可用
fdtdump或dtc -I dtb -O dts反编译运行时加载的.dtb,确认最终生效的配置。
如何写出高质量的设备树?五个最佳实践
- 拆分
.dtsi和.dts
SoC 公共部分放.dtsi,板级定制放.dts,提高可维护性。
- 善用标签和引用
dts &uart1 { status = "okay"; }
比手动复制一大段代码更安全、简洁。
- 遵循上游绑定文档
查阅Documentation/devicetree/bindings/目录下的文档,使用标准属性名和格式。
- 启用 overlay 支持动态扩展
对于 Raspberry Pi HAT、USB 扩展卡等热插拔场景,使用CONFIG_OF_OVERLAY=y,运行时加载.dtbo。
- 定期验证语法
编译时加上检查:
bash make dtbs_check
或手动运行:
bash dtc -I dts -O dtb -o test.dtb your_file.dts
静默通过才算合格。
结语:掌握设备树,你就掌握了硬件入口
设备树不是魔法,它是嵌入式 Linux 中一种极为实用的设计哲学:把硬件描述从代码中剥离出来,变成可配置的数据。
当你能熟练阅读一份.dts文件,看出其中的 CPU、内存、外设连接关系;当你能在不改内核的情况下成功接入一个新的传感器;当你通过dmesg看到“probe success”那一刻——你就真正迈进了驱动开发的大门。
下次再遇到“外设不工作”的问题,别急着怀疑代码,先去看看那棵静静躺在内存里的“树”吧。也许答案,就在/soc/i2c@7e804000的某个角落。
如果你在实践中遇到了具体的设备树难题,欢迎留言讨论。我们可以一起分析
.dts片段,找出隐藏的 bug。毕竟,每一个成功的 probe,都值得庆祝。