各位做嵌入式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_device、i2c_client、gpio等资源。以后换了新板子,只需要改设备树源文件(.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.dts、rk3568-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以及部分支持动态扩展的嵌入式系统中。
理解dts和dtsi的关系非常重要。简单来说,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_node和device_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属性。
对于reg、interrupts这类标准属性,也有对应的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"也是经常被忽略的一点。节点状态支持okay、disabled等。如果芯片原厂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.dts、rk3568-iotest.dts等:不同产品形态的板级文件。- 可能还有大量
.dtsi,比如rk3568-android.dtsi、rk3568-linux.dtsi,里面包含不同系统(Android/Linux)对内存分区、显示、音频等资源的不同配置。
选择的基本原则是:优先选择和你手上板子“同型号”的.dts。如果你是买的核心板 + 自己设计的底板,一般厂家会提供一个对应的底板参考.dts。没有的话,最简单的方式是找一块和自己底板硬件资源最接近的评估板.dts,在此基础上复制一份新文件,然后做增量修改。
5.2 修改RK3568设备树的典型操作流程
我以“把UART2改成调试串口,并在设备树里禁用某个用不到的I2C控制器”为例:
- 复制一份已有板级的
.dts,命名为rk3568-myboard.dts。 - 在文件头部确认
#include "rk3568.dtsi"、#include "rk3568-linux.dtsi"都在。 - 找到UART2节点,确认状态:
&uart2 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&uart2m0_xfer>; };- 找到不用的I2C节点,比如
&i2c2,把它状态改为disabled:
&i2c2 { status = "disabled"; };- 重新编译
dtbs,把新的.dtb替换到启动介质里。
这些操作看起来简单,但真正实战中会遭遇各种细节问题:pinctrl引脚冲突、时钟没开、vbus电源GPIO没设置、reset引脚被复用成GPIO等。所以这里极其强调在改设备树之前,先弄清板子原理图——哪个I2C控制器接到了哪个引脚、哪个GPIO控制了哪颗电源芯片、哪些引脚被Bootloader默认占用,这些信息必须和原理图一一对应。
5.3 OpenHarmony与标准Linux设备树的关系
热词里还提到了“openharmony的rk3568有许多设备树到底咋选”。这里补充一下:OpenHarmony虽然是华为开源的操作系统,但底层内核实际上是Linux内核(LTS分支)的一个发行分支,所以它依然采用设备树来管理ARM硬件资源。OpenHarmony官方RK3568 SDK里,设备树同样分为rk3568.dtsi、rk3568-evb.dts等文件,但会多出一些和HDF驱动框架(HarmonyOS Driver Framework)相关的节点配置。
如果你是在OpenHarmony的RK3568板子上做开发,选择设备树的原则其实和标准Linux一模一样:先看板子型号,再看外设差异,最后看系统框架要求。唯一的区别是,OpenHarmony把部分硬件能力下沉到了HDF层,设备树里的节点不仅要匹配Linux驱动,还要配合HDF驱动框架的device_info配置。这属于“换了层皮,核没变”的范畴,理解标准设备树原理后,你迁移到OpenHarmony不会太费劲。
6. 设备树开发中的常见问题与排查技巧
这块是我最想聊的。很多人在实践中被设备树折磨得死去活来,动不动就是“启动黑屏”“串口没输出”“驱动probe不到”。下面总结几个最高频的问题和排查路径。
6.1 节点状态没问题,驱动却没probe?
这种情况太常见了。先按顺序排查:
- 驱动是否真的编译进了内核?查
/lib/modules/$(uname -r)/modules.alias或直接grep内核配置,确认CONFIG_XXX已开启。 compatible拼写是否完全一致?它不像C语言那样忽略大小写,差一个字符、少一个逗号都可能不匹配。- 设备树是否真的更新到了启动介质?很多人改了
.dts后重新编译内核,却忘了resource分区/boot分区里的.dtb还是旧的。 - 节点父总线是否
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_region和ioremap照样能映射成功,但读写的是另一个不相关外设的寄存器,轻则数据不对,重则直接挂死。
排查思路:先去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)
设备树里的gpios、clocks、pinctrl-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 自己开发板卡时的设备树设计流程
最后聊一下从零做一块板子时,设备树该怎么起步。我一般按这样的步骤来:
- 找到最接近的官方评估板
.dts作为模板,不要从零写。 - 用对比工具(vimdiff/meld)对比官方评估板原理图和自己的底板原理图,逐个外设确认差异。
- 优先处理启动必需项:DDR初始化由Bootloader负责,设备树不用管;调试串口必须最先配好,
stdout-path要指对;EMMC/SD卡、网络接口其次,因为后面调试要频繁用到。 - 再把I2C外设、SPI外设、GPIO点灯、PWM、ADC等次要设备逐个打开。
- 每个外设验证通过后,再做引脚复用冲突检查,删除没用到的节点。
- 最后做一次全量启动测试和长时间稳定性测试,重点关注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或独立文件”的分层方式来管理,长期来看能省掉大量踩坑时间。