☰
嵌入式Linux设备树实战指南:从dts语法到RK3568开发板定制
2026/10/3 12:46:02 网站建设 项目流程

做嵌入式Linux的这几年,我翻过的资料里有一半是在跟设备树打交道。刚入行那会儿,拿到一块新板子第一件事就是对着开机日志看半天,然后满头大汗地找dts文件到底哪里写错了。现在回头看,设备树这玩意儿说难也难,说简单也简单,难的是它牵扯了SoC、板级电路、内核驱动、bootloader好几层东西,简单的是它本质上就是一套描述硬件的"数据结构"。这篇文章我把这些年在RK3568、ZynqMP这些平台上改设备树的经验整理出来,从语法底子到实际动手,再到遇到问题怎么查,一次给你讲清楚。

先回答一个最基础的问题:dts设备树文件到底用来干什么?简单说,它就是一张"硬件配置清单",告诉内核你的板子上有什么外设、接在哪根总线上、用哪个中断、电压多少。以前ARM Linux是靠一大堆写死的board文件来干这个活的,换一块板子就得重新编译内核,后来社区受不了了,就推出了设备树机制,把硬件描述从内核代码里剥离开来。到了今天,不管你用的是NXP的i.MX系列、Xilinx的ZynqMP,还是瑞芯微的RK3568/RK3588,改板子的第一步几乎都是动设备树文件。

在动手之前,还有几个基础概念必须捋清楚:DTS是设备树源文件,DTSI是可复用的设备树头文件,DTB是编译后的二进制文件,DTC是编译器。这几个缩写你会在后续所有开发流程中反复遇到。petalinux、uboot、kernel这些不同阶段都有各自的设备树处理方式,后面我会逐个展开讲。

1. 设备树的核心逻辑:为什么内核非要这张"配置清单"

1.1 从"写死代码"到"数据驱动"的转身

如果你用过Linux 3.x时代的ARM内核源码,一定见过arch/arm/mach-xxx目录下密密麻麻的board文件。每一块开发板都要有对应的C文件,里面写满了结构体、回调函数、内存映射宏,用来告诉内核:我的UART在物理地址0x10000000,我的网卡接在片选2上,我的中断控制器是GIC版本x。这样做带来的问题很直接:开发板厂商只要改一个GPIO的接法,就得改C代码、重新编译整个kernel,社区维护压力巨大。

设备树把这个过程变成纯数据驱动。内核不再关心你用的是哪家板子,它只负责在启动时解析一段二进制数据(DTB),按照里面的节点和属性去初始化驱动。硬件变了吗?那就改数据,编译成本低得多。你甚至可以在不改内核的情况下,靠换一个DTB文件让同一份内核适配多块板卡,这点在做产品序列的时候特别有用。

1.2 设备树是树,不是代码

理解设备树最好的方式就是想象一颗家谱树。根节点是"/",下面挂着CPU节点、内存节点、总线节点、外设节点。每个节点用一对大括号包裹,节点下可以继续挂子节点,也可以直接写属性。属性就是key-value形式的配置项,比如compatible(兼容性字符串)、reg(寄存器地址)、interrupts(中断号)等等。

内核启动时的匹配过程很简单:驱动通过compatible字符串去设备树里找"跟我一样的节点"。找到了就绑定,找不到就跳过。这也是为什么新手犯的最多的错误之一就是把compatible拼错——一旦拼错,驱动逻辑一个都不会执行,设备自然就无法工作。

1.3 三板斧准备好,工具链先配齐

写设备树不需要重型IDE,有Linux环境就够了。核心工具是device-tree-compiler,Debian/Ubuntu系统下执行sudo apt-get install device-tree-compiler就能装好。装完之后你就有dtc命令、fdtdump、fdtget、fdtput这一整套工具。如果做的是petalinux项目,还得把petalinux SDK的环境变量source一下;如果做瑞芯微平台,一般SDK里自带交叉编译脚本,直接用它来make dtbs就行。

