入门到实战:语法解析、编译调试与RK3568案例)
做嵌入式Linux开发的这些年要说哪个知识点最绕不开设备树Device Tree绝对排得上号。芯片手册里那些寄存器地址、中断号、引脚复用关系最终都要通过设备树告诉内核不同开发板之间的差异也大多靠设备树来抹平。这篇文章我会从设备树为什么会出现讲起把节点、属性、reg、interrupts、compatible这些基础语法逐条拆开再配合一段完整的RK3568设备树实例做解析最后把编译、反编译、Petalinux下修改设备树的流程以及我实际调试中踩过的坑都过一遍。适合刚接触设备树的Linux驱动开发者也适合被设备树折磨过但始终没系统整理过语法的朋友。如果你已经在用设备树可以直接跳到后面的排查技巧部分大概率能少走点弯路。1. 设备树到底解决什么问题1.1 从板级文件到设备树一段不得不提的历史早期的Linux内核每个开发板都要在 arch/arm/mach-xxx 目录下写一堆 board 文件用 platform_device 结构体把外设资源一个个注册到内核里。换一个板子、改一个 GPIO 按键都得去改 C 代码然后重新编译内核。设备一多这些 board 文件就变得又长又碎不同厂商的代码风格还不一样维护起来非常痛苦。设备树的思路就是把硬件的资源描述从内核代码里抽出来单独用一种类似树状结构的文本文件来描述。内核不再关心某个 GPIO 究竟接在哪、I2C 控制器地址是多少而是启动时由 U-Boot 把编译好的 DTB 二进制传给内核内核再靠解析这份“硬件清单”去创建设备、匹配驱动。这个改动对 BSP 开发和量产维护是质的变化也是后续所有 Linux 平台统一硬件抽象方式的基石。1.2 设备树的核心价值硬件描述与内核代码解耦设备树本质上是一份“给内核看的硬件描述文件”。以前我在内核源码里加一个板子要动 Kconfig、Makefile、mach 目录、平台驱动注册搞不好还要影响公共代码现在只需要新增一个 dts 文件再编译打包进去硬件改动基本就完成了。这种解耦带来的直接好处是同一份内核镜像可以同时适配多个板子U-Boot 根据实际运行环境选择不同的 dtb 启动即可。Petalinux 这类工具也把设备树当作用户描述硬件差异的默认入口所以哪怕你只是在做一块 RK3568 的评估板改设备树也是日常最高频的操作之一。2. 设备树语法全解析2.1 先分清 dts、dtsi、dtb、dtc 这套名词很多初学者一看到设备树文件就懵其实先分清这四样东西就好办了。dtsDevice Tree Source设备树源文件一般一个板卡对应一个主文件。dtsi可以理解成设备树的公共头文件通常在 SoC 厂商提供的 SDK 里描述芯片内部外设比如 CPU、中断控制器、GPIO 控制器、各串口 i2c 控制器节点。dtbDevice Tree Blobdts 编译出来的二进制U-Boot 加载并传给内核的就是它。dtcDevice Tree Compiler把 dts 编成 dtb、或者把 dtb 反编译成 dts 的工具。实际开发中我们写的板级 dts 通常不会把 SoC 所有外设重写一遍而是通过#include rk3568.dtsi把厂商写好的公共部分引进来再针对板子差异做覆盖。这个习惯非常重要因为 SoC 厂商会持续更新 dtsi你如果自己在 dts 里复制了一大段外设节点后续同步升级会非常痛苦。2.2 节点、属性、键值对的基础语法设备树里最基本的单位是节点node节点就是描述一个硬件对象比如 CPU、I2C 控制器、GPIO 控制器、LED。一个节点里可以嵌套子节点也可以放多个属性property。属性就是硬件信息的键值对写法很直观。一个最小的设备树文件大概长这样/dts-v1/; #include dt-bindings/gpio/gpio.h / { compatible myboard,rk3568; model My RK3568 Board; chosen { stdout-path serial0:115200n8; }; leds { compatible gpio-leds; work-led { label work; gpios gpio0 RK_PD5 GPIO_ACTIVE_LOW; default-state off; }; }; };注意几个小细节根节点用/ { ... };表示每个属性和闭合括号后面都要有分号。字符串属性用双引号数字属性和地址信息用尖括号 多个值用逗号分隔。#include不是设备树语法是 C 预处理器的活儿dtc 编译前会先经过预处理器展开所以可以在 dts 里直接用某个 GPIO 宏、中断类型宏。属性是有类型概念的最常见的类型有字符串、字符串列表、无符号整数、整数列表、物理地址列表等。比如compatible rockchip,rk3568是字符串reg 0x0 0xfeff0000 0x0 0x1000是一组地址值interrupts-extended gic GIC_SPI 149 IRQ_TYPE_LEVEL_HIGH则是同时包含 phandle 引用和整数。属性值里的宏和 中的数字最终都会在编译阶段变成一个个 cell 值内核驱动通过of_property_read_u32这类 API 再解析出来。2.3 label、节点引用与覆盖设备树里最常用的语法糖写设备树最爽的一点是可以用 label 和引用来修改厂商 dtsi 里的节点而不是把整个节点复制一遍。label 的写法是在节点名前面加一个名字加冒号比如i2c0: i2cfeff0000 { compatible rockchip,rk3568-i2c, rockchip,rk3368-i2c; reg 0x0 0xfeff0000 0x0 0x1000; ... };这里i2c0就是 label。之后在板级 dts 里想给这个 I2C0 挂设备只需要这样写i2c0 { status okay; clock-frequency 400000; sensor1a { compatible some-sensor; reg 0x1a; }; };编译器会把i2c0后面的内容合并到原节点中父子节点深度合并属性按名字覆盖。这种方式是设备树里的“语法糖”也是 PetaLinux 里最常看到的修改手法因为板级 dts 和厂商 dtsi 天然就是这种覆盖关系。不过要注意几个坑i2c0引用的 label 必须存在否则会报 “Reference to non-existent node or label”。同一个属性重复定义时后面出现的值会覆盖前面的但数组长度不一致时可能引发隐藏问题。覆盖时只能改属性不能通过覆盖“删除”一个已经存在的子节点删除要用/delete-node/语法。2.4 常用属性速查与含义属性含义典型写法compatible设备兼容标识驱动匹配时使用rockchip,rk3568-i2cmodel板卡型号描述给用户看的My RK3568 Boardreg总线地址、寄存器地址或外设地址0x0 0xfeff0000 0x0 0x1000interrupts中断号和中断触发方式GIC_SPI 149 IRQ_TYPE_LEVEL_HIGHinterrupt-parent中断所属控制器引用gicclocks外设时钟引用cru CLK_I2C0clock-names时钟名称和 clocks 一一对应i2cpinctrl-namespinctrl 状态名列表default, sleeppinctrl-0对应状态下的引脚配置引用i2c0_xferstatus节点使能状态okay/disabled/failedgpiosGPIO 引脚引用gpio0 RK_PD5 GPIO_ACTIVE_LOWphandle节点唯一句柄编译器自动生成phandle 0x1这些属性里compatible和reg是驱动开发时最常打交道的两个。compatible直接决定驱动能不能绑上设备reg决定驱动读到的基础地址和资源大小两个出了问题外设基本起不来。3. RK3568 设备树实例解析3.1 从 GPIO 点灯的完整写法看节点结构GPIO 点灯是嵌入式开发里最基础也最适合入门设备树的例子。假设 RK3568 板子上有一颗 LED 接在 GPIO0 的 D5 脚低电平点亮我们可以这样写#include dt-bindings/gpio/gpio.h / { leds { compatible gpio-leds; work-led { label work; gpios gpio0 RK_PD5 GPIO_ACTIVE_LOW; linux,default-trigger heartbeat; default-state off; }; }; };这里的gpio0引用了 SoC dtsi 里定义的 GPIO0 控制器节点RK_PD5是 Rockchip 平台提供的引脚编号宏GPIO_ACTIVE_LOW是dt-bindings/gpio/gpio.h里的宏表示低电平有效。内核自带 gpio-leds 驱动会去解析gpios属性根据GPIO_ACTIVE_LOW反转输出极性所以硬件上是低电平点亮驱动层照样可以用brightness属性控制。这段代码看起来简单但里面有两个值得展开的点。第一为什么能直接用gpio0因为厂商 dtsi 里早已定义好了gpio0: gpiofdd60000这样的节点并且带上了#gpio-cells 2。这个#gpio-cells说明gpios属性里每个引脚描述占用几个 cellRockchip 用两个 cell第一个是引脚编号第二个是 GPIO 活性标志。第二为什么要有label和linux,default-trigger因为 gpio-leds 驱动会注册成一个 LED class 设备用户空间可以通过sysfs操作label 决定 sysfs 里的名字trigger 则决定系统启动后这个 LED 的默认闪烁行为。3.2 I2C 控制器节点与外部设备挂接解析RK3568 这类 SoC 的 I2C 控制器节点通常都在厂商 dtsi 里定义好了比如i2c0: i2cfeff0000 { compatible rockchip,rk3568-i2c, rockchip,rk3368-i2c; reg 0x0 0xfeff0000 0x0 0x1000; interrupts GIC_SPI 149 IRQ_TYPE_LEVEL_HIGH; clocks cru CLK_I2C0, cru PCLK_I2C0; clock-names i2c, pclk; pinctrl-names default; pinctrl-0 i2c0_xfer; status disabled; };我们板级 dts 要做的事是先在板级头文件里确认引脚、时钟有没有被别的功能占用然后在 dts 里打开并挂上具体外设比如一个 I2C 温湿度传感器i2c0 { status okay; clock-frequency 400000; sensor1a { compatible some-vendor,humidity-sensor; reg 0x1a; interrupt-parent gpio3; interrupts RK_PC5 IRQ_TYPE_LEVEL_LOW; }; };这个例子里最需要理解的是reg 0x1a。在 I2C 节点的子节点中reg不再是内存地址而是外设在 I2C 总线上的从机地址。为什么会这样因为 I2C 控制器总线节点使用了#address-cells 1和#size-cells 0说明这条总线上的子节点只用一个 cell 表示地址没有长度字段。如果你在写 SPI 从设备reg又变成了片选号所以要养成先看总线节点#address-cells、#size-cells的习惯。传感器的中断接到 GPIO3 的 PC5低电平触发。这里interrupt-parent gpio3指定中断控制器interrupts里的第一个值是 GPIO 引脚号第二个是触发类型。如果不指定 interrupt-parent内核会沿着父节点一级级往上找interrupt-parent一般会找到 GIC 上那样解释引脚号就会出错。3.3 compatible 匹配与驱动绑定关系设备树节点本身不会产生任何功能能驱动外设的是内核驱动设备树只负责告诉内核“我这里有这样一个设备”。驱动里通常会有一张of_device_id兼容表比如static const struct of_device_id rk3x_i2c_match[] { { .compatible rockchip,rk3568-i2c }, { .compatible rockchip,rk3368-i2c }, {}, }; MODULE_DEVICE_TABLE(of, rk3x_i2c_match);内核在注册设备时会把设备树节点的compatible字符串和驱动表里的每个条目逐个比较。只要有一个完全匹配就会调用驱动的 probe 函数。所以最常见的问题是设备树的compatible与驱动表对不上比如多了厂商前缀、少了逗号、大小写不一致驱动就永远不被匹配。我调试时习惯先在启动日志里搜request_module或查/sys/bus/platform/devices/下有没有对应设备节点能快速判断设备树节点是否成功创建。厂商设备树里经常写两个或更多 compatible比如rockchip,rk3568-i2c, rockchip,rk3368-i2c这叫兼容性列表。第一个表示本芯片精确匹配后面的表示可以复用老芯片驱动。这样同一个驱动可以用在多代芯片上也方便社区把公共驱动沉淀下来。4. 编译流程与 Petalinux 下修改设备树4.1 标准 DTS 编译与内核集成设备树源文件的编译工具是 dtc。最简单的方式是直接用命令行编译dtc -I dts -O dtb -o rk3568-board.dtb rk3568-board.dts但在实际内核工程里一般不这么手动编因为 dts 里有大量#include依赖内核头文件里的宏定义所以标准做法是在内核源码根目录下执行make ARCHarm64 dtbs或者在平台目录下单独编make ARCHarm64 rk3568-board.dtb编译产物通常会输出到arch/arm64/boot/dts/rockchip/。内核的顶层 Makefile 会自动处理 include 路径把include/dt-bindings/和arch/arm64/boot/dts/加进去这也是为什么设备树源文件可以直接写#include dt-bindings/gpio/gpio.h。如果你用的是 U-Boot 启动内核和设备树是分开加载的U-Boot 环境变量里通常会指定fdt_file或者通过load命令把 dtb 加载到内存地址再在 bootm 时将 dtb 地址传给内核。改完 dts 一定要确认最终打包进启动镜像的 dtb 是重新编译后的版本我见过好几次改了源码但忘了重新打包导致反复复现同一个问题。4.2 在 Petalinux 中定位和修改设备树Petalinux 是 Xilinx 推出的嵌入式 Linux 工具链后来也支持瑞芯微等第三方 SoC 适配使用设备树作为硬件配置入口。Petalinux 工程里用户自定义的设备树文件通常在类似这样的路径下project-spec/meta-user/recipes-bsp/device-tree/files/我习惯的做法是这样的先找到当前工程的设备树配置确认使用的是哪个板级 dts。用文本编辑器打开板级 dts 或 system-user.dtsi。通过引用和覆盖的方式修改外设节点比如打开某个 I2C、配置某个 GPIO。在工程根目录执行petalinux-build -c device-tree这个命令会重新编译设备树并生成对应的 dtb。之后再执行petalinux-package --boot或petalinux-build把新的 dtb 打包进启动镜像。不同版本的 Petalinux 菜单结构会有差异比如有的版本使用petalinux-config -c kernel进入内核配置有的版本提供专门设备树配置界面。我自己踩过的坑是用户自定义 dts 里如果不小心#include了绝对路径会导致跨机编译失败。比较好的写法是只使用相对路径和#include ...形式把公共内容放到 dtsi 中。4.3 反编译 dtb 与确认实际生效内容改完设备树之后最怕的是“我以为我改了但实际内核加载的完全是另一个 dtb”。所以反编译和查看 dtb 内容是必做操作。把 dtb 反编译成 dts 用dtc -I dtb -O dts -o decompiled.dts rk3568-board.dtb在开发板上还可以用/sys/firmware/fdt或者直接/proc/device-tree查看内核实际解析后的设备树。fdtdump也是不错的工具它会按原始格式打印整棵 fdt 结构节点偏移、属性长度、phandle 都能看到。快速定位问题时我一般先用strings查看 dtb 里的字符串确认某个节点名或 compatible 是否存在再用fdtget读取具体属性值fdtget -t s rk3568-board.dtb /leds/work-led label这些命令不需要重新烧写整机能直接在开发板上确认当前加载的设备树内容排查“代码没生效”的问题特别好用。5. 常见问题与排查技巧实录5.1 语法错误与编译报错设备树语法很“死板”少一个分号、漏一个括号都会直接编译失败。最常见的报错是syntax error检查属性和花括号末尾的分号。Error: Label or path ... not found引用了不存在的 label。Error: phandle is not available属性里某个节点但该节点没有定义 phandle一般是因为节点名写错或被 /delete-node/ 删掉了。Error: Bad cell value(s)尖括号里写了无法解析的数字或宏。我排查语法问题时习惯从上往下数括号先看节点层级是否闭合。另外#include头文件里的宏如果写错了报错位置往往会指向头文件而不是出错行这时要看完整编译输出不要只盯着最后的 error 行。5.2 pinctrl 不生效与 GPIO 申请失败很多外设驱动 probe 成功了但功能不正常问题常常出在 pinctrl 上。设备树里引脚复用一般这样声明pinctrl-names default; pinctrl-0 i2c0_xfer;如果i2c0_xfer这个 pinmux 节点没有在对应 pinctrl 控制器里定义或者引用了另一个 SoC 型号的引脚宏内核不会编译报错而是启动时打印一条 pinctrl 相关的警告然后默默跳过引脚配置。这时候 GPIO 可能被别的节点占用或者复用成了普通 GPIO 而不是 I2C 功能。调试这类问题我一般先看内核日志搜pinctrl和gpio关键字。在开发板上还可以查看cat /sys/kernel/debug/gpio cat /sys/kernel/debug/pinctrl/pinctrl-handles cat /sys/kernel/debug/pinctrl/rockchip-pinctrl/pinmux-pins这三条命令能直接看到某个引脚当前被谁占用、mux 成了什么功能。如果发现引脚还在“GPIO 输入”状态说明 pinctrl 节点没生效要回头查pinctrl-0引用的节点是否写对。5.3 reg、中断和时钟属性的排查重点reg 问题常见于 64 位地址的 SoCRK3568 很多外设基地址超过 32 位所以控制器节点通常用#address-cells 2和#size-cells 2。这时候 reg 里必须写两个 cell 的地址和两个 cell 的长度比如reg 0x0 0xfeff0000 0x0 0x1000;如果只写一个0xfeff0000 0x1000内核解析出来的资源地址会错位驱动用platform_get_resource拿到的地址肯定不对。中断排查也是一样。RK3568 使用 GIC中断号必须带 GIC 类型前缀比如interrupts GIC_SPI 149 IRQ_TYPE_LEVEL_HIGH;GIC_SPI、GIC_PPI这些宏来自dt-bindings/interrupt-controller/arm-gic.h。如果不 include 这个头文件直接写数字也行但很容易写错百位级的中断号。我遇到过驱动 request_irq 失败排查了半天最后发现设备树里中断号比手册少了一位原因是把 SPI 和 PPI 编号混用了。时钟属性问题通常表现为驱动 probe 后 clk_enable 失败或者时钟频率不对。设备树里clocks和clock-names必须一一对应驱动通过clock-names按名字取时钟名字不一致就会取空指针。这种错误编译不会报要开CONFIG_COMMON_CLK_DEBUG看时钟状态或者直接在内核日志里搜clk相关 warning。6. 我维护设备树的几个个人习惯做设备树时间久了我慢慢总结出几个非常朴素的习惯。第一每个板子独立一个 dts板级差异只写在板级 dts 里绝对不轻易改厂商 dtsi非要改也要用覆盖别复制整段节点。第二所有引脚宏、中断宏、GPIO 极性都使用 dt-bindings 头文件不用魔法数字。第三每次改完设备树先本地反编译一遍确认改动真的进了 dtb。第四也是我想重点强调的设备树不是“写完就能跑”的配置它和驱动、pinctrl、时钟框架是一套联动系统。每次改设备树之前先打开内核源码里的Documentation/devicetree/bindings/对应文档那里有最权威的属性说明和示例比任何博客都靠谱。设备树看起来语法简单难的是理解硬件、总线、驱动之间的协作关系。我见过很多朋友把大量时间花在“设备树怎么写”上却忽略了先看芯片手册最后反而被寄存器地址、中断号这类基础信息坑了。希望这篇长文能帮你把设备树的语法骨架搭起来后面遇到具体外设直接按“节点、属性、匹配、编译、看日志”这条路子走基本不会出大错。