ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

嵌入式Linux设备树完全指南:从DTS语法到驱动匹配与RK3568实战

嵌入式Linux设备树完全指南:从DTS语法到驱动匹配与RK3568实战 做嵌入式Linux的兄弟八成都有过这种经历板子上电系统起来了终端能敲命令但外设一个都不工作。触摸屏像一块砖WiFi模块没名字SPI设备读半天全是FF。一开始你以为是驱动没写好Debug了一整夜最后发现驱动代码根本就没进probe。问题出在哪出在设备树。设备树Device Tree就是内核读取硬件配置的“户口本”。CPU架构、内存地址、总线布局、引脚复用、中断连线全都写在一棵树上。内核启动时拿到这棵树才知道板上有什么、在什么位置、该怎么初始化。而驱动是照着设备树给的信息去操作硬件的执行者。两者缺一不可。这篇文章我会从设备树的来源讲起把DTS语法、匹配机制、以RK3568为例的修改流程、还有我踩过的坑都串起来。不管你是刚开始写Linux驱动还是已经被设备树折磨了几周都能在这里找到对应的解法。看完你能自己搞定“加一个外设”“调一个引脚”“改一个时钟频率”这类最常见的需求。1. 设备树是怎么来的以及它到底解决了什么问题1.1 板级文件横行的年代我在接触ARM Linux的早期内核版本还是2.6.x那时候硬件描述和驱动代码是揉在一起的。每支持一块新板子内核源码里就要加一个板级文件放在arch/arm/mach-xxx/目录下。比如三星的板子就有mach-s3c64xx.c、mach-exynos.cTI的板子有mach-omap2.c。这些文件干的事特别杂内存起始地址、NOR Flash的分区表、GPIO按键接的哪个引脚、LED灯默认亮不亮全部是C结构体和函数调用。这种写法最大的问题是“平台代码爆炸”。每家芯片厂商把自己的板级代码往内核里塞主线内核根本维护不过来。而且你换一个板子就要重新编译一次内核同一个内核镜像想要兼容两块不同的板卡几乎是不可能的事。更难受的是很多板级文件里描述的硬件信息其实跟驱动逻辑一点关系都没有纯粹是“这个板子出厂时工程师怎么接线的”数据却被打进了驱动代码里。我记得当时调一块新板卡经常要同时改三个地方板级文件里加一个platform_device结构体、驱动里把资源地址写死、中断号在头文件里定义。一旦地址写错内核直接访问非法内存连个像样的报错都没有只能上JTAG去查寄存器。这种日子现在的人是很难体会到的。1.2 设备树的破局思路设备树的概念不是Linux原创而是来自Open FirmwarePowerPC和SPARC时代就在用。核心思路很简单把硬件信息从内核代码里剥离出来变成一份独立的数据结构。内核启动时由bootloader把这份数据从Flash里读到内存以二进制的形式传给内核内核解析这份数据根据里面的节点和属性动态地创建platform_device、注册中断、配置引脚。驱动代码里不再写死“我的I2C控制器的基地址是0xFEB0000”而是问设备树“我这里有哪些设备资源在哪”。这带来的好处是实打实的一套内核镜像能适配多块板子烧不烧某个外设的初始化完全由设备树说了算。厂商要支持新板卡多半只改设备树不用动内核主体。驱动代码更干净关注点回到“如何操作硬件”本身而不是“这块板子怎么接的”。所以在2011年前后ARM Linux从3.x开始全面切换设备树模型板级文件逐步被淘汰。到现在的5.x、6.x内核你几乎看不到老式的mach-xxx板级文件了取而代之的是每个板卡对应的.dts文件。1.3 一套文件从源码到内核的流转链路设备树相关术语新手经常搞混我一次性说清楚。名词全称作用DTSDevice Tree Source设备树源码纯文本后缀 .dtsDTSIDevice Tree Source Include公共设备树头文件后缀 .dtsi被dts引用DTBDevice Tree Blob编译后的二进制设备树bootloader加载内核的输入之一DTCDevice Tree Compiler把DTS编译成DTB的工具也能把DTB反编译回DTS整个流程是芯片厂商写一份rk3568.dtsi定义这颗SoC内置的CPU、内存、I2C控制器、UART、DMA等所有硬件资源板卡厂商再写一份rk3568-evb.dts引用这个dtsi并且描述这块具体的板子接了哪些外设、哪些引脚复用了什么功能。编译的时候DTC把dts和它include的dtsi合并起来输出一个dtb。bootloaderU-Boot启动内核时把dtb放在某个寄存器指定的内存地址内核从那里读取并解析。内核启动完成后你还能在系统里看到实际生效的设备树。路径有两条/proc/device-tree和/sys/firmware/devicetree/base。前者是只读的按目录层级对应设备树节点后者暴露的内容更丰富一些。这也是我排查问题时的第一站确认内核真正拿到的设备树到底是不是我以为的那一份。2. 读懂设备树语法这是驱动匹配的第一步2.1 节点和属性的基本规则设备树本身没有语法黑魔法它就是一棵层级树。最顶层是根节点用/表示。根节点下面分出一堆子节点cpus、memory、soc、chosen等等。每个节点有名字通常写成名称地址的格式比如i2c3fe5b0000后面跟的是这个外设在soc里的基地址主要是为了人眼识别方便不是必须。节点的核心是属性。属性是一个键值对值可以是字符串、32位无符号整数、整数数组、字符串列表等。举个例子一个最简单的I2C外设节点长这样i2c3 { status okay; clock-frequency 400000; tpm: tmp11748 { compatible ti,tmp117; reg 0x48; }; };这里的status okay是打开I2C3控制器clock-frequency设置总线速率400kHztmp11748是挂在I2C3总线上的温度传感器节点reg 0x48表示该器件在总线上的7位从机地址是0x48。驱动匹配的关键属性是compatible它必须和驱动代码里of_match_table给出的字符串一致后面讲。2.2 地址、中断、GPIO、引脚复用这些硬骨头地址相关的属性是新手最容易看懵的。核心概念是#address-cells和#size-cells。这两个属性是设备树里的“约定”表示父节点下子节点的reg属性需要几个单元格cell来描述地址几个单元格来描述长度。一个cell是32位整数。比如描述内存节点memory200000 { device_type memory; reg 0x00000000 0x20000000 0x00000000 0x80000000; };如果父节点定义了#address-cells 2、#size-cells 2那reg的内容分成两段读前两个cell是起始地址0x00000000_0x2000000064位后两个cell是长度0x00000000_0x800000002GB。凡是涉及64位地址的RK3568这种ARM64平台基本都是2个cell。x86的PCI域是用3个cell规则不同别硬套。中断属性也有一组对应关系。节点里要写interrupt-parent指向中断控制器节点比如gic然后interrupts属性里写中断号、类型比如interrupt-parent gpio3; interrupts 5 IRQ_TYPE_LEVEL_LOW;表示这个设备连接在GPIO3的第5号引脚上低电平触发中断。如果系统里有多个中断控制器interrupt-parent就很关键写错会导致中断完全收不到。GPIO控制器的活儿在设备树里通常由pinctrl子系统接管。这个是最让人头疼的因为各个厂商的写法不太一样而且上万行的pinctrl配置是芯片手册的浓缩版。简单理解你要把某个引脚配成UART功能的TXD或者配成GPIO输出不是直接写“启用UART”而是设置一个宏让芯片内部的IOMUX寄存器切到复用模式。RK3568的写法例如uart2 { pinctrl-names default; pinctrl-0 uart2m0_xfer; };uart2m0_xfer在哪里定义在rk3568-pinctrl.dtsi里它自动展开为对IOMUX寄存器的配置。一般你不需要自己造这些pinctrl组从参考板卡上复制修改就行了。但务必留意不同复用组m0/m1的引脚可能不同选错了没输出。2.3 dtsi 和 dts 的覆盖机制设备树支持类似C语言的#include也支持“引用后修改”。这种覆盖机制是实际开发中最重要的技能之一。芯片原厂的rk3568.dtsi把所有内置外设节点都定义好了但默认很多是status disabled。板级dts里要做的事就是打开要用的外设并配上具体参数。写法是用加节点引用比如#include rk3568.dtsi i2c3 { status okay; };这行代码的含义是在最终生成的设备树里i2c3节点的status属性被覆盖为okay同时其它属性不变。如果dtsi里已经写了clock-frequency 100000你可以在覆盖时改成400kHz最终生效的是dts里的值。正因为这种机制很多产品线的板卡差异都可以放在dts层解决型号A和型号B用了不同的触摸屏主板上预留了同一种I2C接口你就在dts里换compatible和reg某根GPIO在型号A上接了LED在型号B上接了按键也是直接改dts里的pinctrl和gpio属性。内核维护者看到的就是“同一套dtsi多份dts”清爽很多。需要注意的是dts和dtsi里同名节点下的同名子节点在编译时不是简单的“后者顶掉前者”而是属性级别的合并。同名属性以最后出现者为准不同名属性互相保留。所以你可以放心地在dts里做增量修改不需要整段复制dtsi里的内容。3. 驱动侧设备树节点是如何变成驱动probe的3.1 platform_driver 和 platform_device设备树的节点并不会自动让一个驱动程序跑起来。它需要先被内核转换成platform_device然后跟驱动程序里的platform_driver匹配上匹配成功才会调用驱动的probe函数。这个过程是Linux设备模型的标准机制。设备树节点里有一类compatible属性会被内核当作“这个设备是谁”的身份证。驱动注册时提供一个of_match_table里面列出它支持的所有compatible字符串。内核在总线注册设备时会把设备树节点的 compatible 和所有已注册驱动的 of_match_table 比较谁匹配上了谁就来接管这个设备。所以很多驱动开发新手困惑的“我明明写了probe怎么不执行”的问题超过一半是compatible没对上。比如设备树写的是ti,tmp117驱动里写的是tmp117边界就差一个厂商前缀匹配失败probe就是不会被调用。3.2 驱动中常用的设备树API驱动一旦被调用probe第一件事通常是从设备树节点拿资源。内核提供了一套丰富的APIof_find_node_by_path(/soc/i2c3)按路径找节点返回struct device_node *of_property_read_u32(node, clock-frequency, freq)读u32属性of_property_read_string(node, compatible, str)读字符串属性of_irq_get(node, 0)拿到第0个中断号devm_platform_ioremap_resource(pdev, 0)根据reg第一个段做ioremap返回虚拟地址of_get_named_gpio(node, gpio-gpios, 0)获取GPIO号老接口新代码推荐用 gpiod 系列其中有几个点我可以展开说一下因为踩坑频率极高。第一devm_platform_ioremap_resource很多人用of_iomap(node, 0)也能跑区别是前者会检查资源冲突并且自动管理释放推荐优先用。它内部依赖设备树节点reg属性描述的第一个address/size区间如果你的节点里reg没写对这里会返回错误指针后续访问必然是非法地址。第二中断号用platform_get_irq(pdev, 0)更符合platform驱动习惯它是of_irq_get的封装失败返回负数要判断错误码再往下走。我曾经在一个项目里没判断返回值直接把负数当成有效中断号去注册中断处理函数一触发就崩。第三GPIO口API在旧代码里用of_get_named_gpio新代码推荐devm_gpiod_get系列配合gpiod_set_value、gpiod_direction_output更安全也更容易读代码。设备树属性名里有-gpios后缀的它才认识。3.3 一个最小可用的platform_driver示例以读取上面那个tmp117温度传感器为例驱动骨架是这样的#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/i2c.h static const struct of_device_id tmp117_of_match[] { { .compatible ti,tmp117, }, { } }; MODULE_DEVICE_TABLE(of, tmp117_of_match); static int tmp117_probe(struct i2c_client *client) { struct device *dev client-dev; int irq; irq platform_get_irq(to_platform_device(dev), 0); if (irq 0) dev_warn(dev, no irq configured\n); dev_info(dev, tmp117 probed, irq%d\n, irq); return 0; } static void tmp117_remove(struct i2c_client *client) { dev_info(client-dev, tmp117 removed\n); } static struct i2c_driver tmp117_driver { .probe tmp117_probe, .remove tmp117_remove, .id_table NULL, .driver { .name tmp117, .of_match_table tmp117_of_match, }, }; module_i2c_driver(tmp117_driver); MODULE_LICENSE(GPL);注意这里用的是i2c_driver而不是platform_driver因为tmp117挂在I2C总线上它对应的驱动类型是i2c_driver。不管是哪种匹配的入口都是of_match_table。内核的I2C子系统会在总线枚举时把设备树里挂在i2c控制器下的节点和设备树里挂着的驱动对上匹配成功就调用probe。这也是很多新手搞混的地方不是所有设备树节点都必须配platform_driver设备挂在什么总线上就注册什么类型的驱动。4. 实操给RK3568板子添加I2C外设全程记录4.1 先找到你的设备树文件瑞芯微RK3568这个平台内核源码里的设备树文件都在arch/arm64/boot/dts/rockchip/目录下。这个目录里文件特别多光rk3568开头的就有几十份对应不同开发板、不同方案。你拿到一块板子第一件事不是找驱动而是确认自己用的到底是哪一份dts。怎么判断有几个土办法很有效看板子丝印和厂商资料比如rk3568-evb1-v10.dts就是官方EVB1板rk3568-nanopi-r5.dts是友善之臂的NanoPi R5。如果你用的是开源鸿蒙OpenHarmony的RK3568镜像会发现SDK里也有多份设备树。这时更应该看defconfig或者编译脚本里指定的dtb文件名以及启动日志里打印的Machine model字段。板子型号对应的字符串会在启动早期打出来。还有一招U-Boot会把参数写进设备树的/chosen节点你可以先在uboot里执行printenv或者进入系统看/proc/cmdline。找到板级dts之后用文本编辑器打开对照原理图确认外设引脚才能动手改。我建议先通读一遍这个dts看看它include了哪些dtsi避免你改的子节点被后续include的另一个文件覆盖。4.2 修改设备树节点挂一个温度传感器假设我要在板子的I2C3总线上挂一个TI的TMP117温度传感器I2C地址是0x48。我找到板级dts然后在文件里加一段覆盖#include rk3568.dtsi i2c3 { status okay; clock-frequency 400000; tmp117: tmp11748 { compatible ti,tmp117; reg 0x48; #address-cells 1; #size-cells 0; }; };如果原板级dts里已经定义了i2c3我通常直接把设备节点加进那个块里而不是另开一块。因为如果两个地方都写i2c3语法上允许编译输出是合并的但可读性很差容易漏看。还要确认引脚复用。RK3568的I2C3有两组引脚复用m0和m1。如果你的原理图用的是I2C3_SDA_M0、I2C3_SCL_M0那在rk3568-pinctrl.dtsi里已经有现成的i2c3m0_xfer配置。在板级dts里加上i2c3 { pinctrl-names default; pinctrl-0 i2c3m0_xfer; };这样内核会先把相关引脚复用成I2C功能再初始化I2C控制器。忘记配置pinctrl波形根本出不来这是最常见的“设备和驱动看起来都对但总线没反应”的原因。4.3 编译、打包、烧录不能省心修改完dts下一步是编译。在Linux内核源码根目录下先确保你已经按板子配置好了.config然后编译设备树make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- dtbs单独编译某个dtb也可以make ARCHarm64 dtbs/rockchip/rk3568-evb1-v10.dtb编出来的dtb在arch/arm64/boot/dts/rockchip/下。烧录的时候注意了很多瑞芯微方案的镜像打包工具会把kernel和dtb打包成一个boot.img烧录的是boot分区。如果你只把新dtb放到了资源分区或者其它地方内核启动时实际上读不到改了个寂寞。一个特别实用的验证手段是反编译在主机上把dtb还原成dts确认内容和你改的一致dtc -I dtb -O dts -o check.dts rk3568-evb1-v10.dtbdtc工具在Linux内核源码scripts/dtc/dtc目录下系统里也一般有独立的dtc包。反编译之后搜索tmp117能搜到就说明编译没问题。这一步我每次必做因为它能避免不少迷之问题比如你改的是dtsi但另一个文件又include了另一份同名头文件编译器最后用了哪个反编译一清二楚。4.4 上板验证与常见查询命令烧录完成后先用串口接上板子启动时注意看内核日志。如果你看到类似下面的输出说明设备树被正确解析驱动也匹配上了i2c i2c-3: I2C adapter 3 at 0x00000000fe5b0000, speed 400000 Hz tmp117 3-0048: tmp117 probed, irq-22这个3-0048表示I2C总线3上的0x48地址。irq-22是因为我的节点没配interrupts驱动里做了非负数判断打了提示不影响功能。然后进入系统shell用工具确认设备树节点是否真的存在ls /proc/device-tree/i2c3/tmp11748/ cat /proc/device-tree/i2c3/tmp11748/compatible如果目录不存在说明设备树根本没进入内核问题在bootloader或分区烧录。如果目录存在但驱动没打印probe那就是compatible不匹配或者驱动没编译进内核。还可以用i2c工具直接看总线上的设备i2cdetect -y 3显示0x48被检测到说明硬件连接、引脚复用、控制器初始化全都通过了。到这一步这个外设就真正“活”了。5. 我在实际项目中踩过的设备树坑和速查清单5.1 “改了设备树但没生效”的真相这是问得最多的问题。我见过太多人修改dts后满怀信心地烧录结果板子行为毫无变化。排除电源没重烧之外主要有几种情况。第一种烧错分区。RK3568方案常见的分区布局是uboot、misc、boot、recovery、rootfs。dtb一般打包在boot分区。你只烧了kernel或者只烧了dtb到resource分区都会导致实际生效环境不符。建议每次改动后确认打包脚本里dtb有没有被包含进去。第二种添加的节点被别的dts覆盖。你改了板级dts但如果SDK里还有一份overlay设备树插件在U-Boot里被动态加载了最终生效的是叠加后的结果。排查方法还是反编译看最终boot分区里的dtb到底是什么。第三种根本没编译进新dtb。有人改了dtsi然后只重新编译了某个dts但那个dts没有include你改的dtsi或者include的是同名其它目录的文件。最省事的检查手段是编译后立刻用dtc反编译搜索自己改动的内容。5.2 匹配失败与资源冲突类问题驱动不probe先查compatible。我见过一个项目芯片厂商在SDK里把compatible定义成了vendor,i2c-touched驱动里写的是vendor,i2c-touch一个字母之差整个触摸屏不工作。排查这种事最快的方式是在驱动probe入口加打印或者直接用of_device_id的data字段打出匹配结果但更高效的是去检查设备树地址不是偏移几个字母的compatible。status没设置成okay也是一个高频问题。芯片原厂dtsi里很多控制器默认是disabled板级dts里漏开设备树节点其实存在但你ls /proc/device-tree能看到它就是没有对应的platform_device被创建。用cat /proc/device-tree/i2c3/status如果输出disabled那就是没打开。中断、IO资源冲突也会导致probe失败。比如两个节点写了同一个中断号或者reg区间重叠内核会在request_irq、devm_ioremap_resource阶段返回-EBUSY或-EINVAL。驱动里对这些错误码的处理如果太粗糙看似没报错其实probe已经退出了。调试时多用dmesg | grep -i去看英文报错不要只盯着自己的dev_info。5.3 调试设备树最实用的命令和习惯我把平时最常用的命令整理成了一张速查表每次调试设备树相关问题时按顺序执行能省很多时间。目的命令查看实际生效的整棵设备树ls -R /proc/device-tree查看某个节点的属性cat /proc/device-tree/node/prop或cat /proc/device-tree/node/compatible反编译运行中的设备树dtc -I fs -O dts /proc/device-tree查看内核启动设备模型ls /sys/bus/platform/devices/看I2C总线上有哪些设备i2cdetect -y bus号查看pinctrl申请状态cat /sys/kernel/debug/pinctrl/pinctrl-rockchip-pinctrl/pinmux-pins查中断申请情况cat /proc/interrupts统查驱动匹配相关日志dmesg养成习惯改完设备树的第一个动作是用dtc -I fs -O dts /proc/device-tree导出一份当前生效的设备树跟你的预期对比。确认了内核看到的树和你改的一模一样再往下查驱动和硬件别让设备树成为第一怀疑对象。5.4 几个容易忽视的细节最后说几个散点不一定成体系但都很关键。chosen节点很特殊它不描述硬件而是存放内核启动参数。U-Boot会往里面填bootargs。有时候你会发现改了内核dts里的bootargs没生效因为U-Boot启动时又覆盖了它。要分清谁的优先级高别在错误的地方使劲。另一个是关于 “适用于Linux的Windows子系统必须更新” 的老梗。很多人在Windows上做嵌入式Linux开发用的是WSL2结果WSL版本太旧导致交叉编译工具链跑不起来。这个跟设备树本身无关但确实能卡住新手半天。我的建议是WSL2里配好crossbuild-essential-arm64或者在干净的Ubuntu容器里做编译不要省这一步。还有一个小技巧RK3568这类多核SoC用menuconfig调整驱动编译成模块M还是编进内核y时因为设备树里没有对应的modalias表模块可能不会自动加载。如果你把驱动编成了.ko别忘了用modprobe或手动insmod测试如果编进内核设备树只要配好probe就一定会跑排查范围更小。我第一次调驱动为了省时间编成模块结果设备树看起来都对就是不probe查了半天才发现是没加载模块。从那以后调试阶段我都优先编进内核。6. 写在最后的一点经验和建议设备树这个东西乍一看是配置文件细看是硬件架构的缩略图用熟了就是你和内核之间的翻译官。我做嵌入式Linux的这段时间最深的体会是设备树的问题90%是“你以为的内核输入”和“内核真实的输入”不一致造成的。所以不管多着急一定要先验证内核实际加载的设备树内容再开始怀疑驱动和硬件。另外如果你所在团队维护多块板卡不妨把每个板卡的dts差异做成一个小矩阵记录在文档里哪个dtsi是公共的、哪个dts是板级定制的、哪些外设被覆盖过。这个矩阵在后期的维护中价值极大特别是当你有几万台设备已经在现场、突然要为一个新版本硬件改一个GPIO定义时清晰的设备树管理能让你从“抢救代码”变成“2分钟改完收工”。我没有把内核源码里Documentation/devicetree/bindings/目录翻烂的耐心但每次写驱动前都会看一下现有类似设备的binding文档。好的binding文档会告诉你哪些属性是必需的、哪些是可选、各自的格式是什么。照着binding写出来的设备树节点内核和驱动都会用得很舒服。最后再分享一个小维护技巧如果你经常切换设备树做测试可以在U-Boot环境变量里设置两套dtb的切换逻辑用同一个内核镜像通过动态加载不同的dtb来测试不同外设组合。这比反复烧boot分区高效得多。我是这样搭了一套测试环境之后才真正理解“同一份内核多份设备树”这句话的威力。
返回列表