ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

嵌入式Linux设备树深度解析:从语法到驱动匹配

嵌入式Linux设备树深度解析:从语法到驱动匹配 1. 先搞清楚设备树到底在解决什么问题如果你想在嵌入式 Linux 圈子里混下去迟早要跟设备树Device Tree打交道。哪怕是只调一块 RK3568 的开发板你也会发现U-Boot 起来之后要加载一个.dtb文件内核启动日志里能看到大量of_开头的函数在跑而你手里那份rk3568-evb.dts修改前后外设行为可能完全不一样。很多人第一次接触设备树语法时看着满屏尖括号、逗号、label和status okay第一反应是这到底算代码还是配置文件。我刚开始也绕了很久后来才意识到设备树不是玄学它就是一张硬件清单把板子上有什么、接在哪里、怎么初始化用内核能读懂的方式写出来。设备树要解决的问题其实非常朴素让同一份内核镜像不用重新编译就能跑在不同硬件上。在它出现之前嵌入式 Linux 的玩法不是这样的。1.1 从硬件写死在驱动里到硬件描述与驱动解耦早期 ARM Linux 的开发方式是在内核源码里为每块板子维护一份arch/arm/mach-xxx/board-xxx.c。你在文件里写死平台设备、IO 资源、GPIO 编号、中断号然后调用platform_add_devices把这些设备注册进内核。驱动里也一样ioremap的地址、中断号、DMA 通道全部硬编码。这样做的后果是同一颗 SoC 做出来的几十款板子内核源码树里就要有几十份几乎重复的板级文件每改一个 GPIO 或换一颗 DDR都得重新编译内核。整个 ARM 内核维护成本高到社区无法忍受这也是后来设备树被大规模引入的直接原因。设备树进入 Linux 主线思路其实是从 PowerPC 的 Open Firmware 那边借鉴过来的。核心就一句话把硬件长什么样从内核源码里拆出来改成一份独立文本由内核在启动时解析这份文本然后动态创建 platform_device、i2c_client、spi_device 等等。驱动不再关心我是哪块板子只关心我匹配到的设备节点上有什么属性。用生活化的方式理解设备树相当于一份硬件配置文件内核是通用程序。以前每次换硬件要改源码重新编译现在只要换配置就能启动。而你改设备树时写的那些节点本质上是在告诉内核这个 I2C 控制器后面挂了一颗触摸屏地址是 0x5d用哪个 GPIO 做中断用哪个 GPIO 做复位。内核收到这些信息才知道该创建什么设备、调用哪个驱动。1.2 为什么 ARM 普及之后设备树成了硬性要求我最早学设备树的时候有个疑问x86 的 Linux 为什么不用这个后来理解了x86 有 ACPI 和 PCI 总线自发现机制固件负责把硬件信息标准化地告诉系统。嵌入式领域没有这套标准板卡差异又极大而设备树恰恰能把SoC 通用部分和板卡差异部分分开描述。同一个 SoC 的通用描述放在.dtsi里不同板卡的差异放在.dts里通过 dts 对 dtsi 的覆盖和引用维护成本大幅下降。像瑞芯微、全志、NXP 这些厂商的 BSP基本都是这个套路rk3568.dtsi描述 SoC 内部所有控制器rk3568-evb.dts只描述这块板子上实际引出和连接的外设。对初学者来说设备树语法不是可学可不学而是读懂内核启动、外设驱动加载、GPIO 引脚复用、中断路由的前提。你去看任何一篇关于 RK3568、i.MX8、树莓派或 PetaLinux 的调试文章到最后都会落到设备树节点上。这篇内容我会从语法本身讲起再用一块 RK3568 开发板的实例拆解节点结构最后聊到 overlay、编译调试和驱动 probe 链路。读完你至少能看懂一份 dts 文件里百分之八九十的内容也能自己改节点、排查常见的设备树问题。2. 设备树语法速览DTS、DTSI、DTC 与节点树设备树相关的文件后缀有.dts、.dtsi、.dtb工具叫 DTCDevice Tree Compiler。.dts是设备树源文件.dtsi是公共头文件通常放 SoC 级描述.dtb是编译产物是二进制格式由 Bootloader 加载后传给内核。理解这套语法首先要明确一个概念设备树在逻辑上是一棵节点树根节点是/下面挂着 cpu、memory、i2c、gpio、uart 等子节点每个节点用属性描述硬件信息。2.1 一个最小的设备树从哪几行开始先看一个最简短的 dts 文件/dts-v1/; / { compatible acme,coyote; #address-cells 1; #size-cells 1; chosen { bootargs consolettyS0,115200; }; memory60000000 { device_type memory; reg 0x60000000 0x20000000; }; };第一行/dts-v1/;是版本标记必须写在文件最前面。然后/ { ... };声明根节点。根节点里的compatible属性用来表示这个系统的品牌型号命名一般用厂商,型号的格式内核启动时也会用它来匹配 machine 描述符。#address-cells和#size-cells是这套语法里最容易忽略却又最关键的属性它们决定了子节点的reg属性里地址用几个 32 位数字表示、长度用几个 32 位数字表示。chosen节点是给内核传递运行时参数的比如bootargs就是内核启动参数。memory节点描述物理内存的起始地址和大小这里的reg 0x60000000 0x20000000表示内存从 0x60000000 开始大小 0x20000000512MB。节点名里的60000000是 unit-address用来区分同一层级下不同地址的同类节点它必须和reg第一个字段对应。2.2 节点、属性、值的写法规则设备树语法本身不长核心就三种东西节点、属性、值。节点用花括号包围属性写在节点内值有固定类型。写错最多的地方也集中在这里。节点名的命名规则是node-nameunit-address后面的部分可以省略。比如i2cfe5c0000i2c是通用名fe5c0000是这个控制器在 SoC 内的寄存器基地址。属性值常见的类型有这么几种类型写法示例字符串用双引号compatible goodix,gt911;字符串列表逗号分隔多个字符串compatible pwm-fan, gpio-fan;u32用尖括号包 32 位整数#address-cells 1;u64用尖括号包两个 u32clock-frequency 0 500000000;phandle用label或数字引用interrupt-parent gic;二进制用方括号包十六进制字节mac-address [00 11 22 33 44 55];混合组合使用reg 0xfe5c0000 0x1000, 0xfe5d0000 0x100;status属性也经常出现值一般是okay或disabled。SoC 级 dtsi 里大部分外设默认disabled板级 dts 里按需打开这是厂商 BSP 的通用做法。属性的顺序没有强制要求但同一节点内不能重复定义同名属性。字符串结尾的分号、属性之间的分号、花括号的闭合任何一处遗漏DTC 都会直接报错实践经验是写 dts 时把它当成严格的 C 代码来对待。2.3 include、标签与公司命名空间的约定真实项目里不会有人从头手写一棵完整的设备树几乎都是#include引用 SoC 级 dtsi再覆盖差异。比如/dts-v1/; #include rk3568.dtsi #include rk3568-evb.dtsi注意这里的#include不是设备树语法本身而是 C 预处理器的指令。设备树源文件先经过 cpp 做宏展开和文件包含再交给 DTC 编译。这也是 dts 里能使用dt-bindings/gpio/gpio.h、dt-bindings/interrupt-controller/irq.h这些东西的原因。标签label是 dts 里引用的基础。一个节点写成uart2: serialfe660000 { compatible rockchip,rk3568-uart; reg 0xfe660000 0x100; interrupts GIC_SPI 115 IRQ_TYPE_LEVEL_HIGH; status disabled; };uart2:就是这个节点的标签。后面板级 dts 里用uart2 { ... };就能覆盖它。DTC 编译时所有label引用会展开成数字 phandle所以标签只存在于源码层面编译后只是整数索引。厂商通常在compatible值里加自己的命名空间比如rockchip,rk3568-uart、goodix,gt911这种厂商,型号的格式是 Linux 设备树社区约定俗成的规则驱动通过of_match_table匹配时也依赖这个字符串。2.4 dtc 编译成 dtb 的基本命令最原始的做法是直接用 DTCdtc -I dts -O dtb -o rk3568-evb.dtb rk3568-evb.dts但实际开发中谁会手敲这行命令呢在内核源码树里make ARCHarm64 dtbs就可以编译全部平台设备树或者用make dtbs dtbs-install安装。只要你的 dts 文件放在arch/arm64/boot/dts/rockchip/下内核构建系统会自动调用 cpp 和 dtc。如果想把 dtb 还原成可读的 dts比如想确认 U-Boot 实际加载的 dtb 里某个节点到底长什么样用反向编译dtc -I dtb -O dts -o dump.dts rk3568-evb.dtb反编译结果里你能看到 phandle 数字、展开后的属性非常有助于排查为什么我改了 dts 但启动没变化这类诡异问题。还需要提一点很多 BSP 会把 dts 的编译结果放进 FIT image 或单独的 dtb 分区。你改完 dts 以后如果只重新编译了内核镜像却没有重新打包 dtb 分区那板子启动时用的还是旧设备树。这个坑后面会详细展开。3. 以 RK3568 为例拆解一块真实板卡上的设备树理论说多了容易飘直接看一块 RK3568 开发板的设备树组织方式会更直观。RK3568 是瑞芯微的一颗四核 Cortex-A55 平台常用于工控、NAS、边缘计算。它的 BSP 在arch/arm64/boot/dts/rockchip/下一般有三个层次SoC 级rk3568.dtsiPMU 和其他公共部分放在更小的 dtsi 里板级rk3568-evb.dts引用并覆盖。3.1 soc.dtsi 里都有什么CPU、内存、总线节点打开rk3568.dtsi你会发现它基本上把一颗 SoC 里所有内部控制器都写了一遍。CPU 节点是典型的多核描述cpus { #address-cells 1; #size-cells 0; cpu0: cpu0 { device_type cpu; compatible arm,cortex-a55; reg 0x0; enable-method psci; clocks scmi_clk 0; }; };这里#address-cells 1和#size-cells 0表示 CPU 节点的reg只用一个数字表示 CPU ID没有长度字段。enable-method psci表示用 PSCI 协议做 CPU 电源管理。不同的 CPU 架构、不同的固件方案这里写的内容都会不一样。往下翻会看到interrupt-controllerfd400000这是 GIC通用中断控制器节点心跳是interrupt-controller;加#interrupt-cells 3;。还会看到timer、pmu、uart0到uart9、i2c0到i2c5、spi0到spi3、gpio0到gpio4、pinctrl等节点。大部分节点默认status disabled只有 SoC 里所有板子都会用到的核心节点默认为okay。一个值得留意的点SoC dtsi 里很多控制器节点会带有中断、时钟、复位等引用这些引用依赖#include dt-bindings/clock/rk3568-cru.h里的时钟 ID 宏。你如果看到clocks cru CLK_UART2不要慌这本质是 phandle 加数字的组合数字含义由宏定义给出。3.2 板级 dts 如何引用并覆盖默认配置板级rk3568-evb.dts开头一般是这样的/dts-v1/; #include rk3568.dtsi #include rk3568-evb.dtsi然后大量使用引用节点并覆盖uart2 { status okay; pinctrl-names default; pinctrl-0 uart2m0_xfer; };这段的意思很明确默认情况下uart2是禁用的但在这块板子上它被引出到调试串口所以打开并且把引脚复用配置成 UART2 的 M0 组。pinctrl-0引用的uart2m0_xfer是 pinctrl 节点里定义的引脚组配置状态。覆盖的规则是同一个节点路径dts 里的属性会覆盖 dtsi 里的同名属性dtsi 里定义的属性如果在 dts 里没有对应项则保留。这就是设备树支持增量覆盖的体现。需要特别注意的是uart2引用必须能对应到 dtsi 中某个带 label 的节点否则 DTC 直接报Reference to non-existent node or label。板级文件里你还会看到大量vcc5v0_sys、vcc3v3_lcd这类电源节点它们用regulator-fixed兼容属性模拟一个固定电压输出配合gpio属性来控制实际供电芯片的使能脚。比如vcc3v3_lcd: vcc3v3-lcd { compatible regulator-fixed; regulator-name vcc3v3_lcd; regulator-boot-on; gpio gpio0 RK_PC5 GPIO_ACTIVE_HIGH; enable-active-high; };这就相当于告诉内核LCD 的 3.3V 供电由一个芯片控制控制引脚是 GPIO0_C5高电平使能。后续 LCD 背光、触摸屏等设备节点可以引用这个 regulator实现上下电顺序管理。3.3 从 dts 反推硬件连接GPIO bank、电源域与 pinctrl学会看设备树以后你会发现它比原理图更容易快速定位某个外设接在哪个引脚上。举个例子RK3568 的 GPIO 分为 gpio0 到 gpio4每组 32 个脚内部按 A/B/C/D 四组、每组 8 个脚排列。gpio1 RK_PB2 GPIO_ACTIVE_LOW这种写法表示 GPIO1 组的 B2 引脚低电平有效。RK_PB2是dt-bindings/pinctrl/rockchip.h里的宏展开以后就是一个数字。看 pinctrl 节点的定义方式也能学到很多pinctrl: pinctrlfe620000 { compatible rockchip,rk3568-pinctrl; reg 0xfe620000 0x10000; rockchip,grf grf; #address-cells 1; #size-cells 1; ranges 0x0 0xfe620000 0x10000; };子节点里会定义uart2m0_xfer、i2c3_xfer、gmac0_rgmii等引脚组。某个外设要使用一组引脚时父节点里先声明pinctrl-names default再用pinctrl-0 xxx引用。内核在 probe 时会自动把这些引脚的复用功能、上下拉、驱动强度配置好。如果你在外设节点里配置了 GPIO又没有正确配置 pinctrl经常会出现明明 GPIO 编号对但电平拉不动的情况因为引脚可能仍然被复用成其他功能。4. 容易被绕晕的 phandle、中断与 reg 属性设备树语法里真正难理解的部分不是节点怎么写而是节点之间怎么互相引用。三个概念最让人头大phandle、中断描述、地址翻译。它们各自都有明确的设计逻辑理解了为什么写法就迎刃而解。4.1 phandle 是怎么实现节点之间互相引用的你在 dts 源码里看到的gpio4、pwm3、cru这些引用在编译后都会变成数字。DTC 会给每个被引用的节点分配一个唯一的 32 位整数这个整数就是 phandle。它本质上是一种指向某个节点的指针。为什么需要 phandle因为设备树是一棵树父子关系可以用路径表示但外设和控制器之间的引用往往是跨层级、多对一的。比如四个 UART 控制器可能都引用同一个 GIC 中断控制器每个设备都会引用 CRU 时钟节点这些引用如果不引入 handle 机制就得写完整路径字符串既冗长又容易因路径拼错而出问题。phandle 机制让引用变得简短且可自动校验编译时找不到标签就报错运行时不存在的 phandle 在解析时会返回空指针驱动侧再做错误处理。你反编译 dtb 时会在所有被引用的节点里看到phandle 0x...这样的属性。如果两个节点都需要被另一个节点引用它们各自会有不同的 phandle。另外 dts 里的label引用只适用于同一份编译输入内的节点跨文件就靠#include合并后再解析。这个过程对使用者透明但理解它你才会明白为什么 overlay 的引用需要额外机制。4.2 interrupts 属性的三要素与中断控制器中断描述比 GPIO 复杂因为中断不是一根线那么简单还有触发方式、中断号、中断类型。RK3568 的 GIC 节点一般长这样gic: interrupt-controllerfd400000 { compatible arm,gic-v3; reg 0xfd400000 0x10000, 0xfd460000 0x10000; interrupt-controller; #interrupt-cells 3; #address-cells 1; #size-cells 0; };interrupt-controller;表示这个节点是一个中断控制器#interrupt-cells 3表示子节点用interrupts属性描述中断时需要填 3 个数字。对 ARM GIC 来说这 3 个数字的含义是中断类型、中断号、触发方式。uart0: serialfdd50000 { interrupt-parent gic; interrupts GIC_SPI 122 IRQ_TYPE_LEVEL_HIGH; };GIC_SPI是宏值为 0表示这是 SPI外设中断如果是 PPI处理器私有中断宏值为 1。中间的 122 是中断号。最后的IRQ_TYPE_LEVEL_HIGH表示高电平触发。如果没有显式写interrupt-parent内核会沿设备树向上寻找最近的interrupt-controller节点。这里有个很容易踩的坑不同中断控制器的#interrupt-cells不一样。比如 GPIO 控制器通常用 2 个 cell表示引脚号 触发方式而 GIC 用 3 个 cell。所以从某个外设节点看到的interrupts gpio1 15 IRQ_TYPE_LEVEL_LOW到设备驱动里会被转换成platform_get_irq返回的 Linux IRQ 号中间经历了一整条映射逻辑。你手动改中断号时必须先确认这个中断到底是接到 GIC 还是 GPIO否则驱动拿到的中断号会完全不对。4.3 reg、ranges 与地址翻译的规则reg表面看是寄存器地址和长度实际它的语义完全由父节点的#address-cells和#size-cells决定。看这个例子pinctrl: pinctrlfe620000 { reg 0xfe620000 0x10000; #address-cells 1; #size-cells 1; ranges 0x0 0xfe620000 0x10000; gpio0: gpiofe620000 { reg 0x0 0x100; }; };父节点 pinctrl 的#address-cells 1、#size-cells 1所以子节点gpiofe620000的reg需要两个字段地址 0x0 和长度 0x100。但是 GPIO 的物理地址不是 0x0而是 0xfe620000于是要用ranges 0x0 0xfe620000 0x10000做翻译。ranges的格式是子地址、父地址、长度。上面这行表示子节点地址 0x0 对应父节点地址 0xfe620000映射长度 0x10000。这样内核从 gpio 节点的reg读出地址 0x0 后经过 ranges 翻译最终得到物理地址 0xfe620000。如果ranges属性为空比如ranges;表示子地址和父地址是 1:1 映射不做偏移。如果你在新的板子上看到一个外设节点reg 0x10000 0x1000第一件事就是去看它父节点和祖父节点的#address-cells、#size-cells、ranges否则你根本无法确定实际物理地址。很多人在 PCIe、MIPI DSI 这类多级总线上栽跟头就是因为没有沿着总线逐层翻译地址。5. overlays 与常用外设绑定GPIO、I2C、SPI、显示掌握了基本语法就可以开始写实际外设节点了。日常开发中我们改得最多的就是这几类I2C 设备、SPI 设备、GPIO 控制、PWM 控制、显示屏和背光。另外如果你做的是扩展板、FPGA 动态加载这类方案还需要了解设备树 overlay 的写法。5.1 用设备树 overlay 实现动态配置设备树 overlay 可以理解成设备树的补丁。它的作用不是修改根设备树文件而是启动后在运行时加载一段补丁把新节点添加进去或者修改已有节点。典型写法/dts-v1/; /plugin/; {/} { test-led { compatible gpio-leds; status okay; }; };overlay 的语法核心是/plugin/;声明以及用label或{/path}定位目标节点。编译方式和普通 dts 一样用 dtc但要加-参数生成符号表dtc - -I dts -O dtb -o test-led.dtbo test-led.dtsU-Boot 支持通过fdt apply命令把 overlay 应用到主设备树Linux 下也有 configfs 接口可以动态加载 dtbo。这个机制在 Raspberry Pi 的 HAT、FPGA 动态重配置、扩展板自动识别场景里非常常见。但 overlay 有个先决条件主设备树编译时必须保留符号信息也就是说主 dtb 也要用-编译否则 overlay 里的label找不到目标节点。很多人的 overlay 加载失败根本不是 overlay 写错了而是主设备树没带符号表。这个点一度让我排查了很久。5.2 一个 I2C 触摸屏节点的完整写法I2C 设备节点是设备树最容易入手的实践场景。RK3568 的 I2C 控制器被引出到开发板的 FPC 座上连接了一颗 GT911 触摸芯片那么板级 dts 里会有类似内容i2c3 { status okay; clock-frequency 400000; pinctrl-names default; pinctrl-0 i2c3_xfer; gt9115d { compatible goodix,gt911; reg 0x5d; interrupt-parent gpio1; interrupts RK_PB2 IRQ_TYPE_LEVEL_LOW; reset-gpios gpio1 RK_PB3 GPIO_ACTIVE_LOW; irq-gpios gpio1 RK_PB2 GPIO_ACTIVE_LOW; }; };gt9115d是子节点名5d对应 I2C 从地址。reg 0x5d必须和芯片的实际 7 位地址一致如果写错I2C 核心扫描不到设备驱动 probe 也不会执行。clock-frequency表示 I2C 总线速率一般触摸屏用 400kHz 或 100kHz。reset-gpios和irq-gpios是 goodix 驱动约定的属性名不是所有驱动都这么叫。驱动源码里通过devm_gpiod_get_optional(client-dev, reset, GPIOD_OUT_LOW)来读取属性名去掉-gpios、把短横线转下划线就是 GPIO 后缀。所以你在写设备树之前最好先翻一下内核里对应驱动的 binding 文档和源码确认到底属性名是什么、GPIO 有效电平怎么定义。很多人想当然地写power-gpios驱动里读的是enable-gpios那自然永远匹配不上。5.3 GPIO-LED 与 PWM 风扇这类控制节点的套路GPIO 控制类设备是最简单的也是最适合练手的。比如一个状态灯leds { compatible gpio-leds; work_led { label work; gpios gpio4 RK_PC4 GPIO_ACTIVE_HIGH; linux,default-trigger heartbeat; }; };linux,default-trigger是 Linux 特有的属性可以帮你在系统启动后自动让 LED 按心跳闪烁。如果这块 LED 被 DT 之外的其他设备占用比如同样 GPIO 出现在两个节点的gpios属性里这里后注册的会请求失败现象就是/sys/class/leds/下没有对应目录。PWM 控制的场景类似。比如给 CPU 散热风扇调速pwm-fan { compatible pwm-fan; pwms pwm3 0 25000 0; cooling-levels 0 100 150 200 255; };pwms的前两个数字是 PWM 控制器 phandle 和通道第三个是 PWM 周期纳秒第四个一般留 0。cooling-levels是温度热冷却框架使用的调速等级。这里最容易写错的是 PWM 周期25000 纳秒对应 40kHz如果你实际想用 25kHz应该是 40000 纳秒。一个数字差风扇噪音和散热曲线就会完全不一样。5.4 调屏幕时最常改的 display-timings 与 backlight显示相关节点是新手最容易崩溃的地方因为涉及背光、面板、显示控制器、PHY 多级节点联动。以 RK3568 MIPI DSI 屏幕为例需要关注三块背光、面板、DSI 控制器。背光如果走 PWM典型写法backlight: backlight { compatible pwm-backlight; pwms pwm0 0 1000000 0; brightness-levels 0 20 40 60 80 100; default-brightness-level 3; power-supply vcc3v3_lcd; enable-gpios gpio0 RK_PC5 GPIO_ACTIVE_HIGH; };面板节点里可以引用背光panel0 { compatible simple-panel; backlight backlight; enable-gpios gpio0 RK_PB7 GPIO_ACTIVE_HIGH; power-supply vcc3v3_lcd; display-timings { native-mode timing0; timing0: timing0 { clock-frequency 50000000; hactive 800; vactive 480; hback-porch 40; hfront-porch 40; hsync-len 20; vback-porch 20; vfront-porch 20; vsync-len 4; }; }; };display-timings里的时序必须来自屏幕数据手册哪怕一个像素的 porch 不对屏幕上都可能出现偏移、闪烁或花屏。如果你只是换个型号的屏幕通常只需要改 panel 节点和 timing如果连接口都变了比如从 RGB 屏换 MIPI 屏那 DSI 控制器节点、PHY 配置、引脚 mux 全都要跟着动。调屏幕期间设备树、驱动日志、示波器三样缺一不可全靠盲改很难定位问题。6. 编译报错、运行时探测与常见坑位设备树写完了接下来的问题就是怎么验证它真的生效了。这个环节的坑一般是两类编译期没过、运行期没反应。我把自己实际踩过的几条链路完整列出来你照着排查能省很多时间。6.1 dtc 编译错误的典型场景与修复DTC 的报错其实相当友好常见错误类型就那几种。第一种是语法错误。最典型的是漏分号或者、()、{}不匹配。DTC 会提示syntax error并给你一个行号直接去那一行附近找就行。第二种是标签未定义。报错信息类似Reference to non-existent node or label uart2。原因一般是#include没把包含该标签的 dtsi 引进来或者标签名拼错。还有可能是你#include了但被某个宏开关屏蔽了。第三种是重复属性。比如同一节点里写了两个statusDTC 会报duplicate property name。这通常发生在你复制粘贴节点时没有清理干净。第四种是类型不匹配。reg 0x100;这种写法就是错的地址必须用尖括号包数字或数字列表。编译报错本身不可怕可怕的是编译通过了但运行不生效。所以我的建议是尽量用内核构建系统的make dtbs去编译而不是手敲 dtc这样可以保证#include路径和宏定义都正确。编译通过后马上反编译一下 dtb确认改动确实进了最终产物这个习惯能帮你避开很多我明明改过了的假象。6.2 /proc/device-tree 与 debugfs 验证设备树是否生效设备树运行时会被内核解析成struct device_node同时导出一份到/proc/device-tree。你可以直接看ls /proc/device-tree/ hexdump /proc/device-tree/model每个节点对应一个目录每个属性对应一个文件。hexdump一个字符串属性能看到明文数字属性则是一堆字节需要用xxd或写个小脚本解析。这个方法可以用来确认当前内核到底用的是哪一份设备树节点里的属性值和你修改的一致吗。如果看不到某个节点第一步不是怀疑设备树而是确认你当前启动用的 dtb 到底是不是你编译的那个。在 U-Boot 里执行printenv fdt_file很多板子的 U-Boot 环境变量里写死了fdt_filerockchip/rk3568-evb.dtb你改了 dts 以后如果只更新内核不认识 dtb 分区加载的还是旧的。还有一种情况是 FIT image 里同时打包了内核和设备树你用新内核时却忘了更新 FIT。检查 U-Boot 实际加载了哪个地址的 dtb配合/proc/device-tree里的内容基本能定位。驱动有没有成功 probe也有一套很有效的验证路径。设备绑定成功后/sys/devices/platform/或/sys/bus/i2c/devices/下会出现对应设备目录/sys/.../driver符号链接指向驱动。如果节点存在但设备没创建多半是 compatible 没匹配上如果设备创建了但 probe 失败去dmesg看驱动打印或者看有没有 Deferred probe 的提示。6.3 我在实际项目中踩过的设备树坑第一个坑是我调 I2C 触摸屏时改了i2c3的status和触摸屏子节点编译也过了但内核启动后触摸完全无反应。排查半天发现 U-Boot 加载的是另一个 dtb 分区我编译出的新 dtb 根本没被烧进去。从那以后我养成了一个习惯每次改设备树后第一件事先看/proc/device-tree里的对应节点确认当前系统用的就是新 dtb。第二个坑是同一个 GPIO 被两个节点引用。当时一块背光板用了gpio0 RK_PC5做使能脚另一块扩展板上也用同一个引脚。我先让背光驱动 probe 成功又把扩展板设备节点加进去后加的驱动请求 GPIO 失败但内核日志里只是简单打印gpio_request: gpio-21 status -16。这种资源冲突在 dts 里很难一眼看出来建议多加检查自己的板子原理图确认没有引脚重叠。厂商的 pinctrl 配置在某些情况下会覆盖 GPIO 功能即使编码不冲突运行时的引脚复用也可能冲突。第三个坑是关于中断触发方式的。我曾经把触摸屏中断写成IRQ_TYPE_EDGE_RISING但实际芯片是低电平触发导致中断风暴。最终在 dmesg 里看到大量unhandled interrupt才意识到问题。设备树里触发方式写错驱动本身很难兜住必须结合芯片手册和示波器确认。其实很多触摸屏在驱动初始化阶段会先读取芯片配置再用设备树里的触发方式做一致性检查不一致时驱动会打印警告看到invalid interrupt flags这类日志时优先怀疑设备树。第四个坑和 status 有关。我把一个 SPI 设备的status留在disabled而驱动模块却在启动时加载了设备自然不 probe。设备树上的 disabled 不是让驱动晚点加载而是让内核根本不去创建这个平台设备。很多人在调试时把节点删了或者留着 disabled但驱动又是 module 方式编译导致日志里完全没有 probe 信息误以为驱动有问题。实际上只要把status改成okay重新生成 dtb设备就出来了。7. 从设备树到驱动 probe找节点、读属性、注册设备设备树语法学到最后一定要打通设备树节点和驱动代码之间的映射关系。不然你只是会改文本不理解内核怎么用文本出了问题还是只能瞎猜。7.1 compatible 匹配的本质驱动怎么认出硬件每个 Linux 平台驱动里都有一张of_device_id表static const struct of_device_id gt911_of_match[] { { .compatible goodix,gt911 }, { } }; MODULE_DEVICE_TABLE(of, gt911_of_match);当平台总线设备注册时内核会遍历设备节点的compatible属性字符串列表和驱动的of_match_table逐一比较。匹配成功就把该驱动和设备绑定在一起随后调用驱动的probe函数。所以compatible值在 dts 和驱动里必须完全一致多一个字母、少一个逗号都不行。7.2 of_xxx API 的常用套路驱动 probe 函数里有一整套“从设备树读信息”的 API。举一个实际驱动的骨架static int gt911_probe(struct i2c_client *client) { struct device_node *np client-dev.of_node; u32 reset_gpio, irq_gpio; struct gpio_desc *reset; ret of_property_read_u32(np, reg, reset_gpio); if (ret) { dev_err(client-dev, missing reg property\n); return -EINVAL; } reset devm_gpiod_get_optional(client-dev, reset, GPIOD_OUT_LOW); if (IS_ERR(reset)) return PTR_ERR(reset); return 0; }这里最常用的是of_property_read_u32、of_property_read_string、devm_gpiod_get、devm_clk_get、platform_get_irq。这些 API 本质上就是解析设备树节点把属性值转成 C 变量。所以设备树属性名、类型必须和驱动里读的方式一一对应否则读出来要么是错值要么直接返回错误。需要特别留意的是devm_前缀的 API。它表示资源随设备生命周期自动释放probe 失败或设备移除时不需要手动free_irq、gpio_free。现代驱动普遍这么写能极大减少资源泄漏问题。我们自己在写驱动时也应当遵守这个惯例。7.3 一次完整的 probe 加载链路回顾把整个链路串起来是这样的U-Boot 启动时将 dtb 放到内存指定地址内核接管后解析设备树生成一棵device_node树。平台子系统遍历树中所有带compatible属性的节点为它们创建对应的platform_device。总线机制再遍历已注册的驱动用of_match_table做匹配。匹配成功后驱动probe被调用从节点里读取寄存器地址、中断、GPIO、时钟、pinctrl 等资源接着初始化硬件。从开发调试的角度看你改一个compatible字符串实际影响的是一整条链路节点能不能生成设备、驱动能不能匹配、probe 会不会被调用。所以我在实际开发中定位设备树改了但外设不工作时会按这个顺序查先看/proc/device-tree确认节点属性再看/sys/bus/platform/devices/或对应总线下有没有设备目录最后看dmesg里有没有驱动 probe 或 defer 的信息。三步走完问题基本就锁定到某一层了。设备树不是独立的配置文件语法它是内核设备模型的一部分。你写下的每一个节点最终都会变成某个struct device都会触发某段驱动的执行。把这个因果关系刻在脑子里再复杂的设备树问题也能拆成节点是否存在、属性是否正确、驱动是否匹配、资源是否冲突四个层面逐一击破。我个人最后再分享一个小习惯每次拿到一块新板子不管有没有现成 BSP我都会先反编译它自带的 dtb花半小时把/proc/device-tree里关键节点和原理图对着看一遍。这个过程比读十篇文档都管用因为它能快速帮你建立设备树节点 ↔ 实际硬件连接的对应感。等你自己动手改设备树时心里就有一张清晰的图了。
返回列表