嵌入式Linux设备树实战:从零编写i.MX6ULL板级DTS与驱动匹配
2026/9/19 14:45:47 网站建设 项目流程

1. 从一个真实需求说起:为什么要手写设备树

我第一次接触设备树是在一块i.MX6ULL的开发板上。当时想把一个I2C接口的OLED屏(SSD1306驱动)接上去,按照教程把驱动编译进内核,结果系统启动后/dev下什么都没有,dmesg里也看不到任何I2C设备被注册。折腾了大半天,最后发现问题出在设备树里——I2C控制器的节点没有使能,屏幕挂载的那个地址也没有描述。那一刻我才真正意识到,设备树不是“可选项”,而是嵌入式Linux板级描述的骨架。

设备树(Device Tree)本质上是一种用文本描述硬件拓扑的数据结构。它把“这块板子上有什么外设、挂在哪个总线上、地址是多少、中断接哪个引脚”这些信息,从内核代码里剥离出来,交给一个独立的二进制文件(DTB)在启动时传给内核。内核拿到这份“硬件清单”后,再去匹配对应的驱动,完成设备注册。这样做的好处很直接:同一份内核镜像可以跑在不同板子上,只要换一个DTB就行,不用重新编译内核。

这篇文章面向的是已经能跑通嵌入式Linux基本流程、但还没系统写过设备树的开发者。我会从零开始,以一块i.MX6ULL板子为例,完整走一遍设备树的编写、编译、验证流程,把每一步背后的逻辑讲清楚。中间会穿插我在实际项目中踩过的坑,以及一些文档里不会写的经验。读完之后,你应该能独立为自己的板子写出一份可用的设备树描述。

2. 设备树的核心概念与整体设计思路

2.1 设备树到底描述了什么

很多人刚看设备树文件时会被一堆嵌套的花括号搞晕。其实它的结构非常朴素,就是一棵树。根节点是/,下面挂各种总线节点(比如i2c1spi2uart3),总线节点下面再挂具体的外设节点(比如oled@3c)。每个节点里有一堆属性(property),属性就是键值对,描述这个节点的特征。

举个例子,一个I2C从设备节点大概长这样:

&i2c1 { status = "okay"; clock-frequency = <100000>; oled: ssd1306@3c { compatible = "solomon,ssd1306"; reg = <0x3c>; status = "okay"; }; };

compatible是设备树里最重要的属性,它是驱动匹配的“暗号”。内核启动时会遍历所有节点,拿compatible的值去和驱动里注册的of_device_id表比对,匹配上了就调用驱动的probe函数。reg描述设备在总线上的地址,I2C设备就是7位从地址。status控制节点是否生效,okay表示启用,disabled表示禁用。

2.2 为什么选择“引用+覆盖”的写法

你可能会注意到上面用的是&i2c1而不是直接写i2c1: i2c@021a0000。这是设备树里一个非常关键的设计:基础SoC描述和板级描述分离

芯片厂商(比如NXP)会提供一份imx6ull.dtsi,里面描述了SoC内部所有控制器的基础信息——寄存器地址、中断号、时钟源等等。这份文件是通用的,所有用这颗芯片的板子都共用。而板级厂商提供的是.dts文件,通过&节点标签的方式去引用SoC描述里的节点,然后覆盖或追加属性。

这样做的好处是:SoC级别的改动只需要改.dtsi,所有板子自动受益;板级差异只在.dts里体现,互不干扰。我在实际项目里遇到过有人直接把.dtsi复制出来改,结果芯片厂商更新了.dtsi之后,他的改动全部丢失,还得重新merge一遍。所以记住一个原则:永远不要改.dtsi,只改.dts

2.3 整体设计思路

构建一份板级设备树,我的思路通常分四步走:

  1. 确认硬件连接:把板子上每个外设的供电、总线、地址、中断引脚、复位引脚全部列出来,画一张表。
  2. 对照SoC手册找控制器:确认每个外设挂在哪个控制器上,控制器的寄存器基地址是多少。
  3. .dts里引用并配置:使能控制器,添加外设子节点,填写compatiblereg、中断等属性。
  4. 编译验证:用dtc编译成DTB,启动后通过/proc/device-treedmesg验证。

