☰
Linux设备树语法详解:从DTS到DTB的驱动开发与调试
2026/10/3 1:25:51 网站建设 项目流程

写设备树的人,十个里有九个是从报错开始入门的。嵌入式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的FDT(Flattened Device Tree)概念,在Linux 3.x时代被ARM平台全面引入,从那时起,新的ARM板卡不再需要创建mach-xxx目录,只要提供.dts/.dtsi文件即可。

这套机制带来的一个直接好处是,很多硬件调整不再需要重编内核。早期我想给某个平台加一颗I2C触摸屏,如果走老路子要改arch/arm/下的C代码,重新编译整个zImage;用设备树之后,只需要在板级dts里加一个i2c子节点,重新生成dtb文件放进启动分区,内核里驱动什么都不用改,开机就自动识别。这块“编译一次内核,到处用设备树适配”的灵活性,就是它被全行业接受的根本原因。

1.2 DTS、DTSI、DTB:设备树的三种形态

设备树文件按后缀分三种,很多人第一次看代码树时容易混。

.dts(Device Tree Source)是板级设备树的源文件,描述一块具体板卡,比如“这块板子叫什么型号、内存从哪里开始、串口使能了没、LED接哪个GPIO”。.dtsi(Device Tree Source Include)是“可包含的公共片段”,通常由SoC原厂维护,描述芯片内部有多少外设、每个外设的寄存器基地址是多少,比如soc.dtsi会定义好uart、i2c、spi、gpio这些节点。dts通过#include把dtsi包含进来,然后只描述板级差异。