我个人的建议是,不管用什么平台,都先把"内核源码树"准备好,因为设备树源文件永远以内核源码里arch/arm64/boot/dts/xx/目录下的文件为准。网上总有人搜什么"kes dts下载"之类的关键字,其实最省心的办法就是从官方内核仓库或芯片厂商的SDK里直接拿,版本对应关系比什么都重要。

2. dts语法底子:节点和属性是你看世界的两只眼

2.1 最小dts长什么样

拿一个最简单的dts文件开胃,路径可以存成test.dts:

/dts-v1/; / { compatible = "vendor,board"; #address-cells = <1>; #size-cells = <1>; chosen { bootargs = "console=ttyS0,115200n8 root=/dev/mmcblk0p2 rw"; }; memory@0 { device_type = "memory"; reg = <0x00000000 0x20000000>; }; uart0: serial@fe100000 { compatible = "arm,pl011"; reg = <0xfe100000 0x1000>; interrupts = <0 24 4>; }; };

你不需要马上理解每一行,先记住几个硬性规则。文件第一行必须是/dts-v1/;,这是告诉编译器用的是v1版本语法。紧跟着的/ { };是根节点,一个dts文件必须有且仅有一个根节点。#address-cells和#size-cells是给父节点下的子设备用的,用来定义reg属性里"地址"和"大小"各占几个u32单元。

2.2 reg属性:说清楚寄存器在哪、占多大

reg是设备树里最常见的属性之一,它的格式是reg = <address1 size1 [address2 size2 ...]>。你要想让内核知道有一个外设,它的寄存器基地址是0xfe100000,长度为0x1000,那就写reg = <0xfe100000 0x1000>。

address和size各占几个"格子"(cell),由父节点的#address-cells和#size-cells决定。比如父节点写#address-cells = <1>; #size-cells = <1>;,那么reg里每两个一组,前一个数秒地址,后一个数秒长度。有的设备父节点定义是#address-cells = <2>,那reg里一个地址就要写两个u32,就像RK3568这类64位平台常见的样子。新手最容易栽在这点:子节点的reg到底写几个数,必须看父节点定义,别凭感觉拍脑袋。

2.3 phandle与label:设备的两种"点名"方式

设备树里没有指针,外设之间要实现引用,靠的就是phandle和label。你可以给一个节点打上标签,比如uart0: serial@fe100000,这里的uart0就是label。别的地方想引用它,直接写&uart0。编译器会把它翻译成一个整数phandle,内核就能通过这个整数找到对应的设备节点。

还有一种更隐晦的引用方式,就是直接在属性里写<&uart0>,例如某个中断控制器节点定义interrupt-parent = <&intc>。这里的意思是把intc节点的phandle作为引用值传给父级。写GPIO引脚、中断引脚、时钟引用的时候全是这套逻辑。理解phandle,你就理解了设备树百分之八十的"连接关系"。

2.4 interrupt属性:中断描述也没那么简单

设备树里中断相关的常用属性有interrupt-parent、interrupts、interrupt-controller、#interrupt-cells这四兄弟。一个节点如果本身是中断控制器,需要声明interrupt-controller,并给出#interrupt-cells说明每个消费者节点用几个cell来描述中断。以ARM GIC为例,#interrupt-cells = <3>,三个cell分别代表中断类型(0是SPI共享外设中断,1是PPI私有外设中断)、中断号、触发方式。

我在实际项目里吃过这个亏:RK3568的GIC定义#interrupt-cells = <3>,结果我在一个触摸屏节点里只写了interrupts = <0 67 IRQ_TYPE_LEVEL_LOW>这种老式写法前半截没问题,但后边的触发方式如果没引用头文件里的枚举定义,编译会报错。更隐蔽的是写反SPI坐标,导致中断号差32,驱动根本收不到中断。

2.5 头文件也能派上用场

设备树源文件支持C语言预处理。最常见的用法是#include "rk3568.dtsi"、#include <dt-bindings/interrupt-controller/irq.h>、#include <dt-bindings/gpio/gpio.h>。这些头文件里定义了一些枚举,比如GPIO_ACTIVE_HIGH、IRQ_TYPE_LEVEL_LOW。写完dts文件后,编译流程本质上先过一遍gcc预处理,把include展开,再交给dtc去编译。所以我平时写dts的时候,跟写C代码一样享受宏定义和头文件的便利。

不过这里有个坑:如果直接使用dtc去编译dts,而不是走内核或者petalinux的make系统,那么include头文件的展开需要靠dtc -H选项或者先手动cpp处理。直接用dtc test.dts -o test.dtb经常会被头文件路径问题卡住。我建议要么把编译动作交给平台的标准流程,要么自己写一个简单的makefile,先跑gcc -E再跑dtc。

3. 项目实战:从dtsi组织到RK3568平台定制

3.1 一颗SoC多块板卡,怎么组织才不乱

刚接触设备树的人有个疑问:一个芯片厂商的SDK里,为什么有那么多长得差不多的dts文件,还都喜欢include来include去?答案是为了复用。一颗SoC芯片的硬件资源是固定的,厂家会把这颗SoC所有的内部外设节点做成一个dtsi,比如rk3568.dtsi,里面写好了所有的UART、I2C、MMC、GPU、DPU这些控制器的地址、中断、时钟,但status默认都写成disabled。

板卡厂商拿到这颗芯片之后,只需要新建一个板级dts,比如rk3568-evb.dts,然后#include "rk3568.dtsi",接着在板级dts里把自己用到的外设节点覆盖成status = "okay",再补充板上额外接的设备(比如某颗触摸屏芯片、某款以太网PHY、某颗eMMC)就能完成一块板的适配。这样SoC厂家和板厂各管各的,互不干扰。你如果要做多款板卡,千万别复制一大份dts再改,正确姿势是抽公共部分进dtsi,每块板子只留差分片段。

3.2 覆盖节点和追加子节点,两种手段要分清

在板级dts里,你想把一个SoC的dtsi中已有的节点打开,写法是:

&uart2 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&uart2m0_xfer>; };

