news 2026/9/4 13:29:10

Linux设备树完全攻略:从语法规则到驱动匹配实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux设备树完全攻略:从语法规则到驱动匹配实战

各位做嵌入式Linux的朋友,相信你们对“设备树”这个词都不陌生。尤其是这两年,从i.MX、RK、全志到Zynq,几乎所有主流ARM SoC平台都切换到设备树(Device Tree)模式来管理硬件资源了。很多刚接触Linux驱动开发的同学,第一个拦路虎往往不是C语言或内核API,而是看不懂那一堆.dts.dtsi文件,不知道驱动代码和这些描述硬件的文本之间到底是什么关系,更搞不清楚为什么改一个引脚就要重新编译内核,以及“设备树到底是怎么被驱动用起来的”。

这篇文章我想换个角度来写,不堆砌概念,完全从实际开发视角出发。我会把设备树的本质、语法规则、与驱动的匹配流程、实际移植调试过程中踩过的坑,以及常见问题排查方法,全部串起来讲一遍。希望能帮那些正在用RK3568、i.MX8M、全志H6甚至Zynq平台做开发的朋友,彻底打通“设备树 + 驱动”这条完整的链路。内容会尽量贴近实战,适合刚入门驱动开发的工程师,也适合被设备树困扰了一段时间、想系统性理清思路的嵌入式开发者。

1. 设备树到底解决了什么问题:一个硬件描述层的演进故事

1.1 从“写死在代码里”到“用数据描述硬件”

在设备树大规模普及之前,尤其在早期ARM Linux时代,BSP工程师的工作方式非常“硬核”:每来一块新板子,就要在内核源码的arch/arm/mach-xxx目录下新建一个board-xxx.c文件,然后在里面用C语言结构体把板子上的所有硬件资源一个个注册进内核。你会看到这样的代码:

static struct platform_device led_device = { .name = "led", .id = -1, .dev = { .platform_data = &led_platform_data, } };

这个platform_device结构体就是用来描述“板子上有一颗LED,它的引脚是GPIO1_3,默认高电平有效”。每来一块新板子,就得写一堆这样的结构体,然后注册进系统。问题很快就暴露了:不同厂家、不同型号的开发板,内存大小不一样、串口地址不一样、外设引脚分配更是千奇百怪。而内核是通用的,不可能为市面上每一块板子都维护一个专门的.c文件。

这就导致了一个很尴尬的局面:同一个内核版本,为了支持几十块板子,arch/arm/mach-*目录下积累了海量平台文件,相当一部分代码都是复制粘贴改参数,而且一旦芯片厂家改了引脚、DDR容量,又要重新动内核代码。内核维护者苦不堪言,嵌入式厂商的BSP工程师也被这种模式折磨到怀疑人生。

设备树(DT, Device Tree)的出现就是为了彻底改变这个局面。它的核心思路是:把硬件描述从内核代码里剥离出来,用一套独立的数据结构文件来描述板级硬件资源,内核启动时解析这份数据,动态生成所需的platform_devicei2c_clientgpio等资源。以后换了新板子,只需要改设备树源文件(.dts)、重新编译成二进制(.dtb),内核源码一行都不用动。

1.2 设备树在老内核、新内核里的不同地位

如果你去翻比较老的内核(比如Linux 3.x时代),你会发现ARM平台是“设备树与传统板级文件共存”的状态,部分架构对设备树的支持还要通过CONFIG_USE_OF来打开。而从Linux 4.x之后,设备树在ARM、PowerPC、RISC-V等平台上已经成为事实标准,arch/arm/mach-*目录下大量板级文件被清理掉,新平台基本不会再走board-xxx.c的老路。

对于x86平台来说,设备树用得相对少,因为x86有ACPI这套更成熟的标准。但嵌入式领域,尤其是基于ARM/RISC-V的IoT设备、工控板、车机,设备树几乎是必绕不开的一环。像瑞芯微RK3568这种平台,SDK里会给你一堆.dts.dtsi文件,很多人刚开始打开看会懵——这怎么选?怎么改?改错了会怎样?这篇文章后面会围绕这类真实问题展开。

提示:理解设备树的本质,可以把它类比成一张硬件的“配方表”。内核是“中央厨房”,设备树就是“菜单”。菜单不负责炒菜(不做硬件初始化),但厨师(内核)严格按照菜单来准备食材(注册设备、分配资源、匹配驱动)。

2. 设备树的核心语法与结构:不懂这些文件就看不懂板级配置

2.1 一块开发板的设备树文件家族:dts、dtsi、dtb、dtbo

