刚接触嵌入式Linux的设备树时,很多人都会被那棵层层嵌套、各种标号和中断绑定的树吓到。说实话,我最初拿到一份RK3568的dts,也觉得像在看一张迷宫地图。但当你真正动手改过几次、被无启动日志折磨过几回后,会发现设备树其实就是一份“硬件配置清单”,它的价值恰恰在于把硬件资源和驱动代码之间的对应关系,用一种树形结构直白地描述出来。这篇文章我会结合瑞芯微RK3568、PetaLinux、U-Boot 2018这些大家在真实项目中常碰到的环境,把dts文件从语法到实操掰开揉碎地讲一遍,不管你是刚入行的驱动新手,还是被上游dtsi搞到头大的老开发,都能直接拿走照用。
1. 设备树是什么?为什么现在绕不开它
1.1 从板级文件到设备树的演进
在Linux 3.x之前,内核里维护着一堆arch/arm/mach-xxx目录,每个平台都放着一堆板级初始化代码,把板子上的GPIO、中断、寄存器基地址全都硬编码进去。那时候换一块新板子,最直接的办法就是把相近的板级文件拷过来,改一改结构体、调一调地址,再编一遍内核。这样做不是不行,但维护成本会随着芯片平台数量的增加滚雪球。各家SoC厂商的板级补丁满天飞,社区每上一个新版本,都要在一堆平台上做心里没底的回归测试。
设备树正是为解决这个撕裂感而生的。它把硬件描述从内核C代码里彻底剥离出来,变成一份数据文件dts,内核只管解析这棵树,然后根据节点里的compatible属性去匹配对应驱动。这样一来,同一个内核二进制可以启动不同硬件:换板卡只需要换一个dtb文件,不需要重新编译内核。它的本质就是硬件与驱动之间的一道契约,承上启下,让BSP开发逻辑更清晰。
1.2 dts、dtsi、dtb三兄弟到底怎么分工
我先从文件名讲清楚,dts是设备树源文件,dtsi是设备树公共头文件,dtb是编译后的二进制目标文件。它们的生成链路是:先有一组dts文件和若干dtsi文件,dts通过#include指令把需要的dtsi包含进来,然后由DTC(Device Tree Compiler)编译成一个dtb。大家习惯把“dts”作为整个设备树的代称,但实际动手时,你改得最多的往往是厂商提供的xxx.dtsi,因为它包含了SoC内部几乎所有的外设节点、中断控制器、时钟和引脚定义。
很多初学者会犯一个理解性错误:以为板级dts是独立写出来的,像写单片机寄存器配置一样从头写到尾。实际上,dts的编写大量依赖dtsi里的公共描述,你的任务更像是一个“覆盖者”:在dts里引用dtsi中已有的控制器节点,补充板级差异的属性,比如外接通了什么设备、I2C地址是多少、GPIO脚接在哪一路LED、内存大小等等。这种设计就是设备树的复用逻辑,跟你写C语言用头文件做抽象如出一辙。
1.3 设备树能做什么,不能做什么
设备树能描述的硬件种类非常多:CPU核心数量与频率、内存布局、中断控制器连接关系、外设总线拓扑、引脚复用、电源域、时钟、GPIO、DMA通道,甚至板子上的背光亮度、风扇转速传感器也能作为属性写在节点里。凡是用通用语言能概括的硬件信息,都可以放进设备树。但要注意,设备树不是一个完整的硬件配置系统,它取代不了驱动程序寄存器级的操作逻辑,也和硬件抽象层HAL不是一回事。
它的边界在于:它只做静态描述。如果一个外设的工作模式需要在系统运行中频繁切换,比如双角色USB要根据插入方向改变主从,那这种动态行为是驱动代码的职责;设备树只负责告诉驱动“我有这个USB控制器,在哪个基地址、支持哪些模式”。把这些边界搞清楚,你写dts时就不会把一堆状态逻辑强行塞进节点属性,导致设备树文件变得臃肿又难查。
2. dts文件的基本语法与节点结构
2.1 节点、属性与值类型速记
设备树的基本单位是节点,起始于一个带有路径名或者 label:node-name@unit-address 的节点定义,花括号里则放它的属性和子节点。根节点固定是“/”,整棵树都从它长出来,每个设备在树里的路径就是从根节点一路下来的斜杠全路径。属性是键值对,键是字符串,值支持几种常见类型:字符串、字符串列表、u32数组、二进制数组,还有更复杂的组合形式。
我随手写个最小例子给你看:
/dts-v1/; / { compatible = "myvendor,myboard"; model = "MyBoard v1"; memory@40000000 { device_type = "memory"; reg = <0x40000000 0x20000000>; }; };这里的memory节点告诉内核物理内存起始地址是0x40000000,大小是512MB。reg属性的两个u32分别是地址和大小,但在64位ARM平台,地址和大小会各占两个u32,写成<0x0 0x40000000 0x0 0x20000000>这种形式。值的定义方式虽然固定,但不同节点的属性含义并不一致,所以撰写时一定要针对具体芯片的参考手册来写,不能光记语法。
2.2 中断属性与中断控制器
中断是dts里最容易出错的部分。每个能动态产生中断的设备节点,一般需要三个关键成员: interrupts 声明中断号与触发方式, interrupt-parent 指向它的上一级中断控制器,而中断控制器本身要在dtsi里标记 interrupt-controller 并为 interrupts 值的个数设置 #interrupt-cells。以ARM常见的GIC为例,GIC-400通常是类型+序号+标志三个cell,而GICv3的中断描述往往要两个cell,再加上一个标识额外组的cell。
我们看到的interrupts属性,好比一张“维修单据”:中断类型相当于报修渠道,中断号是工单编号,触发性质是你希望什么时候被叫醒。你必须在设备树里明确这个中断是从哪个控制器来的,否则内核在初始化时会一脸茫然。实战中,如果两个中断控制器chip且节点没指定interrupt-parent,则会继承父节点的默认设置,要是父节点也含糊,中断注册就会出乱七八糟的失败。所以,只要板级外设和dtsi里的GIC关系变了,我第一件事就是检查这三个字段齐不齐。
2.3 引脚复用:pinctrl与gpio关系
写外设dts时,GPIO和pinctrl经常是一对孪生兄弟。pinctrl负责让引脚工作在某个mux功能:比如GPIO3_B6这个引脚,到底是普通GPIO,还是UART_TX,还是I2C数据线,就要通过pinctrl属性去选。节点里的pinctrl-names用来声明状态名称,pinctrl-0/1则是在某一个状态下选择的一组脚。最常见的有default、sleep等状态,系统启动时自动应用default状态。
GPIO本身则通过“gpio-controller”节点来抽象,外设节点中引用GPIO时,需要给出“哪个控制器?第几号引脚?有效极性?”三个信息。很多新手会有一个误区:以为把GPIO的pin号写在设备节点里,驱动里就能按数字吉凶直接操作了。实际上,设备节点里写的这个偏移号,是针对某个gpio-controller节点的相对偏移,而不是SoC手册里那种“PA0、PB3”的全局逻辑编号。我在RK3568项目里就把这个偏移关系重新换算过一遍,不然用GPIO子系统的框架函数时,index老是错位。
2.4 节点引用、修改与覆盖
设备树的灵活性很大一部分来自节点的“可覆盖性”。dtsi里定义了一个i2c2节点:
&i2c2 { status = "okay"; clock-frequency = <100000>; };这里&i2c2就是引用节点label,然后为它补充板级属性。这种写法让SoC级的dtsi保持通用,板级dts只管覆盖。同样地,要禁用一个设备,就把status写成“disabled”;要修改某个已有终止设备的reg,直接在引用的花括号里覆盖属性。某些场景下还可以用 /delete-node/ 和 /delete-property/ 来删除指定节点或属性,多用于去掉SoC上未引出的功能模块。
需要注意:覆盖的原则是“以最后出现者为准”。一个属性可以被多次修改,后面出现的值会覆盖前面。所以调试时,如果发现改动不生效,先检查是不是dts后面还有别的include也写了同名字段。我记得有人在两个dtsi里重复定义同一个i2c节点的status,一个okay一个disabled,结果内核心事重重地把它禁用了,找问题找了整整半天。养成只在一处地方做覆盖的习惯,能省很多无谓排障。
3. RK3568设备树实战:拿现成的板子写dts
3.1 骨架:compatible、model与memory
我建议你拿到一块RK3568板子时,先看板级dts的头几十行。厂商一般会在include前把根节点和关键说明放在最显眼的位置:
/dts-v1/; #include "rk3568.dtsi" #include "rk3568-evb.dtsi" #include <dt-bindings/gpio/gpio.h> #include <dt-bindings/input/input.h> / { model = "MyRouter RK3568"; compatible = "myrouter,rk3568", "rockchip,rk3568"; chosen { stdout-path = "serial2:150200n8"; }; };compatible列表的顺序是有讲究的,第一个值通常写最精确的型号名,内核在匹配machine时按顺序比较,找到第一个匹配的machine_desc就停。model字符串则用来给人看,串口里打印的板卡型号基本就是它。chosen节点里的stdout-path指向调试串口设备,这个错了,启动阶段内核与控制台就断了联系,你看不到任何日志。所以拿到新板子,先把串口对应关系捋清楚,再往里加业务设备,这是我反复强调的铁律。
3.2 内存与保留内存区域
RK3568的dtsi里一般会根据bootloader传的tags或者ATF编译配置来处理内存,但板级dts也可以写死memory节点。很多量产产品会选择在dts里直接控制内存范围,因为要让预留区域给MFC、ISP、DSP或者ATF使用。比如:
/ { reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; dsp_reserved: dsp@10000000 { reg = <0x0 0x10000000 0x0 0x8000000>; }; }; }; &dsp_reserved { status = "okay"; };这里的reserved-memory节点是标准的内存保留机制。你的内核、DSP、安全固件各占哪一块,都要在设备树里先约定好。如果两个保留区域或内核内存区重叠,启动到一半大概率会发生诡异的数据踩踏,表现为RAM校验失败、驱动注册失败,甚至是随机memset死锁。实际在双系统方案里,我常常在reserved-memory与dsp驱动之间来回比对,确保地址范围严格不越界。
3.3 调试串口与aliases:最快跑通手段
一开始写dts,建议先把串口状态设置正确,这是后续一切日志的前提。RK3568的调试口一般是uart2,你需要确认板级dts里是否把uart2的status改成okay,并且正确设置了pinctrl,比如:
&uart2 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&uart2m0_xfer>; };同时根节点里的aliases最好把serial绑定好:
aliases { serial0 = &uart0; serial1 = &uart1; serial2 = &uart2; };别小看这个aliases。U-Boot和内核找console、找根文件系统设备时,经常需要根据一个稳定的别名来定位,而不是扫描一串可能乱序的设备路径。如果chosen里写的stdout-path是“serial2:150200n8”,但aliases根本没把uart2映射成serial2,控制台设备初始化时就会找不到目标,打印直接丢失。这块我在调一块非标准主板时踩过,连默认console路径都不认,完全是靠看代码才发现的。
3.4 I2C外设节点的写法与驱动匹配
RK3568的I2C外设很常见,比如板载触摸屏、温湿度传感器、RTC。一个典型的I2C设备节点长这样:
&i2c1 { status = "okay"; clock-frequency = <400000>; sensor@76 { compatible = "national,lm75"; reg = <0x76>; interrupt-parent = <&gpio3>; interrupts = <RK_PC1 IRQ_TYPE_LEVEL_LOW>; pinctrl-names = "default"; pinctrl-0 = <&sensor_int_pin>; }; };这里compatible是驱动里of_match_table要匹配的字符串。如果驱动名称是“lm75”,它在match表里会写“national,lm75”,你dts里就不能写成“lm75”或者“lm75a”。初学者最喜欢在这上面翻车:节点写对了,reg地址也对,但驱动就是没probe。用ls /sys/bus/i2c/devices/看看,会发现系统已经枚举出设备,但始终没有driver竞态响应,八成就是compatible没对上。在决定自己的兼容字符串时,建议遵循vendor,device这种规范格式,避免和社区既有命名冲突。
3.5 GPIO-LED与按键的经典写法
调试阶段往板子上挂LED是非常高效的。RK3568的GPIO可能有多个组,比如GPIO0到GPIO4,每组32个pin。LED节点写法:
gpio-leds { compatible = "gpio-leds"; pinctrl-names = "default"; pinctrl-0 = <&green_led_pin>; status-led { gpios = <&gpio0 RK_PA0 GPIO_ACTIVE_HIGH>; default-state = "on"; label = "green:status"; }; };其中的gpio0对应dtsi里的&gpio0节点,RK_PA0宏代表这一组里的第0个IO。GPIO_ACTIVE_HIGH是一种极性宏,如果我们把LED接到VCC和GPIO之间,点亮逻辑是GPIO拉低,那就要写GPIO_ACTIVE_LOW。代码里用gpiod_set_value时,内核会根据设备树里的极性自动转换。如果你板子接法写反了,LED可能常亮不灭,或者一亮一灭刚好反过来,非常直观,也方便回头对照原理图训练自己的设备树感觉。
3.6 pinctrl配置:mux、上拉与驱动强度
引脚复用在RK3568上是个重点。同一个物理引脚可能支持几十种功能,所以dtsi里往往把各个mux组定义成pinctrl节点。有时你需要在板级dts覆盖对应的pinctrl,让引脚工作成特殊功能。比如一个一键休眠键,我们可以把它定义成普通GPIO唤醒源:
&pinctrl { power_key { power_key_pin: power_key-pin { rockchip,pins = <2 RK_PB5 RK_FUNC_GPIO &pcfg_pull_up>; }; }; }; &pmu_io_domains { // 相关电压域配置,具体以芯片手册为准 };rockchip,pins数组最后一个元素是pcfg_xxx,它引用了dtsi里的公共pin配置节点,比如上拉、下拉、驱动强度、施密特触发等。这个数组的值比较多,我通常用厂商提供的模板作为参考,自己完全手写容易漏掉某项。配置完pinctrl后,还得留意被复用到的原功能是否已经被禁用。比如同一个引脚之前被SDMMC用了,你改成I2C功能,就得先找到对应的SDMMC节点status设为disabled,否则两个子系统都会争夺同一个pin,启动时驱动初始化就会互相干架。
4. 编译、烧录与运行时调试
4.1 用dtc编译与反编译设备树
当你改完dts,需要把它变成dtb。常规方法是进入内核源码树执行:
make ARCH=arm64 dtbs或者单独编译某个dtb:
make ARCH=arm64 rk3568-myboard.dtb如果只想快速验证语法,也可以直接用dtc工具:
dtc -I dts -O dtb -o test.dtb test.dts反过来,从现成dtb看别人怎么写的,用反编译:
dtc -I dtb -O dts -o dump.dts boot.dtb对uImage/zImage启动的场景,dtb通常要通过烧录工具单独烧到某个分区。如果是用U-Boot读取dtb,要保证dtb地址和加载大小都对得上,否则内核会读到残缺数据,启动日志可能会在解压初期直接卡死或报“FDT:ERR_OVERLAP”之类的错误。我一般习惯在修改后先反编译dtb再搜一遍关键节点名,确认改动确实编译进去了,防止改错了文件却还在盲调。
4.2 U-Boot 2018下的设备树加载流程
U-Boot 2018这个版本,很多RK和ZynqMP平台都在用。它加载设备树时不会停留在启动内核那一刻,而是在环境变量里通过源码树和分区设定来指定dtb的位置。常见启动流程是:
setenv bootargs console=ttyS2,1500000 root=/dev/mmcblk0p1 rw rootwait fatload mmc 0:1 0x10000000 boot.img ext4load mmc 0:2 0x10080000 myboard.dtb booti 0x10080000 - 0x10000000也可以先用U-Boot的fdt命令对设备树现场打补丁,比如修改mac地址或关闭某个节点:
fdt addr 0x10080000 fdt set /ethernet0 local-mac-address AA:BB:CC:DD:EE:FF fdt set /usbotg status disabled这在量产调试阶段特别管用:不用重新编译dtb,直接在U-Boot命令行改完再启动内核,确认配置影响后再把改动固化到dts源文件。我调试RK3568网卡时,经常这样临时切换phy地址。不过要看清楚U-Boot是在启动前用的fdt,还是直接传递dtb给内核,需要在板上的fdt_addr变量里写好对应内存地址,改错了就是启动崩溃。
4.3 PetaLinux环境里的设备树管理
在Xilinx/AMD的PetaLinux工程里,设备树文件通常位于项目目录的:
components/plnx_workspace/device-tree/device-tree/或者更常见的<project>/project-spec/meta-user/recipes-bsp/device-tree/files/。PetaLinux的优势是硬件平台描述和软件配置可以互相对应,特别是PS侧外设如UART、I2C、CAN这些,它会在生成设备树时从硬件的XML或者hdf中自动带出。但自动生成不等于百分之百满足你的定制需求,所以你仍然要在meta-user的dts或dtsi里写覆盖。
你需要修改PetaLinux设备树时,可以运行:
petalinux-config -c device-tree或者直接编辑文件,然后做一次完整的设备树重建:
petalinux-build -c device-tree需要注意PetaLinux工程里常常会有system-user.dtsi作为用户覆盖入口,你把自定义的节点和属性放到这个文件里,会减少和自动生成dtsi的冲突。如果直接去改那个自动生成的dtsi,下次从Vivado导出hdf并重新build,改动就会被打回原形。保持“自动生成不动,用户覆盖另写”,是最成熟的PetaLinux设备树工作流。
4.4 常见编译错误与节点合法性检测
设备树不仅会报语法错误,还会报节点结构错误。比如“reg property size does not match #address-cells”就是典型的寄存器地址范围描述错乱,往往发生在64位地址加32位size取值混用。需要先检查根节点的#address-cells和#size-cells,再看具体节点的reg里给了几个u32。另一个高频报错是“interrupts property size does not match #interrupt-cells”,说明中断属性cell数写错了,尤其常见的是把GIC type/specifier和其他控制器混在一起抄。
DTC还会提示“Label or path not found”,说明你引用了不存在的节点label。这个我先检查是否漏了include,再检查是不是拼错。设备树编译器给错误信息还算直白,看多了就知道,大部分问题集中在reg、interrupts、clocks这些带数量含义的属性上。建议每次改完都养成“改一个节点、编译一次”的好习惯,不像C语言有完整IDE提示,设备树错误堆栈信息稍弱,小步快跑反而是提效关键。
5. 常见问题与排查技巧实录
5.1 compatible总是匹配不上驱动
如果驱动里查得到of_match_table,但dts里compatible不会触发probe,先用/sys/firmware/devicetree/base/查看设备树解析后的实际节点内容,确认节点里的compatible字符串和驱动match表完全一致。其次检查节点状态是不是“disabled”,很多板级dts把默认外设都关了。还有一种易漏的情况是这个设备的父节点总线没有启用,比如i2c master节点没设status为okay,那么挂在它下面的子设备即使有compatible,也不会被bus探测。
5.2 启动日志里设备注册了但没输出
有时候I2C子设备节点写对了,驱动也匹配成功,但是只有/dev/i2c-x,没有生成实际的/dev/哑设备名。这往往是驱动里的总线探测没有成功,比如设备上电时序不对,此时读寄存器失败,probe直接返回负值。跟设备树本身无关,但你会误以为改dts能解决。我的经验是先看内核日志里有没有“xxx: probe of 1-0076 failed with error -11”,如果是复位延迟不够,就要在驱动或添加延时逻辑,而不是继续改dts时间节点。
5.3 GPIO数量对不上,内核直接报错
RK3568的GPIO组每个控制器有32个IO,dtsi里用GPIO_ACTIVE_HIGH定义极性。如果你在gpio-leds里写了一个超出该控制器范围的gpio号,内核启动时会报“invalid GPIO”并放弃这一项。排查方式非常直接:对照SoC引脚表的行列号,换算成控制器内相对偏移,同时检查另一组控制器是否更像正确归属。建议先把一颗LED按原理图确认能亮,再批量添加其他LED,不然多个GPIO同时写错很难理清。
5.4 中断卡死或频繁触发
设备树中与中断有关的问题往往不会在编译时报错,而是在运行中突然“irq xx: nobody cared”或者产生中断风暴。大概率是触发类型写错了,比如一个电平触发的中断写成边沿触发,或者设备树里中断号与硬件实际断点不一致。我调试底板时,经常在IRQ handler里挂一个打印计数器,通过中断次数判断触发极性是否正常。同时用cat /proc/interrupts确认中断是否注册到了预期编号,这个编号对照设备树里的interrupts描述是最直观的。
5.5 如何快速确认当前生效的dtb到底是谁
生产环境最尴尬的事情不是写错dts,而是不知道自己烧进去的dtb是哪一个。可以在U-Boot阶段把它反编译出来,也可以用内核运行时检查:
ls /proc/device-tree这个虚拟文件系统会把当前内核正在使用的设备树导出成目录结构,你可以直接查看compatible、model等属性内容。如果发现找不到你自己加的节点,而盘里明明烧了新dtb,别急,大概率是U-Boot启动命令里dtb地址或分区选错。此时回到U-Boot用fdt命令逐个地址验证,看哪个地址里的dtb包含你的模型字符串,就能锁定真正加载的文件了。
5.6 生产环境下建议的dts开发流程
在我实际维护RK3568项目的过程中,最推荐的工作流是:先从厂商自带、功能完整的dtsi剪出一个最小可引导板级dts,只留下串口、内存、看门狗,确保能启动到shell;然后每次加一类外设,就做一个独立提交,比如先加上I2C控制器,再加I2C子设备,再加相关pinctrl,最后验证一遍引脚占用;这样后期出了问题,用二分法来回查,效率高到不可思议。
另一位很受用的习惯是把所有板级差异集中注释在一个dts文件的开头,写上对应原理图版本号、核心板型号和分区表。当时同一条产线就出现过两版硬件共用一版软件dtb的经历,排查下来才发现是dts里预留了完全不同的SARADC键值。硬件改版和软件版本必须呈一一对应关系,这个纪律能避免大量潜在问题。设备树的本质本来就是把硬件差异说清楚,你没把它当文档维护,那迟早会被它反咬一口。
最后分享一个小技巧:不管是什么平台,先学会反编译dtb,再开始写自己的dts。因为厂商给你的成品dtb往往比文档表述得更诚实,你能从中看到pinctrl实际选的是哪个mux组,中断到底连到了GIC的哪个中断号,aliases被安排成了什么顺序。把这些模板翻透,再动手覆盖自己的板级差异,比从零开始对着芯片手册拼一个dts要可靠得多。设备树这棵树,看着盘根错节,但只要你每次都从根节点出发,顺着一条具体的硬件路径追下去,它并不难驯服。