这里的&uart2不是新创建一个节点,而是对rk3568.dtsi中已有uart2节点做"覆盖追加"操作。方括号外面写的属性会合并到目标节点里;如果属性名相同,则以板级dts为准;如果目标节点里原本没有这个属性,就新增进去。这种机制非常灵活,也是设备树强大之处。

另外还有一种场景:某个外设芯片挂在I2C0上,但是这个外设节点并不存在于SoC的dtsi里,那就要在板级dts里对它做完整的新增描述。写法是把新增节点直接挂在I2C总线节点下面,然后指定compatible、reg(I2C从机地址)、中断、复位脚等信息。这里要注意,新增节点的reg是I2C从机地址,不是寄存器地址,千万别抄错。

3.3 RK3568实战:定制一块触摸屏的完整过程

拿我近期调过的RK3568平台举例。硬件上我把一颗GT911电容触摸屏挂到了I2C0上,复位脚接到GPIO3_C2,中断脚接到GPIO0_B6。RK3568的SDK里已经有了基础板级dts,我需要做的就是把I2C0的节点打开,然后追加触摸屏子节点。大致改动如下:

#include "rk3568-evb.dts" &i2c0 { status = "okay"; clock-frequency = <400000>; gt911: gt911@5d { compatible = "goodix,gt911"; reg = <0x5d>; interrupt-parent = <&gpio0>; interrupts = <RK_PB6 IRQ_TYPE_LEVEL_LOW>; reset-gpios = <&gpio3 RK_PC2 GPIO_ACTIVE_LOW>; irq-gpios = <&gpio0 RK_PB6 GPIO_ACTIVE_HIGH>; touchscreen-inverted-x = <1>; touchscreen-inverted-y = <1>; }; };

这里面有几个细节值得展开。首先reg = <0x5d>是GT911的I2C地址,这取决于芯片的ADR引脚电平,实际如果地址是0x14之类的也要对应改。其次是interrupt-parent = <&gpio0>,这表示中断线挂在GPIO0组上,中断号和触发方式由interrupts = <RK_PB6 IRQ_TYPE_LEVEL_LOW>给出。RK_PB6这类宏来自DT绑定头文件,它实际上是GPIO bank的偏移计算方式。还要注意把irq-gpios属性一并写上,因为goodix驱动会去申请这颗GPIO作为中断源,如果不写,驱动注册中断时可能因为拿不到GPIO而失败。