实际开发中,你会接触到几种不同后缀的文件,很多人一开始容易混淆。我以一个典型的RK3568 SDK为例,帮你理清这几个文件的关系:

  • .dts:设备树源文件,一块具体板子的硬件描述。通常在arch/arm64/boot/dts/rockchip/目录下,比如rk3568-evb.dtsrk3568-nas.dts
  • .dtsi:设备树“头文件”或者叫“公共片段”,一般用来描述SoC内部公共硬件资源,比如CPU核数、中断控制器、片上外设(I2C、SPI、UART控制器)的寄存器基地址。芯片厂商一般会把一份通用的rk3568.dtsi提供出来,板级.dts通过#include "rk3568.dtsi"引入。
  • .dtb.dts经过dtc工具编译后生成的二进制文件,内核启动时通过Bootloader(U-Boot)加载到内存,然后解析。
  • .dtbo:设备树插件目标文件,主要用于Linux 4.x以后引入的“设备树叠加”机制(Device Tree Overlay)。它允许在不重新编译整个.dtb的情况下,动态加载一小段板级配置,常见于Arduino、BeagleBone以及部分支持动态扩展的嵌入式系统中。

理解dtsdtsi的关系非常重要。简单来说,dtsi描述“芯片上有什么”,dts描述“这块板子上用了芯片的哪些资源、引脚怎么接、外设挂在哪个总线上”。

2.2 设备树节点、属性与值的规则

设备树本身是一种树形结构,有且只有一个根节点,用/表示。根节点下面挂子节点,子节点下面挂孙子节点,形成一个硬件拓扑。每一个节点都有若干“属性”,属性用来描述该硬件的特性。我拆一个实际例子:

/ { model = "EmbedFire RK3568 Board"; compatible = "rockchip,rk3568-evb", "rockchip,rk3568"; chosen { stdout-path = &uart2; }; leds { compatible = "gpio-leds"; work_led { label = "work"; gpios = <&gpio0 3 GPIO_ACTIVE_HIGH>; default-state = "on"; }; }; };

这里面:

  • model属性是板子的名称,用来给用户看的。
  • compatible属性是“兼容性”标识,非常重要。它是一组字符串,格式通常是"厂家,型号"。驱动和设备匹配时,主要就是靠这个属性来“对暗号”。
  • chosen节点比较特殊,它不是描述硬件,而是用来给内核传一些运行时参数,比如stdout-path指定控制台串口是哪个,bootargs指定内核启动参数。
  • leds节点下是自定义的子节点,每个LED一个子节点。gpios属性用<&gpio0 3 GPIO_ACTIVE_HIGH>这种形式描述:引用的gpio0控制器、引脚号是3、高电平有效。

各种属性值有不同写法,字符串、字符串列表、32位无符号整数数组、二进制数据、phandle引用等都会遇到。平时改设备树,接触最多的就是reg(寄存器地址和范围)、interrupts(中断号)、clocks(时钟)、gpios(GPIO)以及各种自定义属性。

2.3 地址编码、中断与GPIO描述方式

在设备树里,地址和中断是最容易被搞错的部分。

reg属性与地址编码reg属性由“地址 + 长度”组成。但是不同的总线,地址到底用几个32位来表示,取决于父节点的#address-cells#size-cells。比如:

uart2: serial@fe6a0000 { compatible = "rockchip,rk3568-uart"; reg = <0x0 0xfe6a0000 0x0 0x100>; };

注意,这里是两个< >中间放了三段数字,是因为RK3568是64位SoC,#address-cells = <2>(64位地址需要两个32位单元),#size-cells = <2>(64位长度也需要两个单元)。所以reg = <0x0 0xfe6a0000 0x0 0x100>表示:地址0x0_ fe6a0000,长度0x0_ 00000100。这个细节在新手改设备树时特别容易踩坑。如果平台是32位SoC,通常#address-cells = <1>#size-cells = <1>,那么reg = <0xfe6a0000 0x100>就够了。

interrupts属性。中断描述同样依赖父节点interrupt-controller#interrupt-cells定义。最常见的ARM GIC(通用中断控制器)是#interrupt-cells = <3>,分别是:中断类型(0表示SPI共享外设中断,1表示PPI私有外设中断)、中断号、触发方式标志。比如:

interrupts = <0 42 IRQ_TYPE_LEVEL_HIGH>;

含义就是:SPI类型,中断号42,高电平触发。

GPIO描述。GPIO在设备树里通常通过gpios属性描述,格式为<&gpio控制器 phandle 引脚号 标志>。例如<&gpio0 3 GPIO_ACTIVE_HIGH>表示GPIO0组的第3号引脚,高电平有效。注意,RK平台GPIO编号是按bank划分的:GPIO0的引脚0-31、GPIO1的引脚0-31……驱动里申请GPIO时会通过gpiod_get()这类API自动换算成全局GPIO号。

这些语法规则看着细碎,但它们是设备树能够被驱动正确解析的基石。下面我来重点讲设备树和驱动到底是怎么“牵手成功”的,这是理解整个机制的核心。

3. 设备树与驱动的匹配机制:compatible是如何对暗号的

3.1 驱动侧和设备侧如何通过compatible建立联系

设备树里的节点代表“硬件设备”,而驱动则是操作这个设备的“软件逻辑”。两者怎么关联起来?答案就是compatible字符串。

我以RK3568的UART为例。设备树里有一个串口节点:

uart2: serial@fe6a0000 { compatible = "rockchip,rk3568-uart", "snps,dw-apb-uart"; reg = <0x0 0xfe6a0000 0x0 0x100>; interrupts = <GIC_SPI 115 IRQ_TYPE_LEVEL_HIGH>; clocks = <&cru SCLK_UART2>, <&cru PCLK_UART2>; clock-names = "baudclk", "apb_pclk"; reg-shift = <2>; reg-io-width = <4>; };

在驱动源码drivers/tty/serial/8250/8250_dw.c里,有一个of_match_table

static const struct of_device_id dw8250_of_match[] = { { .compatible = "snps,dw-apb-uart" }, { .compatible = "rockchip,rk3568-uart" }, { .compatible = "rockchip,rk3288-uart" }, { /* Sentinel */ } }; MODULE_DEVICE_TABLE(of, dw8250_of_match);

内核遍历设备树中所有compatible"snps,dw-apb-uart""rockchip,rk3568-uart"的节点,发现匹配后,就会调用这个驱动的probe()函数。匹配顺序是:当设备树节点里的compatible属性有多个字符串时,驱动逐个尝试匹配,只要驱动匹配到其中一个字符串,就算匹配成功。常用的技巧是:第一字符串写“最具体”的型号(比如rockchip,rk3568-uart),第二字符串写“通用兼容型号”(比如snps,dw-apb-uart)。这样即使未来没有针对新芯片做专门适配,只要新芯片的UART还是DesignWare IP核,老驱动也能跑起来。

注意:compatible匹配是内核设备模型最基础的匹配机制之一。除了它,platform总线还支持platform_device.name匹配和设备树节点device_node.name匹配,但实际开发中最常用的还是compatible。看到这里你应该明白,为什么说“设备树下,驱动尽量根据compatible来写,而不是靠设备名”。

3.2 驱动probe之后如何读取设备树资源

匹配成功并不是终点,关键是驱动的probe()函数要能从设备树节点里拿到它需要的“资源参数”。Linux内核提供了一套完善的device_nodedevice_property访问API。我以一个GPIO LED驱动为例展示这种读取过程:

static int my_led_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct gpio_desc *led_gpio; led_gpio = devm_gpiod_get(dev, "led", GPIOD_OUT_LOW); if (IS_ERR(led_gpio)) { return PTR_ERR(led_gpio); } ... }

这个“led”后缀的GPIO名字,对应设备树里子节点的led-gpios属性(或者对gpios属性而言,devm_gpiod_get会尝试多种命名)。如果你在设备树里这样定义:

work_led { label = "work"; led-gpios = <&gpio0 3 GPIO_ACTIVE_HIGH>; };

那么在驱动里devm_gpiod_get(dev, "led", ...)就会去寻找led-gpios属性。

对于reginterrupts这类标准属性,也有对应的API:

  • device_property_read_u32(dev, "prop-name", &val):读取自定义32位属性。
  • platform_get_resource(pdev, IORESOURCE_MEM, 0):获取reg属性对应的内存资源。
  • platform_get_irq(pdev, 0):获取interrupts属性里的中断号。
  • of_parse_phandle(args)devm_clk_get(dev, "baudclk"):解析时钟、GPIO等引用型属性。

实际开发中,probe()函数里最常见的一段代码就是:先读资源,再注册子系统(串口、网络、输入子系统、IIO等),最后做硬件初始化。

3.3 menuconfig、设备树、驱动三者的协作关系

很多人在刚开始做内核移植时会疑惑:我改了设备树,要不要重新配置menuconfig?驱动为什么没有编译进内核?这里我帮你理清三者关系:

  • **menuconfig(Kconfig)**决定“这个驱动代码要不要编译、以什么方式编译(y编入内核,还是m编成模块)”。
  • 设备树决定“该驱动要绑定的硬件对象是否存在、参数是什么”。
  • 驱动代码决定“硬件对象匹配后怎么初始化、怎么提供读写接口”。

换句话说,即使你的设备树里写了一大堆LED节点,但如果内核配置没有打开CONFIG_LEDS_GPIO,这个驱动根本不会编译,设备节点就白写了。反过来,驱动编译进了内核,但设备树里没有对应的compatible节点,驱动probe()也不会被调用。很多人的UBOOT启动卡在“串口没输出”,排查半天,最后发现是设备树里stdout-path没设,或者对应UART驱动没编译进去。三者必须配合好,硬件才能真正工作起来。

4. 实战拆解:从零写一个基于设备树的字符设备驱动

前面讲的偏原理,这里我完整走一遍“写一个简单字符设备驱动 + 配套设备树”的过程,以当前主流的platform_driver框架为例。这个框架是Linux下绝大多数简单外设驱动的标准写法,理解它之后,再去看Linux内核里各类子系统(I2C、SPI、GPIO、PWM)的驱动代码,就会顺很多。

4.1 一个最简platform驱动骨架

