你可能也遇过这种场面:RK3568 板子拿回来,驱动的 .c 文件写好了,menuconfig 也打开了,结果点灯死活不亮,/sys/class/leds 下面空荡荡的。最后检查来检查去,发现问题根本不在驱动,而在设备树——某个节点 status 还是 disabled,驱动压根没被 probe。这件事给我的教训是:搞 Linux 设备树和驱动的人,光会写.c不行,得先把设备树和驱动的“耦合关系”吃透。设备树不是一堆给内核看的配置文件,它是决定驱动能不能运行、怎么运行的“硬件说明书”。
这篇文章想聊的,就是设备树和 Linux 驱动之间那点事。我会从设备树为什么会出现讲起,拿实际 RK3568 板子上的点灯节点做例子,把这套机制拆开揉碎,适合刚接触设备树的嵌入式开发,也适合那些整天调驱动但始终没把设备树匹配逻辑捋顺的同学。
1. 没有设备树的日子里,Linux 是怎么被硬件变体拖垮的
1.1 板级文件时代的“一片混乱”
现在的年轻人可能体会不到,Linux 早期在 ARM 平台上维护代码有多痛苦。那时候每出一个新的开发板,内核里就要加一个arch/arm/mach-xxx/board-xxx.c,里面写满了这块板子上的外设注册代码。比如你板子上有一个 I2C 触摸屏、一个 GPIO 按键、一个网卡,都得在 board 文件里用platform_device_register()一个一个手动注册。
这样做最直接的后果是:内核源码里堆了成百上千个 board 文件。每换一块板子,哪怕只是把 GPIO 按键从 GPIO1_12 改到 GPIO1_13,也要复制一份 board 文件再改几行。代码仓库越来越臃肿,提交历史里大量内容都是“new board support”,真正有意义的驱动改动被淹没。更麻烦的是,uboot 和内核之间也没有统一的硬件描述格式,都是各自维护各自的配置。
我一个朋友早年维护过某个公司的 BSP,他跟我说,最怕的就是客户说“我们换了个 DDR 型号,帮我适配一下”。听起来只是改内存参数,但由于历史代码里板级信息四处耦合,经常上午改完下午就冒出新问题。这种痛苦我试过几次就明白了:硬件的多样性不可能靠“增加板级代码”解决,必须把硬件描述从驱动里抽离出去。
1.2 设备树到底是什么:一份内核和 Bootloader 共享的硬件清单
设备树(Device Tree)本质上就是一套描述硬件资源的“数据结构”,它从 Open Firmware 那套规范发展而来,目的是让内核通过一份扁平化的树形描述知道:CPU 是什么、内存多大、有哪些外设、外设挂在哪个地址上、中断线接到哪里、引脚复用到什么功能。
这份描述是二进制格式的,叫 DTB(Device Tree Blob)。DTB 的源文件是 DTS(Device Tree Source),公共部分拆成 DTSI(Device Tree Source Include)。编译工具叫 DTC。整条链路是这样:
.dts / .dtsi ---> dtc 编译 ---> .dtb ---> bootloader 加载 ---> 内核解析Linux 内核在启动早期会把 DTB 解析成一颗struct device_node组成的树,然后顺着树上的节点去创建平台设备(platform_device)。后面驱动注册时,总线去匹配这些设备,匹配上了就调用probe()。设备树最大的价值不是“让驱动少写代码”,而是让驱动不再和具体板子绑定。同样一个 GPIO 控制器驱动,可以服务一百种不同接法的板子,只要设备树里描述清楚就行。
有一点必须提醒:设备树不是“万能配置中心”,它能描述资源,但不能凭空创造驱动。如果你的外设内核里根本没有驱动,设备树写得再漂亮,硬件也不会工作。设备树提供的是“位置”和“资源”,驱动的职责才是“行为”。
2. 设备树文件的真实结构:从一份 gpio-leds 设备树开始逐行读
2.1 最小可用的点灯节点拆解
我习惯用点灯来入门设备树,因为一个最简单的 GPIO LED 外设,能把设备树里最核心的属性全部带出来。下面是一份真实 RK3568 SDK 里常见的节点片段:
/ { compatible = "rockchip,rk3568-evb1-ddr4-v10"; model = "Rockchip RK3568 EVB1 DDR4 V10 Board"; gpio-leds { compatible = "gpio-leds"; pinctrl-names = "default"; pinctrl-0 = <&led_work_pin>; led_work: led-0 { label = "work"; gpios = <&gpio0 RK_PC5 GPIO_ACTIVE_LOW>; default-state = "on"; linux,default-trigger = "heartbeat"; }; }; }; &pinctrl { led_work_pin: led-work-pin { rockchip,pins = <0 RK_PC5 RK_FUNC_GPIO &pcfg_pull_none>; }; };看起来是不是有点懵?我把它拆开解释。
/表示根节点,设备树是一棵以/为起点的树。根节点里通常有compatible和model。model是给人看的板子名字,compatible是给内核做板级匹配用的,格式一般是厂商,型号。
gpio-leds这个节点没有reg属性,也没有status属性。它不是一个“地址设备”,而是一个“纯平台设备”。内核的 GPIO LED 驱动drivers/leds/leds-gpio.c通过节点的compatible = "gpio-leds"找到它,然后解析下面的子节点,每个子节点代表一颗 LED。
gpios = <&gpio0 RK_PC5 GPIO_ACTIVE_LOW>是关键。这里的&gpio0是一个“引用”(phandle),指向 SoC 里 GPIO0 控制器的节点。RK_PC5是瑞芯微平台定义好的宏,表示 GPIO0 组的 C 组第 5 脚。GPIO_ACTIVE_LOW表示低电平点亮。这些宏定义通常来自内核里的 dt-bindings 头文件,所以设备树源文件里经常能看到一堆#include。
上电后,这个节点被解析成 platform_device,内核的 gpio-leds 驱动匹配成功后,就会在/sys/class/leds/work下生成一个设备。之后想点灯就简单了:
echo 0 > /sys/class/leds/work/brightness echo 1 > /sys/class/leds/work/brightness如果一切都是正常的,这就是设备树和驱动配合工作的完整闭环。
2.2 dtsi 和 dts 的分工,以及那些让你头疼的宏
实际工程里,设备树文件名五花八门,但文件组织有规律:SoC 厂商提供的是 dtsi,开发者只需要写一个“板级 dts”。RK3568 平台典型的样子大概是:
rk3568.dtsi # SoC 级:CPU、中断控制器、UART、I2C等控制器 rk3568-pinctrl.dtsi # 引脚控制器 rk3568-evb.dtsi # EVB 公共板级配置 rk3568-evb1-ddr4-v10.dts # 具体硬件版本板子,最终编译对象#include把层级串起来。板级 dts 里会覆盖 dtsi 中某些节点的状态,常见写法是:
&uart3 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&uart3_xfer>; };这种写法表示:“我要打开 uart3,并且给它配好默认引脚。”如果 dtsi 里 uart3 默认是status = "disabled",你没有在板级文件里“翻成 okay”,后面配置再多也没用。
宏的理解也特别重要。很多刚从单片机转过来的朋友看到<&gpio0 RK_PC5 GPIO_ACTIVE_LOW>里面全是宏,第一反应是去代码里搜#define RK_PC5到底等于几。这种做法没有意义。设备树不是让你背数字,而是通过宏避免写错。真正要关心的是:父节点的#address-cells、#size-cells决定了一个reg里地址和长度各占几个 32 位格子。如果父节点是:
soc { #address-cells = <2>; #size-cells = <2>; };那么它下面的外设节点写reg = <0x0 0xfdd60000 0x0 0x1000>才是正确的,地址是高 32 位、低 32 位分别两个 cell,长度同理。少了前面那个 0,地址很可能就错位了。这种问题在 64 位 ARM 平台上特别容易踩,后面讲排错时会再展开。
3. 驱动与设备树自动“结婚”的那套算法:match 与 platform_device 生成全过程
3.1 compatible 匹配规则:驱动是怎么认领自己设备的
设备树节点不是天然“属于”某个驱动的。Linux 的设备模型核心是 bus(总线)、device(设备)、device_driver(驱动)三者之间的关系。在平台设备(platform_device)这条线上,platform bus 是虚拟总线,他的匹配函数到处找“哪个驱动应该接管这个设备”。
对设备树场景来说,匹配的核心就是compatible字符串。驱动里会维护一张“我能支持哪些设备”的表,设备树节点用compatible说“我是什么硬件”,两边相等,就有戏。驱动里典型的写法是:
static const struct of_device_id led_demo_match[] = { { .compatible = "myvendor,gpio-demo-led", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, led_demo_match); static struct platform_driver led_demo_driver = { .probe = led_demo_probe, .remove = led_demo_remove, .driver = { .name = "my-gpio-demo", .of_match_table = led_demo_match, }, }; module_platform_driver(led_demo_driver);设备树里写成:
gpio-demo { compatible = "myvendor,gpio-demo-led"; status = "okay"; >static int led_demo_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct gpio_desc *gpio; int ret; gpio = devm_gpiod_get(dev, "data", GPIOD_OUT_LOW); if (IS_ERR(gpio)) { dev_err(dev, "failed to get data gpio\n"); return PTR_ERR(gpio); } ret = device_property_read_u32(dev, "max-timeout-ms", &timeout); if (ret) timeout = 500; return 0; }注意我读>dev_info(dev, "%s enter\n", __func__);
这一行打印能帮你判断问题出在“匹配失败”还是“资源获取失败”,这两个方向的排查路径完全不同。后面第五部分会继续展开,这里先记住一个原则:内核日志是最诚实的调试对象,设备树写没写对,看 dmesg 远比盯代码有效。
4. RK3568 平台选设备树、改配置、烧进板子的完整链路
4.1 为什么板子资料里有一堆设备树文件,你到底该改哪一个
RK3568 作为一颗使用非常广泛的 SoC,同一个 SDK 里有几十份 dts 文件毫不奇怪。第一次看到这种文件夹的人基本都会问:到底哪个是我要改的?其实大多数 SDK 的文件名已经把答案写在脸上了,比如:
rk3568-evb1-ddr4-v10.dts rk3568-evb2-lpddr4-v10.dts rk3568-nvr-demo.dts rk3568-aiot-demo.dts每个名字对应一个具体的硬件形态。关键是看板子上的丝印、内存类型、DDR 版本、屏幕接口。比如你拿到的是 RK3568 EVB1 且内存是 DDR4,那就优先找带evb1-ddr4的 dts;如果板子是你们公司自己画的,硬件工程师一般会明确告诉你兼容哪份 EVB 版本的布局。
最怕的是“凭感觉选”。在 OpenHarmony 移植、Linux SDK 开发、甚至某些第三方 BSP 里,选错默认 dts 导致的启动问题非常常见,而且症状很阴间:开机卡在不同阶段、触摸屏没反应、网卡不通。原因是引脚复用和电源域配置完全不同。
一个比较可靠的判断方法是看设备树根节点的model和compatible:
# 板子启动后用 adb 或串口进入 shell cat /proc/device-tree/model cat /proc/device-tree/compatible这两个文件会存放字符串。如果启动到一半就挂了看不到,就先烧一个已知可用的固件进去,再对照 SDK 里的文件名反推。
4.2 改一次设备树并生效的完整操作路径:从 dts 到 dtb 到分区
假设你确定了目标设备树是rk3568-evb1-ddr4-v10.dts,现在想在上面加一个自定义 GPIO 点灯节点,完整路径是这样:
第一步,找到源文件。通常路径类似:
kernel/arch/arm64/boot/dts/rockchip/rk3568-evb1-ddr4-v10.dts第二步,在根节点下加节点。注意保持缩进和格式,添加完先做语法自查。
第三步,编译 dtb。最推荐的是跟随内核的编译系统。在 kernel 目录下执行:
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- rockchip/rk3568-evb1-ddr4-v10.dtb这会生成:
arch/arm64/boot/dts/rockchip/rk3568-evb1-ddr4-v10.dtb为什么不用单独命令直接跑dtc?因为设备树里大量#include的头文件和宏依赖 C 预处理器展开,直接用dtc xxx.dts会报一堆宏未定义。你当然可以手动先cpp预处理再喂给 dtc,但工程上没必要这么折腾,内核 make 系统已经把这些都处理好了。
第四步,把新 dtb 烧到板子对应分区。不同 SDK 烧录方式不一样,常见的是 Android 工具链里的 resource 分区、boot.img 里的 dtb 段、U-Boot 引导加载后单独加载的 dtb 分区。你可以在 SDK 的文档里搜“resource.img”或“dtb.img”。这一环节最容易出的事就是:改了 dtb,也编译了,烧录了,但 bootloader 加载的根本不是这个文件。
第五步,启动后验证加载版本:
dmesg | grep -i "device tree" cat /proc/device-tree/model如果cat /proc/device-tree/model能显示你改过的 model,说明你改的这棵树确实被内核用起来了。
4.3 在 Ubuntu 主机上交叉编译 RK3568 设备树的实用做法
很多 RK3568 开发者平时不用 Windows,而是用 Ubuntu 服务器做编译。第一次在 Ubuntu 上编 RK3568 设备树,最容易踩的是交叉编译器没装。
编译 ARM64 设备树需要aarch64-linux-gnu-系列工具链。Ubuntu 上最直接的方式:
sudo apt install gcc-aarch64-linux-gnu device-tree-compiler如果 SDK 里已经配置好整个内核编译环境,也可以直接在内核目录里跑:
export ARCH=arm64 export CROSS_COMPILE=aarch64-linux-gnu- make rockchip_linux_defconfig # 不同 SDK 的 defconfig 名字不同 make dtbs注意上面只是常见套路,不同厂家 SDK 的 defconfig 不一定叫这个名字,可能叫rockchip_defconfig或evb_defconfig。最靠谱的方式是打开 SDK 里的编译脚本,搜一下里面用的是什么 target。SDK 里通常有一键编译脚本,比如./build.sh kernel,它会负责 defconfig、交叉编译、打 dtb 包。不要为了“看起来专业”就绕过 SDK 自己乱编。
单独验证某个 dts 时,我偶尔也手动编译:
dtc -I dts -O dtb -o /tmp/test.dtb test.dts但这种方法处理不了带#include和宏的复杂文件。如果只是检验语法,用dtc -I dts -O dtb -o /dev/null test.dts也是可以的,看到一堆宏报错不用慌,这不代表设备树逻辑错了,只是缺少预处理。
5. 设备树调试中的命中率最高的几个坑(以及我的排查顺序)
5.1 现象一:设备树里写了节点,驱动 probe 就是不执行
这是我日常帮人看问题最多的场景。通常驱动代码有,节点也加了,驱动加载时不报错,就是 probe 不打印。我的排查顺序是固定的:
先看设备树节点有没有被内核真正识别到:
ls /proc/device-tree/ ls /proc/device-tree/gpio-demo/ cat /proc/device-tree/gpio-demo/compatible | tr '\0' '\n'注意compatible属性本质是多个以\0结尾的字符串,所以用cat不能直接看得舒服,用tr把空字符替换成换行最方便。如果你在/proc/device-tree/下根本找不到这个节点,说明你编译或烧录的不是这份 dts。
再看节点的 status。我不止一次遇到过,节点在某个 dtsi 里被定义了,并且在另一个 dts 里又定义了 status,但这两段代码最终合并时,你眼睛盯的那份并不是生效那份。设备树多处&节点修改同一个节点时,后面的赋值会覆盖前面的。搜索整个 dts 文件确认status的最终值是"okay"而不是"disabled"。
再确认驱动的of_match_table里有没有挂上,挂对了没有。代码里常犯的错误是写了:
static const struct of_device_id led_demo_match[] = { { .compatible = "myvendor,gpio-demo-led" }, {} };但 platform_driver 里只写了:
.driver = { .name = "my-gpio-demo", },忘了把of_match_table = led_demo_match填进去。这样驱动只能靠 name 字符串匹配,设备树节点的 compatible 就没有参与匹配过程,probe 很大概率不触发。
最后看总线归属。如果你的外设节点挂在 I2C 控制器下面,那么它对应的不是 platform_device,而是 i2c_client。此时内核 I2C 子系统用的是 i2c 的of_match_table匹配,驱动也需要用i2c_driver而不是platform_driver。把 platform_driver 用在 I2C 设备上,就像把钥匙插错门锁,结构再完整也白搭。
5.2 现象二:probe 执行了,但资源读取全是错的
probe 能跑,说明设备树和驱动已经匹配上了。接下来问题往往出在资源描述错误。
比如我遇到过的案例:某外设寄存器地址在 SoC 手册里是0xFDD60000,开发者在设备树里写:
reg = <0xfdd60000 0x1000>;如果此时外设节点挂在soc下面,而soc节点的#address-cells = <1>、#size-cells = <1>,这么写确实没问题。但在 64 位 ARM 平台上,很多 SoC 的地址总线描述会使用两个 cell,比如:
soc { #address-cells = <2>; #size-cells = <2>; };这时正确写法是:
reg = <0x0 0xfdd60000 0x0 0x1000>;一旦地址 cell 数量不匹配,内核解析出来的资源地址是错的,ioremap 返回的虚拟地址自然对不上,读写寄存器要么崩溃要么无效。
再比如 GPIO 引脚冲突。设备树里配好的 pinctrl 如果和别的设备重叠,启动日志里通常能看到类似pin 165 already requested的提示。这种问题经常在两个人同时往设备树里添加外设时发生:A 用了 GPIO0_C5 做 LED,B 用了同一脚接按键,两边在代码里都配了,但物理上只有一个脚。排查方法是把 pinctrl 里的用的 bank、pin 编号逐个对照原理图,不要只盯着 gpio 属性,还要看rockchip,pins里的引脚号。
5.3 现象三:dmesg 里提示 pinctrl 相关的 init 失败
设备树编写里 pinctrl 是一个大坑。很多节点在 dtsi 里会写:
pinctrl-names = "default"; pinctrl-0 = <&some_pin>;这表示设备启动时,引脚控制器要先把某些引脚切换到某种复用功能。如果你的某个外设对 pinctrl 节点引用了不存在的 pin,或者 pin 配置里写的 function 冲突,probe 阶段就可能失败。我之前就把一个 UART 的引脚误配成了 GPIO 功能,结果串口设备注册时始终拿不到正确配置,直接报pinctrl_select_state失败,后面所有初始化都中止。
所以每次新加节点时,我习惯先打开 pinctrl 相关的 dtsi 确认这个 pin 有没有被别的节点占用,而不是只改完自己这个节点就收工。全局搜索目标引脚名是一个很保值的好习惯。
另外,拿到一份外部 BSP 时,第一件事是让板子跑起来后执行:
cat /sys/kernel/debug/gpio cat /sys/kernel/debug/pinctrl/pinctrl-handles看系统目前的 pin 占用全貌。这些 debugfs 信息对排查设备树引脚冲突的帮助,比看十遍源码都好用。
6. 进阶:pstore 节点与 overlay 这种“把设备树当配置中心”的玩法
6.1 给内核崩溃留后手:RK3568 设备树里的 ramoops/pstore 配置
驱动开发哪有不崩溃的。调试驱动时最无语的情况是:内核 panic 后重启,最后几行日志丢了,完全看不出崩溃原因。ARM64 平台上常用的手段是 pstore/ramoops,它可以把崩溃前的内核日志保存在一段保留内存里,重启后读出来。
设备树里通常这样配:
reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; ramoops@110000 { compatible = "ramoops"; reg = <0x0 0x110000 0x0 0xf0000>; record-size = <0x20000>; console-size = <0x20000>; ftrace-size = <0x10000>; pmsg-size = <0x20000>; }; };这段配置的含义是:在物理内存起始 0x110000 的位置划出 0xf0000 大小作为保留区。record-size是保存 panic 日志的缓冲区大小,console-size是内核 console 输出的缓冲区,ftrace-size给 ftrace 用,pmsg-size给用户空间 pmsg 用。具体地址需要根据板子内存布局调整,绝不能和内核镜像、DDR 初始化参数冲突。
内核侧还需要开启相关配置:
CONFIG_PSTORE=y CONFIG_PSTORE_RAM=y CONFIG_PSTORE_CONSOLE=y CONFIG_PSTORE_FTRACE=y CONFIG_PSTORE_PMSG=y CONFIG_RAMOOPS=y板子崩溃重启后,去/sys/fs/pstore/下看:
ls /sys/fs/pstore/ cat /sys/fs/pstore/console-ramoops-0能看到之前的 panic backtrace,就能定位问题。这个钩子不需要驱动代码配合,纯粹是设备树和内核机制之间的配合,也是“设备树描述保留内存资源”的一个典型用法。我在 RK3568 上调试一个崩溃在中断上下文里的驱动时,就是靠 pstore 把最后那几行日志抓出来的。
6.2 设备树 overlay 和“把板级差异写进运行时配置”的思路
设备树并不是“只能烧死在 bootloader 里”。Linux 内核还支持 device tree overlay,简单说就是允许运行时往根设备树上叠加一个新节点或修改现有节点。最常见的应用场景是:一个主设备树适配多种外设扩展板,而不想为每种组合重新编译整个 dtb。
一个最小 overlay 的源码是这样的:
/dts-v1/; /plugin/; / { fragment@0 { target-path = "/"; __overlay__ { my-demo { compatible = "myvendor,gpio-demo-led"; status = "okay"; }; }; }; };编译成 dtbo:
dtc -I dts -O dtb -@ -o demo.dtbo demo-overlay.dts加载到内核(需要内核配置CONFIG_OF_OVERLAY和CONFIG_CONFIGFS_FS):
mkdir /sys/kernel/config/device-tree/overlays/demo cat demo.dtbo > /sys/kernel/config/device-tree/overlays/demo/dtbo卸载时直接删除目录:
rmdir /sys/kernel/config/device-tree/overlays/demooverlay 的价值不在于让你“不用重新编译内核”,而在于把板级外设差异从固定 dts 中解放出来。比如 RK3568 上做网关产品,不同客户可能选不同的 4G 模块,引脚排列也有差异。与其维护几十份完整 dts,不如维护一份基础 dts 加几个 overlay,运行时根据客户型号加载对应 dtbo。这样出厂固件可以统一,硬件配置变成运行时策略。
我个人的习惯是:能用固定设备树解决的问题,不引入 overlay。因为 overlay 的调试手段比固定 dts 少,出问题定位也麻烦。但如果你们的硬件形态确实很多、组合爆炸,overlay 这套机制很值得认真掌握。使用 overlay 时我还建议在启动脚本里记录加载顺序和文件 md5,不然板子多了之后,现场查“为什么有人加载了旧版本 overlay”会非常痛苦。
最后再分享一个设备树调试的小技巧:拿到一块新板子,先别急着写复杂驱动,从点灯开始,把“改 dts -> 编 dtb -> 烧分区 -> 看 /proc/device-tree -> 验证 sysfs”这条链路完整跑通。这个过程看起来简单,但能同时验证你的编译环境、烧录工具、内核设备模型和 pinctrl 配置是否全都正确。链路通了,后面写什么驱动心里都有底。设备树不是洪水猛兽,它就是一套需要你亲手“喂”给驱动的硬件描述协议,跟着实际板子多走几遍,踩过坑自然就通透了。