写完dts后,通过SDK的编译脚本生成dtb,烧进boot分区。如果一切顺利,内核启动时dmesg会输出类似input: Goodix Capacitive TouchScreen这样的信息。如果触摸无反应,先查设备树有没有被正确加载,再查pinctrl有没有被占用,最后查reset gpio的电平时序,这条排查路径我后面还会详细展开。

3.4 pinctrl与GPIO:说不完的爱恨纠葛

设备树里配置引脚功能用的是pinctrl子系统。RK平台的dtsi里通常写了一大堆引脚组定义,比如uart2m0_xfer、i2c0_xfer,这些定义位于pinctrl节点下,由引脚的IOMUX配置生成。你在板级dts里,打开一个外设节点时,通常还要设置pinctrl-0 = <&uart2m0_xfer>,告诉内核:这个节点用哪个复用配置。

刚开始写的时候,我经常忘记写pinctrl,结果就是内核里设备明明注册了,引脚却没被正确复用,串口不出数据、GPIO输出高电平没反应。后来学乖了,打开任何一个外设节点的第一步就是去查dtsi里有没有对应的pinctrl定义,十有八九是有现成资源可以引用的。

GPIO的定义则相对简单,属性通常写成xxx-gpios = <&gpioX 引脚号 GPIO_ACTIVE_HIGH>。比如reset-gpios里引用的RK_PC2表示GPIO3组C2引脚,这里的宏在dt-bindings/pinctrl/rockchip.h里定义。新手最容易把GPIO组号算错,把GPIO3_C2当成GPIO2_C2写进去,点灯怎么都不亮。

4. 编译、反编译与bootloader的配合

4.1 编译一个dtb你该走哪条路

如果你用的是内核源码树,编译设备树一般是make ARCH=arm64 dtbs或者make ARCH=arm64 rockchip/rk3568-evb.dtb这类命令。SDK打包流程里通常也集成了dtb编译,比如直接执行./build.sh就会生成对应的dtb。petalinux工程项目则是通过petalinux-build -c device-tree来重新生 成设备树相关的内容。

如果只是想快速验证一个单独的dts是否语法正确,可以用这个命令:

dtc -I dts -O dtb -o test.dtb test.dts

但如果dts里有#include引用了头文件,这个命令大概率会报错,因为dtc不做C预处理。正确的拆解流程是:

cpp -nostdinc -I include -undef -D__DTS__ -x assembler-with-cpp test.dts test.pre.dts dtc -I dts -O dtb -o test.dtb test.pre.dts

你可以在内核源码里看到dtc的调用参数,其实就是走了一套类似cpp预处理再调用dtc的流程。把这条链路想明白,以后遇到"找不到头文件"之类的编译错误就能抄起命令行自己排查。

4.2 反编译是调试神器

我项目上最常用的调试操作是反编译。板子上跑着的DTB未必和源码一致,你怀疑启动时加载的设备树不对,就直接把boot分区里的dtb文件拉出来反编译:

dtc -I dtb -O dts -o debug.dts boot.dtb

拿到反编译出来的dts,先看有没有你改的节点,再看status是不是okay,最后看phandle有没有被冲掉。很多时候"我改了dts但不生效"的原因就是烧录压根没烧对,反编译看一眼就能定性。

还有一种不需要拉文件的方式:板子在Linux运行时挂载了debugfs,你可以直接查看运行中的设备树视图:

ls /sys/firmware/devicetree/base/ cat /sys/firmware/devicetree/base/model

这个目录下能看到内核实际解析后的设备树结构,每个节点就是一个目录,每个属性就是一个文件,内容全是原始二进制。想看树形结构,可以直接用find /sys/firmware/devicetree/base/ -type f命令遍历一遍。

4.3 uboot 2018与设备树的配合

