1. 从一个真实需求说起:为什么要手写设备树
我第一次接触设备树是在一块i.MX6ULL的开发板上。当时想把一个I2C接口的OLED屏(SSD1306驱动)接上去,按照教程把驱动编译进内核,结果系统启动后/dev下什么都没有,dmesg里也看不到任何I2C设备被注册。折腾了大半天,最后发现问题出在设备树里——I2C控制器的节点没有使能,屏幕挂载的那个地址也没有描述。那一刻我才真正意识到,设备树不是“可选项”,而是嵌入式Linux板级描述的骨架。
设备树(Device Tree)本质上是一种用文本描述硬件拓扑的数据结构。它把“这块板子上有什么外设、挂在哪个总线上、地址是多少、中断接哪个引脚”这些信息,从内核代码里剥离出来,交给一个独立的二进制文件(DTB)在启动时传给内核。内核拿到这份“硬件清单”后,再去匹配对应的驱动,完成设备注册。这样做的好处很直接:同一份内核镜像可以跑在不同板子上,只要换一个DTB就行,不用重新编译内核。
这篇文章面向的是已经能跑通嵌入式Linux基本流程、但还没系统写过设备树的开发者。我会从零开始,以一块i.MX6ULL板子为例,完整走一遍设备树的编写、编译、验证流程,把每一步背后的逻辑讲清楚。中间会穿插我在实际项目中踩过的坑,以及一些文档里不会写的经验。读完之后,你应该能独立为自己的板子写出一份可用的设备树描述。
2. 设备树的核心概念与整体设计思路
2.1 设备树到底描述了什么
很多人刚看设备树文件时会被一堆嵌套的花括号搞晕。其实它的结构非常朴素,就是一棵树。根节点是/,下面挂各种总线节点(比如i2c1、spi2、uart3),总线节点下面再挂具体的外设节点(比如oled@3c)。每个节点里有一堆属性(property),属性就是键值对,描述这个节点的特征。
举个例子,一个I2C从设备节点大概长这样:
&i2c1 { status = "okay"; clock-frequency = <100000>; oled: ssd1306@3c { compatible = "solomon,ssd1306"; reg = <0x3c>; status = "okay"; }; };compatible是设备树里最重要的属性,它是驱动匹配的“暗号”。内核启动时会遍历所有节点,拿compatible的值去和驱动里注册的of_device_id表比对,匹配上了就调用驱动的probe函数。reg描述设备在总线上的地址,I2C设备就是7位从地址。status控制节点是否生效,okay表示启用,disabled表示禁用。
2.2 为什么选择“引用+覆盖”的写法
你可能会注意到上面用的是&i2c1而不是直接写i2c1: i2c@021a0000。这是设备树里一个非常关键的设计:基础SoC描述和板级描述分离。
芯片厂商(比如NXP)会提供一份imx6ull.dtsi,里面描述了SoC内部所有控制器的基础信息——寄存器地址、中断号、时钟源等等。这份文件是通用的,所有用这颗芯片的板子都共用。而板级厂商提供的是.dts文件,通过&节点标签的方式去引用SoC描述里的节点,然后覆盖或追加属性。
这样做的好处是:SoC级别的改动只需要改.dtsi,所有板子自动受益;板级差异只在.dts里体现,互不干扰。我在实际项目里遇到过有人直接把.dtsi复制出来改,结果芯片厂商更新了.dtsi之后,他的改动全部丢失,还得重新merge一遍。所以记住一个原则:永远不要改.dtsi,只改.dts。
2.3 整体设计思路
构建一份板级设备树,我的思路通常分四步走:
- 确认硬件连接:把板子上每个外设的供电、总线、地址、中断引脚、复位引脚全部列出来,画一张表。
- 对照SoC手册找控制器:确认每个外设挂在哪个控制器上,控制器的寄存器基地址是多少。
- 在
.dts里引用并配置:使能控制器,添加外设子节点,填写compatible、reg、中断等属性。 - 编译验证:用
dtc编译成DTB,启动后通过/proc/device-tree和dmesg验证。
这四步里,第一步最容易出错,也最值得花时间。硬件连接搞错了,后面全白搭。
3. 核心细节解析与实操要点
3.1 设备树源文件的组织方式
一个典型的板级设备树工程目录大概是这样:
arch/arm/boot/dts/ ├── imx6ull.dtsi # SoC级描述,厂商提供 ├── imx6ull-pinfunc.h # 引脚复用宏定义 ├── myboard.dts # 板级描述,我们自己写 └── Makefile # 编译规则myboard.dts的开头通常是这样的:
/dts-v1/; #include "imx6ull.dtsi" #include "imx6ull-pinfunc.h" / { model = "My Custom i.MX6ULL Board"; compatible = "fsl,imx6ull-myboard", "fsl,imx6ull"; chosen { stdout-path = &uart1; }; memory@80000000 { device_type = "memory"; reg = <0x80000000 0x20000000>; }; };/dts-v1/;是版本声明,必须放在第一行。model是给人看的板子名称,compatible是给内核匹配machine描述用的。chosen节点里的stdout-path指定内核启动时用哪个串口输出日志,这个在调试阶段非常关键。memory节点描述DDR的起始地址和大小,i.MX6ULL的DDR通常映射在0x80000000,大小根据实际内存颗粒填写。
注意:
memory节点的reg属性如果写错,内核启动时会直接panic,报“Unable to handle kernel paging request”。我第一次写的时候把大小写成了0x10000000(256MB),实际板子是512MB,结果系统只能识别一半内存,跑大程序就OOM。
3.2 引脚复用(Pin Mux)的配置
i.MX6ULL的引脚复用是通过IOMUXC控制器配置的。每个引脚可以工作在多种模式下(GPIO、UART、I2C、SPI等),需要在设备树里显式声明。这部分是新手最容易卡住的地方。
配置引脚复用需要两个东西:pinfunc宏和pinctrl节点。imx6ull-pinfunc.h里定义了每个引脚每种功能的宏,比如:
#define MX6UL_PAD_UART1_TX_DATA__UART1_DCE_TX 0x0084 0x0310 0x0000 0x0 0x0这五个数字分别代表:mux寄存器偏移、配置寄存器偏移、输入选择寄存器偏移、mux值、输入选择值。你不需要记住这些,直接用宏就行。
然后在.dts里定义pinctrl节点:
&iomuxc { pinctrl_uart1: uart1grp { fsl,pins = < MX6UL_PAD_UART1_TX_DATA__UART1_DCE_TX 0x1b0b1 MX6UL_PAD_UART1_RX_DATA__UART1_DCE_RX 0x1b0b1 >; }; pinctrl_i2c1: i2c1grp { fsl,pins = < MX6UL_PAD_UART4_TX_DATA__I2C1_SCL 0x4001b8b0 MX6UL_PAD_UART4_RX_DATA__I2C1_SDA 0x4001b8b0 >; }; };fsl,pins里每行两个值:引脚功能宏 + 配置值。配置值是一个32位整数,编码了上下拉、驱动能力、速度等电气参数。I2C的引脚通常需要开漏输出加外部上拉,所以配置值和UART不一样。这个值怎么算?NXP的参考手册里有详细说明,但实际项目中我一般直接抄参考板的配置,然后根据实测波形微调。
实操心得:如果你不确定某个引脚的配置值,可以先抄一份官方评估板的设备树,把对应的pinctrl节点复制过来,只改引脚宏。官方评估板的配置通常是最稳妥的起点。
3.3 控制器节点的使能与配置
SoC级的.dtsi里,大部分控制器默认是disabled的。你需要在.dts里把它们打开:
&uart1 { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_uart1>; status = "okay"; }; &i2c1 { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_i2c1>; clock-frequency = <100000>; status = "okay"; };pinctrl-names定义了一组引脚配置的名字,pinctrl-0引用具体的pinctrl节点。内核在驱动probe之前会先应用这些引脚配置。clock-frequency是I2C总线速率,标准模式100kHz,快速模式400kHz。SSD1306这类OLED屏用100kHz就够了,速率太高反而容易通信失败。
3.4 外设子节点的编写
以SSD1306 OLED屏为例,挂在I2C1上,从地址0x3C:
&i2c1 { status = "okay"; clock-frequency = <100000>; ssd1306: oled@3c { compatible = "solomon,ssd1306"; reg = <0x3c>; status = "okay"; }; };compatible的值必须是内核驱动里已经注册的。SSD1306的驱动在drivers/gpu/drm/panel/或者drivers/video/fbdev/下,具体取决于你用的内核版本和驱动方案。如果内核里没有对应驱动,你需要自己写一个,或者用simple-framebuffer这类通用方案。
再举一个带中断的例子,比如一个按键:
&gpio1 { status = "okay"; }; &iomuxc { pinctrl_key: keygrp { fsl,pins = < MX6UL_PAD_UART1_CTS_B__GPIO1_IO18 0x1b0b0 >; }; }; gpio-keys { compatible = "gpio-keys"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_key>; key-user { label = "User Button"; gpios = <&gpio1 18 GPIO_ACTIVE_LOW>; linux,code = <KEY_ENTER>; debounce-interval = <50>; }; };gpios属性指定了GPIO控制器、引脚号和有效电平。linux,code是按键上报的键值。debounce-interval是消抖时间,单位毫秒。机械按键通常需要20-50ms消抖,太小会误触发,太大会感觉迟钝。
4. 完整实操流程:从零到启动验证
4.1 硬件信息梳理
假设我们有一块自定义的i.MX6ULL板子,硬件配置如下:
| 外设 | 接口 | 地址/引脚 | 备注 |
|---|---|---|---|
| DDR | - | 0x80000000, 512MB | 起始地址和大小 |
| 调试串口 | UART1 | TX: UART1_TX_DATA, RX: UART1_RX_DATA | 115200-8N1 |
| OLED屏 | I2C1 | SCL: UART4_TX_DATA, SDA: UART4_RX_DATA, 地址0x3C | SSD1306 |
| 用户按键 | GPIO1_IO18 | 低电平有效 | 接KEY_ENTER |
| 网口 | ENET1 | 参考评估板 | YT8521 PHY |
这张表是后面所有工作的基础。每一项都要和原理图核对,尤其是I2C地址和GPIO引脚号,错一位就全盘皆输。
4.2 编写板级DTS文件
新建arch/arm/boot/dts/myboard.dts,内容如下:
/dts-v1/; #include "imx6ull.dtsi" #include "imx6ull-pinfunc.h" / { model = "My Custom i.MX6ULL Board"; compatible = "fsl,imx6ull-myboard", "fsl,imx6ull"; chosen { stdout-path = &uart1; }; memory@80000000 { device_type = "memory"; reg = <0x80000000 0x20000000>; }; gpio-keys { compatible = "gpio-keys"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_key>; key-user { label = "User Button"; gpios = <&gpio1 18 GPIO_ACTIVE_LOW>; linux,code = <KEY_ENTER>; debounce-interval = <50>; }; }; }; &uart1 { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_uart1>; status = "okay"; }; &i2c1 { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_i2c1>; clock-frequency = <100000>; status = "okay"; ssd1306: oled@3c { compatible = "solomon,ssd1306"; reg = <0x3c>; status = "okay"; }; }; &fec1 { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_enet1>; phy-mode = "rmii"; phy-handle = <ðphy0>; status = "okay"; mdio { #address-cells = <1>; #size-cells = <0>; ethphy0: ethernet-phy@0 { compatible = "ethernet-phy-ieee802.3-c22"; reg = <0>; clocks = <&clks IMX6UL_CLK_ENET_REF>; clock-names = "rmii-ref"; }; }; }; &iomuxc { pinctrl_uart1: uart1grp { fsl,pins = < MX6UL_PAD_UART1_TX_DATA__UART1_DCE_TX 0x1b0b1 MX6UL_PAD_UART1_RX_DATA__UART1_DCE_RX 0x1b0b1 >; }; pinctrl_i2c1: i2c1grp { fsl,pins = < MX6UL_PAD_UART4_TX_DATA__I2C1_SCL 0x4001b8b0 MX6UL_PAD_UART4_RX_DATA__I2C1_SDA 0x4001b8b0 >; }; pinctrl_key: keygrp { fsl,pins = < MX6UL_PAD_UART1_CTS_B__GPIO1_IO18 0x1b0b0 >; }; pinctrl_enet1: enet1grp { fsl,pins = < MX6UL_PAD_ENET1_RX_EN__ENET1_RX_EN 0x1b0b0 MX6UL_PAD_ENET1_RX_ER__ENET1_RX_ER 0x1b0b0 MX6UL_PAD_ENET1_RX_DATA0__ENET1_RDATA00 0x1b0b0 MX6UL_PAD_ENET1_RX_DATA1__ENET1_RDATA01 0x1b0b0 MX6UL_PAD_ENET1_TX_EN__ENET1_TX_EN 0x1b0b0 MX6UL_PAD_ENET1_TX_DATA0__ENET1_TDATA00 0x1b0b0 MX6UL_PAD_ENET1_TX_DATA1__ENET1_TDATA01 0x1b0b0 MX6UL_PAD_ENET1_TX_CLK__ENET1_REF_CLK1 0x4001b031 MX6UL_PAD_ENET1_RX_CLK__ENET1_RX_CLK 0x1b0b0 >; }; };这份文件里,fec1是i.MX6ULL的以太网控制器。phy-mode = "rmii"指定PHY接口模式,phy-handle指向MDIO总线上的PHY节点。YT8521是一款常见的千兆PHY,但在RMII模式下只能跑100Mbps。如果你用的是RMII模式,PHY的时钟必须由SoC提供,所以clocks属性要指向IMX6UL_CLK_ENET_REF。
4.3 编译设备树
编译设备树有两种方式:在内核源码树里编译,或者用dtc单独编译。
在内核源码树里,把myboard.dts加入arch/arm/boot/dts/Makefile:
dtb-$(CONFIG_SOC_IMX6ULL) += \ myboard.dtb然后执行:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- myboard.dtb生成的myboard.dtb在arch/arm/boot/dts/目录下。
如果只想单独编译,可以用dtc:
dtc -I dts -O dtb -o myboard.dtb myboard.dts但这种方式不会处理#include,需要先用C预处理器处理一遍:
cpp -nostdinc -I include -I arch/arm/boot/dts -undef -x assembler-with-cpp myboard.dts > myboard.pre.dts dtc -I dts -O dtb -o myboard.dtb myboard.pre.dts注意:
dtc编译时如果报“syntax error”,大概率是某个节点的花括号没配对,或者属性末尾漏了分号。设备树对语法很严格,一个分号都不能少。
4.4 反编译验证
编译完成后,强烈建议反编译回DTS看一眼,确认所有节点和属性都正确生成:
dtc -I dtb -O dts -o myboard.decompiled.dts myboard.dtb反编译出来的文件里,所有#include和宏都已经展开,你能看到最终生效的完整设备树。重点检查:
memory节点的reg是否正确- 各个控制器的
status是否为okay - 外设节点的
compatible和reg是否和预期一致 - pinctrl节点的
fsl,pins是否展开正确
这一步能提前发现90%的低级错误,比启动后抓瞎强得多。
4.5 启动验证
把DTB和内核镜像一起烧录到板子上,启动后通过串口查看日志。内核启动时会打印设备树相关信息:
[ 0.000000] Booting Linux on physical CPU 0x0 [ 0.000000] Linux version 5.4.70 ... [ 0.000000] Machine model: My Custom i.MX6ULL Board [ 0.000000] Memory: 512MB availableMachine model必须和你写的model属性一致,Memory大小必须和reg一致。如果不一致,说明DTB没被正确加载,检查U-Boot的bootargs里fdt_addr是否指向了正确的DTB地址。
然后检查各个外设:
# 查看I2C设备是否注册 ls /sys/bus/i2c/devices/ # 应该看到 0-003c 这样的条目 # 查看GPIO按键 cat /proc/bus/input/devices # 应该看到 gpio-keys 设备 # 查看网络接口 ip link show # 应该看到 eth0如果I2C设备没出现,先检查dmesg | grep i2c,看控制器是否probe成功。如果控制器probe失败,通常是pinctrl配置有问题,或者时钟没使能。
5. 常见问题与排查技巧实录
5.1 设备树编译报错速查
| 错误信息 | 原因 | 解决方法 |
|---|---|---|
syntax error | 花括号不配对、漏分号 | 用dtc逐行检查,或反编译对比 |
label or path not found | 引用了不存在的节点标签 | 检查.dtsi里是否有该标签,拼写是否正确 |
duplicate label | 同一标签定义了两次 | 搜索整个文件,删除重复定义 |
reg property has wrong size | reg的#address-cells和#size-cells不匹配 | 检查父节点的cell定义 |
5.2 启动后外设不工作的排查思路
I2C设备不识别:先看dmesg | grep i2c,确认控制器probe成功。如果控制器OK但设备没出现,用i2cdetect -y 1扫描总线,看设备地址是否响应。如果扫描不到,检查硬件上拉电阻是否焊接、地址引脚是否正确。
GPIO按键无响应:cat /proc/bus/input/devices看设备是否注册。如果注册了但按键没反应,用evtest工具测试事件上报。如果没注册,检查gpios属性里的引脚号是否和原理图一致,以及pinctrl是否配置为GPIO模式。
网口不工作:先看dmesg | grep fec,确认控制器和PHY是否probe成功。如果PHY没识别到,检查MDIO总线的reg地址是否正确。YT8521的PHY地址通常由硬件引脚决定,常见的是0或1。如果PHY识别了但链路不通,检查phy-mode是否和硬件一致(RMII还是RGMII)。
5.3 独家避坑经验
坑一:pinctrl配置值抄错。不同板子的电气参数不一样,抄参考板的时候一定要确认供电电压和上下拉需求。我曾经把1.8V的配置值用在3.3V的板子上,结果I2C通信时好时坏,折腾了两天才发现是驱动能力不够。
坑二:status属性覆盖顺序。如果你在.dtsi里某个节点是disabled,在.dts里改成okay,但又在另一个&节点里写了disabled,最终生效的是最后出现的那个。设备树的属性覆盖是按出现顺序来的,后面的覆盖前面的。
坑三:compatible字符串拼写。内核驱动匹配是精确字符串匹配,多一个空格、少一个逗号都会导致匹配失败。建议直接从驱动源码的of_device_id表里复制。
坑四:DTB加载地址冲突。U-Boot加载DTB的地址不能和内核镜像、根文件系统重叠。i.MX6ULL上通常用0x83000000加载DTB,0x80800000加载内核。如果地址冲突,内核启动时会报“Unable to handle kernel paging request”。
坑五:忘记编译DTB。改了.dts之后只编译了内核,没编译DTB,烧录的还是旧DTB。这个错误我犯过不止一次,后来养成了每次改完设备树先make dtbs的习惯。
5.4 调试工具推荐
dtc:设备树编译器,必备。-I dtb -O dts反编译功能在排查问题时非常有用。fdtdump:另一个DTB查看工具,输出格式和dtc略有不同,可以互补。i2cdetect/i2cget/i2cset:I2C总线调试三件套。evtest:输入设备事件测试工具,调试按键、触摸屏必备。gpiodetect/gpioinfo:GPIO状态查看工具,确认引脚方向和电平。/proc/device-tree/:内核启动后,设备树以目录形式挂载在这里,可以直接cat查看每个属性的原始值。
6. 进阶话题:从能用到好用
6.1 设备树覆盖(Overlay)的使用
设备树覆盖(DTBO)允许你在不重新编译主DTB的情况下,动态修改设备树。这在产品迭代中非常实用——比如同一块核心板,配不同的底板,只需要为每个底板写一个DTBO,启动时叠加到主DTB上。
编译DTBO:
dtc -I dts -O dtb -o myoverlay.dtbo myoverlay.dts在U-Boot里加载:
load mmc 0:1 0x83000000 myboard.dtb load mmc 0:1 0x84000000 myoverlay.dtbo fdt addr 0x83000000 fdt resize 0x10000 fdt apply 0x84000000 bootz 0x80800000 - 0x83000000fdt resize是必须的,因为叠加DTBO需要额外的空间。fdt apply会把DTBO的修改合并到主DTB里。
6.2 设备树与驱动匹配的底层逻辑
内核启动时,unflatten_device_tree()把DTB展开成一棵device_node树。然后对于每个device_node,内核会创建对应的platform_device,并把compatible属性作为匹配依据。驱动注册时,of_device_id表里的compatible字符串会和device_node的compatible逐一比对,匹配成功就调用probe。
这个过程可以用一个生活类比理解:设备树是“招聘启事”,驱动是“求职者”。启事上写了“需要会Python和Linux”,求职者简历上写了“会Python和Linux”,双方匹配成功,求职者入职(probe)。如果启事写的是“会Python”,求职者写的是“会python”(大小写不同),那就匹配不上。
6.3 多平台适配的经验
如果你做的产品需要支持多款SoC(比如i.MX6ULL和RK3568),设备树的组织方式需要提前规划。我的做法是:
- 每个SoC一个
.dtsi,描述SoC内部资源 - 每个板子一个
.dts,描述板级外设 - 公共的外设描述(比如SSD1306、按键)抽成
.dtsi,通过#include复用
RK3568的设备树结构和i.MX6ULL类似,但引脚配置方式不同。RK3568用rockchip,pins属性,格式是<bank pin function config>,和i.MX的fsl,pins不一样。跨平台移植时,pinctrl部分基本要重写,但外设节点的compatible和reg通常可以复用。
6.4 设备树在国产化平台上的实践
近几年国产SoC越来越多,设备树的写法基本遵循标准规范,但各家都有自己的扩展属性。比如瑞芯微的RK3568,在设备树里增加了rockchip,grf、rockchip,pmu等私有属性,用于配置系统寄存器和电源管理。这些属性在标准设备树规范里没有,需要参考厂商的文档。
我的建议是:拿到新平台的第一件事,是找厂商的SDK,把官方评估板的设备树完整读一遍。重点看三部分:pinctrl配置、clock配置、power-domain配置。这三块是平台差异最大的地方,也是最容易出问题的地方。
7. 我个人的一些实操体会
设备树这个东西,看文档觉得简单,真上手写才发现细节多如牛毛。我最大的体会是:不要试图一次写对,要小步快跑,逐步验证。先让串口能输出,再加I2C,再加GPIO,每加一个外设就启动一次,确认没问题再加下一个。这样出问题时,范围很小,容易定位。
另一个体会是:善用反编译和对比。每次改完设备树,反编译成DTS,和参考板的设备树做diff,看看差异在哪里。很多时候问题就藏在那些不起眼的差异里。
最后,设备树不是孤立的,它和U-Boot、内核、驱动都有关联。排查问题时,不要只盯着设备树看,要结合dmesg、/proc/device-tree、硬件原理图一起分析。我见过太多人改了半天的设备树,最后发现是U-Boot传错了DTB地址,或者硬件上拉电阻没焊。工具是死的,思路是活的,多动手、多测量、多对比,比死磕文档管用得多。