
写设备树的人十个里有九个是从报错开始入门的。嵌入式Linux做到今天Arm64、RISC-V、甚至不少x86 SoC内核描述硬件的方式已经全部统一到设备树Device Tree这一套纯文本上。你可以不写C代码就改驱动行为也可以只改一个.dts文件就让内核认出一块新板子这套语法看起来只是几十个关键字但它决定了串口能不能输出、网卡能不能link上、GPIO按键能不能把系统唤醒。我最早接触设备树时对着一个几百行的dtsi文件完全摸不着头脑后来踩遍了编译告警、probe失败、中断不触发这些坑慢慢把节点、属性、phandle、reg、ranges这些概念串成了一条线。这篇就把这些年积累的设备树语法和实例拆解整理出来想彻底搞懂设备树的新手、正在改板子BSP的驱动工程师、还有那些被自定义设备树折腾到头秃的玩家都应该能从里面找到自己需要的那一块拼图。1. 设备树是什么为什么现在写驱动绕不开它1.1 从C语言板级文件到纯文本描述老一代内核里每块ARM板卡在arch/arm/mach-xxx/目录里都有一堆冬冬响的C文件平台设备、寄存器地址、中断号、I2C总线上的从设备全部硬编码在board-xxx.c里。换一个板载外设就要重新组织platform_device的resource数组加一块新的内存映射又要改动map_io。这样的代码维护成本极高而且大量板级文件挤在arch/arm下整个内核的ARM目录越来越像一个杂物间。设备树的思路是把硬件信息从内核代码里拆出来用一份文本描述好“CPU外部有什么、总线怎么连、设备地址在哪、中断走哪条线”内核启动时拿到这份描述再做资源解析。它借用了PowerPC时代Open Firmware的FDTFlattened Device Tree概念在Linux 3.x时代被ARM平台全面引入从那时起新的ARM板卡不再需要创建mach-xxx目录只要提供.dts/.dtsi文件即可。这套机制带来的一个直接好处是很多硬件调整不再需要重编内核。早期我想给某个平台加一颗I2C触摸屏如果走老路子要改arch/arm/下的C代码重新编译整个zImage用设备树之后只需要在板级dts里加一个i2c子节点重新生成dtb文件放进启动分区内核里驱动什么都不用改开机就自动识别。这块“编译一次内核到处用设备树适配”的灵活性就是它被全行业接受的根本原因。1.2 DTS、DTSI、DTB设备树的三种形态设备树文件按后缀分三种很多人第一次看代码树时容易混。.dtsDevice Tree Source是板级设备树的源文件描述一块具体板卡比如“这块板子叫什么型号、内存从哪里开始、串口使能了没、LED接哪个GPIO”。.dtsiDevice Tree Source Include是“可包含的公共片段”通常由SoC原厂维护描述芯片内部有多少外设、每个外设的寄存器基地址是多少比如soc.dtsi会定义好uart、i2c、spi、gpio这些节点。dts通过#include把dtsi包含进来然后只描述板级差异。.dtbDevice Tree Blob是编译产物dtc编译器把dts编译后的二进制。内核不读源文件只认dtb。U-Boot等bootloader会把dtb加载到内存在跳转内核前通过通用寄存器把dtb物理地址传过去内核拿到后解包成一颗可遍历的树。以Arm64为例启动协议里x0寄存器传的就是dtb地址这也是为什么dts写错了内核要么起不来要么起来后一部分设备“凭空消失”。还有一类.dtbo是overlay设备树叠加层专门用来在系统运行后动态修改设备树后面第5章会专门讲它的编译和加载。1.3 设备树下探一层内核拿到DTB后发生了什么内核启动早期unflatten_device_tree()会把dtb里的二进制展开成struct device_node节点组成的链表每个节点挂着一串struct property属性。之后driver core在注册platform设备时通过of_platform_populate()把顶层节点变成platform_device平台驱动再用of_match_table里的compatible字段去和节点匹配。这里的关键点是设备树不是“给内核看的可执行代码”它只是给驱动提供“资源的数据库”。驱动把compatible、reg、interrupts、gpios、clocks这些属性读出来再去做ioremap、request_irq、gpiod_get。所以设备树写错了很多情况下不是编译报错而是驱动probe时拿不到期望的参数表现为“设备没启动但不报硬错误”这也是后续排查的难点所在。影响范围还在继续扩大除了LinuxU-Boot自身也会解析dtb来做内存初始化、固定时序配置optee、部分RTOS也支持读取FDTRISC-V平台从一开始就把设备树作为描述硬件的标准方式x86上的部分SoC同样提供了设备树支持。可以说整个嵌入式固件生态都在围绕同一份dts/dtb转弄懂这套语法等于拿到所有平台硬件描述的通用水电图。2. 设备树基础语法节点、属性、值一次讲透2.1 树的骨架根节点与节点命名规则设备树是标准的树形结构最顶端是根节点写法非常简单/dts-v1/; / { model VirtEmbed EVK Board; compatible virt,virtboard; };根节点用/ { }表示整个文件开头要写/dts-v1/;告诉编译器版本。root节点内部可以放子节点子节点可以继续套子节点组成一棵硬件树。内核里每个节点会对应一个device_node每个节点可以有多个属性。节点命名的规范是node-nameunit-address比如serial10000000。后面的unit-address通常是对应设备在父总线上的寄存器首地址用来区分同一总线上的多个同名节点比如i2c0和i2c1。要注意unit-address只写十六进制数字不带0x前缀serial0x10000000是错的编译器会直接警告unit name should not have a leading 0x。如果一个节点没有寄存器地址的概念就不要画蛇添足加比如LED节点写成led-power而不是ledpower。名称本身推荐全小写、单词之间用连字符这是内核社区约定俗成的风格。2.2 属性的值类型字符串、数字、数组、二进制设备树属性是“键值对”写法是property value;核心在于值有五种类型每一种的写法都不一样值类型写法示例说明字符串compatible virt,uart;必须用双引号32位无符号数max-speed 115200;尖括号内就是u3232位无符号数数组reg 0x10000000 0x1000;每个数字占一个cell字符串列表clock-names uart, baud;逗号分隔多个字符串二进制数据local-mac-address [00 11 22 33 44 55];方括号内是十六进制字节尖括号里的内容在编译时允许展开宏这是内核构建dts时先经过C预处理器cpp处理带来的能力。所以你可以写interrupts GIC_SPI 37 IRQ_TYPE_LEVEL_HIGH;这些宏来自dt-bindings头文件。这个能力非常重要否则每个中断类型、GPIO高有效低有效标志都要背数字几乎不可能维护。还有一种特殊的“空属性”只写属性名不写值比如gpio-controller;、ranges;。它的语义是“这个属性存在即为真”后续章节讲中断控制器和地址映射时会反复看到。2.3 标签、引用与phandle设备树的指针机制只靠嵌套节点设备树只能表达“谁挂在谁底下”但驱动经常会引用其他节点比如UART要引用某个时钟节点dts的板级文件想修改dtsi里定义好的某个子节点。这就需要一套引用机制。给节点打标签的写法是uart0: serial10000000 { compatible virt,uart; reg 0x10000000 0x1000; };冒号前面的uart0就是标签。之后在文件任意位置写uart0 { ... };就是对这个节点的引用和修改编译时这个引用会被合并进目标节点。这是设备树工程里最常用的语法可以说没有它dtsi就没法和dts优雅组合。属性里也可以引用节点比如console-device { device uart0; };这里尖括号里的内容编译后会变成一个叫做phandle的u32数字。phandle全称是pointer handle可以理解成“节点指针的编号”每个被引用的节点会在编译时自动分配一个唯一的数字ID内核在运行时通过of_parse_phandle()就能把数字解析回device_node指针。2.4 新手最容易踩的语法细节设备树语法看起来简单但格式坑一个接一个。第一是分号每个属性结束都有分号节点右大括号后面也要分号。少写一个分号dtc报的syntax error经常指向下一行排查起来非常头疼。第二是逗号字符串列表用逗号分隔但不能在最后一个字符串后面加逗号这个和C语言的习惯不太一样。第三是status的取值。节点默认应该写disabled还是okay内核约定只有okay和disabled两个常用状态不要写ok、enable、disable这些自己想出来的值。编译器不会报错但状态解析不出来设备就是不工作。第四是属性和节点不要混用。reg是给驱动读的地址后面的unit-address是给parser做命名区分的两者不是一回事。要养成的习惯是写完一个dts文件先会过一遍dtc -I dts -O dtb把它给的每一条warning当错误处理。3. 驱动开发最关心的标准属性reg、中断、GPIO、时钟与pinctrl3.1 reg与address-cells/size-cells寄存器地址如何被解析reg属性是设备树里最重要的资源描述它的格式不是简单的地址,长度而是由父节点的#address-cells和#size-cells决定的。举个例子soc { #address-cells 1; #size-cells 1; uart0: serial10000000 { reg 0x10000000 0x1000; }; };#address-cells 1表示地址占1个cell#size-cells 1表示长度占1个cell所以reg每个寄存器块就是“1个地址cell 1个长度cell”即0x10000000 0x1000。如果父节点写的是#address-cells 264位地址就需要两个cell来拼reg 0x0 0x80000000 0x0 0x20000000;这里前两个cell是地址后两个cell是长度。多个寄存器块可以写在一个reg里比如reg 0x10000000 0x1000 0x10020000 0x1000;。驱动里platform_get_resource(pdev, IORESOURCE_MEM, 0)拿第一个块下标1拿第二个。如果cells数量和reg里实际提供的数字对不上内核解析出来的地址、长度就会错位ioremap回来就是一片错误的内存驱动访问寄存器直接挂掉或读到全F。3.2 ranges父子地址空间的映射规则设备树里父子节点之间的地址不是天然相等的。子节点reg里的地址是“子总线视角的本地地址”要映射成父总线的地址就要靠ranges属性。ranges有三种情况ranges;不带参数表示子地址和父地址一一对应也就是identity mapping。这是SoC内部总线最常见的写法soc节点里写一个空ranges;子节点的地址就可以直接当成系统总线的地址用。ranges child-bus-addr parent-bus-addr length;显式映射。比如ranges 0x0 0x10000000 0x20000000;意思就是子地址0x0映射到父地址0x10000000映射长度0x20000000。没有ranges属性表示子地址空间和父地址空间完全隔离物理上不互通。某些PCI、特殊总线的子节点通常不写ranges驱动也就无法直接把子地址映射到系统总线。理解ranges的关键是“地址翻译发生在遍历树的过程”。内核在把某个子节点地址转成系统地址时会一路沿父节点往上做ranges翻译所以SoC顶层soc节点只要开了空ranges里面所有外设的寄存器地址就和CPU视角一致这也是绝大多数ARM SoC的设备树都这么写的原因。3.3 interrupts中断是怎么挂上的中断描述比reg复杂一点因为它牵扯到中断控制器。中断控制器节点自己要声明两个属性interrupt-controller;表示“我是中断控制器”#interrupt-cells 3;表示“每个中断描述占3个cell”。以GIC为例常见的写法是GIC_SPI 37 IRQ_TYPE_LEVEL_HIGH第一个cell区分SPI/PPI第二个cell是中断号第三个cell是触发类型。消费者节点用interrupts属性描述自己占用哪个中断uart0: serial10000000 { compatible virt,uart; reg 0x10000000 0x1000; interrupts GIC_SPI 37 IRQ_TYPE_LEVEL_HIGH; };如果设备树里没有显式写interrupt-parent内核会一路往上找直到找到一个声明了interrupt-controller的节点。为了省事很多SoC的顶层根节点会设一个全局interrupt-parent gic;这样所有子节点都不用重复写。如果一个设备有多个中断且来自不同的中断控制器就要用interrupts-extendedinterrupts-extended gic GIC_SPI 37 IRQ_TYPE_LEVEL_HIGH, gpio0 1 IRQ_TYPE_EDGE_RISING;注意interrupts-extended不能和interrupts同时出现在一个节点里。调试中断问题常见的坑是父中断控制器的#interrupt-cells改过但子节点的cells数量没跟上parse中断时返回错误request_irq直接失败。3.4 GPIO、clock、pinctrl与驱动资源获取GPIO在设备树里的套路和中断很像。GPIO控制器节点声明gpio-controller;和#gpio-cells 2;消费节点写power-gpios gpio0 5 GPIO_ACTIVE_HIGH;两个cell分别是“引脚号”和“有效电平标志”。GPIO_ACTIVE_HIGH等于0GPIO_ACTIVE_LOW等于1这些宏在dt-bindings/gpio/gpio.h里。驱动里如果调用devm_gpiod_get(dev, power, GPIOD_OUT_LOW)内核会去查属性power-gpios所以属性名的前缀必须和驱动函数的第二个参数对应。如果驱动里写的是devm_gpiod_get(dev, NULL, ...)查找的就是没有前缀的gpios属性。时钟节点的基本写法是clk: clock-controller { compatible virt,clk; #clock-cells 1; };消费者写clocks clk 12;表示使用这个时钟控制器输出编号为12的时钟。如果再加clock-names uart;驱动就能通过devm_clk_get(dev, uart)拿到不用关心时钟在数组里的下标。pinctrl是另一个高频属性组常见写法是在外设节点里加uart0 { pinctrl-names default; pinctrl-0 uart0_pins; };pinctrl-0里放一个phandle指向pin控制节点里预先定义好的引脚复用组。pinctrl框架会在设备probe时自动应用default状态的pinmux驱动不用手动切换引脚功能。不同SoC的pinctrl子节点格式差别很大但外设节点上的pinctrl-names/pinctrl-0写法是通用的。4. 设备树实例解析从零搭建一块虚拟板卡4.1 框架先行根节点、chosen、memory、aliases手写设备树不用怕核心是先搭框架。一颗SoC的设备树顶端必须有这样几个基础节点根节点里的model是给人看的板卡名字比如“VirtEmbed EVK Board”compatible是给内核匹配用的格式约定是“厂商,型号”例如compatible virt,virtboard;。内核会根据compatible去寻找对应的machine描述写错厂商前缀或者型号拼写最常见的表现就是内核依然能启动但某些平台初始化代码不会执行。chosen节点存放由bootloader或启动阶段填写的运行时参数最常见的是bootargs和stdout-pathchosen { bootargs consolettyS0,115200 root/dev/mmcblk0p2 rw; stdout-path uart0; };stdout-path让早期printk知道应该往哪个串口打印。memory节点标明内存布局必须有device_type memory;memory80000000 { device_type memory; reg 0x80000000 0x20000000; };节点名后面的地址是起始地址reg里的0x80000000和0x20000000分别对应起始地址和大小512MB。aliases节点则是给一些常用的子系统提供稳定的编号比如aliases { serial0 uart0; i2c0 i2c0; };serial0这样的alias会让内核的earlycon、console子系统能以固定的编号引用串口即使底层节点名改了别名不变很多启动脚本就不会断。4.2 soc与dtsi通用硬件放到公共描述里下面构造一个完整的教学用SoC。芯片内部的东西尽量放到dtsi里virt-soc.dtsi因为它是“换板不换芯片”的部分。/dts-v1/; / { interrupt-parent gic; gic: interrupt-controllera000000 { compatible arm,gic-v3; reg 0x0a000000 0x10000; interrupt-controller; #interrupt-cells 3; }; soc { compatible simple-bus; #address-cells 1; #size-cells 1; ranges; uart0: serial10000000 { compatible virt,uart; reg 0x10000000 0x1000; interrupts GIC_SPI 37 IRQ_TYPE_LEVEL_HIGH; clocks clk 12; status disabled; }; i2c0: i2c10020000 { compatible virt,i2c; reg 0x10020000 0x1000; interrupts GIC_SPI 38 IRQ_TYPE_LEVEL_HIGH; #address-cells 1; #size-cells 0; status disabled; }; gpio0: gpio100a0000 { compatible virt,gpio; reg 0x100a0000 0x1000; interrupts GIC_SPI 40 IRQ_TYPE_LEVEL_HIGH; gpio-controller; #gpio-cells 2; }; clk: clock-controller { compatible virt,clk; #clock-cells 1; }; pinctrl: pinctrl100b0000 { compatible virt,pinctrl; reg 0x100b0000 0x1000; }; }; };关注几个关键点。soc节点为了内部的uart/i2c/gpio地址能和CPU视角保持一致开了空ranges;。soc自己的子节点各自带了寄存器基地址这些地址是芯片的设计文档规定的基本不会变。uart0、i2c0、gpio0这些标签是为了让板级dts和aliases能够引用它们。所有外设节点默认status disabled由板级dts按需打开这是SoC厂商dtsi最常见的策略目的是一块板卡上没用的外设干脆不初始化。注意i2c节点里没有gpio-controller之类的属性但i2c总线下挂的每个从设备节点需要自己的reg这由#address-cells 1、#size-cells 0决定所以挂在i2c0下的某个传感器reg只需要一个celli2c0 { status okay; clock-frequency 400000; temp48 { compatible virt,temp; reg 0x48; }; };这里reg0x48就是I2C从机地址size-cells为0表示该节点不需要长度域这是I2C总线上的典型写法。4.3 板级DTS完整示例从uart到led逐行看有了virt-soc.dtsi板级文件virtboard.dts就专注板卡差异/dts-v1/; #include dt-bindings/gpio/gpio.h #include dt-bindings/interrupt-controller/irq.h #include dt-bindings/interrupt-controller/arm-gic.h #include virt-soc.dtsi / { model VirtEmbed EVK Board; compatible virt,virtboard; chosen { bootargs consolettyS0,115200 root/dev/mmcblk0p2 rw; stdout-path uart0; }; memory80000000 { device_type memory; reg 0x80000000 0x20000000; }; aliases { serial0 uart0; i2c0 i2c0; }; leds { compatible gpio-leds; power_led: led-power { label power; gpios gpio0 5 GPIO_ACTIVE_HIGH; default-state on; }; }; }; uart0 { status okay; pinctrl-names default; pinctrl-0 uart0_pins; }; pinctrl { uart0_pins: uart0grp { pins uart_tx, uart_rx; function uart; bias-disable; }; }; i2c0 { status okay; clock-frequency 400000; temp48 { compatible virt,temp; reg 0x48; }; };这个文件里重点看几个关键字的作用。uart0 { status okay; ... };是在dtsi定义好的节点上追加属性编译后等效于在uart0节点内部直接写status、pinctrl-names、pinctrl-0。这是dts/dtsi组合工作的精粹。leds节点挂在根下面因为板级LED不属于SoC内部外设。gpio-leds是内核里的一个现成驱动它读取子节点的label作为LED名称读gpios拿到gpio引脚和有效电平default-state控制初始状态。如果GPIO配置成低有效只要把宏换成GPIO_ACTIVE_LOW驱动会自动翻转逻辑完全不需要改C代码。pinctrl里定义uart0_pins这个label是给pinctrl-0引用的。不同厂商的pinctrl子节点格式天差地别但外设节点通过pinctrl-0引用某组pin的定义这套机制是通用的。4.4 设备树如何与内核驱动握手设备树本身不会触发任何硬件动作它只是把资源摆在驱动面前。以uart驱动为例平台驱动里一般会有这样一段static const struct of_device_id virt_uart_of_match[] { { .compatible virt,uart }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, virt_uart_of_match); static int virt_uart_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap_resource(pdev-dev, res); irq platform_get_irq(pdev, 0); ... clk devm_clk_get(pdev-dev, NULL); ... } static struct platform_driver virt_uart_driver { .probe virt_uart_probe, .driver { .name virt_uart, .of_match_table virt_uart_of_match, }, };内核在遍历设备树时会把每个节点的compatible字段和驱动的of_match_table逐项比较匹配上就调用probe。probe里通过platform_get_resource拿reg通过platform_get_irq拿中断通过devm_clk_get拿时钟。这些API的名字和顺序背后对应的就是设备树里那些属性的解析逻辑。所以设备树写的属性名如果和驱动期望的不一致不会产生编译错误只有运行时API返回失败这也是为什么设备树问题排查比C代码bug更“虚”因为你面对的不是崩溃现场而是静默的NULL返回和打印日志。5. 编译、加载与运行时调试三板斧5.1 dtc编译、反编译与make dtbs编译设备树的核心工具是dtcDevice Tree Compiler。手动编译一份dtsdtc -I dts -O dtb -o virtboard.dtb virtboard.dts如果dts里用了#include和宏需要先经过C预处理器cpp -nostdinc -I include -undef -D__DTS__ -x assembler-with-cpp \ virtboard.dts virtboard.dts.preprocessed dtc -I dts -O dtb -o virtboard.dtb virtboard.dts.preprocessed实际工程里很少手动做这两步。在内核源码目录下直接make ARCHarm64 dtbs或者指定单板make ARCHarm64 xxx.dtb内核的kbuild流程会自动调用cppdtc这也是dts里能直接写#include dt-bindings/...的原因。反编译dtb同样用dtc方向反过来dtc -I dtb -O dts -o virtboard.dts.out virtboard.dtb反编译是排查问题的利器。你怀疑bootloader传的dtb不是自己编的那份反编译出来看model、看reg、看某个节点有没有status一切见分晓。还有一招是从运行中的系统导出设备树dtc -I fs -O dts /sys/firmware/devicetree/base running.dts导出的dts就是内核当前真正解析出来的设备树所有引用都被展开成phandle非常直观。dtb dts dtc fs这四个选项基本覆盖了设备树生老病死的全程。5.2 运行时如何检查和验证设备树设备树加载进内核后要验证是不是“生效的是我这版dtb”最直接的办法是看运行时视图。/proc/device-tree是/sys/firmware/devicetree/base的符号链接把每个节点映射成目录把每个属性映射成文件。查看board型号cat /proc/device-tree/model看一个节点是否存在ls /proc/device-tree/soc/serial10000000/reg文件是二进制直接cat会看到乱码用十六进制看xxd /proc/device-tree/soc/serial10000000/reg如果看到的值和dts里写的不一样八成是dtb没更新或者bootloader在启动前用fdt命令改过。内核启动早期也会打印machine信息比如日志里的OF: fdt: Machine model: VirtEmbed EVK Board和dts里的model对应上基本就能确认版本无误。在调试阶段还可以用fdtdump直接看dtb内部结构fdtdump virtboard.dtbfdtdump输出会带一些编译元信息但它的好处是不需要先启动系统就能看到每个节点的phandle编号和引用关系配合fdtget可以做更细粒度的属性提取fdtget -t s virtboard.dtb / model那几条命令熟练之后改设备树的效率能翻一倍至少不会再出现“我明明加了节点内核里怎么就是没有”这种乌龙。5.3 overlay设备树叠加与语法糖设备树overlay提供了一套运行时修改设备树的机制很适合模块化硬件基础板上跑一个主dtb外挂的小板子或扩展设备各自带一个dtbo加载时叠加到现有树上。写overlay源文件时开头要多一行/plugin/;/dts-v1/; /plugin/; uart1 { status okay; current-speed 115200; }; {/} { leds { status okay; }; };编译时给dtc加-参数给基础dtb加符号表dtc - -I dts -O dtb -o base.dtb base.dts dtc - -I dts -O dtb -o my_overlay.dtbo my_overlay.dts加载overlay最常用的是configfs接口mount -t configfs configfs /configfs mkdir /configfs/device-tree/overlays/myboard cat my_overlay.dtbo /configfs/device-tree/overlays/myboard/dtbo加载成功后在/proc/device-tree里就能看到新节点移除时直接rmdir对应目录。overlay底层的实现是fragment机制uart1这种写法会被dtc展开成fragment0节点内含target目标节点的phandle和__overlay__字段本质上就是一种语法糖。原理明白后你就知道overlay加载失败最常见的原因基础dtb编译时没加-导致没有符号表或者目标节点在基础树里根本不存在。6. 实战中的高频问题与排查思路记录6.1 编译期警告与报错警告/报错原因解决方法Node has a unit name, but no reg property节点名带地址但没有reg属性加reg或者去掉地址unit name should not have a leading 0x后面写了0x前缀改成16进制纯数字如serial10000000syntax error缺少分号、引号未闭合、逗号不对从报错上一行开始检查分隔符phandle reuse同一个label重复定义到不同节点改名或删除其中一个labelReference to non-existent nodexxx引用了不存在的标签检查拼写和dtsi是否包含duplicate node name两个同名节点未合并确认地址不同或去掉多余节点编译警告不要忽略。Node has a unit name, but no reg property这种告警多半是新手给LED节点带了0、1虽然dtc不报error但后续工具和内核解析时容易产生困惑。还有一类“逻辑上不报错”的习惯性问题dts里写status disable不报错但内核永远匹配不到这类问题就得靠运行时排查了。6.2 运行期设备不工作的定位顺序设备树导致的运行期问题一套排查顺序能解决大部分。第一步确认内核拿到的就是自己想要的dtb。反编译手头的dtb对比/proc/device-tree里的model、各节点reg不一致就回去查bootloader加载路径和分区。第二步查设备节点的compatible。驱动of_match_table里写了virt,uart设备树里写成了virt,uart1或者厂商前缀反了probe永远不会跑。用ls /sys/bus/platform/devices/找不到对应平台设备就该怀疑这一步。第三步查status。很多SoC厂商dtsi默认把外设写成disabled板级dts漏了status okay;的一行设备树里节点其实存在但内核不会把它变成平台设备。这一条是“设备不工作”的头号嫌疑。第四步查reg解析。父节点#address-cells、#size-cells写错reg错位驱动ioremap出来的地址是错的寄存器读写全失效。日志里通常会伴随ioremap失败或者readl返回0xFFFFFFFF。第五步查中断和GPIO。platform_get_irq返回负数、devm_gpiod_get返回ERR_PTR按这些返回值倒推到设备树属性核对interrupt-cells、gpio-cells和实际给出的cell数量是否一致。6.3 几个让我印象深刻的翻车现场第一批翻车现场是我在调一块板子的GPIO按键时GPIO节点明明在dts里给了两个flags驱动却一直报GPIO解析失败。后来发现板级dts里某个头文件被覆盖gpio控制器节点的#gpio-cells被写成了1等于内核在解析第二个cell时直接越界整个gpios属性都读不出来。这种问题只看自己的设备树节点根本看不出来必须把整个设备的父节点链路上的cells都过一遍。第二批翻车现场是某平台调I2C触摸屏。设备树节点加好了compatible也对触摸屏就是不出中断。反复排查后发现问题不在中断而在clocks触摸屏节点依赖的一路时钟默认处于关闭状态时钟框架在驱动probe的时候把clk引用拿出来了但时钟源没被使能寄存器访问全部超时。这说明设备树里时钟属性不光是“写对了就行”上游时钟的status也跟着牵扯。第三批翻车现场是overlay加载。基础dtb当时没有用-编译于是overlay里每个fragment都找不到target加载时报Failed to apply overlay。后来我养成了习惯凡是准备用overlay的基础dtbdtc命令必定带-并且先加载一个空overlay做测试确认configfs通路是通的再去改业务逻辑。7. 最后分享几个设备树工程习惯设备树代码写得多了慢慢会形成一些默认的工程约束。dtsi和dts的职责要分开SoC厂商给的dtsi文件原则上只在原厂BSP包更新时替换板级差异全部写在dts里这样后续同步上游代码时冲突最少。写节点之前先翻一翻内核文档对应目录在Documentation/devicetree/bindings/特别是vendor binding文档很多“为什么这么写”的答案都在那里而不是在网站帖子的评论区。每次修改设备树先跑一遍dtc编译把所有warning清零再提交。运行时每加一个节点习惯性去/proc/device-tree下看一眼对应目录是否出现出现后再去看驱动日志。这看起来多花十秒钟但能省掉后面很多“为什么没生效”的反复确认。dts里不要直接写魔法数字中断、GPIO、时钟相关的宏能引就引等三个月后你回来看自己的代码宏名比一堆裸数字有价值得多。我个人在实际操作里的体会是设备树最反直觉的地方在于它太像“配置”以至于很多人忽略它其实是一份有严格结构、有引用关系、有生命周期的前端代码。节点就是对象属性就是接口参数phandle就是指针overlay就是动态补丁当你用写代码的心态去对待dts时很多语法自然就串起来了。这套东西上手确实有点陡但一旦把树形结构和引用逻辑刻进脑子后面换一个SoC、换一种体系架构你会发现内核描述硬件的语言从来没变过变的只是节点名和属性名而已。希望这篇长文能帮你少走我当年走过的弯路。