
1. 为什么要学设备树从代码堆硬件到用数据描述硬件1.1 内核移植的历史包袱平台文件是如何失控的很多刚接触嵌入式Linux的人都会有一个疑问设备树到底解决什么问题为什么现在几乎所有ARM Linux、RISC-V Linux都要用它要回答这个问题得先看它出现之前的内核是什么样子。在Linux 3.x之前的ARM架构里每移植一块新的开发板通常要在内核源码树的arch/arm/mach-xxx目录下新建一个board-xxx.c文件。这个文件里写的是什么本质上是把板子上所有硬件的初始信息硬编码进C代码内存起始地址和大小、定时器寄存器地址、GPIO定义、串口波特率、I2C控制器编号、中断号通通写死在结构体里。如果板子改了某个外设地址就要改C代码、重新编译内核。更麻烦的是arch/arm下的mach-目录越来越多同一个SoC被重复描述无数次代码膨胀严重。就在这种背景下PowerPC和嵌入式ARM社区推动了一个方案让内核不再关心板子长什么样而是由一段独立于内核的二进制数据来描述硬件。这段数据就是设备树DTBDevice Tree Blob描述它的文本格式就是DTSDevice Tree Source。内核启动时解析这段二进制把硬件资源逐项注册进内核的设备模型驱动再按compatible兼容字符串去认领设备。这个方案在2011年前后进入ARM主线随后扩散到RISC-V、X86模拟环境成为嵌入式平台的标准配置方式。我之前移植一块RK3568板卡时体会特别深同样是基于rk3568-evb这颗SoC不同公司做的板子网口PHY地址不一样、LED引脚不一样、LCD屏幕时序不一样。有了设备树之后我只需要维护一个差分很小的DTS文件内核主目录完全不用动改动成本从重编内核降到了改DTS再编dtb效率提升了不止一个量级。1.2 设备树的本质一份让内核看懂硬件的说明书设备树从设计上就强调一件事它是一份数据文件不是程序。DTS里看不到循环、判断、函数调用有的只是节点node、属性property和值value。你可以把它理解为一份结构化说明书告诉内核这个系统有一个CPU主频多少有一块内存地址范围在哪有一个串口用的哪个中断、哪个引脚。这份说明书在逻辑上分成两部分描述SoC通用信息的部分存成.dtsi后缀的公共文件比如这个芯片有4个UART控制器、基地址分别在xxx、支持哪些中断控制器描述具体板卡的部分存成.dts后缀的板级文件比如这块板子的串口0接到了调试口、LED接在GPIO3的A组第5脚。.dtsi和.dts的关系有点像C语言的头文件和源文件。你会在板级文件顶部看到大量#include语句包含SoC级的dtsi、pinctrl定义、bindings头文件然后通过节点修改的方式覆盖SoC级的默认值。这种设计让同一个SoC、不同的板子能够共存而不互相干扰也让多个板卡共享一套芯片级的描述同时保留自己差异的部分。设备树的另一层本质是把硬件拓扑与驱动代码解耦。驱动只写一次它声明我支持哪些compatible然后在probe回调里去读reg、读interrupts、读gpio属性。至于这些属性值是多少由DTS决定。驱动换到另一块板子上不用改源码只需要设备树给它提供对应的资源描述。1.3 DTS、DTSI、DTB、DTC四件套分别是什么角色初学者经常被这套术语绕晕。我列个对照表就当速记手册用缩写全称本质作用DTSDevice Tree Source文本源文件描述整个系统硬件拓扑由开发者编写DTSIDevice Tree Source Include公共文本片段一般是SoC级描述被DTS include进去DTCDevice Tree Compiler编译工具把DTS/DTSI编译成DTB的二进制工具DTBDevice Tree Blob二进制产物内核启动时实际解析的数据文件DTSI中常出现的.dtb目标产物bootloader加载并传递给内核的二进制对象与内核镜像、根文件系统并列整个流程走一遍你把board.dts通过#include引用了soc.dtsi、board-common.dtsi然后执行dtc -I dts -O dtb -o board.dtb board.dts得到一个二进制DTB。U-Boot读取这个DTB放到内存里启动内核时把物理地址传给内核内核在启动早期调用unflatten_device_tree函数把它展开成一棵struct device_node树后续所有的driver探测、irq资源注册、pinctrl配置都基于这棵运行时树。因此设备树在内核里通常被称为flattened devicetree扁平设备树启动时再反扁平化展开成树形链表结构。我建议初学者把这份数据流画成一个链路记板卡原理图 → DTS文本描述 → DTC编译 → DTB二进制 → U-Boot加载 → 内核dts解析器 → device_node链表 → platform_device注册 → 驱动匹配probe。后面所有语法细节都挂在这条链路的某个环节上。2. 语法速通节点、属性、值类型与标签引用的完整规则2.1 节点的写法与生命周期结构、地址与生命周期设备树的基本单位是节点node一个节点既是一个独立的硬件单元也是一个可被驱动匹配的device对象。语法格式如下/dts-v1/; / { model Example Embedded Board; compatible example,board, example,soc; memory60000000 { device_type memory; reg 0x60000000 0x40000000; }; chosen { stdout-path uart0; }; };第一行的/dts-v1/;是版本声明表示使用Linux设备树v1语法。它必须出现在文件的第一个有效token位置否则DTC会报错。根节点用/表示它不写node name是因为它代表整棵树。其余每个节点至少要有节点名可选的unit-address表示该寄存器的起始地址或者编号。比如uartfe360000表示这个串口寄存器基址为fe360000。节点名有个隐藏规则很多人忽视同一父节点下不同的单位地址之后的部分不能重复否则DTC会认为你在定义同一个节点而两个名字不同但unit-address相同的节点会被解析成同一个物理设备。所以我在写新增外设节点时一定会先查SoC手册确认基地址再查父节点里有没有同样addr的节点避免静默冲突。节点在dtb里会被展开成struct device_node每一个节点都有一个完整路径比如/soc/serialfe360000。路径的作用类似文件系统路径可用于其他节点的引用。节点本身的属性property会变成struct property链表挂到device_node上驱动侧可以用of_property_read_u32、of_find_property等API读取。2.2 属性值类型字符串、数组、字符串列表与特殊符号属性是节点的数据每个属性由一个名字和一个值组成。设备树语法里值类型一共就那么几种但非常容易混淆尤其是初学时经常把字符串数组和字符串列表搞混。字符串string用双引号包裹。比如model RK3568 Board;。32位无符号整数用尖括号 包裹多个数字用空格分隔。比如reg 0x60000000 0x40000000;表示两个数字起始地址和长度。64位无符号整数数字后加h标记如0x0 0x0 0x1 0x0表示一对64位数字这在描述大内存时常用因为32位整型撑不住超大地址。字符串列表string list把多个字符串并列写在一起中间用逗号分隔最终值是一个字符串数组按顺序排列。最常见的就是compatible example,board, example,soc;。二进制数据bytes用方括号[ ]包裹每一对16进制字符表示一字节多字节按顺序排列如[ 0xde 0xad 0xbe 0xef ]。一个节点可以混合多种类型属性内核有对应的read函数。要判断一个属性该怎么写最靠谱的方法是去内核源码树Documentation/devicetree/bindings/目录里查对应外设的binding文档或者去看相近板卡的DTS写法。完全凭感觉乱写类型等驱动加载时会得到错误数据这个问题比语法错误更难排查因为编译期根本不会报错。2.3 标准属性逐项拆解model、compatible、reg、ranges、status设备树里有一批约定俗成的标准属性认识它们就等于拿到了解读大部分DTS的主钥匙。model人类可读的板卡名称例如model Rockchip RK3568 EVB2 DDR4 Board;。它通常只出现在根节点作用是方便使用者一眼识别硬件在内核里也会被打印到启动日志但不参与驱动匹配。compatible这是最重要、也被骂得最多的属性。它指定这个设备能兼容哪些驱动。内核驱动通过of_match_table里的字符串与之比对。它的值必须是一组字符串列表且第一项是当前平台最精确、最特定的描述越靠后越宽松。比如某块板子的根节点写compatible rockchip,rk3568-evb, rockchip,rk3568;那么匹配时先查rockchip,rk3568-evb找不到再查rockchip,rk3568。machine_desc的匹配逻辑里有一条铁律compatible字符串必须保证特定性递减顺序否则在多个平台有相似前缀时会选错machine描述。reg与#address-cells、#size-cellsreg描述设备在总线上的地址空间它的含义由父节点的#address-cells和#size-cells两个属性决定。#address-cells表示用几个32位整型表示地址#size-cells表示用几个32位整型表示长度。没有这两个属性时默认各是1但很多SoC dtsi会明确写成#address-cells 2用于支持超过4GB的物理地址。比如一个节点想描述起始物理地址0xfe360000长度0x10000如果父节点是#address-cells 1; #size-cells 1;那reg就写reg 0xfe360000 0x10000;。如果父节点是#address-cells 2; #size-cells 2;则要写reg 0x0 0xfe360000 0x0 0x10000;。地址含几个cell完全由父节点决定子节点自身没有选择权。ranges负责做地址翻译。它可以让子节点用局部地址表示由ranges映射到父总线地址。比如一个PCIe桥下的设备物理地址经过桥转换就可以通过ranges把cpu侧的物理地址空间映射给子节点总线。ranges的格式是local-addr child-addr length每组三元按下标循环每个地址占的cell数由当前节点和父节点的#address-cells决定。最简单的写法是ranges;——空ranges表示子节点地址与父节点地址1:1映射不做偏移。status节点是否可用的开关。值一般是okay、disabled、fail、fail-sss几种。常见场景是SoC默认把用不到的控制器写成status disabled板级文件再改成status okay。okay表示设备使能disabled表示驱动不会probefail和fail-sss表示设备虽在但检测失败这里sss是错误码。一个小坑有些工程师喜欢直接删节点而不是disable这在引用关系复杂时容易引发符号缺失尽量只改status不要删。2.4 标签引用与节点覆盖符号背后的覆盖机制一个DTS文件通常先包含dtsidtsi里定义好SoC全部外设默认值是厂家参考设计。板级文件要做的事情就是改这些默认值——改status、改pinctrl、改时钟频率。怎么改用标签label加符号。uart2 { status okay; pinctrl-names default; pinctrl-0 pinctrl_uart2; };当DTC编译时遇到uart2它会去全局查找一个名为uart2的label然后把这个花括号块里的属性合并到uart2节点上。这个机制叫做节点覆盖node override是设备树最重要的语法糖。合并规则如果属性名相同新的值覆盖旧值如果属性名不同两者共存如果覆盖块里出现子节点那子节点会追加到原节点下。比如soc dtsi里uart2里有个clocks属性板级又写了一个clocks那板级的就会占据最终位置。覆盖还有一个隐含作用DTC不会报重复属性错误只默默合并。所以排查问题时如果发现某属性值不是你写的先查是不是dtsi里同样有定义、你的覆盖写错了拼写——标签拼写错了不会编译报错吗会。但如果你覆盖的是qart2这种不存在的labelDTC会报Error: Reference to non-existent node or label这类报错能直接定位问题。反而是属性名拼错如punctrl-0编译完全正常只有驱动运行时读不到pinctrl这类静默错误才是最常见的坑。2.5 头文件与宏定义让DTS像C语言一样复用设备树源码在预处理阶段允许#include所以你可以把C头文件里的宏、枚举引入DTS。内核源码里就放了大量dt-bindings头文件里面的宏定义专门给设备树使用。#include dt-bindings/gpio/gpio.h #include dt-bindings/interrupt-controller/irq.h gpio3 { status okay; led-gpios gpio3 5 GPIO_ACTIVE_LOW; };#define GPIO_ACTIVE_LOW 1这样的宏被CPPC预处理器展开后DTC读到的就是数字。这种写法有几个实操好处不用去记忆各种魔法数字让DTS可读性大幅提升GPIO_ACTIVE_LOW一眼就看到电平含义。跟Linux头文件保持同一份定义SoC厂家更新驱动和bindings时DTS里的宏也不会快速失真。头文件里还可以定义phandle相关的固定编号便于跨节点引用。我习惯在编写自定义DTS时也把常用常量抽到本地头文件比如#define MY_PIN_LED 5再用#include引入避免在DTS文件里到处写魔法数字。但要注意DTSI文件在预处理时会展开所有#include中间如果有重复包含导致宏重定义需要在DTSI顶层加#ifndef保护这和C语言的头文件守卫是一个道理。有几个语法糖细节很关键#include必须在DTC编译前的cpp阶段展开所以dtsi里面的#define和#undef同样有用。dt-bindings头文件里有些宏定义依赖__DTS__等宏来避免在C编译时意外生效所以include的顺序万不可随便更换。DTC支持/memreserve/等编译指令但这个偏向保留内存平时板级DTS很少碰比如内核对特殊内存的保留可以用它标记避免操作系统把某段内存当普通内存分配掉。一句话总结语法部分设备树其实就是一堆嵌套的节点节点里挂一堆属性属性按binding文档约定取值。最难的部分不是语法字符而是属性的语义和父节点与子节点之间的关系。语法学会了后面看实例才有底气。3. 从零解析一份真实设备树RK3568平台DTS实例拆解3.1 顶层骨架dtsi的include层级与整体布局理论讲太多容易飘直接拿瑞芯微RK3568的板级DTS来拆。RK3568是一个四核Cortex-A55的SoC面向AIoT、边缘计算它的设备树在官方BSP里有大量可参考代码而且层次清晰很适合当教学样本。打开一个真实的板级文件比如rk3568-evb.dts它的开头一般是/dts-v1/; #include rk3568-pinctrl.dtsi #include rk3568.dtsi #include dt-bindings/gpio/gpio.h #include dt-bindings/pinctrl/rockchip.h #include dt-bindings/interrupt-controller/irq.h看到没有它同时用了.dtsi的引用和dt-bindings头文件引用。rk3568.dtsi是整个SoC级描述里面定义了CPU节点、内存控制器、soc下的全部外设节点、GIC中断控制器、pinctrl、时钟和reset控制器。这块文件的内容量大得惊人几乎是一份SoC手册的数据化镜像。而rk3568-pinctrl.dtsi则单独存放引脚复用配置。继续往下通常是板级的自定义功能节点LED、按键、调试串口别名、pwm风扇、gpio-export、USB VBUS控制、电源管理等。流程基本是这样通过include拿进SoC全部基础描述在根节点下增加板级自定义节点用xxx覆盖SoC节点设置status和具体参数最后用pinctrl或gpio引用引脚。3.2 通过aliases与chosen定位启动设备板级DTS里几乎必有一组aliasesaliases { serial0 uart0; serial1 uart1; mmc0 sdmmc0; };aliases的作用是给节点起一个稳定的别名避免因为bus探测顺序变化导致设备名不固定。内核启动后/dev/ttyS0对应serial0不会因为USB转串口先注册而抢占编号。在调试的时候这是救命的东西你永远知道consolettyS0指向的是哪个物理UART而不是靠即插即用的枚举顺序瞎猜。chosen节点用于传递内核启动参数和引导配置chosen { stdout-path uart2; bootargs earlyconuart8250,mmio32,0xfe660000 consolettyS0,1500000n8; };stdout-path告诉内核早期输出走哪个串口这个串口往往是调试口。我在采集RK3568日志时经常改bootargs里的earlycon来打开早期串口输出排查那些发生在驱动加载模型调用前的启动死机问题。如果没有早期console你只能看到U-Boot的输出去到一半就黑屏完全不知道Linux内核死在哪一步。这种场景下chosen节点就是调试的生命线。3.3 追踪一个真实驱动程序从compatible到probe现在来看最关键的匹配机制。以I2C控制器为例RK3568的I2C设备树节点长这样i2c0: i2cfe830000 { compatible rockchip,rk3568-i2c; reg 0x0 0xfe830000 0x0 0x1000; interrupts GIC_SPI 47 IRQ_TYPE_LEVEL_HIGH; #address-cells 1; #size-cells 0; clocks cru CLK_I2C0, cru PCLK_I2C0; clock-names i2c, pclk; pinctrl-names default; pinctrl-0 i2c0_xfer; status disabled; };这个节点里能演很多知识点。compatible是驱动匹配的钥匙reg用两对0x0 0xfe830000 0x0 0x1000是因为父节点soc的#address-cells 2; #size-cells 2interrupts GIC_SPI 47 IRQ_TYPE_LEVEL_HIGH表示它走GIC中断控制器是一个SPI共享外设中断中断号47高电平触发。这些字段的语义来自Documentation/devicetree/bindings/i2c/i2c-rk3x.yaml严格遵循schema定义的格式。内核侧驱动文件一般是drivers/i2c/busses/i2c-rk3x.c里面的匹配表是static const struct of_device_id rk3x_i2c_match[] { { .compatible rockchip,rk3568-i2c, .data rk3x_i2c_soc_data }, { .compatible rockchip,rk3399-i2c, .data rk3x_i2c_soc_data }, ... }; MODULE_DEVICE_TABLE(of, rk3x_i2c_match);compatible字符串逐行比对成功后驱动probe开始执行。probe里用接口函数读取reg、申请中断、配置时钟、注册i2c adapter。从这里可以看到设备树和驱动是双向配合的关系设备树提供资源清单驱动提供处理逻辑。追踪外设挂载假设这块板子I2C0上挂了一个eeprom那么板级DTS中就会有这样的代码i2c0 { status okay; clock-frequency 400000; eeprom50 { compatible microchip,24c02, atmel,24c02; reg 0x50; pagesize 16; }; };注意eeprom节点直接写在i2c0的覆盖块里。它不用写reg的地址前面的父地址因为#address-cells 1; #size-cells 0表示寄存器只占一个cell也就是I2C从设备地址0x50长度为0。中断、pinctrl等属性也一样子节点会继承父节点的总线寻址语义。这个细节很容易出错比如在I2C子节点里多写了一个长度值DTC会静默截断或错位导致驱动读到错误的地址。3.4 pinctrl子系统与引脚状态管理设备树里最复杂的部分是pinctrl。RK3568的引脚复用配置决定了某个PIN脚究竟是GPIO、UART TX还是I2C SCL同时还要配置上下拉和驱动强度。DTS里常见的pinctrl写法是pinctrl: pinctrlfd8d0000 { compatible rockchip,rk3568-pinctrl; rockchip,grf sys_grf; #address-cells 2; #size-cells 2; ranges; i2c0_xfer: i2c0-xfer { rockchip,pins 0 RK_PB3 1 pcfg_pull_up, 0 RK_PB4 1 pcfg_pull_up; }; };rockchip,pins每组有四个部分bank号、pin号、mux功能索引、配置项。0 RK_PB3 1 pcfg_pull_up表示bank0的B3引脚复用为功能1也就是i2c0的SCL并设置上拉。这些宏定义在dt-bindings/pinctrl/rockchip.h里。实际设备节点通过pinctrl-0 i2c0_xfer;引用刚才定义的引脚组。关于pinctrl状态多组别pinctrl-names可以有多组比如default、sleep、active。默认驱动probe时自动启用default状态切到休眠时会切到sleep。如果板级DTS里pinctrl状态命名和驱动代码里请求的state名字不一致驱动虽然不会崩溃但永远不会切到睡眠态导致休眠漏电流偏大这种问题完全靠排查日志很难发现属于典型的电芯突然少了一截级别的隐形耗电元凶。一个实操建议当你需要确认某个引脚的配置是否生效时不要去看理论直接上设备树属性来实测——在系统起来后在/sys/kernel/debug/pinctrl/*/pinmux-pins文件里翻对应pin的功能如果实际状态与你设想的mux编号不符说明DTS里引用的pinctrl组没被正确匹配到或者节点本身就没probe成功。这个debugfs是排pinctrl问题最快的工具没有之一。3.5 手写一个最小板级DTSLED、按键、串口光看不练等于白学。我拿三样最常见的东西练手一个GPIO LED、一个GPIO按键、一个调试串口。这段代码可以直接跑在任何支持设备树的ARM板子上注意基地址改成你自己的SoC手册值。/dts-v1/; #include your-soc.dtsi #include dt-bindings/gpio/gpio.h #include dt-bindings/interrupt-controller/irq.h / { model My Custom Board; compatible mycompany,myboard, mycompany,soc; leds { compatible gpio-leds; power_led: led-0 { label power; gpios gpio3 5 GPIO_ACTIVE_HIGH; /* GPIO3_A5 */ linux,default-trigger heartbeat; default-state keep; }; }; keys { compatible gpio-keys; recovery-key { label recovery; linux,code KEY_ENTER; gpios gpio0 2 GPIO_ACTIVE_LOW; }; }; }; uart2 { status okay; pinctrl-names default; pinctrl-0 uart2_xfer; };gpio-leds和gpio-keys是内核里现成的驱动一个节点即可点灯、按键。GPIO的单元格格式是gpioX pin flags其中flags里的GPIO_ACTIVE_HIGH和GPIO_ACTIVE_LOW不仅要表达电平极性还会影响驱动内部逻辑GPIO_ACTIVE_LOW会让驱动在点亮时输出逻辑低电平而不是拿反多元件极性。如果上电后发现LED状态反了默认亮期望默认灭优先查gpio偏移和default-state而不是去改驱动。按键的linux,code需要#include dt-bindings/input/input.h通过KEY_ENTER宏直接用比写数字清晰一百倍。最小实例跑通之后你对节点、compatible、GPIO属性、pinctrl引用就有了完整概念。下一步就是编译它看它能不能被内核吃进去。4. 编译、反编译与内核匹配从DTS到驱动激活的完整链路4.1 DTC编译工具与DTB生成设备树的编译工具叫dtc。在完整内核源码树里构建系统会在编译时自动生成dtc并调用它。如果你只想单独编译一份dtb也可以直接用系统里的dtc工具dtc -I dts -O dtb -o rk3568-myboard.dtb rk3568-myboard.dts常见参数-I dts输入格式为dts文本-I dtb输入是二进制。-O dtb输出格式为dtb-O dts输出文本。-o输出文件路径。-生成带符号表的dtb允许dtbo叠加。这个参数在驱动模块移植时很关键如果你要做overlay调试不加-会丢失label符号。-H epapr控制phandle生成的兼容变体一般不用管。-Wno-unit_address_vs_reg关闭某些check的告警会在需求例外时使用。在内核源码树里编译默认会开启CONFIG_OF并利用make的dtb规则make ARCHarm64 rk3568-myboard.dtb输出到arch/arm64/boot/dts/rockchip/目录下。内核源码里的scripts/dtc/dtc.c就是DTC本体它是内核构建时的工具之一。注意如果你直接用手写的dtc而不走内核的Kbuild有可能会因为头文件路径缺失导致#include dt-bindings/...片段无法展开。这时需要自己加-i头文件搜索路径或者直接去内核源码树里编译dtb后者更省心。我在整理自定义板卡时一般是把DTS放到kernel源码里对应目录走完Kbuild流程同时也能让dtbs_check帮你做schema校验。4.2 反编译实测用dtc把DTB还原成DTS大多数情况下你拿到手的板子带的是DTB或者从bootloader分区里导出的二进制原始DTS分散在BSP里不见得能对应上。这时候用反编译强行还原dtc -I dtb -O dts -o dumped.dts rk3568-evb.dtb反编译出来的字体和我们源文件写法不完全一样因为DTC会把所有属性和节点值全部展开成标准的、最冗长的格式——label全部变成phandle常量、宏全部变成数字、uart2变成节点路径引用。这是理解设备树真正送进内核的是什么的绝佳手段。比如下面的代码uart2 { status okay; };反编译后会变成serialfe660000 { status okay; };因为label和路径引用在编译后已经被消解掉了节点合并已完成引用变成了实际路径。还有一个经典差异pinctrl-0 pinctrl_uart2;反编译后大概率变成pinctrl-0 0x000000a3;因为引用已经转为phandle数字。所以如果你在后期的运维现场看到的是DTB反编译出来的DTS就不要试图把它当作可直接改写的源文件来操作而是应该回到仓库里的原始DTS去改。推荐一个实操链路改DTS源文件 → 内核make编译dtb → 拿到板上替换dtb分区 → 重启后用dtc反编译新dtb → diff确认变更已生效。这一条链路跑完你基本不会再犯改了DTS但没编进去的乌龙。4.3 内核如何从DTB展开到驱动probe匹配全链路追踪驱动匹配不像表面看起来那么简单完整链路如下内核启动早期setup_arch里调用unflatten_device_tree把DTB转成device_node树。平台初始化阶段遍历device_node树对每个节点判断其是否有compatible属性。如果有则创建对应的platform_device或者i2c_client、spi_device等创建完成后驱动模型开始匹配。匹配顺序从platform_bus_type的match函数开始先查of_match_table再查id_table最后查ACPI或名字匹配。命中驱动后调用probe驱动在probe里读取设备树资源platform_get_resource、of_property_read_u32、devm_ioremap_resource等。我要强调一下创建platform_device的条件并不仅仅是compatible存在。设备树里那些代表外设节点的device_node是否被内核心照单全收取决于节点在展开时注册到哪个bus。比如i2cfe830000这样的节点内核看到i2c2的compatible时先注册为platform_device接着i2c驱动在probe时解析其子节点然后为每个子节点创建i2c_client。也就是说总线节点本身转platform_device总线子节点转从设备client这是两套注册流程。所以你的DTS里如果写了一个下游设备节点在i2c控制器下但控制器节点的status还是disabled那么控制器不会注册子节点也就永远不会被扫描到。这就是我明明在DTS里加了eeprom内核却根本没看到它的常见原因。驱动匹配顺序还有一个隐性细节同一设备节点可能匹配多个驱动模块但只有MODULE_ALIAS和MODULE_DEVICE_TABLE声明会被modprobe识别。若你的驱动编译成module却忘了在module信息里写of匹配表insmod之后内核根本不会去匹配它。此时你去/sys/bus/platform/devices下能看到节点对应的device存在但没有driver链接就说明匹配没有发生方向很明确。5. 实战避坑清单设备树踩坑实录、schema校验与调试工具5.1 编译阶段的高频错误设备树语法错误种类不多但报错信息有时很绕。我把踩过的坑集中列在这里Error: Reference to non-existent node or label出现这个错误本质是你的xxx引用了不存在的label。字面意思好懂但有两个容易被忽略的场景标签拼写正确但对应节点在dtsi里被条件编译#if掉或者还没更新到当前内核版本的dtsi里。比如某些新SoC外设节点只存在于branches分支主线dtsi没有。跨文件引用时前一个文件内容还没有通过include引入。i2c2必须在i2c2节点被include后的同一预处理输出里可见如果dts文件没包含定义它的dtsi怎么可能找得到Error: phandle is not 32bit / New node has unit name, but no reg property这类问题基本都出在节点依赖#address-cells、#size-cells比较严格的时候。比如你在soc下新增一个子节点写着nodefe123456却忘了在一个需要reg的父节点下提供regDTC会警告。严格模式还会直接报错。Warning: Avoid unit name and reg mismatch节点名后面的数字和reg属性第一个cell不一致。比如gpiofe380000里reg却写reg 0xfe380001DTC会警告。虽然在一些老dtsi里常见这种警告但有新代码最好避免因为kernel的of_node路径会以机器可读的方式依赖节点名里的unit-address来生成唯一ID不一致容易造成解析歧义。我见过最离奇的编译错误是板级DTS里写了两个完全相同的节点名比如led { ... }和led { ... }DTC报Duplicate node name。其实这类问题在根节点下合并自不同dtsi时特别容易发生两个dtsi各自定义一个同名子节点最终合并时才发现重名。解决思路是不要合并两个一起定义同一个板级设备而是用一个对所有板卡通用的公共节点再在板级DTS里用label覆盖。5.2 运行期静默异常编译过了但驱动加载失败编译通过并不代表硬件描述正确。这类静默失败最耗费时间我按排查优先级给出方向先看status一个外设节点想在板级生效必须满足status okay。如果dtsi默认disabled板级忘了改那这个设备什么都不会发生。排查方法ls -l /sys/bus/platform/devices/看设备存在不存在如果不存在十有八九status没打开。再看compatible如果设备节点在但drivers目录里你期望的管理驱动却没有绑定去/sys/bus/platform/drivers/你的驱动名/下看有没有设备链接。没有就说明compatible和驱动的of_match_table不一致。注意compatible字符串是完全一致匹配不能多空格、不能大小写不一致经常有人把rockchip,rk3568写成rockchip,rk3568 肉眼根本看不出。然后看reg解析如果上面两个都对了驱动还是工作异常优先怀疑reg地址和#address-cells组合。用dtc -I dtb -O dts反编译看reg属性值再和SoC手册物理地址核对。当年我在调一个QSPI控制器时发现寄存器地址读出来偏移了一大截就是因为父节点是#address-cells 2而我在覆盖时忘写高32位0。这个错误完全无法在编译阶段发现驱动却会在ioremap后访存错误死得悄无声息。最后看中断中断资源描述格式受中断控制器类型影响。ARM GIC的cells通常是GIC_SPI 47 IRQ_TYPE_LEVEL_HIGHGIC_SPI0表示共享外设中断还有一些GIC的cells是0 0 47 4格式。如果你引用的中断父节点是intc但cells写错个数内核在处理irq时会产生脏数据轻则中断无法触发重则启动过程就PANIC。多级中断控制器场景如PCIe bridge还涉及interrupt-map这个更复杂很难单独通过DTS格式搞定需要同时对照中断控制器binding文档。5.3 避免过度覆写差异越少越好标准越新越稳写板级DTS有一条很重要的工程原则尽量减少对SoC级dtsi的覆盖只在你真正需要的地方写覆盖。原因很实际如果SoC迭代升级dtsi内容更新你的板级覆盖过多大概率会跟新版dtsi产生冲突或者语义漂移编译时的merge行为会让最终结果出乎意料。差异越小未来适配成本越低。同时保持与内核绑定文档对齐。内核的Documentation/devicetree/bindings/里每个外设都有对应的schema文件格式是YAMLdt-schema的json schema它描述了每个属性允许的取值类型、是否必选、相互约束。现代的dtbs_check就是基于这套schema来校验的。make ARCHarm64 dtbs_check这个check会一边编译你的dtb一边用schema文件去校验属性和节点结构。它的报错信息比DTC的原始警告精确得多比如reg has only 1 cell but 2 were expected、clocks is a required property等等。我非常建议在交付DTS前跑一次这个检查能把大量低级错误拦在合入之前。另一个调试利器是fdtdump和fdtget它们在Android环境和嵌入式系统里也很常见fdtdump /path/to/kernel.dtb fdtget /path/to/kernel.dtb /soc/serialfe660000 statusfdtdump输出dn格式文本比dtc反编译更接近未展开的树形结构fdtget则可以直接查某个节点某个属性值适合在shell脚本里做断言验证。跑完这些再配合/proc/device-tree和/sys/firmware/fdt基本能覆盖整个设备树生命周期里的每一个环节。5.4 调试技巧从/proc/device-tree到sysfs一步步确认真实生效状态设备树被内核展开后有一系列用户态接口可以直接观察最终结果/proc/device-tree以目录树形式呈现设备树节点。每个节点一个目录每个属性一个文件。比如cat /proc/device-tree/soc/serialfe660000/compatible会输出rockchip,rk3568-uart和rockchip,rk3568-serial两个字符串。/sys/firmware/devicetree/base和上面完全相同的树不过是sysfs入口有些工具链习惯用它。/sys/bus/platform/devices内核为每个带compatible的节点创建的platform_device都在这里节点名通常会追加.sequence比如fe660000.serial。/sys/bus/platform/drivers/serial/: 驱动绑定情况在这里体现driver目录下通常还会有bind/unbind文件可以手动解绑和重新绑定驱动在调试设备树属性是否写对时很好用。我在现场排查时常用的流程是ls /sys/bus/platform/devices/里搜设备名确认真实节点有没有注册。cd /proc/device-tree/某节点/用hexdump或者cat检查每个属性文件的实际内容。看/sys/bus/platform/devices/xxx/driver_override以及驱动目录下的device链接确认真实匹配结果。配合dmesg | grep 驱动名看probe是否被调用如果probe没被调但device存在多半是compatible匹配问题如果probe被调却出错重点看驱动代码里读哪个属性读得不对。若涉及中断看/proc/interrupts里有没有对应的中断号占用如果节点在但中断没有出现在列表里优先怀疑interrupts属性格式或interrupt-parent配置。这套链路走完九成问题都能定位到属性值写错还是节点根本就没注册两个方向。设备树排障说白了就是这三步先确认节点在不在再确认属性对不对最后确认驱动匹配绑没绑。每一步都有对应的文件系统接口不需要去猜。设备树这套语法说难不难说简单也不算。它不像C语言那样有复杂的控制流难在语义约定每个属性的含义在不同外设、不同SoC、不同binding文档里都可能不一样。学的时候最好是先套标准再查文档最后按实际平台反推我这些年移植板卡的经验是无论多急都要在交付前跑一遍dtbs_check再拿着反编译结果去核对reg、interrupts这些最容易被坑的资源描述字段。只要这两步做到位设备树大部分老问题都能在上板前就被拦下来。