做嵌入式Linux开发,提到“dts设备树文件”,基本就是“这块板子的硬件说明书”。我前几年第一次接触RK3568设备树时,光搞清楚 dts、dtsi、dtb 这几个后缀之间的关系就绕了一大圈,后面自己动手改节点、加外设、调pinctrl,每一步都是踩坑踩出来的经验。这篇文章不整虚的,直接说dts设备树文件怎么组织、怎么写、怎么编译、怎么调,尽量口语化,但内容保证能落地。面向刚接手Linux驱动的开发者,也适合正在RK3568这类瑞芯微平台上快速上手的工程师,只要看完能自己改一个GPIO点灯节点或者挂上一个I2C外设,这篇文章就没白写。
1. 设备树到底是什么?先搞懂这几个概念
1.1 为什么会有设备树这个“万恶之源”
在设备树出现之前,ARM Linux内核每支持一块板子,就要在arch/arm/mach-xxx/下塞一堆board-xxx.c文件,里面全是硬编码的 platform_device、GPIO申请、中断注册表。芯片种类一多,这些文件膨胀到基本没法维护,而且一个内核镜像只能服务一块板子,换个评估板就得重新编译内核。设备树(Device Tree)说白了就是把“硬件怎么接”的信息从C代码里剥离出来,做成一份独立的数据描述文件,内核启动时再根据这份文件来生成平台设备。同一个内核,配上不同的dtb,就能适配不同硬件,这就是为什么现在几乎所有ARM Linux平台都默认走设备树。
对普通开发者来说,你并不需要关心设备树是怎么被发明出来的,你只需要记住一件事:dts设备树文件描述的是“有什么硬件、硬件挂在哪个地址、用什么中断、占哪些引脚”,驱动则是通过节点里的compatible字符串来找自己的设备。写错dts,驱动就找不到设备,表现出来就是“设备没注册、不probe、没反应”。
1.2 dts、dtsi、dtb、dtbo 别再傻傻分不清
很多新手一进内核目录就被这些后缀搞晕,其实它们的关系很好理解:
| 后缀 | 全称/含义 | 在项目中的作用 |
|---|---|---|
| .dts | Device Tree Source | 板级设备树源文件,描述一整块具体板子 |
| .dtsi | Device Tree Source Include | SoC级公共片段,可被多个板级dts引用 |
| .dtb | Device Tree Blob | dts经过预处理和编译后的二进制,启动时传给内核 |
| .dtbo | Device Tree Blob Overlay | overlay格式的设备树增量,用于运行时动态修改设备树 |
可以这么类比:dtsi是芯片原厂给的“标准户型图”,dts是你的“装修方案”,dtb是“最终施工图纸”,dtbo是“后续加装家具的增补图纸”。我们平时改外设,绝大部分都是在板级dts里覆盖或者追加SoC级dtsi中的节点,比如rk3568.dtsi是瑞芯微官方定义芯片内部外设,你的板子dts再#include "rk3568.dtsi",然后打开你要用的串口、I2C、SPI等资源。
1.3 启动流程里谁是中间人
设备树文件从源码到真正生效,大致链路是这样的:dts/dtsi 先经过C预处理器展开宏和头文件,再由DTC编译器编成dtb;U-Boot引导时读入dtb,将它放到内存某个地址,然后把地址通过寄存器传给内核(ARM Linux约定r2寄存器);内核启动早期解析dtb,把节点转换成设备模型,接着驱动通过compatible匹配并执行probe。
这里有个最容易被忽略的点:内核镜像本身的CONFIG_OF要开,U-Boot环境变量里选的dtb文件名要正确。很多“改了dts没生效”的案例,并不是语法写错,而是你改了内核的dts,烧录时却只更新了内核镜像,dtb还是旧文件;或者U-Boot里fdt_file指向了另一个dtb文件。我自己的经验是:先确认启动日志里打印的 model 和你期望的值是否一致,再开始排查节点问题。
2. dts基本语法和必备属性
2.1 节点、属性、值的基本写法
先看一个最简设备树源文件骨架:
/dts-v1/; #include <dt-bindings/gpio/gpio.h> #include <dt-bindings/pinctrl/rockchip.h> #include <dt-bindings/interrupt-controller/irq.h> #include "rk3566.dtsi" / { model = "My RK3568 Board"; compatible = "my,rk3568-board", "rockchip,rk3568"; #address-cells = <2>; #size-cells = <2>; chosen { stdout-path = &uart2; }; memory@a0000000 { device_type = "memory"; reg = <0x0 0xa0000000 0x0 0x80000000>; }; };/dts-v1/;是版本声明,必须有。根节点用/ { };表示,里面是一堆子节点。节点名的格式是“节点名@unit-address”,比如memory@a0000000,其中@后面的地址要和该节点reg属性的第一个地址一致,虽然不一致也能编译,但属于有病不改,内核和社区工具解析时会留下隐患。
属性值常见就几种:字符串写model = "My Board";,整数数组写reg = <0x0 0xa0000000>;,字符串列表写compatible = "a", "b";,字节数组写binary = [00 12 34];。父节点里的#address-cells和#size-cells决定了子节点reg里地址和长度分别占几个32位单元,RK3568这类64位SoC顶层通常是2个cell表示64位地址,千万不要拍脑袋乱写。
2.2 compatible、status、reg 这些属性怎么用
compatible是设备树里最重要的属性,驱动通过它来找设备。格式通常是“厂商,型号”,比如"st,lsm6ds3"代表意法半导体的lsm6ds3。内核驱动源码里的of_device_id表会逐个比较,匹配上就probe。建议写两个值,第一个是精确型号,第二个是通用兼容型号,比如compatible = "my,board", "rockchip,rk3568";,这样即使后续板卡改版,内核兼容性也更好。
status只有几个合法值,"okay"表示启用,"disabled"表示禁用。原厂dtsi里很多外设为了省电或者避免资源冲突都是disabled,板级dts里当你确认控制器没被占用,再打开它。reg描述设备在总线上的地址或者寄存器偏移范围,具体几个cell由父节点决定。比如I2C子设备里reg = <0x6b>;就是该外设在I2C总线上的7位地址。
另外顶层根节点还会写model字符串,这个会显示在启动日志里,方便确认当前跑的是哪份设备树。chosen节点不是真实硬件,它保存内核运行参数,比如stdout-path指向串口节点、bootargs放命令行参数,调试设备树时优先确认chosen。
2.3 中断、GPIO、时钟、pinctrl 的描述方法
中断描述有三个核心点:中断控制器节点必须声明interrupt-controller;和#interrupt-cells = <N>;,使用中断的外设通过interrupt-parent指定控制器,然后写interrupts。ARM GIC 一般3个cell:第1个是中断类型(SPI或PPI),第2个是中断号,第3个是触发类型。RK3568里常见写法是:
&uart0 { interrupt-parent = <&gic>; interrupts = <GIC_SPI 82 IRQ_TYPE_LEVEL_HIGH>; };GPIO中断则是把GPIO控制器当作中断控制器用,比如按键:
button { interrupt-parent = <&gpio3>; interrupts = <RK_PA2 IRQ_TYPE_LEVEL_LOW>; };GPIO描述也很固定。GPIO控制器节点要有gpio-controller;和#gpio-cells = <2>;,消费设备里写:
gpios = <&gpio2 RK_PB2 GPIO_ACTIVE_HIGH>;第1个cell是引脚名,第2个cell是极性标志。时钟入门写法是clocks = <&cru CLK_UART0>;加clock-names = "baudclk";,驱动里用devm_clk_get(dev, "baudclk")取时钟。pinctrl则描述引脚复用,板级dts里一般只写:
pinctrl-names = "default"; pinctrl-0 = <&uart0_xfer>;这里uart0_xfer是在dtsi里已经定义好的引脚复用组。记住一个原则:某个引脚如果被pinctrl配置成外设功能,就不能在GPIO节点里再用同一个引脚,内核会报pin busy。
2.4 怎么引用和覆盖 dtsi 里的节点
想修改原厂dtsi里的节点,不能在板级dts里重新写一个同名节点,那样会重复定义导致编译或解析问题。正确做法是使用 label 引用,比如原厂在dtsi里写了uart0: serial@fdd50000 { ... };,你在板级dts里用&uart0 { status = "okay"; };添加或覆盖属性。如果要往某个控制器下挂子设备,也在引用块里追加:
&i2c1 { status = "okay"; clock-frequency = <400000>; lsm6ds3@6b { compatible = "st,lsm6ds3"; reg = <0x6b>; interrupt-parent = <&gpio1>; interrupts = <RK_PA2 IRQ_TYPE_LEVEL_LOW>; vdd-supply = <&vcc3v3_sys>; }; };这套“引用+覆盖”的写法是设备树区别于普通配置文件的核心优美之处:SoC级文件保持不动,板级dts只做增量修改,既能跟随原厂更新,又能保持自己的定制内容。
3. 实操编写:从一个RK3568外设节点开始
3.1 动手写dts之前先做这三件事
第一,拿到板子原理图,确认外设挂在哪个控制器上。以RK3568为例,芯片有6组UART、多组I2C/SPI,但引脚复用不是随便接的,必须先看datasheet里的复用表,确定要用哪个MUX组,否则pinctrl配置就是错的。第二,看原厂rk3568.dtsi里这个节点默认是什么状态,很多控制器默认disabled,你必须知道要打开哪些时钟、哪些引脚组。第三,看内核源码里对应驱动的of_match_id,确认compatible字符串和驱动匹配表完全一致,尤其是大小写和逗号前后都不能错。
很多人习惯直接抄老项目的节点,但不同内核版本、不同芯片原厂,甚至不同SDK分支,节点名、时钟名、pinctrl组名都会变化。我踩过最大的坑就是拿RK3399的I2C节点硬套到RK3568上,结果时钟名不对,probe一直失败。
3.2 先写一个GPIO LED节点练手
GPIO LED是最容易上手的实验目标,因为它不依赖复杂时钟和pinctrl,只要一个GPIO空闲就能亮。RK3568板级dts里添加:
/ { leds { compatible = "gpio-leds"; work_led: led-0 { label = "work"; gpios = <&gpio2 RK_PB2 GPIO_ACTIVE_HIGH>; linux,default-trigger = "heartbeat"; default-state = "on"; }; }; };这段代码的意思是在根节点下创建一个leds子节点,compatible = "gpio-leds"交给内核的leds-gpio驱动处理。label会出现在/sys/class/leds/下面,写成work后操作的就是/sys/class/leds/work。linux,default-trigger驱动会自动关联到触发源,这里设成心跳灯,可以直观看到内核活着。如果你想验证GPIO极性,把default-state改成"on",如果灯不亮而反而不亮,就把GPIO_ACTIVE_HIGH换成GPIO_ACTIVE_LOW。
3.3 挂一个I2C外设:LSM6DS3加速度计
如果GPIO已经会写了,下一步我强烈建议练挂I2C从设备。I2C外设在设备树里无非三件事:控制器节点要打开、pinctrl要选对、子设备的地点和中断要确定。下面是一个完整示例:
&i2c1 { status = "okay"; clock-frequency = <400000>; lsm6ds3@6b { compatible = "st,lsm6ds3"; reg = <0x6b>; interrupt-parent = <&gpio1>; interrupts = <RK_PA2 IRQ_TYPE_LEVEL_LOW>; vdd-supply = <&vcc3v3_sys>; vddio-supply = <&vcc3v3_sys>; }; };我特别强调reg = <0x6b>这个值。数据手册里写地址经常是8位形式,比如0xD6,但设备树里I2C地址统一用7位表示,0xD6右移一位就是0x6B。很多新手直接抄手册的0xD6,结果I2C总线扫描时在0x6B能看到设备,驱动却去访问0xD6,自然失败。中断脚也要看原理图确认这个引脚有没有被别的外设占用,同样的引脚如果已经在某个pinctrl组里被用作UART或者SPI,probe时就会报中断申请失败。
3.4 从dts到dtb的编译过程
独立编译一个dts可以用DTC工具:
dtc -I dts -O dtb -o rk3568-my.dtb rk3568-my.dts但实际内核项目里不建议这么干,因为你的dts里有一堆#include和宏,必须让内核对它做预处理。正确做法是在内核根目录执行:
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs如果只编自己板子的dtb,可以先确认arch/arm64/boot/dts/rockchip/Makefile里有没有把你的dts目标加进去,然后执行:
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- rockchip/rk3568-my.dtb构建系统会自动先调用C预处理器展开所有#include和宏,再调用DTC生成二进制。看到这里你应该明白了,为什么很多人在纯命令行下用dtc编译报“无法识别宏”的错——因为少了预处理这一步。
3.5 U-Boot和PetaLinux环境下怎么处理设备树
U-Boot 2018以后的版本默认也带设备树,arch/arm/dts/目录下的dts用来描述U-Boot阶段需要的外设,比如PMIC、DDR、网络、串口。它和内核设备树是两棵独立的树,修改时必须分清你改的是哪一棵。U-Boot源码配置里可以通过环境变量fdt_file指定加载哪个dtb文件名,比如fdt_file=rockchip/rk3568-my.dtb,这样U-Boot启动时会从boot分区读取对应的dtb,再传给内核。
如果你在用PetaLinux这种上层构建工具,设备树的处理又会包一层。通常工具链会生成一个基础设备树,然后允许你在user层覆盖自定义dts或dtsi,比如PetaLinux工程里把自定义dts放到project-spec/meta-user/recipes-bsp/device-tree/files/,执行petalinux-build -c device-tree重新打包。这类工具的坑在于它会做二次处理,直接改内核源码里的dts不一定会进最终的镜像,你要养成反编译最终dtb确认内容的习惯。
4. 设备树调试的几个实战技巧
4.1 反编译dtb,确认最终烧进去的是什么
设备树这行最实用的一句话:一切以最终dtb为准。很多时候你以为自己改了,但烧进去还是旧文件。拿到一个dtb后,先用命令反编译成可读的dts:
dtc -I dtb -O dts -o dump.dts boot.dtb然后直接看compatible、model、status这些关键属性。想快速读某个属性,也可以用fdtget:
fdtget boot.dtb / leds-node fdtget boot.dtb /model如果手头没有独立dtc,可以用fdtdump:
fdtdump boot.dtb | less调试状态下我一般会先看启动日志里打印的 model 字符串是不是我预期的,如果不是,说明U-Boot加载的并不是当前目录下的dtb,优先排查分区烧录和环境变量。
4.2 运行时设备树: /proc/device-tree 和 /sys/firmware/devicetree
设备树被内核解析后,会以目录树形式暴露出来。/proc/device-tree和/sys/firmware/devicetree/base指向同一个东西。你可以直接在板子上查看:
ls /proc/device-tree/ cat /proc/device-tree/model cat /proc/device-tree/leds/led-0/compatible属性文件末尾带\0,直接cat有时会看到乱码,建议用:
tr -d '\0' < /proc/device-tree/model这个运行时树是极好的调试入口。比如驱动没probe,你可以进这个目录检查对应节点在不在;节点在但status是disabled,那就一目了然。另一个实用点是配合watch命令,动态观察热插拔设备的overlay节点是否加载成功。此方法比反复重启快太多,我已经习惯了上板第一件事就是看/proc/device-tree。
4.3 用overlay实现动态设备插拔
有些场景不适合改动主dtb,比如你想在运行时不重新编译内核的情况下挂一个临时的I2C外设,设备树overlay就是干这个的。overlay的dts写法多了一层fragment和__overlay__:
/dts-v1/; /plugin/; / { fragment@0 { target-path = "/"; __overlay__ { test-led { compatible = "gpio-leds"; }; }; }; };编译overlay用DTC的-@选项保留符号表:
dtc -@ -I dts -O dtb -o test-led.dtbo test-led.dts然后通过configfs把dtbo写入内核。我建议初学者先不搞overlay,它虽然看起来很酷,但调试难度比直接改主dtb高不少。先熟练掌握整体修改,再研究动态加载。
5. 常见问题与排查思路
5.1 问题速查表
| 现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| 改了dts没反应 | dtb没重新编译/没烧录/U-Boot加载了别的dtb | 反编译最终dtb,确认model |
| 驱动不probe | compatible不匹配/节点status是disabled/驱动模块没加载 | 查of_device_id与dts兼容字符串 |
| pinctrl报pin busy | 引脚被多个节点占用 | 全局搜索GPIO号,清掉冲突节点 |
| 中断申请失败 | interrupt-parent选错/中断号越界/触发类型不对 | 对照SoC手册GIC中断表 |
| I2C找不到设备 | 地址写错/从设备供电没开/时钟频率过高 | 用i2cdetect扫描确认7位地址 |
| 编译dts报宏错误 | 直接用dtc裸编,没走内核预处理 | 用内核make dtbs带预处理流程 |
5.2 改完不生效:99%是流程问题
这是出现频率最高的问题。每次改动dts后,我的固定节奏是:重新编译dtb,确认产物时间戳;反编译看关键节点;烧录到正确分区;启动日志确认model;进入/proc/device-tree验证节点存在。如果做了全套还是没有,才回去查语法。千万不要只在你的电脑上编译完说“明明改了”,因为板子跑的很可能还是U-Boot从某个固定分区加载的旧dtb。另外注意部分bootloader会从FIT镜像或者boot分区内嵌dtb,这时即使你擦除了根分区的dtb文件也没用,得重新打包镜像。
5.3 compatible匹配不上:驱动到底在找什么
内核驱动和设备树的匹配非常简单:驱动源码里有of_device_id表,比如:
static const struct of_device_id lsm6ds3_of_match[] = { { .compatible = "st,lsm6ds3" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, lsm6ds3_of_match);dts节点里的compatible只要任何一个字符串能和表里的匹配,驱动就会probe。匹配不上的常见原因:抄错了内核版本,比如老内核驱动还叫st,lsm6ds2;或者你在compatible里写了空格;或者内核编译时根本没把驱动编进去,成了“即使匹配上也没probe”。我建议遇到不probe先做两件事:grep compatible在内核驱动源码里搜一遍,确认关键字完全一致;再执行modprobe或看/sys/bus/i2c/drivers/下有没有对应驱动。
5.4 GPIO和pinctrl冲突:同一个脚不能干两份活
你在dts里既把某个引脚定义成GPIO输出,又在某个串口的pinctrl-0里把它复用成UART引脚,内核启动时会报类似pin 10 already requested by serial0的错。这种问题最隐蔽,因为它不会导致内核崩溃,只会在创建GPIO设备时悄悄失败。排查方法是全局搜索这个GPIO编号在dts里的所有引用,然后用cat /sys/kernel/debug/pinctrl/pinctrl-rockchip-pinctrl/pinmux-pins查看当前占用情况。一般的处理思路是:如果外设必须用这个引脚,就删除或disbale冲突的GPIO节点;如果只是调试,就换一个空闲引脚。
5.5 启动阶段解析设备树失败会怎样
设备树本身有语法错误,或者地址范围设置不对,内核启动早期可能直接panic,连串口日志都来不及完整打印。这种时候不要慌,先检查dtc编译阶段有没有警告,特别留意unit address mismatch和DTC: Warning (reg_format)这类信息。另一个常见原因是reg的cell数写错,比如在#address-cells = <1>的父节点下写了两个地址cell,内核解析时会把寄存器基址和长度读错,后续任何外设的ioremap都会异常。养成每次编译都盯警告的习惯,能省下大量启动调试时间。
最后再分享一点我的实操心得
设备树没有想象中神秘,它本质上就是一份有约定格式的硬件清单,而dts设备树文件写得对不对,考验的不是语法背得熟不熟,而是你会不会看原理图和SoC手册。我个人项目里最常用的操作其实是grep,拿到一个外设节点后,先在内核源码里grep它的compatible,找到驱动实现再反向去读dts,比凭空猜测高效得多。如果你刚接触设备树,我的建议是先拿GPIO LED这类最简单的节点把完整流程跑通,从改dts到编dtb、烧录、看/proc/device-tree,过程中感受一次“改动生效”,后面再碰复杂外设就有底气了。还有一个我自己长期保留的好习惯:每次修改前都先把原厂dtsi打开放在旁边对照,因为我发现至少一半错误不是语法问题,而是没有理解原厂已经帮你做了什么。希望这篇文章能让你在遇到dts文件时少一点烦躁,多一点“原来如此”的感觉。