我们做RK3568平台时用的uboot版本大多是2017.09或2018.xx系列。从uboot 2016开始,uboot自身也使用了设备树机制,uboot的dts一般放在u-boot源码的arch/arm/dts/目录下,板级文件叫rk3568-evb.dts。uboot在启动时会读取自己的DTB,完成外设初始化,然后把kernel的DTB地址传给内核。

在uboot里,你可以用fdt addr命令指定dtb内存地址,用fdt print查看节点内容,用fdt set修改属性。这些命令在做产品调试时非常有用。我们曾经遇到过一个问题:kernel启动后串口控制台不出来,排查后发现在uboot阶段fdt set修改了chosen节点的bootargs,把console参数覆盖成了另一个tty串口。命令行的字符串一旦写错,内核拿到了错误参数,控制台自然就没有输出。

如果你的平台是ZynqMP或者versal系列的Xilinx FPGA,那么大概率会用到petalinux。petalinux设备树的管理方式和普通内核树不太一样,它默认在project-spec/meta-user/recipes-bsp/device-tree/files/目录下放两个核心文件:system-user.dtsi和pl.dtsi。system-user.dtsi就是留给用户的覆盖文件,你可以在这里追加外设节点或者覆盖某些属性。petalinux-build的时候,系统会用dtc把这些dtsi片段和内核里的基座设备树合并起来,生成最终的DTB。所以你在petalinux工程里改设备树,主战场就是system-user.dtsi,尽量不要直接去动内核源码里的原始dts。

4.4 设备树overlay机制聊两句

除了直接把所有内容写进一个dtb,Linux还支持设备树overlay(插件)机制。简单说就是把板级扩展设备的描述编译成一个单独的dtbo文件,运行时通过configfs加载,动态覆盖到基础设备树上。这个在树莓派、BeagleBone上用得非常多,RK平台也支持类似能力。

overlay的语法和普通dts基本一样,但顶部声明稍有不同,例如:

/dts-v1/; /plugin/; &i2c1 { status = "okay"; mydevice@0x48 { compatible = "myvendor,mydevice"; reg = <0x48>; }; };

注意/plugin/;这个声明,有了它dtc才会把外面的&i2c1当成一个"覆盖外部节点"的片段,而不是再去创建一颗独立的树。编译方式也类似:dtc -I dts -O dtb -@ -o myoverlay.dtbo myoverlay.dts,其中-@表示生成带符号信息,允许后续解析目标路径。

在我做量产固件时,overlay最大的价值是:内核镜像不变,只需要按需加载不同的dtbo,就能让同一份系统支持多种硬件配置。不过这需要内核开启CONFIG_OF_OVERLAY和CONFIG_OF_CONFIGFS,并在用户空间挂载configfs,你先在开发板上把这条链路验证通了再往生产环境推广也不迟。

5. 我在项目里踩过的设备树坑

5.1 常见报错与排查思路

做设备树开发,不可避免会踩各种坑。我整理一张表,把高频问题、现象和排查思路列出来,方便你以后“按图索骥”:

现象可能原因排查方法
内核找不到某外设,dmesg无任何绑定日志compatible字符串拼写错误反编译dtb后grep字符串,与驱动of_match_table逐一比对
外设探测到了,但功能异常(如串口乱码)pinctrl未配置或引脚复用冲突检查节点pinctrl-0引用,确认引脚不被其他节点占用
中断一直不触发interrupts类型或中断号算错对照SoC手册和dtsi里中断控制器定义,确认SPI号
启动阶段卡在memory相关报错reg或#address-cells/#size-cells定义不匹配在dts里打印memory节点,确认大小未被截断
触摸屏等设备ID重复,busy提示多个外设reg地址冲突用i2cdetect扫描总线,确认设备地址唯一
uboot启动内核时"flattened device tree"报错dtb地址无效或dtb超限uboot下用fdt addr手动加载,打印内存布局

5.2 排查思路不能只会百度

遇到设备树相关的问题,我的固定套路是五步法:

第一步,先从源码和反编译两个角度确认当前DTB里的真实内容。很多时候你以为自己改了某一行,实际烧录的还是旧文件。

