ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

嵌入式Linux设备树语法实战:从基础解析到调试避坑

2026/10/3 1:22:42 拓冰建站 浏览量
嵌入式Linux设备树语法实战:从基础解析到调试避坑 做嵌入式Linux开发尤其是驱动开发设备树是绕不过去的一座山。不管你是刚拿到RK3568这类Cortex-A平台开发板的新手还是准备从老式board文件维护中解放出来的老手最终都要回到“设备树语法”这门基本功上。这篇文章我打算用实际案例拆解的方式把设备树的语法规则、设计思路、编译调试方法和踩坑现场一次性讲透不堆概念全部是可以直接上手上板子的干货。1. 设备树到底解决了什么问题1.1 为什么嵌入式平台需要设备树在设备树出现之前Linux内核里描述一个平台硬件的方式是“平台代码”也就是一堆C语言结构体和注册函数。比如一块板子上面I2C控制器挂在哪里、中断号是多少、SPI总线的片选引脚复用了哪个GPIO这些信息全部写死在arch/arm/mach-xxx/board-xxx.c文件里。问题在于嵌入式硬件型号千差万别同一颗SoC可以做出几十款开发板每做一款板子就得往内核里塞一个board文件整个内核维护起来非常痛苦。后来社区就想了一个办法把硬件描述从内核源码里抽出来用一套固定的树形结构文本描述这就是设备树Device Tree。它的本质就是一份“硬件说明书”告诉内核我有几颗CPU、内存从哪个地址开始、串口挂在哪个控制器上、GPIO哪个引脚接了LED。内核启动的时候拿到这份说明书自己决定怎么初始化驱动程序。这样同一个内核镜像只要换一份设备树就能适配不同板子硬件和驱动代码的耦合彻底解开了。我自己刚接触设备树时最直接的感受是以前看一个外设引脚信息要满内核翻宏定义现在打开dts文件硬件拓扑一表明细做驱动移植的工作量大大缩小。这也是为什么现在BSP厂商比如瑞芯微、NXP、ST提供的SDK里绝大部分硬件适配都是围绕dts/dtsi文件展开的。1.2 设备树在系统启动中的位置设备树的完整生命周期包括三个阶段源码编写、编译打包、运行时解析。源码就是. dts和.dtsi文本文件通过dtcDevice Tree Compiler编译器变成.dtb二进制文件bootloader比如U-Boot启动时把dtb加载到内存并把地址传给内核内核在启动较早阶段会解析dtb把硬件信息组织成device节点然后逐个匹配驱动。这里有一个很关键的认知设备树文件不是给人直接读二进制的东西也不是“配置文件”那么简单。它是一个被内核严格消费的数据结构。你在dts里写错一个属性名内核不会报编译错误只会悄悄找不到该找的硬件资源。所以写设备树要时时想着“后面内核和驱动是怎么读这个节点的”这样才能理解语法设计中那些看似繁琐的细节。2. 设备树语法核心拆解2.1 节点与属性看懂一个dts文件的最小单位设备树的语法结构其实非常简单核心就是节点和属性。一个设备树就是一棵树树的每个节点就是一个硬件设备或者总线/控制器节点节点下面放各种属性来描述硬件的具体参数。先看一个最简单的最小节点写法/ { model My RK3568 Board; compatible mycompany,rk3568-board, rockchip,rk3568; };“/”表示根节点一个dts文件必须且只能有一个根节点。花括号里就是这个节点的“体”里面的model和compatible都是属性。属性的写法是“属性名 值”加分号结尾。节点的子节点写在父节点的花括号内部比如串口节点就放在根节点下面。这里要注意/dts-v1/;这个头信息用来声明语法版本早期的老文件没有这一行现在编译时会直接报错这个我后面在编译章节会细讲。节点命名的规范是“node-nameunit-address”。后面的unit-address通常是外设的寄存器地址或索引号用来区分同级别的多个节点。例如uart0: serialff5a0000表示串口0节点它对应的基地址是0xff5a0000。冒号前面的uart0是label相当于给这个节点起了一个别名方便在dtsi中通过uart0来引用增强。这个label不是必需的但强烈建议写后面几乎所有外设功能的打开都要靠label来操作。属性值可以有好几种类型包括字符串、整数、字符串列表、整数列表以及“二进制数据”。用reg 0xff5a0000 0x100这种方式时尖括号里的就是32位整数列表通常用来描述寄存器地址和长度。字符串列表用双引号包含、逗号分隔例如compatible属性就可以同时写多个字符串代表该节点兼容多个设备型号。2.2 常见数据类型与写法细节设备树支持的数据类型并不复杂但是很多新手会在引号和尖括号的使用上翻车。我把常用类型归纳成一张速查表数据类型写法示例说明字符串compatible rockchip,rk3568;双引号包裹字符串列表compatible myboard, rockchip,rk3568;逗号分隔匹配时按顺序尝试32位整数clock-frequency 24000000;尖括号内单个数值整数列表reg 0xff5a0000 0x100;逗号分隔通常成对表示地址/长度64位整数reg 0x0 0x20000000 0x0 0x800000;一个64位值拆成两个32位空属性status okay;或dma-coherent;裸布尔属性表示“存在即启用”看起来简单但实际工程里陷阱都在细节。比如整数列表中的cell大小是由父节点的#address-cells和#size-cells属性决定的。我在刚学设备树时看到reg 0x0 0x20000000 0x0 0x10000000会非常困惑为什么要写4个数字。后来才理解当#address-cells 2时每个地址要占用2个cell的宽度所以reg被解释为“地址0x0 0x20000000长度0x0 0x10000000”合起来就是一个64位的起始地址和64位长度。这里务必记住reg属性的解释不是由名字决定的而是由父节点的address-cells和size-cells决定的这是整个设备树语法里最容易理解错的地方。还要注意数值的进制。不带前缀的数字默认是十六进制比如0x1000等于十进制的4096。如果你直接写reg 4096那和你想要表达的0x1000就不是一回事了。我曾经在写一个memory节点时把十进制数当成十六进制填进去结果内核把内存DDR区域算错启动日志里直接显示内存只有不到4MB排查了很久才发现是进制问题。2.3 标准属性速查reg、interrupts、gpio等设备树里有一些属性是“标准财产”内核有专门函数去读取属性名是约定俗成的。最核心的几个compatible兼容属性最重要的属性。驱动和设备之间的匹配依据就是它。格式通常是厂商,型号比如rockchip,rk3568-uart。写法上有讲究越具体的放前面越通用的放后面。内核匹配时从头到尾逐个尝试所以一定要把最精确的型号写第一位。reg寄存器区域或设备地址。前面已经讲过它由父节点的address-cells与size-cells决定。对于外设寄存器它表示基地址和长度对于内存节点它表示内存范围。具体怎么解析驱动里会用of_iomap这类API来做。interrupts中断描述需要结合父节点的interrupt-controller属性和#interrupt-cells来理解。以GPIO中断为例interrupt-parent gpio0; interrupts 3 IRQ_TYPE_LEVEL_HIGH;这里的值数量取决于#interrupt-cells比如GPIO控制器一般定义成2个cell一个是pin号一个是触发类型。触发类型通常用内核头文件里的宏名称比如IRQ_TYPE_LEVEL_HIGH在dts里会被宏处理成数值。编译时如果用的是cpp预处理这些宏是可以直接使用的但如果单纯用dtc编译没做宏展开就会报错。gpios / gpioGPIO描述常见的写法是gpios gpio0 RK_PA0 GPIO_ACTIVE_HIGH;。不同平台GPIO描述方式差异很大有的用两个cell有的用三个cell。最稳妥的办法是查看芯片厂商提供的dtsi怎么定义#gpio-cells再照着写。status节点的启用状态常用值是okay和disabled。这点很实用SoC厂商提供的dtsi默认会把所有外设节点都定义好但设置成disabled开发板BSP里再用uart0 { status okay; };打开需要使用的接口。这个机制让“同一颗芯片适配多种板卡”变得非常方便。除了这些还有clocks、resets、dmas、power-domains等进阶属性它们大多由具体IP的驱动来决定格式一定要参考厂商SDK里的原厂dtsi。2.4 节点引用、标签与overlay单纯的dts文件体积往往很大实际工程里都是拆成多个dtsi文件通过#include引入。比如我常用的Rockchip BSP结构大概是这样rk3568.dtsi # 芯片级定义包含所有CPU、总线、外设节点 rk3568-evb.dtsi # 开发板公共配置打开了部分外设 myboard.dts # 具体项目板级文件最顶层在dtsi里定义好的节点如果板级文件想修改就需要用标签引用的语法uart0 { status okay; pinctrl-names default; pinctrl-0 uart0_xfer uart0_cts; };uart0会把尖括号里的内容合并到原来标签对应的节点上这个过程叫做“叠加”。如果原来节点里已有同名属性新内容会覆盖旧内容。比如原dtsi里status disabled这边一叠加就变成okay。还有一个容易被忽视的细节uart0这种引用语法写在多个文件里时只要它们是同一个节点标签就都可以叠加生效。不同板型即使dtsi内容差异大只要标签名字保持一致复用性就很强。设备树overlay机制则是更灵活的运行时叠加方式。简单说就是可以把一个外设扩展节点单独编译成一个dtbo在Linux运行时通过configfs动态加载比如树莓派的HAT设备扩展就是这个思路。它的语法和板级叠加相似但外层会包一个/plugin/;声明。普通BSP开发很少用到但做模块化产品设计时很实用。3. 实例解析从RK3568开发板看懂完整设备树3.1 顶层骨架分析dtsi与dts的分工拿一块典型的RK3568开发板来实操。在Rockchip SDK里最核心的文件是rk3568.dtsi和具体板卡的rk3568-evb.dts。先看rk3568.dtsi的顶层结构通常长这样/dts-v1/; #include rk3568-pinctrl.dtsi / { compatible rockchip,rk3568; interrupt-parent gic; #address-cells 2; #size-cells 2; cpus { #address-cells 1; #size-cells 0; cpu0: cpu0 { device_type cpu; compatible arm,cortex-a55; reg 0x0; enable-method psci; clocks cru ARMCLK; operating-points-v2 cpu0_opp_table; }; ... }; memory200000 { device_type memory; reg 0x0 0x00200000 0x0 0x00000000; }; uart0: serialff5a0000 { compatible rockchip,rk3568-uart, snps,dw-apb-uart; reg 0x0 0xff5a0000 0x0 0x100; interrupts GIC_SPI 2 IRQ_TYPE_LEVEL_HIGH; clocks cru SCLK_UART0, cru PCLK_UART0; clock-names baudclk, apb_pclk; reg-shift 2; reg-io-width 4; status disabled; }; };大家注意最前面的/dts-v1/;是绝不可少的版本声明。#address-cells 2和#size-cells 2定义了根节点的地址单元宽度所以RK3568这种64位平台下reg里的地址都是两个cell表示。这个过程从第一个节点就要遵守否则内核解析时会把地址完全搞错。再看一下memory节点它描述DDR内存在物理地址空间的位置。上面我写的时候故意留了个不合理的reg值实际上RK3568开发板的内存会根据芯片型号和DDR容量来配置常见做法是把这部分放到板级dts里以适配不同内存颗粒。为什么要注意这一点因为如果reg描述的内存范围超过了DDR实际可用范围内核会保留这部分地址做映射其他设备申请DMA内存就可能会踩到异常。3.2 关键节点逐行解读继续看uart0节点。serialff5a0000这个地址块是UART0控制器的寄存器基址。compatible里的rockchip,rk3568-uart是SoC专用兼容串后面的snps,dw-apb-uart是DesignWare通用IP的兼容串这种写法能让内核优先匹配到瑞芯微定制驱动失败则回退到通用8250驱动。interrupts GIC_SPI 2 IRQ_TYPE_LEVEL_HIGH其中GIC_SPI是一个宏表示这是GIC中断控制器下的SPI类型中断。RK3568的GIC类型通过#interrupt-cells 3定义所以这个3个值的格式是“中断类型、中断号、触发条件”。这里的写法和中断主控制节点的定义强相关如果你把某个外设的中断迁移到其他中断控制器下一定要连interrupt-parent一起改。第3小节里clocks和clock-names这两个属性搭配使用。驱动代码里可以用devm_clk_get(pdev-dev, baudclk)根据名字拿到对应的时钟句柄。如果没有clock-names那驱动只能按clocks属性里的顺序进行索引一旦上游dtsi里新增了一个时钟所有按序号获取时钟的驱动都可能错位。所以我一直建议厂商dtsi里所有带clocks的节点都写上clock-names这是规范性问题直接影响后续开发和升级维护。pinctrl相关属性在uart节点里没有直接列出而是在pinctrl子节点中定义。pinctrl-0 uart0_xfer这个属性负责把引脚复用功能绑定到设备节点上。pinctrl机制是设备树中另一个大主题简单理解就是一个外设工作前需要先让引脚处于正确的电气特性与复用状态pinctrl子系统会在设备驱动probe前后自动应用这些状态。3.3 设备树到驱动的匹配过程设备树中的节点到底怎么激活驱动程序内核平台总线机制中有两道匹配流程。首先是of_match_table驱动文件里定义一个of_device_id数组里面列出这个驱动支持的compatible字符串比如static const struct of_device_id rk3568_uart_of_match[] { { .compatible rockchip,rk3568-uart, }, { .compatible snps,dw-apb-uart, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, rk3568_uart_of_match); static struct platform_driver rk3568_uart_driver { .probe rk3568_uart_probe, .driver { .name rk3568-uart, .of_match_table rk3568_uart_of_match, }, };当内核把设备树里的uart0节点注册为platform_device时系统会逐个检查of_device_id里的compatible字符串是否与节点匹配匹配上了就调用probe函数。这也是为什么我前面反复强调compatible要写“具体型号在前通用型号在后”目的就是让厂商私有驱动优先被选中。匹配成功之后驱动在probe里通过一组of_开头的API去读取节点属性。比如of_match_node查找匹配项of_iomap读取reg里的地址长度并映射of_irq_get解析interrupts得到中断号of_get_named_gpio获取gpio属性对应的GPIO号码注意一个容易踩的坑设备树里看到的GPIO编号比如RK_PA0这种宏和Linux内核对外的GPIO编号不是一回事。of_get_named_gpio会从gpio控制器节点解析出硬件级GPIO信息再通过gpiolib框架转换成全局GPIO号。如果你在用户态看到某个GPIO号为约几百那是Linux全局编号而不是SoC手册上的物理引脚号。很多人直接在dts里写裸数字结果和驱动读到的完全对不上这就是没有理解设备树GPIO属性和内核全局编号的转换逻辑。3.4 手动修改设备树点亮一颗LED讲完抽象的匹配机制用一颗LED把流程串起来。假设开发板的GPIO0_A2引脚接了一个LED我们要通过设备树把它注册成Linux标准的LED设备。第一步确认pinctrl已经打开这个pin。一般在pinctrl dtsi里定义好了引脚的“group”和“function”但写起来太啰嗦很多BSP已经处理好了。实际更快的验证方法是先用GPIO子系统在用户态测试在dts里追加一个gpio-leds节点/ { leds { compatible gpio-leds; work_led: led-0 { label work; gpios gpio0 RK_PA2 GPIO_ACTIVE_HIGH; default-state off; }; }; };第二步把GPIO0的设备树controller定义放对位置。实际上RK3568的GPIO节点在dtsi里已经定义好gpio0引用的是gpio0这个标签RK_PA2是头文件里的宏表示GPIO0组A口第2个引脚。编译时这些宏必须通过预处理器展开成数字否则dtc直接编译dts不会认识RK_PA2。第三步重编设备树、烧录重启在用户态验证cat /sys/class/leds/work/brightness往这个文件写1LED就亮了。驱动gpio-leds这时其实已经被设备树节点激活。如果写1后灯不亮优先检查GPIO_ACTIVE_HIGH极性还有pinctrl是否把该引脚从默认状态切到了GPIO功能。这类排查里我最有用的工具就是设备树反编译加上gpio调试目录后面统一说。4. 设备树编译调试与烧录4.1 dtc编译命令与反编译设备树源码要通过dtc编译成dtbdtc默认在Linux内核源码目录下的scripts/dtc里自带也可以单独安装。常用命令# 编译dts为dtb多文件包含宏的场合需要cpp预处理 cpp -nostdinc -I arch/arm64/boot/dts/ -undef -D__DTS__ -x assembler-with-cpp \ rk3568-evb.dts rk3568-evb.dts.preprocessed dtc -I dts -O dtb -o rk3568-evb.dtb rk3568-evb.dts.preprocessed实际开发中大家很少手动敲全命令通常在SDK的内核目录下直接用make dtbsKbuild会自动处理cpp和dtc两个阶段。也就是说设备树源文件其实会经过一个类似C语言的预处理流程#include、#define、宏展开都能用所以dts里出现RK_PA2这样的宏才变得合法。反向操作把dtb反编译成可读的dts文本排查问题特别顺手dtc -I dtb -O dts -o dump.dts rk3568-evb.dtb反编译出来的文件没有宏定义里面的数字都是已经被展开的裸值。当你怀疑“我的宏定义没有正确展开”时反编译一下就能原形毕露。比如期望里看到gpios gpio0 2 0反编译出来的却是gpios gpio0 0x102 0说明宏没展开成pin号却是另一个值这时候就该去查头文件路径或宏名了。4.2 内核加载设备树的过程与日志内核启动时U-Boot会从分区或者FIT镜像里读取dtb到指定内存地址并在跳转内核前把设备树二进制地址传入寄存器。你可以在U-Boot环境里用printenv查看fdt_addr等信息U-Boot常用命令fdt addr ${fdt_addr} fdt print /soc/uart0这条命令能在U-Boot阶段直接查看设备树里某个节点的内容方便做“系统还没进内核时检查dtb是否烧对”的排查。进入Linux后启动日志里也会打印设备树相关信息比如OF: fdt: Machine model: My RK3568 Board看到这个日志就可以确认内核已经解析了你烧进去的那份dtb。如果开发板默认的model名字还是EVB那就说明你烧录的dtb不是预期文件或者U-Boot选择dtb时用了错误逻辑。完整查看内核内部设备树视图可以在运行系统中# 显示当前生效的设备树完整结构 ls /sys/firmware/devicetree/base # 查看某个节点有无特定属性 cat /sys/firmware/devicetree/base/model这个目录实际上就是将设备树以文件系统的方式暴露出来。属性名为目录名或文件名属性值是文件内容二进制内容会以原始字节呈现所以用cat看reg的值会看到乱码这时可以用hexdump来读hexdump -C /sys/firmware/devicetree/base/soc/serialff5a0000/reg这比看源码还直观因为它是内核解析完成后的“最终版本”。4.3 常用调试手段设备树调试图的就是回答三个问题节点有没有解析成功属性值有没有按预期读取驱动匹配上了没有常用的工具和通道有这么几个。第一个是/sys/firmware/devicetree/base前面的查属性就用它。但需要注意内核启用设备树overlay后设备树里有些节点是动态叠加生成的在这个目录下看到的是已应用的结果。第二个是设备树相关的内核log。很多探测失败都会打印OF错误或者probe失败原因比如rk3568-uart ff5a0000.serial: could not find pctldev for node /pinctrl/serial0, deferring probe这句日志说明驱动probe因为pinctrl资源没有就绪而被挂起就是deferred probe机制。看到这类日志不要第一时间怀疑设备树写错了很可能是依赖的GPIO/pinctrl控制器还没初始化完成内核会记录deferred状态等后续重试。第三个是我最常用的直接在驱动probe里加dev_info打印打印of_node的名称和compatible。很多时候“驱动根本没进probe”和“属性读不对”完全是两种问题分清楚才能定位。调试时还有一个经验修改设备树后不一定要完整烧写整个系统。如果你的U-Boot支持从网络或存储分区读取单独的dtb就可以只替换dtb文件保留原来的内核镜像。在流水线产品开发中这种“内核不变、只换设备树”的部署方式特别管用因为设备树改动频率远高于内核代码改动。我自己做过一个方案把dtb单独放在一个小分区U-Boot启动时从该分区读入整个迭代周期从分钟级降到秒级。5. 避坑指南与经验总结5.1 高频报错速查表整理了这些年遇到过的设备树典型问题方便大家直接对照排查。现象常见原因解决思路内核启动后外设无响应compatible不匹配或reg地址写错反编译dtb确认节点存在查看dmesg中probe错误驱动probe不调用of_device_id里compatible与dts节点不一致检查compatible字符串是否包含空格或厂商前缀写错中断一直触发或触发异常interrupts里中断号/触发类型写错查看interrupt-controller节点的#interrupt-cells定义GPIO操作不了极性写反或pinctrl复用未配置确认GPIO_ACTIVE_HIGH/LOW检查pinctrl-0引用内存显示只有几十MBmemory节点reg里地址长度错重点检查address-cells和size-cells是否匹配dtc编译报语法错误少了分号/括号不匹配/属性重复定位到具体行注意宏展开后的括号是否闭合宏名无法识别编译时没有经过cpp预处理确认使用的构建目标是dtbs而不是直接dtc修改后与预期不一致覆盖节点未生效/有多个dts叠加用dump.dts确认真实编译结果确认标签引用正确这里要特别说明一下“属性重复”的坑。设备树语法允许在多个dtsi中对同一个节点进行叠加但不允许在同一个节点体内写同一个属性名两次。比如某个dtsi里已经写了status disabled你在板级dts里再写一次status okay之前必须保证它处于一个不同的叠加块中也就是通过xxx { }来写。如果直接在同一个节点体中重复写同名属性dtc会直接报“duplicate property name”错误。5.2 设备树编写的经验法则写设备树不是“能编译过就行”代码习惯决定排错效率。我总结几条经验供参考。第一不管多小的节点都建议加上label哪怕当前没有叠加需求。我见过很多老BSP里有些节点没有标签后来做板级移植想打开某个外设却不知道如何引用它只能把整个节点原样拷贝到板级文件结果同名节点冲突。加label是零成本的投资。第二compatible字符串一定要保持统一风格。推荐使用厂商,ip名称格式比如rockchip,rk3568-uart。不要写裸的uart或者带空格的值。内核匹配是字符串精确匹配一个空格差异都能让驱动找不到设备。第三多使用宏定义而不是裸数字。直接用2这种值后面看代码的人完全不知道2代表什么。用RK_PA2、IRQ_TYPE_LEVEL_HIGH这类宏后dts文件的可读性和可维护性会好很多。前提是正确引入“dts”相关的头文件路径。第四凡是修改外设功能要养成“同步检查pinctrl”的习惯。很多外设打不开不是节点本身有问题而是引脚复用状态没有切过去。在RK系列平台里原理图中的pin功能与pinctrl节点名称往往没有直观对应关系你需要查看原厂dtsi来确认。这个排查顺序能节省大量时间。第五注意status的默认状态。外设节点在dtsi里通常都是disabled只有板级使用时才开。如果你在板级文件里新加了一个外设节点但忘了把状态改成okay那一切配置都白做驱动当然不会被加载。5.3 从设备树语法到实际工程落地光会语法还不够设备树真正难的点在于“在一个陌生的SDK里快速定位并修改正确的文件”。拿到一套新板子的源码时我推荐按这个顺序入手先找dts顶层文件看看它include了哪些dtsi然后全局搜板级dts里xxx标签打开的节点这些就是已经启用的外设再打开原理图对照板级dts里引脚相关的pinctrl和gpio描述理解板子硬件资源的分配方式。这个流程走一遍基本能快速摸清这套BSP的组织方式和设计思路。还要讲讲设备树overlay的实际落地场景。产品形态越做越多样化后一个主板可能要用在多个配件组合中比如显示器A和显示器B的屏幕参数不同但主板全兼容。你可以在运行时根据硬件识别信息动态加载不同的dtbo而不用为每个组合烧写整份dtb。这种“主板统一配件各自带设备树片段”的方案适合量产组合丰富的产品线。前提是overlay的语法要和板级dts的引用关系解耦好标签名不要冲突属性命名最好加上设备前后缀。最后再分享一个我判断设备树修改是否生效的土办法改动前后各跑一次dtc -I dtb -O dts反编译并用diff对比两份dump出来的文本。能编译通过不代表运行正确能运行正确也不代表这就是你心目中想表达的硬件连接。使用diff来做回归比对可以清晰看到每次修改对环境产生了哪些实质影响这在多人协作时尤其好用。设备树这门手艺本质上是把硬件工程问题转换成了数据描述问题。语法部分几天就能上手真正见功夫的是对硬件细节的把握和在内核运行机制中准确找到归属的能力。希望这篇围绕设备树语法和实例展开的文章能帮你少走几步弯路。