嵌入式Linux设备树实战:从零编写i.MX6ULL板级DTS与驱动匹配

嵌入式Linux设备树实战:从零编写i.MX6ULL板级DTS与驱动匹配 1. 从一个真实需求说起为什么要手写设备树我第一次接触设备树是在一块i.MX6ULL的开发板上。当时想把一个I2C接口的OLED屏SSD1306驱动接上去按照教程把驱动编译进内核结果系统启动后/dev下什么都没有dmesg里也看不到任何I2C设备被注册。折腾了大半天最后发现问题出在设备树里——I2C控制器的节点没有使能屏幕挂载的那个地址也没有描述。那一刻我才真正意识到设备树不是“可选项”而是嵌入式Linux板级描述的骨架。设备树Device Tree本质上是一种用文本描述硬件拓扑的数据结构。它把“这块板子上有什么外设、挂在哪个总线上、地址是多少、中断接哪个引脚”这些信息从内核代码里剥离出来交给一个独立的二进制文件DTB在启动时传给内核。内核拿到这份“硬件清单”后再去匹配对应的驱动完成设备注册。这样做的好处很直接同一份内核镜像可以跑在不同板子上只要换一个DTB就行不用重新编译内核。这篇文章面向的是已经能跑通嵌入式Linux基本流程、但还没系统写过设备树的开发者。我会从零开始以一块i.MX6ULL板子为例完整走一遍设备树的编写、编译、验证流程把每一步背后的逻辑讲清楚。中间会穿插我在实际项目中踩过的坑以及一些文档里不会写的经验。读完之后你应该能独立为自己的板子写出一份可用的设备树描述。2. 设备树的核心概念与整体设计思路2.1 设备树到底描述了什么很多人刚看设备树文件时会被一堆嵌套的花括号搞晕。其实它的结构非常朴素就是一棵树。根节点是/下面挂各种总线节点比如i2c1、spi2、uart3总线节点下面再挂具体的外设节点比如oled3c。每个节点里有一堆属性property属性就是键值对描述这个节点的特征。举个例子一个I2C从设备节点大概长这样i2c1 { status okay; clock-frequency 100000; oled: ssd13063c { compatible solomon,ssd1306; reg 0x3c; status okay; }; };compatible是设备树里最重要的属性它是驱动匹配的“暗号”。内核启动时会遍历所有节点拿compatible的值去和驱动里注册的of_device_id表比对匹配上了就调用驱动的probe函数。reg描述设备在总线上的地址I2C设备就是7位从地址。status控制节点是否生效okay表示启用disabled表示禁用。2.2 为什么选择“引用覆盖”的写法你可能会注意到上面用的是i2c1而不是直接写i2c1: i2c021a0000。这是设备树里一个非常关键的设计基础SoC描述和板级描述分离。芯片厂商比如NXP会提供一份imx6ull.dtsi里面描述了SoC内部所有控制器的基础信息——寄存器地址、中断号、时钟源等等。这份文件是通用的所有用这颗芯片的板子都共用。而板级厂商提供的是.dts文件通过节点标签的方式去引用SoC描述里的节点然后覆盖或追加属性。这样做的好处是SoC级别的改动只需要改.dtsi所有板子自动受益板级差异只在.dts里体现互不干扰。我在实际项目里遇到过有人直接把.dtsi复制出来改结果芯片厂商更新了.dtsi之后他的改动全部丢失还得重新merge一遍。所以记住一个原则永远不要改.dtsi只改.dts。2.3 整体设计思路构建一份板级设备树我的思路通常分四步走确认硬件连接把板子上每个外设的供电、总线、地址、中断引脚、复位引脚全部列出来画一张表。对照SoC手册找控制器确认每个外设挂在哪个控制器上控制器的寄存器基地址是多少。在.dts里引用并配置使能控制器添加外设子节点填写compatible、reg、中断等属性。编译验证用dtc编译成DTB启动后通过/proc/device-tree和dmesg验证。这四步里第一步最容易出错也最值得花时间。硬件连接搞错了后面全白搭。3. 核心细节解析与实操要点3.1 设备树源文件的组织方式一个典型的板级设备树工程目录大概是这样arch/arm/boot/dts/ ├── imx6ull.dtsi # SoC级描述厂商提供 ├── imx6ull-pinfunc.h # 引脚复用宏定义 ├── myboard.dts # 板级描述我们自己写 └── Makefile # 编译规则myboard.dts的开头通常是这样的/dts-v1/; #include imx6ull.dtsi #include imx6ull-pinfunc.h / { model My Custom i.MX6ULL Board; compatible fsl,imx6ull-myboard, fsl,imx6ull; chosen { stdout-path uart1; }; memory80000000 { device_type memory; reg 0x80000000 0x20000000; }; };/dts-v1/;是版本声明必须放在第一行。model是给人看的板子名称compatible是给内核匹配machine描述用的。chosen节点里的stdout-path指定内核启动时用哪个串口输出日志这个在调试阶段非常关键。memory节点描述DDR的起始地址和大小i.MX6ULL的DDR通常映射在0x80000000大小根据实际内存颗粒填写。注意memory节点的reg属性如果写错内核启动时会直接panic报“Unable to handle kernel paging request”。我第一次写的时候把大小写成了0x10000000256MB实际板子是512MB结果系统只能识别一半内存跑大程序就OOM。3.2 引脚复用Pin Mux的配置i.MX6ULL的引脚复用是通过IOMUXC控制器配置的。每个引脚可以工作在多种模式下GPIO、UART、I2C、SPI等需要在设备树里显式声明。这部分是新手最容易卡住的地方。配置引脚复用需要两个东西pinfunc宏和pinctrl节点。imx6ull-pinfunc.h里定义了每个引脚每种功能的宏比如#define MX6UL_PAD_UART1_TX_DATA__UART1_DCE_TX 0x0084 0x0310 0x0000 0x0 0x0这五个数字分别代表mux寄存器偏移、配置寄存器偏移、输入选择寄存器偏移、mux值、输入选择值。你不需要记住这些直接用宏就行。然后在.dts里定义pinctrl节点iomuxc { pinctrl_uart1: uart1grp { fsl,pins MX6UL_PAD_UART1_TX_DATA__UART1_DCE_TX 0x1b0b1 MX6UL_PAD_UART1_RX_DATA__UART1_DCE_RX 0x1b0b1 ; }; pinctrl_i2c1: i2c1grp { fsl,pins MX6UL_PAD_UART4_TX_DATA__I2C1_SCL 0x4001b8b0 MX6UL_PAD_UART4_RX_DATA__I2C1_SDA 0x4001b8b0 ; }; };fsl,pins里每行两个值引脚功能宏 配置值。配置值是一个32位整数编码了上下拉、驱动能力、速度等电气参数。I2C的引脚通常需要开漏输出加外部上拉所以配置值和UART不一样。这个值怎么算NXP的参考手册里有详细说明但实际项目中我一般直接抄参考板的配置然后根据实测波形微调。实操心得如果你不确定某个引脚的配置值可以先抄一份官方评估板的设备树把对应的pinctrl节点复制过来只改引脚宏。官方评估板的配置通常是最稳妥的起点。3.3 控制器节点的使能与配置SoC级的.dtsi里大部分控制器默认是disabled的。你需要在.dts里把它们打开uart1 { pinctrl-names default; pinctrl-0 pinctrl_uart1; status okay; }; i2c1 { pinctrl-names default; pinctrl-0 pinctrl_i2c1; clock-frequency 100000; status okay; };pinctrl-names定义了一组引脚配置的名字pinctrl-0引用具体的pinctrl节点。内核在驱动probe之前会先应用这些引脚配置。clock-frequency是I2C总线速率标准模式100kHz快速模式400kHz。SSD1306这类OLED屏用100kHz就够了速率太高反而容易通信失败。3.4 外设子节点的编写以SSD1306 OLED屏为例挂在I2C1上从地址0x3Ci2c1 { status okay; clock-frequency 100000; ssd1306: oled3c { compatible solomon,ssd1306; reg 0x3c; status okay; }; };compatible的值必须是内核驱动里已经注册的。SSD1306的驱动在drivers/gpu/drm/panel/或者drivers/video/fbdev/下具体取决于你用的内核版本和驱动方案。如果内核里没有对应驱动你需要自己写一个或者用simple-framebuffer这类通用方案。再举一个带中断的例子比如一个按键gpio1 { status okay; }; iomuxc { pinctrl_key: keygrp { fsl,pins MX6UL_PAD_UART1_CTS_B__GPIO1_IO18 0x1b0b0 ; }; }; gpio-keys { compatible gpio-keys; pinctrl-names default; pinctrl-0 pinctrl_key; key-user { label User Button; gpios gpio1 18 GPIO_ACTIVE_LOW; linux,code KEY_ENTER; debounce-interval 50; }; };gpios属性指定了GPIO控制器、引脚号和有效电平。linux,code是按键上报的键值。debounce-interval是消抖时间单位毫秒。机械按键通常需要20-50ms消抖太小会误触发太大会感觉迟钝。4. 完整实操流程从零到启动验证4.1 硬件信息梳理假设我们有一块自定义的i.MX6ULL板子硬件配置如下外设接口地址/引脚备注DDR-0x80000000, 512MB起始地址和大小调试串口UART1TX: UART1_TX_DATA, RX: UART1_RX_DATA115200-8N1OLED屏I2C1SCL: UART4_TX_DATA, SDA: UART4_RX_DATA, 地址0x3CSSD1306用户按键GPIO1_IO18低电平有效接KEY_ENTER网口ENET1参考评估板YT8521 PHY这张表是后面所有工作的基础。每一项都要和原理图核对尤其是I2C地址和GPIO引脚号错一位就全盘皆输。4.2 编写板级DTS文件新建arch/arm/boot/dts/myboard.dts内容如下/dts-v1/; #include imx6ull.dtsi #include imx6ull-pinfunc.h / { model My Custom i.MX6ULL Board; compatible fsl,imx6ull-myboard, fsl,imx6ull; chosen { stdout-path uart1; }; memory80000000 { device_type memory; reg 0x80000000 0x20000000; }; gpio-keys { compatible gpio-keys; pinctrl-names default; pinctrl-0 pinctrl_key; key-user { label User Button; gpios gpio1 18 GPIO_ACTIVE_LOW; linux,code KEY_ENTER; debounce-interval 50; }; }; }; uart1 { pinctrl-names default; pinctrl-0 pinctrl_uart1; status okay; }; i2c1 { pinctrl-names default; pinctrl-0 pinctrl_i2c1; clock-frequency 100000; status okay; ssd1306: oled3c { compatible solomon,ssd1306; reg 0x3c; status okay; }; }; fec1 { pinctrl-names default; pinctrl-0 pinctrl_enet1; phy-mode rmii; phy-handle ethphy0; status okay; mdio { #address-cells 1; #size-cells 0; ethphy0: ethernet-phy0 { compatible ethernet-phy-ieee802.3-c22; reg 0; clocks clks IMX6UL_CLK_ENET_REF; clock-names rmii-ref; }; }; }; iomuxc { pinctrl_uart1: uart1grp { fsl,pins MX6UL_PAD_UART1_TX_DATA__UART1_DCE_TX 0x1b0b1 MX6UL_PAD_UART1_RX_DATA__UART1_DCE_RX 0x1b0b1 ; }; pinctrl_i2c1: i2c1grp { fsl,pins MX6UL_PAD_UART4_TX_DATA__I2C1_SCL 0x4001b8b0 MX6UL_PAD_UART4_RX_DATA__I2C1_SDA 0x4001b8b0 ; }; pinctrl_key: keygrp { fsl,pins MX6UL_PAD_UART1_CTS_B__GPIO1_IO18 0x1b0b0 ; }; pinctrl_enet1: enet1grp { fsl,pins MX6UL_PAD_ENET1_RX_EN__ENET1_RX_EN 0x1b0b0 MX6UL_PAD_ENET1_RX_ER__ENET1_RX_ER 0x1b0b0 MX6UL_PAD_ENET1_RX_DATA0__ENET1_RDATA00 0x1b0b0 MX6UL_PAD_ENET1_RX_DATA1__ENET1_RDATA01 0x1b0b0 MX6UL_PAD_ENET1_TX_EN__ENET1_TX_EN 0x1b0b0 MX6UL_PAD_ENET1_TX_DATA0__ENET1_TDATA00 0x1b0b0 MX6UL_PAD_ENET1_TX_DATA1__ENET1_TDATA01 0x1b0b0 MX6UL_PAD_ENET1_TX_CLK__ENET1_REF_CLK1 0x4001b031 MX6UL_PAD_ENET1_RX_CLK__ENET1_RX_CLK 0x1b0b0 ; }; };这份文件里fec1是i.MX6ULL的以太网控制器。phy-mode rmii指定PHY接口模式phy-handle指向MDIO总线上的PHY节点。YT8521是一款常见的千兆PHY但在RMII模式下只能跑100Mbps。如果你用的是RMII模式PHY的时钟必须由SoC提供所以clocks属性要指向IMX6UL_CLK_ENET_REF。4.3 编译设备树编译设备树有两种方式在内核源码树里编译或者用dtc单独编译。在内核源码树里把myboard.dts加入arch/arm/boot/dts/Makefiledtb-$(CONFIG_SOC_IMX6ULL) \ myboard.dtb然后执行make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- myboard.dtb生成的myboard.dtb在arch/arm/boot/dts/目录下。如果只想单独编译可以用dtcdtc -I dts -O dtb -o myboard.dtb myboard.dts但这种方式不会处理#include需要先用C预处理器处理一遍cpp -nostdinc -I include -I arch/arm/boot/dts -undef -x assembler-with-cpp myboard.dts myboard.pre.dts dtc -I dts -O dtb -o myboard.dtb myboard.pre.dts注意dtc编译时如果报“syntax error”大概率是某个节点的花括号没配对或者属性末尾漏了分号。设备树对语法很严格一个分号都不能少。4.4 反编译验证编译完成后强烈建议反编译回DTS看一眼确认所有节点和属性都正确生成dtc -I dtb -O dts -o myboard.decompiled.dts myboard.dtb反编译出来的文件里所有#include和宏都已经展开你能看到最终生效的完整设备树。重点检查memory节点的reg是否正确各个控制器的status是否为okay外设节点的compatible和reg是否和预期一致pinctrl节点的fsl,pins是否展开正确这一步能提前发现90%的低级错误比启动后抓瞎强得多。4.5 启动验证把DTB和内核镜像一起烧录到板子上启动后通过串口查看日志。内核启动时会打印设备树相关信息[ 0.000000] Booting Linux on physical CPU 0x0 [ 0.000000] Linux version 5.4.70 ... [ 0.000000] Machine model: My Custom i.MX6ULL Board [ 0.000000] Memory: 512MB availableMachine model必须和你写的model属性一致Memory大小必须和reg一致。如果不一致说明DTB没被正确加载检查U-Boot的bootargs里fdt_addr是否指向了正确的DTB地址。然后检查各个外设# 查看I2C设备是否注册 ls /sys/bus/i2c/devices/ # 应该看到 0-003c 这样的条目 # 查看GPIO按键 cat /proc/bus/input/devices # 应该看到 gpio-keys 设备 # 查看网络接口 ip link show # 应该看到 eth0如果I2C设备没出现先检查dmesg | grep i2c看控制器是否probe成功。如果控制器probe失败通常是pinctrl配置有问题或者时钟没使能。5. 常见问题与排查技巧实录5.1 设备树编译报错速查错误信息原因解决方法syntax error花括号不配对、漏分号用dtc逐行检查或反编译对比label or path not found引用了不存在的节点标签检查.dtsi里是否有该标签拼写是否正确duplicate label同一标签定义了两次搜索整个文件删除重复定义reg property has wrong sizereg的#address-cells和#size-cells不匹配检查父节点的cell定义5.2 启动后外设不工作的排查思路I2C设备不识别先看dmesg | grep i2c确认控制器probe成功。如果控制器OK但设备没出现用i2cdetect -y 1扫描总线看设备地址是否响应。如果扫描不到检查硬件上拉电阻是否焊接、地址引脚是否正确。GPIO按键无响应cat /proc/bus/input/devices看设备是否注册。如果注册了但按键没反应用evtest工具测试事件上报。如果没注册检查gpios属性里的引脚号是否和原理图一致以及pinctrl是否配置为GPIO模式。网口不工作先看dmesg | grep fec确认控制器和PHY是否probe成功。如果PHY没识别到检查MDIO总线的reg地址是否正确。YT8521的PHY地址通常由硬件引脚决定常见的是0或1。如果PHY识别了但链路不通检查phy-mode是否和硬件一致RMII还是RGMII。5.3 独家避坑经验坑一pinctrl配置值抄错。不同板子的电气参数不一样抄参考板的时候一定要确认供电电压和上下拉需求。我曾经把1.8V的配置值用在3.3V的板子上结果I2C通信时好时坏折腾了两天才发现是驱动能力不够。坑二status属性覆盖顺序。如果你在.dtsi里某个节点是disabled在.dts里改成okay但又在另一个节点里写了disabled最终生效的是最后出现的那个。设备树的属性覆盖是按出现顺序来的后面的覆盖前面的。坑三compatible字符串拼写。内核驱动匹配是精确字符串匹配多一个空格、少一个逗号都会导致匹配失败。建议直接从驱动源码的of_device_id表里复制。坑四DTB加载地址冲突。U-Boot加载DTB的地址不能和内核镜像、根文件系统重叠。i.MX6ULL上通常用0x83000000加载DTB0x80800000加载内核。如果地址冲突内核启动时会报“Unable to handle kernel paging request”。坑五忘记编译DTB。改了.dts之后只编译了内核没编译DTB烧录的还是旧DTB。这个错误我犯过不止一次后来养成了每次改完设备树先make dtbs的习惯。5.4 调试工具推荐dtc设备树编译器必备。-I dtb -O dts反编译功能在排查问题时非常有用。fdtdump另一个DTB查看工具输出格式和dtc略有不同可以互补。i2cdetect/i2cget/i2csetI2C总线调试三件套。evtest输入设备事件测试工具调试按键、触摸屏必备。gpiodetect/gpioinfoGPIO状态查看工具确认引脚方向和电平。/proc/device-tree/内核启动后设备树以目录形式挂载在这里可以直接cat查看每个属性的原始值。6. 进阶话题从能用到好用6.1 设备树覆盖Overlay的使用设备树覆盖DTBO允许你在不重新编译主DTB的情况下动态修改设备树。这在产品迭代中非常实用——比如同一块核心板配不同的底板只需要为每个底板写一个DTBO启动时叠加到主DTB上。编译DTBOdtc -I dts -O dtb -o myoverlay.dtbo myoverlay.dts在U-Boot里加载load mmc 0:1 0x83000000 myboard.dtb load mmc 0:1 0x84000000 myoverlay.dtbo fdt addr 0x83000000 fdt resize 0x10000 fdt apply 0x84000000 bootz 0x80800000 - 0x83000000fdt resize是必须的因为叠加DTBO需要额外的空间。fdt apply会把DTBO的修改合并到主DTB里。6.2 设备树与驱动匹配的底层逻辑内核启动时unflatten_device_tree()把DTB展开成一棵device_node树。然后对于每个device_node内核会创建对应的platform_device并把compatible属性作为匹配依据。驱动注册时of_device_id表里的compatible字符串会和device_node的compatible逐一比对匹配成功就调用probe。这个过程可以用一个生活类比理解设备树是“招聘启事”驱动是“求职者”。启事上写了“需要会Python和Linux”求职者简历上写了“会Python和Linux”双方匹配成功求职者入职probe。如果启事写的是“会Python”求职者写的是“会python”大小写不同那就匹配不上。6.3 多平台适配的经验如果你做的产品需要支持多款SoC比如i.MX6ULL和RK3568设备树的组织方式需要提前规划。我的做法是每个SoC一个.dtsi描述SoC内部资源每个板子一个.dts描述板级外设公共的外设描述比如SSD1306、按键抽成.dtsi通过#include复用RK3568的设备树结构和i.MX6ULL类似但引脚配置方式不同。RK3568用rockchip,pins属性格式是bank pin function config和i.MX的fsl,pins不一样。跨平台移植时pinctrl部分基本要重写但外设节点的compatible和reg通常可以复用。6.4 设备树在国产化平台上的实践近几年国产SoC越来越多设备树的写法基本遵循标准规范但各家都有自己的扩展属性。比如瑞芯微的RK3568在设备树里增加了rockchip,grf、rockchip,pmu等私有属性用于配置系统寄存器和电源管理。这些属性在标准设备树规范里没有需要参考厂商的文档。我的建议是拿到新平台的第一件事是找厂商的SDK把官方评估板的设备树完整读一遍。重点看三部分pinctrl配置、clock配置、power-domain配置。这三块是平台差异最大的地方也是最容易出问题的地方。7. 我个人的一些实操体会设备树这个东西看文档觉得简单真上手写才发现细节多如牛毛。我最大的体会是不要试图一次写对要小步快跑逐步验证。先让串口能输出再加I2C再加GPIO每加一个外设就启动一次确认没问题再加下一个。这样出问题时范围很小容易定位。另一个体会是善用反编译和对比。每次改完设备树反编译成DTS和参考板的设备树做diff看看差异在哪里。很多时候问题就藏在那些不起眼的差异里。最后设备树不是孤立的它和U-Boot、内核、驱动都有关联。排查问题时不要只盯着设备树看要结合dmesg、/proc/device-tree、硬件原理图一起分析。我见过太多人改了半天的设备树最后发现是U-Boot传错了DTB地址或者硬件上拉电阻没焊。工具是死的思路是活的多动手、多测量、多对比比死磕文档管用得多。