.dtb(Device 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时拿不到期望的参数,表现为“设备没启动但不报硬错误”,这也是后续排查的难点所在。

影响范围还在继续扩大:除了Linux,U-Boot自身也会解析dtb来做内存初始化、固定时序配置;optee、部分RTOS也支持读取FDT;RISC-V平台从一开始就把设备树作为描述硬件的标准方式,x86上的部分SoC同样提供了设备树支持。可以说,整个嵌入式固件生态都在围绕同一份dts/dtb转,弄懂这套语法,等于拿到所有平台硬件描述的通用水电图。

2. 设备树基础语法:节点、属性、值,一次讲透

2.1 树的骨架:根节点与节点命名规则

设备树是标准的树形结构,最顶端是根节点,写法非常简单:

/dts-v1/; / { model = "VirtEmbed EVK Board"; compatible = "virt,virtboard"; };

根节点用/ { }表示,整个文件开头要写/dts-v1/;告诉编译器版本。root节点内部可以放子节点,子节点可以继续套子节点,组成一棵硬件树。内核里每个节点会对应一个device_node,每个节点可以有多个属性。

节点命名的规范是node-name@unit-address,比如serial@10000000。@后面的unit-address通常是对应设备在父总线上的寄存器首地址,用来区分同一总线上的多个同名节点,比如i2c@0和i2c@1。要注意unit-address只写十六进制数字,不带0x前缀,serial@0x10000000是错的,编译器会直接警告unit name should not have a leading "0x"。如果一个节点没有寄存器地址的概念,就不要画蛇添足加@,比如LED节点写成led-power而不是led@power。名称本身推荐全小写、单词之间用连字符,这是内核社区约定俗成的风格。

2.2 属性的值类型:字符串、数字、数组、二进制

设备树属性是“键值对”,写法是property = value;,核心在于值有五种类型,每一种的写法都不一样:

值类型写法示例说明
字符串compatible = "virt,uart";必须用双引号
32位无符号数max-speed = <115200>;尖括号内就是u32
32位无符号数数组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: serial@10000000 { 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、时钟与pinctrl

3.1 reg与address-cells/size-cells:寄存器地址如何被解析

reg属性是设备树里最重要的资源描述,它的格式不是简单的地址,长度,而是由父节点的#address-cells和#size-cells决定的。

举个例子:

soc { #address-cells = <1>; #size-cells = <1>; uart0: serial@10000000 { reg = <0x10000000 0x1000>; }; };

#address-cells = <1>表示地址占1个cell,#size-cells = <1>表示长度占1个cell,所以reg每个寄存器块就是“1个地址cell + 1个长度cell”,即<0x10000000 0x1000>。如果父节点写的是#address-cells = <2>,64位地址就需要两个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: serial@10000000 { compatible = "virt,uart"; reg = <0x10000000 0x1000>; interrupts = <GIC_SPI 37 IRQ_TYPE_LEVEL_HIGH>; };

如果设备树里没有显式写interrupt-parent,内核会一路往上找,直到找到一个声明了interrupt-controller的节点。为了省事,很多SoC的顶层根节点会设一个全局interrupt-parent = <&gic>;,这样所有子节点都不用重复写。

如果一个设备有多个中断且来自不同的中断控制器,就要用interrupts-extended:

interrupts-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等于0,GPIO_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-path:

chosen { bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2 rw"; stdout-path = &uart0; };

stdout-path让早期printk知道应该往哪个串口打印。memory节点标明内存布局,必须有device_type = "memory";:

memory@80000000 { 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-controller@a000000 { compatible = "arm,gic-v3"; reg = <0x0a000000 0x10000>; interrupt-controller; #interrupt-cells = <3>; }; soc { compatible = "simple-bus"; #address-cells = <1>; #size-cells = <1>; ranges; uart0: serial@10000000 { compatible = "virt,uart"; reg = <0x10000000 0x1000>; interrupts = <GIC_SPI 37 IRQ_TYPE_LEVEL_HIGH>; clocks = <&clk 12>; status = "disabled"; }; i2c0: i2c@10020000 { compatible = "virt,i2c"; reg = <0x10020000 0x1000>; interrupts = <GIC_SPI 38 IRQ_TYPE_LEVEL_HIGH>; #address-cells = <1>; #size-cells = <0>; status = "disabled"; }; gpio0: gpio@100a0000 { 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: pinctrl@100b0000 { 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只需要一个cell:

&i2c0 { status = "okay"; clock-frequency = <400000>; temp@48 { compatible = "virt,temp"; reg = <0x48>; }; };

这里reg=<0x48>就是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 = "console=ttyS0,115200 root=/dev/mmcblk0p2 rw"; stdout-path = &uart0; }; memory@80000000 { 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>; temp@48 { 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

编译设备树的核心工具是dtc(Device Tree Compiler)。手动编译一份dts:

dtc -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 ARCH=arm64 dtbs

或者指定单板:

make ARCH=arm64 xxx.dtb

内核的kbuild流程会自动调用cpp+dtc,这也是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/serial@10000000/

reg文件是二进制,直接cat会看到乱码,用十六进制看:

xxd /proc/device-tree/soc/serial@10000000/reg

如果看到的值和dts里写的不一样,八成是dtb没更新,或者bootloader在启动前用fdt命令改过。内核启动早期也会打印machine信息,比如日志里的OF: fdt: Machine model: VirtEmbed EVK Board,和dts里的model对应上,基本就能确认版本无误。

在调试阶段,还可以用fdtdump直接看dtb内部结构:

fdtdump virtboard.dtb

fdtdump输出会带一些编译元信息,但它的好处是不需要先启动系统就能看到每个节点的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展开成fragment@0节点,内含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进制纯数字,如serial@10000000
syntax error缺少分号、引号未闭合、逗号不对从报错上一行开始检查分隔符
phandle reuse同一个label重复定义到不同节点改名或删除其中一个label
Reference to non-existent node&xxx引用了不存在的标签检查拼写和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的基础dtb,dtc命令必定带-@,并且先加载一个空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、换一种体系架构,你会发现内核描述硬件的语言从来没变过,变的只是节点名和属性名而已。希望这篇长文能帮你少走我当年走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询