嵌入式Linux设备树详解:从硬件描述到驱动分离的实践指南

发布时间:2026/8/5 1:43:41
嵌入式Linux设备树详解:从硬件描述到驱动分离的实践指南 1. 从“硬编码”到“软描述”设备树为何而生如果你是从单片机或者早期的嵌入式Linux开发转过来的第一次接触设备树Device Tree这个概念可能会觉得有点绕。以前写驱动硬件信息都是直接写在C代码里的比如一个I2C设备的地址是0x50一个GPIO引脚是PD3这些信息都硬编码在驱动源码中。这种方式简单直接但有一个致命问题一份驱动代码只能适配一块特定的板子。当硬件稍有变动比如I2C地址变了或者换了个GPIO引脚你就得去修改内核源码重新编译内核甚至要发布不同版本的内核镜像。设备树就是为了解决这个“耦合”问题而生的。它的核心思想是将硬件描述与内核驱动代码分离。你可以把它想象成一份写给内核的“硬件配置清单”或者“地图”。内核在启动时会读取这份清单设备树二进制文件即.dtb然后根据清单上的描述去动态地创建和配置相应的设备。这样一来同一份内核镜像搭配不同的设备树文件就能跑在不同的硬件平台上。这对于芯片厂商、板卡厂商和终端开发者来说是巨大的解放。设备树文件有两种形式一种是人类可读的文本格式称为设备树源文件.dts另一种是经过编译器dtc编译后的二进制格式.dtb供内核解析。我们开发者主要打交道的是.dts文件。一个完整的设备树从结构上看像一棵倒置的树有根节点有子节点每个节点描述一个总线或设备节点内部用“属性property”来详细描述该设备的各项特性。那么内核是如何知道这份“清单”里写的reg 0x10000 0x1000对应的是哪个设备呢这就依赖于一套标准属性。这些属性名和值的格式是预先约定好的驱动代码在初始化时会通过内核提供的OFOpen FirmwareAPI来读取这些标准属性从而获取硬件信息。理解这些标准属性是读懂和编写设备树的关键。2. 设备树基础结构节点、兼容性与地址在深入属性之前我们必须先理解设备树是如何组织信息的。一切硬件描述都封装在“节点node”中。节点用花括号{}定义最基本的节点是根节点/。/ { compatible vendor,board-model; #address-cells 1; #size-cells 1; cpus { // CPU子节点... }; memory0 { device_type memory; reg 0x00000000 0x40000000; }; soc { // 片上系统总线... serial101f0000 { compatible ns16550a; reg 0x101f0000 0x1000; clock-frequency 1843200; interrupts 10; }; }; };上面是一个极度简化的示例但它包含了几个最核心的概念节点路径与命名每个节点都有一个名字比如memorysocserial。名字后面可以跟一个符号和单元地址如101f0000这有助于在树中唯一标识该节点尤其是在有多个同类设备时如i2c0i2c1。节点的“全路径”是从根节点到该节点的名字串联例如/soc/serial101f0000。compatible属性驱动的“身份证”这是最重要的属性没有之一。它建立了设备树节点与内核驱动之间的绑定关系。它的值是一个或多个字符串列表。compatible vendor,device-model, generic-driver;内核在启动扫描设备树节点时会读取每个节点的compatible属性。然后它会在已编译进内核或已加载的模块中寻找一个其of_device_id表里声明了相同字符串的驱动。匹配规则是从左到右优先匹配最具体的。例如驱动可以声明它兼容vendor,device-model也可以声明它兼容更通用的generic-driver。内核会优先选择匹配最具体字符串的驱动。vendor,device-model这种格式vendor是制造商device-model是具体型号这是一种良好的实践。寻址相关属性reg#address-cells#size-cells这是描述设备在总线或地址空间中位置的属性组理解它们需要一点耐心。#address-cells和#size-cells 这两个属性不是描述设备本身而是描述父节点的“寻址格式”。它们定义在父节点中用于解释其所有子节点的reg属性。#address-cells 定义子节点reg属性中“地址”字段占用多少个32位整数cell。#size-cells 定义子节点reg属性中“长度”字段占用多少个32位整数cell。如果长度为0则此项为0。reg属性 这是子节点中描述自身资源通常是内存映射I/O或寄存器范围的属性。它的值是一个或多个(地址, 长度)对组成的列表。其中“地址”和“长度”的单元格数量就是由其父节点的#address-cells和#size-cells决定的。让我们看一个例子假设一个SoC内部总线其地址用1个cell表示长度也用1个cell表示soc { #address-cells 1; // 子节点reg的“地址”部分占1个cell #size-cells 1; // 子节点reg的“长度”部分占1个cell compatible simple-bus; ranges; // 表示子节点地址与父节点地址空间是1:1映射 serial101f0000 { compatible ns16550a; reg 0x101f0000 0x1000; // 地址0x101f0000 长度0x1000 }; gpio10200000 { compatible vendor,gpio-controller; reg 0x10200000 0x1000; // 地址0x10200000 长度0x1000 #gpio-cells 2; }; };对于更复杂的总线比如带片选的存储器总线可能需要两个cell来表示地址例如片选号 偏移地址。此时父节点的#address-cells可能为2。ranges属性地址翻译表在上面的例子中soc节点有一个空的ranges;属性。这是一个特例表示子节点的地址空间与父节点的地址空间是1:1映射的即物理地址相同。ranges属性更通用的作用是一个地址翻译表用于将子总线地址空间映射到父总线地址空间。它的格式是一个三元组列表子总线地址 父总线地址 长度。这对于描述具有独立地址空间的桥接设备如PCIe主机控制器至关重要。3. 中断与时钟系统协同的关键信号现代SoC中中断和时钟是设备与CPU、设备与设备之间协同工作的基础。设备树也必须清晰地描述这些资源。中断控制器与interrupt-parent首先系统中必须有一个或多个中断控制器如GIC、GPIO中断控制器。它们在设备树中也是一个节点并拥有interrupt-controller属性来声明自己的身份。同时它们会定义#interrupt-cells属性说明其子节点或引用它的节点在描述一个中断时需要几个cell。一个设备的中断信号连接到哪个中断控制器是通过interrupt-parent属性来指定的。通常这个属性可以定义在设备节点自身也可以定义在其父节点上子节点会继承父节点的中断父控制器。为了清晰建议在靠近设备的地方显式指定。interrupts属性设备节点使用interrupts属性来描述自己产生的中断。这个属性的值其格式必须符合其interrupt-parent所指向的中断控制器定义的#interrupt-cells。最常见的情况是一个中断需要两个cell来描述中断号 在该中断控制器内部的硬件中断线编号。中断触发类型 是一个标志位定义中断是边沿触发还是电平触发是高有效还是低有效。常用值如1 上升沿触发IRQ_TYPE_EDGE_RISING2 下降沿触发IRQ_TYPE_EDGE_FALLING4 高电平触发IRQ_TYPE_LEVEL_HIGH8 低电平触发IRQ_TYPE_LEVEL_LOW例如一个设备连接到GIC使用SPI中断号168高电平触发device_node { compatible vendor,mydevice; interrupt-parent gic; // 使用phandle引用gic节点 interrupts 0 168 4; // 对于GIC通常是3个cell: 中断类型 SPI/PPI编号 标志 // 中断类型0表示SPI共享外设中断 };注意 这里有一个非常容易混淆的点。interrupts属性的具体含义完全取决于其父中断控制器的绑定文档。不同控制器的#interrupt-cells含义天差地别。例如对于简单的GPIO中断控制器可能只需要一个cellGPIO引脚号和一个标志位而对于复杂的GIC可能需要三个cell。务必查阅你所用的芯片或中断控制器的设备树绑定Binding文档这是唯一权威的来源。内核源码目录下的Documentation/devicetree/bindings/是宝藏。时钟与clocksclock-names属性时钟的描述方式与中断类似。系统中有时钟控制器设备节点通过clocks属性来引用它所需的时钟源。clocks属性的值是一个“phandle节点句柄 时钟标识符”的列表。device_node { compatible vendor,mydevice; clocks clkctrl 10, clkctrl 11; // 引用clkctrl节点的第10和第11个时钟输出 clock-names core, bus; // 为上面的时钟命名 };clocks clkctrl 10clkctrl是时钟控制器节点的phandle引用10是该控制器内部对此时钟输出的一个索引或ID。clock-names 这是一个可选的属性但强烈建议使用。它为clocks列表中的每一个时钟起了一个名字。这样在驱动代码中你就可以通过devm_clk_get(pdev-dev, core)来按名字获取时钟而不是依赖容易出错的顺序索引。这大大提高了代码的可读性和健壮性。驱动中获取时钟的典型代码如下struct clk *core_clk; struct clk *bus_clk; core_clk devm_clk_get(pdev-dev, core); if (IS_ERR(core_clk)) { return PTR_ERR(core_clk); } bus_clk devm_clk_get(pdev-dev, bus); // ... 后续配置和使能时钟4. 引脚控制与GPIO管脚复用的声明在高度集成的SoC中一个物理引脚往往可以复用为多种功能比如GPIO、I2C的SDA、SPI的MOSI等。引脚控制子系统pinctrl就是用来管理这种复用的。设备树需要告诉内核在初始化某个设备时应该把相关的引脚配置成什么功能。pinctrl 控制器首先SoC的引脚控制器会在设备树中声明为一个节点例如pinctrl: pinctrl1000000 { compatible vendor,pinctrl-soc; reg 0x1000000 0x1000; // 这个节点内部会定义许多子节点每个子节点描述一种引脚配置状态 };引脚配置状态节点在pinctrl节点内部会定义许多子节点每个子节点描述一组引脚的配置。通常我们会按功能或设备来组织这些节点。pinctrl { // 定义一组配置将引脚 12 和 13 复用为UART0的TX和RX并设置上拉 uart0_default: uart0-default { pins GPIO12, GPIO13; function uart0; bias-pull-up; }; // 定义另一组配置将引脚 14 和 15 复用为I2C0 i2c0_default: i2c0-default { pins GPIO14, GPIO15; function i2c0; drive-strength 8; // 驱动强度8mA }; };设备节点引用pinctrl状态然后具体的设备节点如UART、I2C通过pinctrl-*属性来引用这些配置。serial101f0000 { compatible ns16550a; reg 0x101f0000 0x1000; interrupts 10; pinctrl-names default; // 定义状态名列表 pinctrl-0 uart0_default; // 将“default”状态关联到具体的pinctrl配置节点 }; i2c10200000 { compatible vendor,i2c-controller; reg 0x10200000 0x1000; pinctrl-names default; pinctrl-0 i2c0_default; };pinctrl-names 定义了一个状态名称字符串列表如defaultsleep。驱动可以在运行时切换状态例如在系统挂起时切换到低功耗的sleep引脚配置。pinctrl-0pinctrl-1... 这些属性将pinctrl-names中的状态名与具体的pinctrl配置节点通过phandle引用绑定起来。pinctrl-0对应列表中的第一个状态名。GPIO 的特殊描述如果一个引脚被用作普通的GPIO而不是复用为特定外设功能描述方式略有不同。通常我们直接在一个GPIO控制器下描述一个“GPIO消费者”。gpio_keys { compatible gpio-keys; button { label Power Button; gpios gpio 5 GPIO_ACTIVE_LOW; // 使用gpio控制器的第5号GPIO低电平有效 linux,code KEY_POWER; }; }; leds { compatible gpio-leds; heartbeat { label sys_led; gpios gpio 12 GPIO_ACTIVE_HIGH; linux,default-trigger heartbeat; }; };gpios属性 这是标准属性用于引用一个GPIO。其格式通常是gpio_controller_phandle GPIO号 标志。标志常用宏定义如GPIO_ACTIVE_HIGH/LOW表示高/低电平有效。gpio-controller和#gpio-cells 在GPIO控制器节点中需要用gpio-controller属性声明自身并用#gpio-cells定义gpios属性需要几个cell通常是2个GPIO编号和标志位。5. 别名、选择与状态提升可读性与动态管理设备树中还有一些用于组织、引用和管理节点状态的属性它们让设备树更灵活、更易读。aliases节点给节点起“外号”根节点下可以有一个aliases节点用于给一些重要的节点路径起一个简短的别名。这主要是为了向后兼容某些通过设备路径寻址的旧有软件接口或者为了方便在U-Boot等bootloader中通过一个固定的名字来访问设备。/ { aliases { serial0 uart0; // 为节点 uart0 起别名 serial0 ethernet0 eth0; mmc0 sdhci; }; uart0: serial101f0000 { ... }; eth0: ethernet20000000 { ... }; };在驱动或脚本中可以通过/aliases/serial0这个路径来找到实际的UART设备节点。不过在现代驱动编程中直接通过compatible属性查找设备是更推荐的方式。chosen节点由系统固件传递的“运行时选择”chosen节点并不代表一个真实的硬件设备而是一个数据传递区用于在启动阶段如由bootloader向内核传递一些运行时信息。最常见的就是内核命令行参数和标准输入输出设备。/ { chosen { bootargs consolettyS0,115200 earlycon root/dev/mmcblk0p2 rootwait; stdout-path uart0; // 有些平台还会在这里传递内存的起始地址和大小尤其是当设备树本身是动态生成时 }; };bootargs 内核命令行参数。这是bootloader如U-Boot通过chosen节点传递给内核的。stdout-path 指定内核启动过程中默认的标准输出设备。这确保了内核的printk信息能输出到正确的串口。status属性控制节点“生效与否”status属性是一个字符串用于指示一个设备节点是否“可用”。这是一个非常实用的属性特别是在一个.dts文件需要适配带有不同硬件配置的板卡变种时。uart1 { status disabled; // 该UART控制器在此板卡上未使用内核将忽略它 }; i2c2 { status okay; // 默认值就是okay显式写出更清晰 }; usb0 { status okay; }; usb1 { status disabled; // 第二个USB主机控制器被禁用 };常见的status值有okay或ok 设备启用。disabled 设备禁用。内核在解析设备树时会跳过此节点对应的驱动也不会被探测。其他值如failfail-sss等用于更复杂的错误状态描述但较少使用。通过修改status属性我们可以轻松地在同一个基础设备树文件.dts上派生出针对不同硬件配置的衍生文件.dtsi包含基础定义.dts包含板级差异而无需复制大量代码或使用大量的#ifdef。6. 实际案例拆解为一个虚拟的I2C温度传感器编写节点让我们把上面所有的知识串联起来为一个假设的、挂在I2C总线上的温度传感器lm75编写一个完整的设备树节点。假设我们的硬件信息如下SoC内部有一个I2C控制器i2c0寄存器基址0x20000000。lm75传感器挂在i2c0总线上从机地址为0x487位地址。该传感器使用一个GPIO引脚GPIO23作为中断线低电平触发。该传感器需要一个名为vdd的电源电压为3.3V。首先我们需要确保I2C控制器节点本身是正确描述的// 1. 定义I2C控制器节点 i2c0: i2c20000000 { compatible vendor,i2c-controller; reg 0x20000000 0x1000; #address-cells 1; // 子节点I2C设备的reg属性中地址部分占1个cell即I2C地址 #size-cells 0; // I2C设备没有“长度”概念所以为0 clocks clkctrl 5; clock-names i2c; pinctrl-names default; pinctrl-0 i2c0_pins; // 假设pinctrl配置已定义好 status okay; // 注意lm75节点将作为i2c0的子节点添加在这里 };注意 I2C总线父节点的#size-cells 0非常关键。因为I2C设备只有地址没有寄存器长度范围所以子节点的reg属性只包含地址。然后我们在i2c0节点内部添加lm75子节点// 2. 在i2c0节点内添加lm75设备节点 i2c0: i2c20000000 { ... // 上述属性 lm75: temperature-sensor48 { compatible national,lm75; // 使用厂商标准兼容字符串 reg 0x48; // I2C 7位地址。注意这里只有一个cell因为父节点#size-cells0 interrupt-parent gpio_intc; // 假设gpio_intc是GPIO中断控制器 interrupts 23 IRQ_TYPE_LEVEL_LOW; // GPIO 23 低电平触发 #thermal-sensor-cells 0; // 如果该传感器被用作thermal zone可能需要此属性 vdd-supply vdd_3v3_reg; // 引用一个电压调节器节点 }; };关键点解析节点命名与地址temperature-sensor48。名字部分temperature-sensor具有可读性48是I2C地址十六进制这有助于在树中识别。compatiblenational,lm75。内核中标准的LM75驱动会匹配这个字符串。reg0x48。因为父节点i2c0设置了#address-cells 1和#size-cells 0所以这里的reg只有一个cell即I2C从机地址。中断描述 通过interrupt-parent和interrupts属性清晰地描述了中断连接关系。这里假设GPIO中断控制器需要2个cell引脚号和触发方式。电源管理vdd-supply vdd_3v3_reg。这是一种描述电源依赖关系的常见方式。它引用了设备树中另一个名为vdd_3v3_reg的电压调节器节点。内核的电源管理子系统会据此管理上电/下电顺序。这属于“供应Supply”属性的一种其他还有vin-supplyvcc-supply等具体取决于硬件绑定。#thermal-sensor-cells 如果这个温度传感器需要被配置为系统的热区Thermal Zone传感器可能需要这个属性。 0表示该传感器节点本身就是一个完整的传感器描述不需要额外的参数。这属于特定子系统thermal的绑定属性。这个例子展示了如何将多个标准属性组合起来完整地描述一个真实设备所需的硬件资源总线地址、中断、电源、以及可能的子系统接口。7. 调试与验证如何检查你的设备树是否正确生效编写完设备树后如何确认内核正确识别了你的配置呢这里有几个非常实用的调试方法。查看解析后的设备树内核在启动时会将设备树二进制文件.dtb解析成一个内部数据结构并挂载到/proc/device-tree目录下。这是一个以目录结构呈现的完整设备树镜像。# 查看根节点的属性 cat /proc/device-tree/compatible # 查看某个节点的属性例如我们之前定义的i2c0 cat /proc/device-tree/soc/i2c20000000/compatible cat /proc/device-tree/soc/i2c20000000/reg # 查看lm75传感器的属性 cat /proc/device-tree/soc/i2c20000000/temperature-sensor48/compatible cat /proc/device-tree/soc/i2c20000000/temperature-sensor48/reg如果某个属性不存在cat命令会返回错误。这是最直接的验证方法。使用devicetree工具系统可能提供devicetree命令或相关工具包。dtc工具不仅可以编译还可以反编译。你可以将内核使用的.dtb文件复制出来并反编译以检查最终生效的设备树与你编写的是否一致。# 从启动介质或/proc/device-tree/fdt获取dtb方法因平台而异 # 假设你得到了一个叫 system.dtb 的文件 dtc -I dtb -O dts -o system.dts system.dtb less system.dts查看内核启动日志dmesg内核在探测设备时会打印大量信息。使用dmesg命令并配合grep过滤是定位问题的好方法。# 查看所有与设备树OF相关的信息 dmesg | grep -i of # 查看所有探测到的I2C设备 dmesg | grep -i i2c # 查看特定驱动的探测信息例如lm75 dmesg | grep lm75如果驱动成功匹配并探测你通常会看到类似这样的日志lm75 0-0048: supply vdd not found, using dummy regulator lm75 0-0048: Registered as hwmon0如果驱动没有加载或者compatible不匹配则不会有相关日志。如果属性读取错误可能会打印错误信息。检查/sys文件系统成功加载的设备和驱动会在/sys下创建相应的条目。# 检查I2C总线上的设备 ls /sys/bus/i2c/devices/ # 可能会显示 0-0048 这样的目录对应i2c-0总线上的0x48地址设备 # 进入该设备目录查看详细信息 cd /sys/bus/i2c/devices/0-0048 cat name # 可能会输出 lm75 ls -la # 查看设备拥有的属性文件如temp1_input温度值常见问题排查思路驱动未加载首先检查compatible字符串是否完全正确包括大小写和逗号。与内核驱动源码of_device_id表中的字符串逐字核对。检查内核是否编译了对应的驱动CONFIG_SENSORS_LM75或模块是否已加载lsmod | grep lm75。检查节点或父节点的status属性是否为okay。资源获取失败如 -EINVAL -ENXIO寄存器地址错误 检查reg属性值。确保地址和长度与芯片手册一致。检查父节点的#address-cells和#size-cells定义是否正确。中断申请失败 检查interrupts属性的cell数量和值是否符合其interrupt-parent控制器所要求的格式。这是最常见的错误来源之一。务必、务必、务必查阅中断控制器的绑定文档时钟获取失败 检查clocks属性中的phandle和时钟ID是否正确。检查clock-names是否与驱动中请求的名字匹配。检查时钟控制器驱动是否已正常加载。pinctrl 配置失败检查pinctrl-0引用的配置节点phandle是否正确。检查引脚配置节点中定义的引脚号和功能名是否在引脚控制器的驱动中有效。错误的function名字会导致配置失败。调试设备树是一个需要耐心和细致的过程。从内核日志入手结合/proc/device-tree和/sys文件系统提供的信息逐步缩小问题范围最终总能找到配置错误的地方。记住设备树的绑定文档Documentation/devicetree/bindings/是你最好的朋友。