这四步里,第一步最容易出错,也最值得花时间。硬件连接搞错了,后面全白搭。

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; }; memory@80000000 { 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”。我第一次写的时候把大小写成了0x10000000(256MB),实际板子是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上,从地址0x3C:

&i2c1 { status = "okay"; clock-frequency = <100000>; ssd1306: oled@3c { 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-8N1
OLED屏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; }; memory@80000000 { 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: oled@3c { 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-phy@0 { 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/Makefile

dtb-$(CONFIG_SOC_IMX6ULL) += \ myboard.dtb

然后执行:

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- myboard.dtb

生成的myboard.dtbarch/arm/boot/dts/目录下。

如果只想单独编译,可以用dtc

dtc -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
  • 外设节点的compatiblereg是否和预期一致
  • 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 available

Machine model必须和你写的model属性一致,Memory大小必须和reg一致。如果不一致,说明DTB没被正确加载,检查U-Boot的bootargsfdt_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加载DTB,0x80800000加载内核。如果地址冲突,内核启动时会报“Unable to handle kernel paging request”。

坑五:忘记编译DTB。改了.dts之后只编译了内核,没编译DTB,烧录的还是旧DTB。这个错误我犯过不止一次,后来养成了每次改完设备树先make dtbs的习惯。

5.4 调试工具推荐

  • dtc:设备树编译器,必备。-I dtb -O dts反编译功能在排查问题时非常有用。
  • fdtdump:另一个DTB查看工具,输出格式和dtc略有不同,可以互补。
  • i2cdetect/i2cget/i2cset:I2C总线调试三件套。
  • evtest:输入设备事件测试工具,调试按键、触摸屏必备。
  • gpiodetect/gpioinfo:GPIO状态查看工具,确认引脚方向和电平。
  • /proc/device-tree/:内核启动后,设备树以目录形式挂载在这里,可以直接cat查看每个属性的原始值。

6. 进阶话题:从能用到好用

6.1 设备树覆盖(Overlay)的使用

设备树覆盖(DTBO)允许你在不重新编译主DTB的情况下,动态修改设备树。这在产品迭代中非常实用——比如同一块核心板,配不同的底板,只需要为每个底板写一个DTBO,启动时叠加到主DTB上。

编译DTBO:

dtc -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 - 0x83000000

fdt resize是必须的,因为叠加DTBO需要额外的空间。fdt apply会把DTBO的修改合并到主DTB里。

6.2 设备树与驱动匹配的底层逻辑

内核启动时,unflatten_device_tree()把DTB展开成一棵device_node树。然后对于每个device_node,内核会创建对应的platform_device,并把compatible属性作为匹配依据。驱动注册时,of_device_id表里的compatible字符串会和device_nodecompatible逐一比对,匹配成功就调用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部分基本要重写,但外设节点的compatiblereg通常可以复用。

6.4 设备树在国产化平台上的实践

近几年国产SoC越来越多,设备树的写法基本遵循标准规范,但各家都有自己的扩展属性。比如瑞芯微的RK3568,在设备树里增加了rockchip,grfrockchip,pmu等私有属性,用于配置系统寄存器和电源管理。这些属性在标准设备树规范里没有,需要参考厂商的文档。

我的建议是:拿到新平台的第一件事,是找厂商的SDK,把官方评估板的设备树完整读一遍。重点看三部分:pinctrl配置、clock配置、power-domain配置。这三块是平台差异最大的地方,也是最容易出问题的地方。

7. 我个人的一些实操体会

设备树这个东西,看文档觉得简单,真上手写才发现细节多如牛毛。我最大的体会是:不要试图一次写对,要小步快跑,逐步验证。先让串口能输出,再加I2C,再加GPIO,每加一个外设就启动一次,确认没问题再加下一个。这样出问题时,范围很小,容易定位。

另一个体会是:善用反编译和对比。每次改完设备树,反编译成DTS,和参考板的设备树做diff,看看差异在哪里。很多时候问题就藏在那些不起眼的差异里。

最后,设备树不是孤立的,它和U-Boot、内核、驱动都有关联。排查问题时,不要只盯着设备树看,要结合dmesg/proc/device-tree、硬件原理图一起分析。我见过太多人改了半天的设备树,最后发现是U-Boot传错了DTB地址,或者硬件上拉电阻没焊。工具是死的,思路是活的,多动手、多测量、多对比,比死磕文档管用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询