1. 项目概述:为什么我们需要设备树?
搞Linux驱动开发,特别是嵌入式Linux,如果你还在用老一套的arch/arm/mach-xxx下面写满platform_device的板级文件,那真的有点“上古时代”的味道了。我刚开始接触驱动那会儿,一个内核源码要适配公司好几款不同配置但硬件相似的板子,每次都要小心翼翼地复制、修改那一大堆C文件,生怕改错一个GPIO号就导致整个系统启动不了。那种痛苦,经历过的人都懂。
后来,设备树(Device Tree)这东西的出现,简直是一场解放生产力的革命。简单来说,它就是把原先硬编码在内核源码里的硬件描述信息,抽离出来,变成了一个可读的、结构化的文本文件(.dts或.dtsi)。内核在启动时,由Bootloader(如U-Boot)将这个文件传递给内核,内核再根据这个“地图”来动态地创建和注册各种设备。这样一来,一个内核镜像,搭配不同的设备树文件(.dtb),就能跑在不同的硬件平台上,实现了内核与硬件描述的分离。这对于像瑞芯微(Rockchip)、全志(Allwinner)这类芯片原厂来说尤其重要,他们发布一个BSP(Board Support Package),里面一个通用内核,配上几十个不同客户板的设备树,维护成本大大降低。
所以,当你看到rk3568-evb.dts或imx6ull-14x14-evk.dts这样的文件时,它描述的就是那块特定开发板上的CPU、内存、外设(如I2C、SPI、GPIO控制器)以及这些外设上挂了哪些具体设备(如触摸屏、以太网PHY、音频Codec)。搞懂设备树,你就能真正理解嵌入式Linux系统是如何“认识”硬件的,也是你进行驱动开发、定制化移植的必备技能。无论你是想为RK3568添加一个自定义的传感器,还是想调试IMX6ULL的显示输出(VOP),都得从修改设备树开始。
2. 设备树核心语法与结构拆解
设备树源文件(.dts)的语法有点像一种简化的、层次化的数据结构描述语言。它并不复杂,但必须理解其核心概念和规则。
2.1 节点(Node)与属性(Property):一切的基石
设备树可以看作一棵树,树上的每个“枝杈”就是一个节点。节点用来描述一个总线、一个设备,或者一个总线控制器。每个节点由节点名和若干属性组成。
一个最简单的节点定义如下:
node-name@unit-address { property1 = value1; property2 = value2; ... };node-name: 节点名,通常表示设备类型,如i2c、spi0、gpio。@unit-address: 单元地址。用于在同一个父节点下区分多个同类型节点。通常是该设备在总线上的地址(如I2C的0x50),或寄存器的首地址。如果不需要区分,可以省略@unit-address部分。property: 属性。以键值对(key = value;)的形式存在,是描述节点具体信息的核心。值可以是多种类型。
属性值的常见类型:
- 字符串(String):
compatible = “fsl,imx6ull-i2c”, “fsl,imx21-i2c”; - 32位无符号整数(u32):
reg = <0x020a0000 0x4000>; - 字符串列表(String list):
pinctrl-names = “default”, “sleep”; - 二进制数据(Byte string):
local-mac-address = [00 11 22 33 44 55]; - 节点引用(Phandle):
interrupt-parent = <&gpio1>;(&gpio1就是引用了一个标签为gpio1的节点) - 混合列表:
reg = <0x31020000 0x10000>, <0x31030000 0x10000>;表示两组地址和大小。
2.2 标准属性解读:内核如何识别设备
内核解析设备树时,会依赖一些标准属性来决定如何初始化设备。这几个属性至关重要:
compatible:这是最重要的属性,没有之一。它是一个字符串列表,定义了设备与哪个(些)驱动程序兼容。内核启动时,会遍历所有注册的驱动,将驱动的.of_match_table(或.driver里的compatible字段)与设备节点的compatible属性进行匹配。匹配成功,这个驱动才会被用来初始化这个设备。通常格式是“制造商,型号”,越具体的放前面。例如,一个I2C温度传感器的节点可能这样写:compatible = “ti,tmp102”, “i2c-sensor”;内核会优先寻找能匹配
”ti,tmp102”的驱动,如果没找到,再尝试匹配更通用的”i2c-sensor”。reg:描述设备占用的地址空间资源。对于内存映射设备(MMIO),它的值通常是<起始地址 长度>。这个地址是相对于其父节点(通常是总线控制器)定义的地址空间。例如,对于一个挂在soc总线上的UART设备:uart1: serial@02020000 { compatible = “fsl,imx6ul-uart”, “fsl,imx6q-uart”; reg = <0x02020000 0x4000>; ... };这表示UART1的寄存器物理基地址是
0x02020000,寄存器区域长度是0x4000(16KB)。#address-cells和#size-cells:这两个属性用在父节点上,用于规定其子节点reg属性的“编址格式”。它们定义了reg中,用多少个32位数(cell)来表示起始地址(#address-cells)和地址空间长度(#size-cells)。这是一个容易混淆但必须理解的点。- 在根节点
/下,可能定义为#address-cells = <2>; #size-cells = <2>;,因为系统内存空间很大,需要64位地址。 - 在SOC内部总线节点
soc下,可能定义为#address-cells = <1>; #size-cells = <1>;,因为片内外设通常用32位地址就够了。 - 在I2C总线控制器节点下,通常定义为
#address-cells = <1>; #size-cells = <0>;,因为I2C子设备(如EEPROM)只有7位或10位设备地址,没有“长度”概念。
- 在根节点
status:设备状态。常用值有:“okay”:设备可用,内核会尝试初始化它。“disabled”:设备存在但暂时不可用,内核会跳过它。“fail”/“failed”:设备存在但有严重问题,通常也会被跳过。在调试时,临时将某个设备的status改为“disabled”,是判断它是否引起问题的常用手段。
model与device_type:model通常描述整个板子的型号(如“Freescale i.MX6 UltraLite 14x14 EVK Board”),而device_type在老式设备树中用于描述节点类型(如“memory”),现在很多情况下已被compatible属性取代。
2.3 设备树源码的组织:.dts、.dtsi 与 include
一个复杂的硬件平台,其设备树源码通常不是一个大而全的.dts文件,而是采用模块化设计,通过#include预处理指令进行组织,这大大提高了可维护性和复用性。
.dtsi(Device Tree Source Include):可以理解为设备树的“头文件”或“通用模板”。它描述的是芯片(SoC)级别的硬件共性。例如,imx6ull.dtsi文件里定义了i.MX6ULL这颗芯片的所有内部模块:CPU架构、中断控制器(GIC)、各种时钟控制器、Pinctrl、IOMUXC,以及所有片上外设(如UART、I2C、SPI控制器)的共性部分。这些外设节点在.dtsi里可能只定义了compatible、reg、interrupts等核心属性,但状态是status = “disabled”;。.dts(Device Tree Source):这是针对具体板卡的最终描述文件。它通过#include “xxx.dtsi”来包含芯片的通用定义,然后在此基础上进行覆盖和补充。这是设备树最精妙的地方之一:后出现的属性会覆盖先出现的同名属性。// imx6ull-myboard.dts #include “imx6ull.dtsi” &i2c1 { // 通过标签引用 imx6ull.dtsi 中定义的 i2c1 节点 clock-frequency = <100000>; // 覆盖或添加属性:设置I2C1总线频率为100kHz status = “okay”; // 覆盖状态,使能I2C1控制器 pmic@8 { compatible = “ti,tps65218”; reg = <0x8>; // 这是一个在板级.dts中新增的I2C子设备 }; }; &usdhc1 { // 引用SD卡控制器节点 status = “okay”; // 使能SD卡 pinctrl-names = “default”, “state_100mhz”, “state_200mhz”; pinctrl-0 = <&pinctrl_usdhc1>; // 引用在板级文件中定义的引脚复用配置 bus-width = <4>; no-1-8-v; };这种“引用-覆盖”机制,使得芯片原厂维护一个通用的
.dtsi,下游板卡厂商或开发者只需在.dts中轻量级地修改和添加自己板子的特定配置(如使能哪些外设、外设上挂了什么设备、引脚复用如何配置等),清晰又高效。
3. 设备树与驱动开发的深度交互
理解了设备树的静态结构,我们来看看内核驱动是如何与它动态交互的。这是驱动开发者最需要掌握的部分。
3.1 驱动中如何获取设备树信息
在Linux的设备-总线-驱动模型中,支持设备树的驱动属于“Platform Driver”框架的一种扩展。驱动通过一组以of_(Open Firmware)为前缀的API来读取设备树节点中的信息。
一个典型的带有设备树支持的平台驱动结构如下:
#include <linux/of.h> #include <linux/of_device.h> static const struct of_device_id my_driver_of_match[] = { { .compatible = “vendor,my-device-1” }, { .compatible = “vendor,my-device-2” }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_driver_of_match); static int my_driver_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct device_node *np = dev->of_node; // 关键!获取本设备对应的设备树节点指针 int irq_num, ret; u32 reg_val, clock_freq; struct resource *res; // 1. 读取reg属性,获取内存资源 res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(dev, “Failed to get MEM resource\n”); return -ENODEV; } // 也可以使用 of_address_to_resource 等OF API // 2. 读取中断属性 irq_num = platform_get_irq(pdev, 0); // 获取第一个中断号 if (irq_num < 0) { return irq_num; // 可能返回 -ENXIO } // 3. 直接使用OF API读取属性 // 读取一个u32属性 ret = of_property_read_u32(np, “clock-frequency”, &clock_freq); if (ret) { // 读取失败,使用默认值 clock_freq = 100000; // 默认100kHz dev_info(dev, “Using default clock frequency: %u\n”, clock_freq); } else { dev_info(dev, “Clock frequency from DT: %u\n”, clock_freq); } // 4. 读取字符串属性 const char *label; of_property_read_string(np, “label”, &label); // 5. 读取布尔属性(属性存在即为真) if (of_property_read_bool(np, “big-endian”)) { // 配置设备为大端模式 } // ... 其他驱动初始化操作 return 0; } static struct platform_driver my_driver = { .driver = { .name = “my-device”, .of_match_table = of_match_ptr(my_driver_of_match), // 匹配表 }, .probe = my_driver_probe, .remove = my_driver_remove, }; module_platform_driver(my_driver);关键点:在probe函数中,通过pdev->dev.of_node即可获得与该驱动实例对应的设备树节点指针(struct device_node *),之后所有的硬件描述信息都从这里读取。这彻底取代了旧驱动中硬编码的platform_data。
3.2 引脚控制(Pinctrl)与设备树的结合
现代SoC的引脚功能高度复用(Multiplex),一个物理引脚可能既可以作为GPIO,也可以作为UART的TX,还可以是I2C的SCL。引脚控制子系统(Pinctrl)就是用来管理这个的,而它的配置几乎完全通过设备树完成。
在设备树中,Pinctrl的配置分为两步:
- 在Pinctrl控制器节点下定义引脚复用组(Pin Group)和配置(Configuration)。这通常在
.dtsi中由芯片原厂定义好。// 在 imx6ull.dtsi 中 iomuxc: iomuxc@020e0000 { compatible = “fsl,imx6ul-iomuxc”; reg = <0x020e0000 0x4000>; // 这里定义了许多 pinctrl_xxx 组 }; - 在具体设备节点中,通过
pinctrl-*属性引用这些配置组。这通常在板级.dts中完成。
当UART1驱动被// 在 imx6ull-myboard.dts 中 &iomuxc { // 为UART1设备定义引脚复用:TX复用为GPIO1_IO08,RX复用为GPIO1_IO09 pinctrl_uart1: uart1grp { fsl,pins = < MX6UL_PAD_UART1_TX_DATA__UART1_DCE_TX 0x1b0b1 MX6UL_PAD_UART1_RX_DATA__UART1_DCE_RX 0x1b0b1 >; }; }; &uart1 { pinctrl-names = “default”; // 状态名 pinctrl-0 = <&pinctrl_uart1>; // 引用上面定义的组 status = “okay”; };probe时,Pinctrl子系统会自动根据pinctrl-0找到pinctrl_uart1组,并将对应的物理引脚配置为UART功能,同时设置电气属性(如上拉、驱动强度等,由0x1b0b1这样的魔数编码)。驱动开发者无需在代码里操作GPIO寄存器来切换功能,一切由设备树和内核子系统自动完成。
3.3 实例解析:为RK3568添加一个I2C温度传感器
假设我们手头有一块RK3568的开发板,原理图上显示I2C5总线(通常对应某个40pin扩展接口)上连接了一个TMP102温度传感器,地址是0x48。我们需要在系统里启用它。
步骤一:确认硬件连接与I2C控制器状态首先,查看原厂提供的rk3568-evb.dtsi或类似文件,找到I2C5控制器的定义。
// 在 rk3568.dtsi 中可能找到 i2c5: i2c@fe5e0000 { compatible = “rockchip,rk3568-i2c”, “rockchip,rk3399-i2c”; reg = <0x0 0xfe5e0000 0x0 0x1000>; interrupts = <GIC_SPI 84 IRQ_TYPE_LEVEL_HIGH>; clocks = <&cru CLK_I2C5>, <&cru PCLK_I2C5>; clock-names = “i2c”, “pclk”; pinctrl-names = “default”; pinctrl-0 = <&i2c5m1_xfer>; // 注意这个pinctrl引用 #address-cells = <1>; #size-cells = <0>; status = “disabled”; // 默认是关闭的 };注意,它的状态是disabled,且使用了i2c5m1_xfer这个pinctrl组(意味着引脚可能复用在某一组特定的GPIO上)。
步骤二:在板级.dts中使能I2C5并添加设备我们在自己的板级文件rk3568-myboard.dts中操作。
// rk3568-myboard.dts #include “rk3568-evb.dtsi” // 包含基础配置 // 覆盖并扩展I2C5节点 &i2c5 { status = “okay”; // 首先,使能控制器 clock-frequency = <400000>; // 可选:设置I2C总线频率为400kHz(Fast Mode) // 注意:pinctrl-0已经在.dtsi中定义,如果引脚复用正确,通常无需修改。 // 但如果我们的传感器接在另一组GPIO上,就需要在这里覆盖pinctrl-0。 // pinctrl-0 = <&i2c5m0_xfer>; // 例如,切换到另一组引脚 // 添加TMP102温度传感器子节点 tmp102@48 { // 节点名:设备类型@I2C地址 compatible = “ti,tmp102”; reg = <0x48>; // I2C从设备地址,7位地址,所以是0x48 #address-cells = <1>; #size-cells = <0>; // 可以添加更多属性,例如中断引脚(如果使用中断模式) // interrupts-extended = <&gpio0 RK_PA4 IRQ_TYPE_LEVEL_LOW>; // 或者指定一个自定义名称 label = “board_temp_sensor”; }; };步骤三:编译与验证
- 使用设备树编译器(DTC)将
.dts编译为.dtb:dtc -I dts -O dtb -o rk3568-myboard.dtb rk3568-myboard.dts。实际开发中,这通常由内核的构建系统(make dtbs)完成。 - 将新的
.dtb文件加载到开发板(替换Bootloader加载的那个)。 - 启动系统后,检查:
ls /sys/bus/i2c/devices/下应该能看到5-0048这样的目录(表示I2C总线5,地址0x48的设备)。dmesg | grep tmp102或dmesg | grep i2c5查看内核日志,确认驱动是否成功匹配并probe。- 如果驱动正常,在
/sys/class/hwmon/hwmonX/下应该能找到温度读数文件temp1_input。
实操心得:在修改设备树时,最常遇到的坑就是引脚复用冲突。比如你使能了I2C5,但它的两个引脚(SDA和SCL)可能已经被另一个功能(比如UART或普通的GPIO)占用了。这会导致I2C控制器无法正常工作,表现为
probe失败或读写数据出错。排查时,一定要仔细核对芯片的引脚复用表(Datasheet),并检查设备树中相关节点的pinctrl-0属性是否正确,确保没有其他节点复用了同一组引脚。可以使用cat /sys/kernel/debug/pinctrl/pinctrl-handles(如果内核配置了CONFIG_DEBUG_FS)来查看当前的引脚复用状态。
4. 高级主题与调试技巧
当基础操作熟练后,你会遇到更复杂的场景,需要用到设备树的一些高级特性。
4.1 设备树覆盖(Device Tree Overlay)与动态配置
设备树覆盖是设备树机制的动态扩展,允许在系统运行时(通常是Linux用户空间)动态地修改设备树,增删设备节点。这在一些支持热插拔或FPGA动态重配置的场景中非常有用。它的本质是向内核传递一个“补丁”.dtbo文件,内核会将其应用到当前运行的设备树上。
例如,通过ConfigFS接口加载一个覆盖:
mkdir /config/device-tree/overlays/my_overlay cat my_overlay.dtbo > /config/device-tree/overlays/my_overlay/dtbo如果my_overlay.dtbo中定义了一个新的I2C设备,那么加载后,这个设备会立刻出现在系统中,相应的驱动也会被自动匹配和加载。这对于嵌入式产品中基于插件板的扩展功能非常方便。
4.2 中断、DMA与时钟描述
除了基本的reg,复杂外设还需要中断、DMA和时钟资源,设备树对此有完善的描述方式。
中断(Interrupts):
device_node { interrupts = <GIC_SPI 66 IRQ_TYPE_LEVEL_HIGH>; // 中断号,触发类型 interrupt-parent = <&gic>; // 指定中断控制器,通常由父节点继承 };GIC_SPI 66表示这是GIC中断控制器的共享外设中断(SPI)第66号。IRQ_TYPE_LEVEL_HIGH表示高电平触发。驱动中通过platform_get_irq()获取的就是这个中断号对应的虚拟中断号(virq)。DMA请求:对于有DMA功能的设备,需要描述DMA通道。
dmas = <&dma_controller 0>, <&dma_controller 1>; // 引用DMA控制器,并指定通道号 dma-names = “tx”, “rx”; // 为每个通道命名驱动中可以通过
dma_request_slave_channel()和通道名来申请DMA通道。时钟(Clocks):现代SoC有复杂的时钟树,设备需要声明其时钟来源。
clocks = <&clk IMX6UL_CLK_UART1_IPG>, <&clk IMX6UL_CLK_UART1_SERIAL>; clock-names = “ipg”, “per”;驱动中通过
devm_clk_get(dev, “ipg”)来获取并控制这些时钟。
4.3 设备树调试实战:当设备不工作时
设备树配置错误是驱动无法正常工作的常见原因。以下是一套系统的调试流程:
检查编译产物:首先确保你的
.dts修改被正确编译进了最终的.dtb文件。可以使用反编译命令验证:dtc -I dtb -O dts -o decompiled.dts your_board.dtb,然后搜索你添加或修改的节点和属性,确认它们存在且值正确。查看内核解析结果:系统启动后,内核会将设备树展开成一个位于
/proc/device-tree的虚拟文件系统。你可以直接cat或hexdump这里的文件来查看内核实际“看到”的设备树。# 查看根节点的compatible属性 cat /proc/device-tree/compatible # 查看某个节点下的所有属性 ls -la /proc/device-tree/soc/i2c@021a0000/ # 查看一个属性值(可能是二进制) hexdump -C /proc/device-tree/soc/i2c@021a0000/reg使用
of_API调试:在驱动代码的probe函数开头,添加详细的dev_info()或dev_dbg()打印,输出从设备树读取到的所有关键属性值(如reg、irq、clocks等),与你的预期进行比对。关注内核启动日志:使用
dmesg | grep -E “(OF|DT|i2c|uart)”过滤内核日志。重点关注:OF: [device tree] -> [platform device]相关的转换日志。- 你的设备节点是否成功转换成了
platform_device。 - 你的驱动
compatible字符串是否与设备节点匹配成功。 - 驱动
probe函数是否被调用,以及调用后报出的具体错误(如资源申请失败、寄存器映射失败等)。
排查引脚复用:如前所述,使用
debugfs查看引脚复用状态,或直接在驱动初始化早期,通过寄存器查看工具(如devmem2)检查相关GPIO/IOMUX寄存器的值,确认引脚功能是否配置正确。利用设备树绑定(Bindings)文档:内核源码目录
Documentation/devicetree/bindings/下有大量设备树绑定的说明文档(.yaml或.txt格式)。当你为一个新设备编写节点时,务必查阅对应的绑定文档,了解必须的(required)、**可选的(optional)**属性以及它们的格式和含义。这是避免配置错误的权威指南。
5. 从设备树到实际驱动:一个完整的思维链路
最后,让我们把整个流程串起来,形成从硬件原理图到驱动工作的完整思维链路。假设你要为一个新的自定义FPGA IP核编写Linux驱动。
硬件设计阶段:确定IP核的硬件特性。它在系统总线上的物理基地址是多少?(比如
0x43C0_0000)。它产生哪个中断线?(比如连接到GIC的SPI 90)。它需要几个时钟?(比如axi_lite_clk和ip_core_clk)。它有哪些可配置的寄存器?这些信息来自硬件工程师的设计文档或原理图。设备树描述阶段:在板级
.dts文件中,为这个IP核创建一个节点。假设它挂在AXI总线上,可以放在amba或soc节点下。&amba { // 或 &soc my_fpga_ip: my_ip@43c00000 { compatible = “my-company,my-fpga-ip-1.0”; reg = <0x0 0x43c00000 0x0 0x10000>; // 64位地址,长度64KB interrupts = <GIC_SPI 90 IRQ_TYPE_LEVEL_HIGH>; clocks = <&clk_fpga_axi>, <&clk_ip_core>; clock-names = “axi_lite”, “ip_core”; // 自定义属性,例如配置模式 my-company,mode = “high-performance”; status = “okay”; }; };你需要根据芯片手册,确认
clk_fpga_axi和clk_ip_core这些时钟在设备树中是否有定义,或者需要自己添加。驱动开发阶段:
- 在驱动代码中,定义
of_device_id匹配表,包含“my-company,my-fpga-ip-1.0”。 - 在
probe函数中,使用platform_get_resource获取reg定义的IO内存区域,并用devm_ioremap_resource进行映射。 - 使用
platform_get_irq获取中断号,并注册中断处理函数。 - 使用
devm_clk_get获取时钟,并调用clk_prepare_enable使能它们。 - 使用
of_property_read_*系列函数读取自定义属性my-company,mode,并根据其值配置IP核的工作模式。 - 最后,完成字符设备、平台设备或其他类型设备的注册,向上层提供访问接口。
- 在驱动代码中,定义
集成测试阶段:将编译好的驱动模块(
.ko)和设备树二进制文件(.dtb)加载到目标板。观察内核日志,确认驱动匹配、probe成功。通过用户空间程序(如devmem、自定义测试程序)访问驱动提供的接口,验证读写寄存器、中断响应等功能是否正常。
整个过程中,设备树扮演了硬件描述清单和驱动配置媒介的角色。它将硬件信息从内核源码中解耦,使得驱动代码更加通用和清晰。作为驱动开发者,你的核心任务就是正确理解硬件,并用设备树语言准确地描述它,然后在驱动中稳健地解析和使用这些信息。这需要你对硬件手册、设备树语法和Linux驱动框架都有深入的理解。踩过几次坑之后,你会发现这套流程虽然繁琐,但条理清晰,极大地提升了嵌入式Linux系统的可配置性和可维护性。