#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/uaccess.h> #define DRIVER_NAME "my_demo_dev" static int major = 0; static struct class *demo_class; static struct cdev demo_cdev; static dev_t dev_num; static void __iomem *reg_base; static int demo_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { char kernel_buf[16]; u32 reg_val; reg_val = readl(reg_base); sprintf(kernel_buf, "0x%x\n", reg_val); if (copy_to_user(buf, kernel_buf, strlen(kernel_buf))) { return -EFAULT; } return strlen(kernel_buf); } static const struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, }; static int demo_probe(struct platform_device *pdev) { struct resource *res; int ret; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENXIO; reg_base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(reg_base)) return PTR_ERR(reg_base); ret = alloc_chrdev_region(&dev_num, 0, 1, DRIVER_NAME); if (ret) return ret; cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; ret = cdev_add(&demo_cdev, dev_num, 1); if (ret) { unregister_chrdev_region(dev_num, 1); return ret; } demo_class = class_create(THIS_MODULE, DRIVER_NAME); if (IS_ERR(demo_class)) { cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(demo_class); } device_create(demo_class, NULL, dev_num, NULL, DRIVER_NAME); dev_info(&pdev->dev, "my_demo_dev probed successfully\n"); return 0; } static int demo_remove(struct platform_device *pdev) { device_destroy(demo_class, dev_num); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); return 0; } static const struct of_device_id demo_of_match[] = { { .compatible = "myvendor,my-demo-device" }, { /* Sentinel */ } }; MODULE_DEVICE_TABLE(of, demo_of_match); static struct platform_driver demo_platform_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = DRIVER_NAME, .of_match_table = demo_of_match, }, }; module_platform_driver(demo_platform_driver); MODULE_LICENSE("GPL");

这段代码做的事情可以拆成几块:demo_of_match[]负责和设备树节点匹配;demo_probe()在匹配成功后执行,先从设备树里拿寄存器资源(IORESOURCE_MEM),然后注册一个字符设备;demo_read()从映射的寄存器地址读值,返回给用户态。这套框架非常通用,你可以照着它快速搭出自己外设驱动的雏形。

4.2 配套的设备树片段

有了驱动,设备树里必须有一个“对口”的节点。假设我的硬件是一个挂在某个总线上的寄存设备,寄存器起始地址是0x10000000,长度0x1000。设计设备树节点如下:

&bus { demo_device: my-demo@10000000 { compatible = "myvendor,my-demo-device"; reg = <0x0 0x10000000 0x0 0x1000>; interrupts = <0 55 IRQ_TYPE_LEVEL_HIGH>; status = "okay"; }; };

这里compatible字符串必须和驱动里的demo_of_match[]一致,否则永远匹配不上。reg的写法取决于父总线节点的#address-cells#size-cells。如果在32位平台下,可能是reg = <0x10000000 0x1000>;

status = "okay"也是经常被忽略的一点。节点状态支持okaydisabled等。如果芯片原厂SDK默认把某些不用的外设写成disabled,又不小心在板级.dts里没有覆盖为okay,驱动就永远不会被枚举到。

4.3 编译与验证流程

驱动模块编译,可以直接放在内核源码树里建目录,也可以使用独立模块的Makefile方式。比较推荐的是放到内核源码里的drivers/misc/下,或者用内核模块外部编译模式:

obj-m += demo_drv.o KDIR := /path/to/kernel-source all: make -C $(KDIR) M=$(PWD) modules clean: make -C $(KDIR) M=$(PWD) clean

设备树编译则依赖dtc工具。在内核源码根目录下执行:

make ARCH=arm64 dtbs

或者如果你只改了一个文件:

make ARCH=arm64 rk3568-evb.dtb

生成的.dtb拷贝到启动分区,让U-Boot加载即可。

验证时,加载模块后先看日志:

insmod demo_drv.ko dmesg | tail

如果看到my_demo_dev probed successfully,说明驱动和设备树匹配成功。再查看设备节点:

ls /dev/my_demo_dev

然后可以用cat /dev/my_demo_dev读寄存器值。整个链路就通了。

5. 实战难点:RK3568平台设备树文件选择与修改

网页热词里频繁出现瑞芯微RK3568的“设备树到底咋选”“ubuntu如何修改RK3568设备树”。我必须说,这个问题是很多人拿到RK3568开发板后的第一个大事。RK3568是瑞芯微一款很火的AIoT芯片,四核A55,带NPU,很多国产工控板、核心板、NAS主板都在用。但它的SDK里设备树文件极多,选错文件、改错位置,会浪费大量时间。

5.1 一份RK3568 SDK里有哪些设备树文件

通常arch/arm64/boot/dts/rockchip/目录下会有一批:

  • rk3568.dtsi:SoC级公共外设定义,包括CPU、GIC、CRU(时钟和复位)、GRF(通用寄存器文件)、I2C、SPI、UART、SDMMC、EMMC、USB、GMAC、PCIe、HDMI等控制器的基本描述。
  • rk3568-evb.dts/rk3568-evb1-v10.dts/rk3568-evb2-lp4x-v10.dts:不同型号评估板的板级文件。
  • rk3568-nas.dtsrk3568-iotest.dts等:不同产品形态的板级文件。
  • 可能还有大量.dtsi,比如rk3568-android.dtsirk3568-linux.dtsi,里面包含不同系统(Android/Linux)对内存分区、显示、音频等资源的不同配置。

