公司动态

嵌入式Linux设备树详解:从原理到驱动开发实战

📅 2026/8/13 5:47:36
嵌入式Linux设备树详解:从原理到驱动开发实战
1. 项目概述为什么我们需要设备树搞Linux驱动开发特别是嵌入式Linux如果你还在用老一套的arch/arm/mach-xxx下面写满platform_device的板级文件那真的有点“上古时代”的味道了。我刚开始接触驱动那会儿一个内核源码要适配公司好几款不同配置但硬件相似的板子每次都要小心翼翼地复制、修改那一大堆C文件生怕改错一个GPIO号就导致整个系统启动不了。那种痛苦经历过的人都懂。后来设备树Device Tree这东西的出现简直是一场解放生产力的革命。简单来说它就是把原先硬编码在内核源码里的硬件描述信息抽离出来变成了一个可读的、结构化的文本文件.dts或.dtsi。内核在启动时由Bootloader如U-Boot将这个文件传递给内核内核再根据这个“地图”来动态地创建和注册各种设备。这样一来一个内核镜像搭配不同的设备树文件.dtb就能跑在不同的硬件平台上实现了内核与硬件描述的分离。这对于像瑞芯微Rockchip、全志Allwinner这类芯片原厂来说尤其重要他们发布一个BSPBoard 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-nameunit-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: serial02020000 { compatible “fsl,imx6ul-uart”, “fsl,imx6q-uart”; reg 0x02020000 0x4000; ... };这表示UART1的寄存器物理基地址是0x02020000寄存器区域长度是0x400016KB。#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_typemodel通常描述整个板子的型号如“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控制器 pmic8 { 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: iomuxc020e0000 { compatible “fsl,imx6ul-iomuxc”; reg 0x020e0000 0x4000; // 这里定义了许多 pinctrl_xxx 组 };在具体设备节点中通过pinctrl-*属性引用这些配置组。这通常在板级.dts中完成。// 在 imx6ull-myboard.dts 中 iomuxc { // 为UART1设备定义引脚复用TX复用为GPIO1_IO08RX复用为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”; };当UART1驱动被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: i2cfe5e0000 { 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总线频率为400kHzFast Mode // 注意pinctrl-0已经在.dtsi中定义如果引脚复用正确通常无需修改。 // 但如果我们的传感器接在另一组GPIO上就需要在这里覆盖pinctrl-0。 // pinctrl-0 i2c5m0_xfer; // 例如切换到另一组引脚 // 添加TMP102温度传感器子节点 tmp10248 { // 节点名设备类型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编译为.dtbdtc -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和时钟资源设备树对此有完善的描述方式。中断Interruptsdevice_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/i2c021a0000/ # 查看一个属性值可能是二进制 hexdump -C /proc/device-tree/soc/i2c021a0000/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_ip43c00000 { 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系统的可配置性和可维护性。