第二步,让内核先跑起来,看dmesg和/proc/device-tree目录下的值。cat属性文件能看到原始二进制数值,比如你想确认某个reg配置是否正确,直接读对应文件,再用十六进制工具看值就知道内核拿到的是什么。

第三步,利用sysfs验证驱动和设备的绑定关系。查看/sys/bus/platform/devices/或/sys/bus/i2c/devices/,如果节点下的驱动是spi_master或者i2c_driver之类的名字,说明绑定成功;如果显示unknown,说明compatible没对上或者驱动没编译进内核。

第四步,如果怀疑pinctrl问题,去/sys/kernel/debug/pinctrl/下查看pin的状态。这里能看到当前每个引脚的复用功能和谁占用。两个外设抢同一根引脚,在这里一眼就能看出冲突。

第五步,实在不行,用printk或dev_info往驱动里加点调试打印,看看of_match过程匹配到的到底是哪个compatible字符串。这一步会把问题范围缩小到"是设备树描述错了"还是"驱动逻辑错了"。

5.3 我踩过的一个"阴间"bug

说一个我印象特别深的装备树问题。在ZynqMP平台上调一款PHY芯片,kernel启动时mdio总线能扫到这个PHY,但网络死活link不上。我在dts里对比了phy-mode、phy-handle、reg地址,都和硬件手册一致。最后用uboot的mii命令去读PHY寄存器,发现PHY ID读出来全是0xff,才意识到是PHY的复位GPIO时序问题。复位引脚在设备树里虽然有描述,但reset-deassert-us参数配置过短,芯片上电后还没完成内部初始化就被主控访问了。把延时从50us改到1000us后一切正常。

这个坑其实不算纯dts语法问题,但它揭示了设备树不止是地址和中断的集合,很多外设的时序参数、电流参数、读写策略也藏在设备树里。你在写设备树的时候,一定要养成查阅对应芯片官方设备树绑定文档的习惯,别凭想当然写参数。

5.4 版本对应关系千万不能乱

设备树跟内核版本有非常强的耦合。你在Linux 5.10内核上写好的dts,放到Linux 6.1内核上未必能编译通过;同理,uboot的dts和内核的dts也不是同一个文件,两者可能语法上完全能用,但节点表示方式差异很大。所以,启动阶段uboot修改设备树、内核随后解析,这中间如果格式不一致,也会出现资源冲突或解析失败。

我一般会记录一份每个BSP版本的"三树对照表":uboot使用的dts路径、kernel使用的dts路径、petalinux的system-user.dtsi覆盖路径。改代码之前先确认三棵树的对应关系,可以省掉许多让人血压升高的排错时间。

6. 最后给你几条实在的建议

跟设备树打了这几年交道,我最想说的是:别把设备树当C代码写,也别把它当文本文件乱改。它的本质是一套严谨的数据描述,最好的学习路径就是亲手在一块板子上把某个外设从零调起来。

实际操作中,我的习惯是先在文档里画一个硬件连接草图,标明每个外设的基地址、中断号、GPIO引脚、I2C地址、相关时钟、复位流程,然后再去dts里逐个节点落地。这个草图其实比任何工具都好用,它能逼着你把每个属性都对应到硬件本身。

调试阶段,优先用反编译工具确认当前系统解析的设备树和你设想的一致;启动阶段,多看uboot启动日志里的device tree字符串;运行阶段,多用sysfs和debugfs查询运行时状态,而不是一遍遍重新编译烧录去赌运气。

还有一个小技巧:在板级dts里加一条自己独特的model字符串,比如model = "my-evb-rk3568";,这样以后从任何一份dmesg日志里,都能快速确认当前烧的到底是不是你改的那份配置。这个习惯帮助我在一堆测试固件里准确找到目标版本。

设备树文件看似平凡,但它承载了整块板卡的“硬件基因”。把dts和dtsi的组织逻辑、节点引用方式、pinctrl与中断机制搞清楚,你在嵌入式Linux开发里基本就打通了硬件和软件之间那道最关键的关卡。后面遇到再复杂的平台,无非是这几招来回用。希望这篇能帮你少摔几跤,少掉几根头发。

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

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

立即咨询