选择的基本原则是:优先选择和你手上板子“同型号”的.dts如果你是买的核心板 + 自己设计的底板,一般厂家会提供一个对应的底板参考.dts。没有的话,最简单的方式是找一块和自己底板硬件资源最接近的评估板.dts,在此基础上复制一份新文件,然后做增量修改。

5.2 修改RK3568设备树的典型操作流程

我以“把UART2改成调试串口,并在设备树里禁用某个用不到的I2C控制器”为例:

  1. 复制一份已有板级的.dts,命名为rk3568-myboard.dts
  2. 在文件头部确认#include "rk3568.dtsi"#include "rk3568-linux.dtsi"都在。
  3. 找到UART2节点,确认状态:
&uart2 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&uart2m0_xfer>; };
  1. 找到不用的I2C节点,比如&i2c2,把它状态改为disabled
&i2c2 { status = "disabled"; };
  1. 重新编译dtbs,把新的.dtb替换到启动介质里。

这些操作看起来简单,但真正实战中会遭遇各种细节问题:pinctrl引脚冲突、时钟没开、vbus电源GPIO没设置、reset引脚被复用成GPIO等。所以这里极其强调在改设备树之前,先弄清板子原理图——哪个I2C控制器接到了哪个引脚、哪个GPIO控制了哪颗电源芯片、哪些引脚被Bootloader默认占用,这些信息必须和原理图一一对应。

5.3 OpenHarmony与标准Linux设备树的关系

热词里还提到了“openharmony的rk3568有许多设备树到底咋选”。这里补充一下:OpenHarmony虽然是华为开源的操作系统,但底层内核实际上是Linux内核(LTS分支)的一个发行分支,所以它依然采用设备树来管理ARM硬件资源。OpenHarmony官方RK3568 SDK里,设备树同样分为rk3568.dtsirk3568-evb.dts等文件,但会多出一些和HDF驱动框架(HarmonyOS Driver Framework)相关的节点配置。

如果你是在OpenHarmony的RK3568板子上做开发,选择设备树的原则其实和标准Linux一模一样:先看板子型号,再看外设差异,最后看系统框架要求。唯一的区别是,OpenHarmony把部分硬件能力下沉到了HDF层,设备树里的节点不仅要匹配Linux驱动,还要配合HDF驱动框架的device_info配置。这属于“换了层皮,核没变”的范畴,理解标准设备树原理后,你迁移到OpenHarmony不会太费劲。

6. 设备树开发中的常见问题与排查技巧

这块是我最想聊的。很多人在实践中被设备树折磨得死去活来,动不动就是“启动黑屏”“串口没输出”“驱动probe不到”。下面总结几个最高频的问题和排查路径。

6.1 节点状态没问题,驱动却没probe?

这种情况太常见了。先按顺序排查:

  1. 驱动是否真的编译进了内核?查/lib/modules/$(uname -r)/modules.alias或直接grep内核配置,确认CONFIG_XXX已开启。
  2. compatible拼写是否完全一致?它不像C语言那样忽略大小写,差一个字符、少一个逗号都可能不匹配。
  3. 设备树是否真的更新到了启动介质?很多人改了.dts后重新编译内核,却忘了resource分区/boot分区里的.dtb还是旧的。
  4. 节点父总线是否status = "disabled"?如果I2C控制器本身被disable了,挂在它下面的所有设备都不会被枚举。

我建议你养成一个习惯:板子起来后,先看/proc/device-tree/目录(或者/sys/firmware/devicetree/base/),检查内核实际解析到的设备树内容,和你的预期是否一致。比如:

ls /proc/device-tree/ cat /proc/device-tree/model ls /proc/device-tree/soc/serial@fe6a0000/

如果这里能看到你添加的节点,说明设备树被正确加载了;如果看不到,问题就出在“编译/烧录/加载”环节。

6.2 引脚冲突和pinctrl配置导致的怪异现象

RK这类SoC的引脚复用非常灵活,一个引脚可以同时是UART的TX、I2C的SCL、GPIO的普通输入。设备树里通过pinctrl节点来配置引脚复用功能。

&pinctrl { uart2 { uart2m0_xfer: uart2m0-xfer { rockchip,pins = <0 RK_PB0 2 &pcfg_pull_up>, <0 RK_PB1 2 &pcfg_pull_up>; }; }; };

如果你不小心把两个外设配置到同一个物理引脚上,系统启动时pinctrl子系统会在日志里打出冲突警告,但很多情况下外设只是表现为“数据错乱”或者“功能不稳定”,并不一定立刻崩溃。遇到这类问题,要养成先看dmesg输出的习惯,同时可以用/sys/kernel/debug/pinctrl/下的调试接口确认每个引脚的复用状态。

