1. 从“硬编码”到“软描述”:为什么我们需要设备树文件
如果你是从单片机或者早期的嵌入式Linux开发转过来的,第一次接触设备树(Device Tree)这个概念,可能会觉得有点懵。以前我们写驱动,硬件信息都是直接写在C代码里的,比如一个GPIO的物理地址是0x12345678,一个I2C控制器挂在哪个总线,地址是多少,都通过宏定义或者数组写死在驱动里。这种方式简单直接,但有一个致命的问题:一个内核镜像,只能对应一块特定的板子。
想象一下,你公司基于同一颗芯片(比如瑞芯微的RK3568)设计了两款产品,一款是智能摄像头,用了摄像头A和Wi-Fi模块B;另一款是工业网关,用了以太网PHY C和4G模块D。它们的核心CPU一样,但外设完全不同。在“硬编码”时代,你需要为这两款产品分别编译两个不同的内核,驱动里充斥着大量的#ifdef BOARD_CAMERA和#ifdef BOARD_GATEWAY,代码臃肿,维护起来简直是噩梦。
设备树就是为了解决这个“一个内核,多种硬件”的问题而生的。它的核心思想是把硬件描述从内核代码中剥离出来,变成一个独立的、可读的配置文件。这个文件就是设备树源文件,也就是我们常说的DTS(Device Tree Source)。你可以把它理解为一份给内核看的“硬件清单”或者“接线图”。内核在启动时,会读取这份清单,然后根据清单上的描述,去动态地加载和初始化对应的驱动程序,完成硬件的“即插即用”。
所以,当你拿到一块新的开发板,比如RK3568或者RK3588,第一件事往往就是看它的设备树文件。它告诉你这块板子上有什么资源,它们是怎么连接的。而作为驱动开发者,你的任务就是确保这份“清单”准确无误,并且为清单上的每一个“设备条目”准备好对应的“服务员”(驱动程序)。接下来,我们就深入这份“清单”的内部,看看它到底是怎么写的。
2. DTS文件语法详解:从节点到属性的硬件“语言”
一份DTS文件,本质上是一个树状结构的数据描述文件,语法上有点类似JSON,但更简洁。它用节点(Node)来代表一个设备或总线,用属性(Property)来描述这个设备的详细信息。我们先从一个最简单的例子看起。
2.1 设备树的骨架:根节点与兼容性
每个DTS文件都必须有一个根节点,用/表示。所有其他设备都挂在这棵“树”的根或分支上。
/dts-v1/; // 指定设备树语法版本,目前都是v1 / { model = "FriendlyElec NanoPi R4S"; // 板子型号 compatible = "friendlyelec,nanopi-r4s", "rockchip,rk3399"; // 兼容性列表 #address-cells = <2>; // 用于子节点reg属性的地址字段长度(单位:cell) #size-cells = <2>; // 用于子节点reg属性的大小字段长度 ... };model: 一个字符串,描述这块板子的名字。在/proc/device-tree里能看到它。compatible: 这是整个设备树中最重要的属性之一。它是一个字符串列表,定义了此硬件与哪个(或哪些)内核驱动程序兼容。内核驱动会声明自己兼容的字符串(如"rockchip,rk3399"),启动时,内核会遍历设备树节点,将节点的compatible属性与所有驱动的兼容字符串进行匹配,匹配成功则绑定驱动。- 匹配规则是从前往后,优先匹配最具体的。比如上例,驱动会优先匹配
"friendlyelec,nanopi-r4s",如果没有,再尝试匹配更通用的"rockchip,rk3399"。这允许你为特定板卡提供优化驱动,同时保持对通用芯片驱动的向后兼容。
- 匹配规则是从前往后,优先匹配最具体的。比如上例,驱动会优先匹配
#address-cells和#size-cells: 这两个属性定义了在当前节点视角下,如何解析子节点的reg属性(寄存器区域描述)。<2>表示用两个32位的数(即一个64位地址)来表示地址和大小。这是根节点的“寻址规则”,会继承给子节点。
2.2 描述设备的核心:节点、地址与中断
硬件设备通常表现为一个子节点。我们以一颗挂在I2C总线上的陀螺仪芯片MPU6050为例。
// 首先,描述I2C控制器这个“总线” i2c1: i2c@ff110000 { compatible = "rockchip,rk3399-i2c"; reg = <0x0 0xff110000 0x0 0x1000>; interrupts = <GIC_SPI 59 IRQ_TYPE_LEVEL_HIGH 0>; clocks = <&cru SCLK_I2C1>; #address-cells = <1>; // I2C总线上的设备地址通常用1个cell表示(7位或10位地址) #size-cells = <0>; // I2C设备没有地址空间大小概念,所以为0 status = "okay"; // 然后,描述挂在I2C总线上的设备“MPU6050” mpu6050@68 { compatible = "invensense,mpu6050"; reg = <0x68>; // I2C设备地址是0x68 interrupt-parent = <&gpio1>; // 中断所属的控制器 interrupts = <RK_PC6 IRQ_TYPE_EDGE_FALLING>; // 具体的中断引脚和触发方式 }; };我们来拆解这个结构:
I2C控制器节点 (
i2c1: i2c@ff110000):i2c1是节点的标签(label),方便在其他地方通过&i2c1来引用这个节点。i2c@ff110000是节点名,@后面通常是该设备的主地址(这里是寄存器基地址0xff110000)。reg = <0x0 0xff110000 0x0 0x1000>: 描述该设备在CPU内存空间中的物理地址和占用大小。根据根节点的#address-cells=<2>和#size-cells=<2>,这里前两个数字0x0 0xff110000组成64位起始地址,后两个数字0x0 0x1000表示大小为4KB。interrupts: 描述该设备的中断号。<GIC_SPI 59 IRQ_TYPE_LEVEL_HIGH 0>是Linux内核中断描述符的常见格式,表示连接到GIC中断控制器的SPI中断59号,高电平触发。clocks: 引用时钟源。&cru指向另一个名为cru的时钟控制器节点,SCLK_I2C1是具体的时钟ID。#address-cells和#size-cells: 这里为子节点(即I2C从设备)定义了新的寻址规则。I2C地址通常只需1个cell(如0x68),且无大小概念,所以#size-cells设为0。status = "okay": 表示该设备启用。如果设为"disabled",内核将不会初始化该设备。
MPU6050设备节点 (
mpu6050@68):compatible = "invensense,mpu6050": 内核中MPU6050的驱动会声明匹配这个字符串。reg = <0x68>: 在父节点(i2c1)定义的寻址规则下,I2C从设备的地址就是0x68这一个cell。interrupt-parent和interrupts: 描述了该设备的中断连接关系。interrupt-parent指向GPIO控制器节点gpio1,interrupts指定使用该控制器的RK_PC6这个GPIO引脚(通常会被定义为某个整数),下降沿触发。这告诉内核,当这个GPIO引脚出现下降沿时,应该触发MPU6050的中断服务程序。
2.3 代码重用与模块化:#include与.dtsi文件
如果每块板子的DTS都从头写一遍,那和硬编码区别也不大了。设备树支持类似C语言的#include预处理指令,以及.dtsi(Device Tree Source Include)文件来实现硬件描述的模块化和分层。
通常,芯片原厂(如Rockchip)会提供一个基础的.dtsi文件(如rk3568.dtsi),里面定义了该芯片所有内部外设的通用信息:CPU架构、内存映射、内部时钟、中断控制器、内置的I2C/SPI/UART控制器等。这个文件描述的是“芯片能提供什么”。
// rk3568.dtsi (片段) / { cpus { cpu0: cpu@0 { compatible = "arm,cortex-a55"; device_type = "cpu"; reg = <0x0 0x0>; }; // ... 其他CPU核心 }; soc { i2c1: i2c@fe5a0000 { compatible = "rockchip,rk3568-i2c", "rockchip,rk3399-i2c"; reg = <0x0 0xfe5a0000 0x0 0x1000>; interrupts = <GIC_SPI 47 IRQ_TYPE_LEVEL_HIGH>; clocks = <&cru CLK_I2C1>; #address-cells = <1>; #size-cells = <0>; }; // ... 其他SOC内部外设 }; };然后,板卡厂商(或开发者)会编写具体的板级DTS文件(如nanopi-r5s.dts),通过#include引用芯片的.dtsi,并在此基础上进行覆盖和补充,描述“这块板子上实际用了哪些资源,以及怎么连接的”。
// nanopi-r5s.dts /dts-v1/; #include "rk3568.dtsi" // 包含芯片通用定义 #include <dt-bindings/gpio/gpio.h> #include <dt-bindings/pinctrl/rockchip.h> / { model = "FriendlyElec NanoPi R5S"; compatible = "friendlyelec,nanopi-r5s", "rockchip,rk3568"; // 1. 启用/禁用芯片中已定义的设备 &i2c1 { status = "okay"; // 在芯片dtsi中可能默认是disabled,这里启用它 }; // 2. 添加芯片dtsi中没有的外部设备 // 假设板子上有一个通过GPIO模拟的LED gpio-leds { compatible = "gpio-leds"; pwr_led { label = "nanopi-r5s:green:pwr"; gpios = <&gpio0 RK_PC5 GPIO_ACTIVE_HIGH>; default-state = "on"; }; }; // 3. 覆盖芯片dtsi中的某些属性(较少用,需谨慎) // 例如,修改某个引脚的默认复用功能 &uart2 { pinctrl-names = "default"; pinctrl-0 = <&uart2m0_xfer>; // 使用另一组引脚作为UART2 }; };这种分层结构极大地提高了代码的复用性和可维护性。当你需要为同一芯片的不同板卡适配时,大部分工作就是编写或修改这个顶层的板级DTS文件。
3. 从DTS到内核:编译、反编译与调试实战
写好的DTS文本文件,内核是无法直接理解的。它需要被编译成一种紧凑的二进制格式,即DTB(Device Tree Blob)。这个编译工具就是DTC(Device Tree Compiler)。
3.1 编译流程与常见错误解析
在Linux内核源码树中,DTC工具位于scripts/dtc/目录下。通常我们不会直接调用它,而是通过内核的Makefile来编译。
# 在内核源码根目录下,为特定的板子编译内核和DTB make nanopi-r5s_defconfig # 加载板级默认配置 make dtbs # 专门编译设备树 # 或者直接编译整个内核镜像(包含DTB) make -j8 Image dtbs编译过程可能遇到的错误,大多与DTS语法有关:
Error: scripts/dtc/dtc: No such file or directory: 这是最常见的问题之一。意味着DTC工具没有编译。解决方法通常是先编译一次DTC工具:make scripts/dtc/dtc或者更简单地,先执行一次
make dtbs或make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs(指定架构和交叉编译器),内核构建系统会自动先编译所需的工具。语法错误: 如缺少分号、括号不匹配、节点名重复等。DTC会给出具体的行号和错误信息,例如
FATAL ERROR: Unable to parse input tree。这类错误需要仔细检查DTS文件。引用错误: 使用了未定义的标签(如
&some_label),或者引用的属性路径不存在。错误信息类似Reference to non-existent node or label "some_label"。
3.2 反编译与验证:窥探DTB的奥秘
DTB是二进制文件,但我们有办法把它“变回”可读的DTS格式,这对于调试和学习至关重要。这就是dtc的反编译功能。
# 反编译DTB为DTS dtc -I dtb -O dts -o my_board.dts.dump my_board.dtb # 反编译并反汇编(显示更底层的结构) dtc -I dtb -O dts -s my_board.dtb反编译有什么用?
- 验证最终配置:你写的DTS,经过内核处理(可能合并了多个
.dtsi,应用了overlay)后,生成的最终设备树是什么样子?反编译一看便知。特别是当你修改了属性,但驱动行为不符合预期时,首先应该反编译DTB,确认你的修改是否真的生效了。 - 学习他人配置:拿到一个现成的DTB文件(比如官方SDK里的),反编译它是理解其硬件配置最快的方式。
- 调试启动问题:内核在启动早期就会解析DTB。如果DTB有问题(比如地址溢出、格式错误),可能导致内核无法启动。结合
dump dtb的内核启动参数,可以获取内核实际使用的DTB进行反编译分析。
3.3 运行时调试:在系统中查看设备树
Linux内核启动后,会将设备树以文件系统的形式挂载到/proc/device-tree。这是一个虚拟目录,完整反映了设备树的层次结构。
# 查看根节点的兼容性 cat /proc/device-tree/compatible # 输出:friendlyelec,nanopi-r5srockchip,rk3568 # 查看模型 cat /proc/device-tree/model # 输出:FriendlyElec NanoPi R5S # 以树状结构查看所有节点(需要dtc工具在系统中) apt-get install device-tree-compiler dtc -I fs -O dts /proc/device-tree | less此外,/sys/firmware/devicetree是/proc/device-tree的符号链接。你还可以通过of_*系列的内核API在驱动中访问设备树信息,这是驱动开发者必备的技能。
4. 进阶:设备树绑定(Bindings)与驱动匹配的深层逻辑
理解了DTS语法,我们还需要知道内核驱动是如何“读懂”这些描述的。这就涉及到设备树绑定(Device Tree Bindings)文档。
4.1 绑定文档:设备与驱动之间的“契约”
绑定文档(通常位于内核源码的Documentation/devicetree/bindings/目录下)是一个文本文件(.yaml或.txt格式),它定义了一个设备节点必须包含哪些属性、可以包含哪些属性、这些属性的数据类型和含义是什么。
例如,Documentation/devicetree/bindings/i2c/i2c-rockchip.yaml描述了Rockchip I2C控制器节点的规范。驱动开发者编写驱动时,需要遵循这个规范来解析设备树节点;而硬件描述者(写DTS的人)也需要参照这个规范来编写正确的节点。
查看一个简单的GPIO LED绑定:
# Documentation/devicetree/bindings/leds/leds-gpio.yaml compatible: oneOf: - items: - const: gpio-leds '#address-cells': const: 1 '#size-cells': const: 0 patternProperties: "^led-[0-9a-f]$": type: object properties: gpios: maxItems: 1 label: description: | The label for this LED. If omitted, the label is taken from the node name (excluding the unit address). ...它规定了gpio-leds兼容字符串的节点下,子节点的命名模式(led-*),以及每个子节点应该包含gpios等属性。
4.2 驱动匹配过程揭秘
内核启动过程中,of_platform模块会扫描设备树,为每个具有compatible属性的节点创建一个platform_device。驱动的匹配过程如下:
- 节点发现:内核解析DTB,遇到一个节点,例如:
my_device { compatible = "vendor,awesome-device"; reg = <0x12345678 0x1000>; }; - 创建设备:内核为其创建一个
platform_device结构体,并将compatible、reg等属性填入其中。 - 驱动注册:一个内核驱动模块通过
module_platform_driver宏注册自己,并声明自己的compatible表:static const struct of_device_id awesome_dt_ids[] = { { .compatible = "vendor,awesome-device" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, awesome_dt_ids); - 总线匹配:内核的核心总线逻辑(如
platform_bus)会遍历所有注册的驱动,将设备的compatible与驱动的of_device_id表进行比对。 - 驱动绑定:一旦找到匹配的
compatible字符串,内核就会调用该驱动的probe函数,并将对应的platform_device(包含从设备树解析出的所有资源)传递给它。在probe函数里,驱动通过of_*API(如of_get_property,of_property_read_u32)读取具体的属性值,完成设备的初始化。
4.3 常见问题排查:为什么我的驱动没被加载?
这是调试设备树时最常遇到的问题。排查思路如下:
- 检查DTB是否包含你的节点:使用
dtc反编译最终使用的DTB文件,确认你添加或修改的节点确实存在,且属性正确。 - 检查
compatible属性:确保节点中的compatible字符串与驱动代码中声明的完全一致,包括大小写和标点。一个空格或逗号的差异都会导致匹配失败。 - 检查
status属性:确认节点或其父节点的status是"okay",而不是"disabled"。 - 检查驱动是否编译进内核:使用
lsmod查看驱动模块是否加载,或者检查内核配置(/proc/config.gz或内核.config文件)中对应的驱动(CONFIG_XXX)是否设置为y或m。 - 查看内核日志:使用
dmesg | grep -i awesome(替换为你的设备关键词)查看是否有相关日志。内核在匹配成功或失败时,通常会有信息输出。特别关注of_platform相关的日志。 - 使用
of_find_node_by_path调试:可以在驱动初始化代码中临时添加调试语句,直接查找节点,看是否能找到。struct device_node *np = of_find_node_by_path("/path/to/your/device"); if (np) { pr_info("Node found!\n"); of_node_put(np); } else { pr_err("Node NOT found!\n"); }
5. 复杂场景与最佳实践:PHY配置、引脚控制与Overlay
5.1 复杂外设配置示例:以太网PHY
以太网、USB等复杂外设的配置,是设备树中最能体现其价值的地方。以RK3588的以太网PHY配置为例,它不仅仅是定义一个节点,还涉及时钟、复位、引脚复用、MDIO总线等多个方面。
&gmac1 { /* 启用GMAC1控制器 */ status = "okay"; /* 物理接口配置:RGMII */ phy-mode = "rgmii"; clock_in_out = "output"; /* MAC提供时钟给PHY */ /* 指定PHY设备:通过MDIO总线,地址为0x1 */ phy-handle = <&rgmii_phy1>; phy-supply = <&vcc3v3_sys>; /* PHY的电源 */ /* 引脚控制:一组引脚用于RGMII数据线,另一组用于MDIO管理接口 */ pinctrl-names = "default"; pinctrl-0 = <&gmac1_miim &gmac1_tx_bus2 &gmac1_rx_bus2 &gmac1_rgmii_clk &gmac1_rgmii_bus>; /* 复位GPIO配置 */ snps,reset-gpio = <&gpio3 RK_PC1 GPIO_ACTIVE_LOW>; snps,reset-active-low; snps,reset-delays-us = <0 20000 100000>; /* 指定MDIO总线 */ mdio { compatible = "snps,dwmac-mdio"; #address-cells = <1>; #size-cells = <0>; /* PHY设备节点 */ rgmii_phy1: phy@1 { compatible = "ethernet-phy-ieee802.3-c22"; reg = <0x1>; // MDIO地址 /* PHY芯片特定的配置,可能通过设备树属性或内核驱动固定配置 */ // 例如,某些PHY需要配置LED模式 // led-0-mode = /bits/ 8 <0x0f>; // 示例属性,具体看PHY数据手册和驱动支持 }; }; };这个例子展示了设备树如何将硬件连接关系(PHY挂在哪个MDIO总线、地址多少)、硬件配置(接口模式、时钟方向)、系统资源(复位GPIO、电源)和底层驱动参数(复位时序)清晰地表达出来。驱动(这里是dwmac-rockchip)在probe时,会解析所有这些属性,并依此正确初始化硬件。
5.2 引脚控制(Pinctrl)的妙用
pinctrl-0 = <&gmac1_miim ...>这行引用了引脚控制配置。这些配置通常在另一个文件(如rk3588-pinctrl.dtsi)中定义,由芯片原厂提供。它定义了在gmac1功能下,具体哪些物理引脚被复用为MAC的TX、RX、CLK等信号线。
// rk3588-pinctrl.dtsi 片段 gmac1 { /omit-if-no-ref/ gmac1_miim: gmac1-miim { rockchip,pins = /* MDC, MDIO */ <3 RK_PC4 2 &pcfg_pull_none>, <3 RK_PC5 2 &pcfg_pull_none>; }; gmac1_rgmii_clk: gmac1-rgmii-clk { rockchip,pins = /* RGMII CLK */ <3 RK_PB7 2 &pcfg_pull_none>; }; // ... 其他引脚组定义 };这种将引脚复用配置与设备功能配置分离的方式非常清晰。当你要修改一个设备的引脚时,通常只需要在板级DTS中引用不同的pinctrl节点即可,无需改动驱动代码。
5.3 设备树覆盖(Overlay)与动态配置
设备树覆盖(Device Tree Overlay)是设备树一个非常强大的特性,它允许在系统运行时动态地修改设备树,而无需重新编译内核或重启。这在嵌入式Linux中常用于支持插件式硬件模块,比如一个通过SPI或I2C接口连接的扩展板。
其原理是将一个描述增量变化的DTB片段(Overlay),在运行时应用到基础DTB上。内核的配置选项CONFIG_OF_OVERLAY必须打开。
虽然Overlay非常有用,但在生产环境中需要谨慎使用,因为它增加了运行时的不确定性。更常见的做法是,将不同硬件变体的配置编译成不同的静态DTB,在启动时由Bootloader根据硬件ID选择加载。
5.4 编写与维护DTS的最佳实践
- 充分参考绑定文档和已有示例:在添加一个新设备节点前,务必查阅内核源码中的绑定文档(
.yaml或.txt),并参考类似设备的现有DTS代码。这是避免低级错误的最快方法。 - 善用标签(Label):为需要引用的节点(如时钟、GPIO、pinctrl组)定义清晰的标签(如
&i2c1),这能让你的DTS文件更易读、更易维护。 - 保持层次清晰:按照“总线/控制器 -> 设备”的层次组织节点。将相关的设备放在其父总线节点下。
- 注释是必要的:对于不直观的配置(如某个魔数寄存器值、特定的工作模式选择),添加注释说明其来源(数据手册章节)和原因。
- 版本控制与差异化:将芯片级的
.dtsi和板级的.dts文件分开管理。当为同一芯片设计多款产品时,可以创建多个板级.dts文件,它们共同引用同一个芯片.dtsi,最大化复用代码。 - 编译验证:任何修改后,都要执行
make dtbs来检查语法错误。养成用dtc反编译验证最终DTB内容的习惯。 - 回归测试:修改设备树后,要进行充分的硬件功能测试。一个错误的时钟或中断配置可能导致系统不稳定或外设无法工作。
设备树是连接硬件描述与软件驱动的关键桥梁。掌握DTS的编写,意味着你能够精准地告诉内核:“硬件长这样,请按这个方式来驱动它。”这个过程需要细心、耐心和对硬件手册的深入理解。当你成功配置好一个复杂外设并看到它正常工作时,那种成就感,正是嵌入式开发的乐趣所在。从看懂一份DTS开始,到能为自己设计的板子编写一份正确的DTS,这条路没有捷径,唯有多看、多写、多调试。