cat /sys/kernel/debug/pinctrl/pinctrl-rockchip-pinmux/pinmux-pins

这个文件会列出所有引脚的当前mux状态,排查冲突非常有用。

6.3 寄存器资源和中断号对不上

驱动probe到了,但是读写寄存器就崩溃,或者中断一直触发不了,这类问题通常出在设备树资源描述和SoC实际寄存器地址不一致上。以RK3568为例,UART2的基地址是0xFE6A0000,如果你写成了0xFE690000,驱动用request_mem_regionioremap照样能映射成功,但读写的是另一个不相关外设的寄存器,轻则数据不对,重则直接挂死。

排查思路:先去SoC datasheet/TRM里查外设基地址,确定无误后再看设备树。如果驱动里用了platform_get_resource,可以用下面这种临时调试代码打印出来:

dev_info(dev, "mem resource: start=%llx len=%llx\n", (unsigned long long)res->start, (unsigned long long)resource_size(res));

中断号问题也是类似。ARM GIC的SPI中断号通常有一个“从0开始”还是“从32开始”的偏移问题。设备树里interrupts = <0 115 IRQ_TYPE_LEVEL_HIGH>中的115已经是GIC编号体系里的SPI号了,驱动里platform_get_irq拿到的也是这个编号。如果你自己参考文档去配中断号,一定要确认SoC到底把哪个中断号对应哪个外设。

6.4 调试工具与dump设备树的方法

最后推荐几个我日常调试设备树依赖的工具和方法:

  • dtc反编译:拿到.dtb后可以反编译回.dts,快速确认二进制里的内容和你的源文件是否一致。命令:dtc -I dtb -O dts -o output.dts input.dtb
  • fdtdump:直接以树形格式查看.dtb文件内容,适合不想安装完整dtc的环境。
  • /proc/device-tree:运行时查看内核加载后的设备树,用法见上文。
  • /sys/devices/platform/目录:查看平台设备注册情况,如果节点被驱动匹配成功,一般会有一个对应的platform设备目录出现。
  • trace event:Linux内核的/sys/kernel/debug/tracing/events/下,有of相关事件,可以跟踪设备树解析和匹配过程。

注意:status = "disabled"只是设备树层面的“软禁用”。如果硬件本身已经上电,但设备树里设为disabled,驱动不会加载,但寄存器地址空间依然可能被其他驱动占用。所以调试时不要只看“有没有这个节点”,还要看系统里实际有哪些设备被注册了。

7. 从设备树出发,进阶理解Linux设备模型

7.1 platform bus、device、driver三件套

设备树只是硬件描述层,真正把它串起来的是Linux设备模型。每次设备树节点被解析的时候,内核会根据节点内容创建device对象,然后挂到对应的总线(platform bus、i2c bus、spi bus等)上。驱动则在初始化时注册到同一条总线的driver链表中。总线的match()函数负责判断哪个device和哪个driver配对,配对成功就调用driver->probe()

以platform总线为例,它的match()函数会依次尝试多种匹配方式:

static int platform_match(struct device *dev, struct device_driver *drv) { // 1. 尝试 of_driver_match_device:比较 device_node 的 compatible 和驱动 of_match_table if (of_driver_match_device(dev, drv)) return 1; // 2. 尝试 acpi_driver_match_device // 3. 尝试 platform driver 的 id_table 匹配 // 4. 尝试 driver name 和 device name 直接比较 }

因此,设备树节点被解析成platform_device后,只要compatible能和驱动的of_match_table匹配,就万事大吉。理解这个流程,你会明白为什么很多时候你在/sys/bus/platform/devices/下能看到一个设备,但找不到对应驱动——因为没有匹配成功或驱动没编译进去。

7.2 从设备树到各子系统(GPIO、Pinctrl、Clock、Regulator)

设备树里的gpiosclockspinctrl-0等属性并不是孤立的,它们最终会关联到具体的子系统和硬件控制框架。比如:

  • 设备树里的clocks = <&cru SCLK_UART2>,驱动里devm_clk_get(dev, "baudclk")拿到的不只是描述信息,而是一个能被clk_prepare_enable()控制的真实时钟对象。这个对象来自Clk框架注册的时钟树,由rk3568-cru驱动初始化。
  • gpios = <&gpio0 3 GPIO_ACTIVE_HIGH>,驱动里devm_gpiod_get()得到的是GPIO描述符,最终操作的寄存器是GPIO控制器的GPIO_SWPORT_DR_L寄存器。
  • pinctrl-0 = <&uart2m0_xfer>,驱动pinctrl_select_state()时,会调用pinctrl框架去配置对应引脚的复用和上下拉。

这种“属性到驱动API到寄存器”的映射,是设备树设计的精髓。一个外设驱动不直接写寄存器地址,而是通过标准API向后端框架请求资源,资源的具体实现在设备树里配置。这种方式让驱动变得通用,也让BSP工程师的工作重心逐渐从“写驱动适配硬件”变成了“写设备树描述硬件”。

7.3 ACPI与设备树:两条路殊途同归

有朋友可能会问:x86平台怎么不用设备树?其实x86有ACPI,它也是类似设备树的一种“硬件描述表”。ACPI表是BIOS/UEFI固件在启动时提供给OS的,里面包含了设备树里常见的那些信息——设备ID、资源、电源管理策略等。Linux内核在设备模型层做了很好的抽象:x86走acpi_device,ARM/RISC-V走platform_device(由设备树生成),但驱动注册、总线匹配、资源管理的核心框架是同一套。你理解了设备树驱动的这套东西,以后看ACPI驱动的实现也不会太陌生。

8. 一些实战开发的进阶建议

8.1 驱动模块加载顺序和设备树无关

不少初学者以为设备树里节点顺序影响驱动加载顺序。其实不是。驱动加载顺序主要由内核initcall等级、模块依赖、模块加载顺序决定。设备树只是描述硬件是否存在,不负责排序。如果你有两个驱动A和B,B需要用到A提供的接口,那么你在设备树里把B节点放在A节点前面并不会改变加载顺序。正确做法:在内核配置里让A先编译进内核,或者把B作为依赖A的模块,或者驱动里通过-EPROBE_DEFER延迟probe并等待依赖资源就绪。

-EPROBE_DEFER是驱动开发的重要机制。当驱动probe()时发现时钟、GPIO、电源等依赖资源还没准备好,可以返回这个特殊错误码,内核会把这个设备放到“待重试”队列,等依赖资源注册完成后再次尝试调用probe()。有了它,设备树节点哪个先解析、驱动哪个先注册都不重要了,这是现代Linux驱动开发里必须掌握的一环。

8.2 设备树节点的复用与裁剪

在设计设备树时,我建议遵循“最小化原则”:只打开板子上实际用到的外设。很多时候原厂SDK的板级文件默认打开了一堆用不到的外设,不仅浪费引脚,还可能在pinctrl配置上造成冲突。比如一块工控板只需要串口、网口、USB和几个GPIO,那就把SDK默认的HDMI、MIPI-DSI、eDP、PCIe等节点全部disabled掉。这样调试时的问题域会小很多。

同时,善用设备树“覆盖”的技巧。dtsi是公共配置,板级.dts是特定配置。板级文件里可以用&uart2 { ... };这样的引用语法覆盖dtsi里的默认属性。这是设备树语法的重要特性:同一个节点可以在文件多处出现,后面的配置会覆盖或合并到前面的基础配置。

8.3 自己开发板卡时的设备树设计流程

最后聊一下从零做一块板子时,设备树该怎么起步。我一般按这样的步骤来:

  1. 找到最接近的官方评估板.dts作为模板,不要从零写。
  2. 用对比工具(vimdiff/meld)对比官方评估板原理图和自己的底板原理图,逐个外设确认差异。
  3. 优先处理启动必需项:DDR初始化由Bootloader负责,设备树不用管;调试串口必须最先配好,stdout-path要指对;EMMC/SD卡、网络接口其次,因为后面调试要频繁用到。
  4. 再把I2C外设、SPI外设、GPIO点灯、PWM、ADC等次要设备逐个打开。
  5. 每个外设验证通过后,再做引脚复用冲突检查,删除没用到的节点。
  6. 最后做一次全量启动测试和长时间稳定性测试,重点关注pinctrl、regulator、时钟频率相关的告警。

这套流程走下来,比盲目照搬原厂配置要稳妥得多。

9. 踩坑实录与排查手册

下面把我这些年整理常见问题和排查思路整理成速查表,供你遇到问题时快速检索。

9.1 问题速查表

现象可能原因排查方向
启动后串口无输出stdout-path没设或指向错误检查chosen节点
串口输出乱码波特率不对或UART引脚复用错误检查pinctrl,确认时钟源
设备树节点在/proc/device-tree里看不到烧录的dtb不对反编译实际dtb确认
驱动模块加载但probe没执行compatible不匹配或status为disabled检查of_match_table和节点状态
probe执行了但读寄存器崩溃reg地址写错或ioremap失败对照TRM确认基地址
GPIO申请失败引脚被pinctrl占用或gpio被其他驱动申请查pinctrl调试信息
时钟申请失败(-EPROBE_DEFER)时钟树初始化晚于设备probe确认clk驱动是否编译,检查clk名称
中断一直不触发中断号不对或触发类型不匹配对照GIC编号,检查interrupts属性
外设有时正常有时不正常电源域或复位时序问题检查regulator、reset相关节点

9.2 一个典型的dmesg排查片段

假设你发现I2C外设设备在/sys/bus/i2c/devices/下看不到,首先执行dmesg | grep i2c,常见的输出有:

i2c i2c-0: adapter [soc:i2c@fe650000] registered i2c i2c-1: adapter [soc:i2c@fe660000] registered rk3x-i2c fe670000.i2c: Initialized RK3xxx I2C bus at 0xfe670000

如果某一组总线没有registered,说明设备树里对应I2C控制器节点status可能不是okay,或者pinctrl配置有问题导致驱动probe失败。这时再用cat /sys/kernel/debug/pinctrl/pinctrl-rockchip-pinmux/pinmux-pins查看引脚复用情况,一步步缩小问题范围。

9.3 一个容易忽视的细节:interrupt-cells与GPIO的cell大小

很多人在写自定义设备节点时,会随手写interrupts = <55>gpios = <&gpio0 3>,而忘了看父节点的#interrupt-cells#address-cells#size-cells#gpio-cells。如果父节点定义的是#interrupt-cells = <3>,你只写了两个数,解析时数据就对不上。这种问题用dt-validate工具(新版dtc自带的schema校验)可以在编译期暴露出来,但很多老版本环境没有生效,只能靠经验排查。我建议你在自定义节点时,先看清对应控制器节点的cell定义,再去写属性。

10. 写在最后的经验之谈

说点实在的。设备树这套机制,初看起来就是一些text格式的配置文件,但它背后是整个Linux设备模型的骨架。你越早摆脱“改设备树靠试”的状态,越早把“compatible -> of_match_table -> probe -> 资源API”这条链路吃透,后面做外设适配、内核裁剪、BSP移植都会快很多。

我个人在实际开发中的体会是:设备树文档和内核源码本身就是最好的教材。遇到不懂的节点,先grep一下内核源码里对它的解析方式,基本都能找到答案。比如你想知道reg-shift是什么含义,直接在drivers/tty/serial/8250/8250_dw.c里搜这个名字,就能看到of_property_read_u32的解析逻辑和使用场景。

最后再分享一个小技巧:在多平台、多板卡项目里,一定养成为每个产品型号维护独立.dts文件并用git版本管理的好习惯。不要图省事在公共dtsi上直接改,否则某天一个产品的外设改动会影响另一个完全不相关的产品,排查起来会非常崩溃。建议用“公共dtsi保持纯净、板级dts做具体配置、产品差异用overlay或独立文件”的分层方式来管理,长期来看能省掉大量踩坑时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/4 13:29:06

ESP32-WROOM-32UE-N16R2:16MB Flash加2MB PSRAM,这大概是ESP32模组里的顶配了

前段时间做一个带屏的智能中控项目&#xff0c;跑LVGL界面加上本地语音唤醒&#xff0c;固件体积直接飙到了6MB多。之前用的4MB Flash版本根本装不下&#xff0c;后来换了ESP32-WROOM-32UE-N16R2&#xff0c;16MB的空间一下子从容多了。N16R2的配置到底有多高ESP32-WROOM-32UE-…

作者头像 李华
网站建设 2026/9/4 13:28:43

Budibase数据导出实操:3个动作出文件

Budibase数据导出实操&#xff1a;3个动作出文件 【免费下载链接】budibase AI agents, automations and apps that run your operations. Model agnostic. 项目地址: https://gitcode.com/GitHub_Trending/bu/budibase 周五下午五点&#xff0c;业务方要本周的销售明细…

作者头像 李华
网站建设 2026/9/4 13:26:59

Ice 终极指南:macOS 14 以上如何安装、授权并彻底整理菜单栏

Ice 终极指南&#xff1a;macOS 14 以上如何安装、授权并彻底整理菜单栏 【免费下载链接】Ice Powerful menu bar manager for macOS 项目地址: https://gitcode.com/GitHub_Trending/ice/Ice Ice 是一款面向 macOS 14 及以上系统的菜单栏管理工具&#xff0c;解决的核心…

作者头像 李华
网站建设 2026/9/4 13:26:00

LeRobot开源机器人框架:让SO-100机械臂自己学会干活

LeRobot开源机器人框架&#xff1a;让SO-100机械臂自己学会干活 【免费下载链接】lerobot &#x1f917; LeRobot: Making AI for Robotics more accessible with end-to-end learning 项目地址: https://gitcode.com/GitHub_Trending/le/lerobot SO-100 是 LeRobot 官方…

作者头像 李华
网站建设 2026/9/4 13:25:52

Canal 与 HBase/Hudi 实时入湖:MySQL 数据实时同步到数据湖的架构实践

Canal 与 HBase/Hudi 实时入湖&#xff1a;MySQL 数据实时同步到数据湖的架构实践 1. Canal 简介 Canal是阿里巴巴开源的一款基于数据库增量日志解析的中间件&#xff0c;主要用于解决数据库实时同步问题。它通过解析MySQL的binlog日志&#xff0c;实现对数据库变更的捕获和传递…

作者头像 李华
网站建设 2026/9/4 13:24:11

当国风品牌遇见商业插画: 让创意落地真实的品牌商业场景

核心摘要 • 画星人与茶颜悦色的合作&#xff0c;本质上是一次创意人才与真实品牌需求之间的连接。 • 合作不只是“提供插画”&#xff0c;更重要的是把创作者带入真实商业项目&#xff0c;让插画能力参与品牌视觉与内容创新。 • 从需求理解、创意产出到项目落地&#xff0c…

作者头像 李华