5.5 设备树
在 STM32F103 这类传统微控制器上编写串口驱动时,波特率寄存器与数据收发缓冲区的物理基地址通常由芯片数据手册指定并以宏定义形式硬编码在头文件里(例如 0x40013800)。若将代码移植至另一款型号的芯片上,往往不得不反复修改源码重新编译链接。
然而,对于 Linux 这类通用操作系统内核,同一套编译出的单一二进制内核映像往往需要兼顾并平稳运行在成百上千种硬件拓扑迥异的板卡上。每块开发板的物理内存基地址从何处起始、板载串口挂接在总线哪个基地址、中断信号线路由至处理器的哪个物理引脚、以及外部 I²C 或 SPI 总线上究竟挂载了何种具体型号的外设芯片,都绝不应该以硬编码的形式深嵌进内核驱动代码中。现代嵌入式 Linux 将这类板级物理硬件的拓扑结构与电气参数完整剥离为独立的数据资产,在系统自举引导阶段由引导加载程序(如 U-Boot)以标准格式移交给内核解析——这一关键的软硬件解耦桥梁,便是设备树(Device Tree)。
本节将从操作系统的硬件感知机理出发,逐层解构设备树源文件(Device Tree Source, DTS)的核心语法规范:包括节点树形层级、属性键值格式、compatible 驱动匹配契约、reg 物理寻址与 #address-cells 的跨层级约束、中断路由机制、标签引用以及 .dtsi 模块化继承哲学;随后结合内核源码中真实的 QEMU Versatile Express 与 STM32MP157 Discovery 单板配置文件,演示如何为实物外设(如 MPU6050 六轴传感器)编写规范的设备节点与运行时覆盖层(Overlay),并掌握 dtc 编译、反编译及现场驱动匹配故障的排错链路。
1. 为什么需要设备树
从操作系统底层视角审视,计算机硬件可以清晰地划分为两类形态。一类属于具备「自描述能力」的可枚举硬件:例如标准的 USB 设备在接入物理端口后,主机端 USB 核心能够直接通过控制端点读取其设备描述符与配置描述符,瞬间获知其厂商 ID、产品 ID 以及具体是人机接口键盘还是大容量存储;PCI/PCIe 设备亦在其标准配置空间中清晰刻录了厂商号、设备号及请求的基地址寄存器(BAR)大小。对于此类总线挂载的硬件,操作系统仅需通过标准化的总线枚举(enumeration)协议便能全自动感知设备的存在。
然而,嵌入式系统中普遍存在的则是第二类「无自描述能力」的物理外设:例如片上集成的 SoC 总线外设(UART、I²C 控制器、硬件定时器、以太网 MAC 等),其物理地址在硅片流片设计时便已固化,外设本身并不具备任何能够回读自身拓扑与身份标识的特殊寄存器;至于挂载在 I²C 或 SPI 总线末端的各类温湿度传感器或惯性测量单元,在主机主动发起寻址通信前整条总线完全处于静默状态,若无上层明确告知总线挂接的芯片地址与中断管脚,内核绝无可能凭空发现外设。在 ARM 架构尚未引入设备树的早期时代,开发者不得不为每一块板卡手写成套的板级 C 代码(board-*.c),导致内核源码树内充斥着数以万计的重复硬件描述,任何板级微调均需经历漫长的内核全量重新编译。
设备树的革命性设计,在于将硬件参数的描述彻底从 C 源码中抽离出来,演进为标准化的硬件拓扑数据结构:
.dts 是给人看的文本,dtc (device tree compiler) 把它编译成扁平的二进制 DTB。引导程序把 DTB 的地址通过寄存器或命令行交给内核,内核在启动早期展开这棵树,为每个 status = "okay" 的节点创建一个设备,再拿节点里的 compatible 去和已注册的驱动比对,匹配上就调用驱动的 probe。
这样一来,同一份内核映像换一块板子,只需要换 DTB。板子的差异全在数据里,驱动的代码一行不改。设备树的格式由 Devicetree Specification 定义,与 Linux 无关;U-Boot、FreeBSD、Zephyr 都读同一种 DTB。
2. 语法
设备树源文件的结构只有两层:节点 (node) 与属性 (property)。节点可以嵌套,属性是键值对。下面是一份完整的、能说明大部分规则的例子:
/dts-v1/;
/ {
#address-cells = <1>;
#size-cells = <1>;
compatible = "mdr,demo-board", "mdr,demo-soc";
model = "MDR Demo Board";
chosen {
bootargs = "console=ttyS0,115200";
};
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x10000000>;
};
soc {
compatible = "simple-bus";
#address-cells = <1>;
#size-cells = <1>;
ranges;
uart0: serial@40010000 {
compatible = "mdr,demo-uart";
reg = <0x40010000 0x1000>;
status = "okay";
};
i2c1: i2c@40020000 {
compatible = "mdr,demo-i2c";
reg = <0x40020000 0x1000>;
#address-cells = <1>;
#size-cells = <0>;
status = "okay";
imu@68 {
compatible = "invensense,mpu6050";
reg = <0x68>;
};
};
};
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/ 是根节点,其余节点都是它的后代。& 开头的是标签引用,第 3 节讲。
2.1 节点名与单元地址
节点名的一般形式是 名称@单元地址。serial@40010000 里,serial 是节点名,40010000 是单元地址 (unit address),取自 reg 的第一个值,用十六进制、不带 0x。单元地址是给人看的,也让同一层里可以有多个同类节点。dtc 会检查二者是否一致,不一致时给一条警告:
$ dtc -I dts -O dtb -o demo.dtb demo.dts
Warning (unit_address_vs_reg): /soc/serial@40010000: node has a unit name, but no reg or ranges property2
上面这条是反向的例子:把 reg 删掉而留着单元地址。另一种是单元地址写成 0x40010000 或 40010000 与 reg 对不上,dtc 不报错,但读的人会被误导,内核里 of_node_name_eq() 之类的按名字查找也可能失效。
节点名里不允许出现逗号、空格和 @ 以外的标点。厂商前缀只出现在 compatible 里,不出现在节点名里。
2.2 属性值的四种形态
属性的值只能是下面几种,写错类型编译期就报错:
| 形态 | 写法 | 例子 |
|---|---|---|
| 字符串 | 双引号,可写多个,逗号分隔 | compatible = "mdr,demo-uart"; |
| 32 位整数数组(cells) | 尖括号,空格分隔 | reg = <0x40010000 0x1000>; |
| 字节串 | 方括号,两位十六进制 | mac-address = [00 11 22 33 44 55]; |
| 空属性 | 只有名字,没有值 | interrupt-controller; |
字符串属性可以有多个值,按从具体到通用排列,这一点在 compatible 上最重要:
compatible = "mdr,demo-uart", "mdr,demo-serial";内核匹配时按顺序找,先用第一个,找不到再用第二个。所以厂商型号写在前,通用型号写在后。compatible 的格式是 厂商,型号,厂商名要用 Devicetree 规范注册过的前缀,MPU6050 的是 invensense,ST 的是 st,Arm 的是 arm。自己造的板子随便取,但要全项目统一,比如 mdr。
compatible 的匹配顺序
compatible 是一个字符串列表,按从具体到通用的顺序书写。内核拿列表中的每一项依次与驱动的 of_match_table 比对,第一个匹配上的决定用哪个驱动。驱动只写自己认识的那一项,不关心列表里还有别的什么。这个匹配只看字符串本身,与节点名、单元地址、属性顺序都无关。
2.3 reg 与父节点的 cells
reg 的格式由父节点的两个属性决定:#address-cells 说地址占几个 32 位单元格,#size-cells 说长度占几个。这是设备树里最容易写错的地方,因为它跨了一层。
看例子里的 imu@68。父节点 i2c1 声明了 #address-cells = <1>、#size-cells = <0>,所以 reg = <0x68> 是一个单元格的地址、没有长度。I²C 设备只有 7 位地址,没有「占多少字节」的概念,#size-cells = <0> 正对应这一点。
再看 serial@40010000。它的父节点 soc 声明了 #address-cells = <1>、#size-cells = <1>,所以 reg = <0x40010000 0x1000> 是地址加长度:从 0x40010000 开始,占 0x1000 字节。
根节点的 #address-cells 与 #size-cells 是给它的直接子节点用的,memory@80000000 就按根节点的规则写:reg = <0x80000000 0x10000000>,1 GB 内存。
cells 数由父节点决定
reg 里每个地址占几个单元格、每个长度占几个,由它所处的父节点的 #address-cells 与 #size-cells 决定。写一个节点的 reg 之前,先往上找一层,看父节点声明了什么。父节点没写这两个属性时,按规范默认都是 2。
写错 cells 数的后果分两种。多写或少写时 dtc 给出警告,长度与父节点声明的对不上:
$ dtc -I dts -O dtb -o demo.dtb demo.dts
Warning (reg_format): /soc/i2c@40020000/imu@68:reg: property has invalid length (8 bytes) (#address-cells == 1, #size-cells == 0)2
而写了正确长度、但数字含义错了(比如把 7 位地址写成 8 位形式 0xD0),dtc 不会报任何东西,要等驱动 probe 时读到的地址不对才发现。
2.4 status
status 控制节点是否启用。取值有四个,日常只用前两个:
| 值 | 含义 |
|---|---|
"okay" | 启用,内核为它创建设备 |
"disabled" | 存在但不启用,常见于 SoC 的 .dtsi 里 |
"reserved" | 硬件存在但不应使用 |
"fail" | 探测失败,运行期由内核写 |
SoC 级的 .dtsi 把所有外设都写全,但把大部分标成 disabled;板级文件只把实际用到的改成 okay。这样同一份 SoC 描述能服务所有板子。没写 status 的节点默认视为 okay。
2.5 ranges
ranges 声明子总线地址到父总线地址的转换。soc 节点里写的是空的 ranges;,意思是「子地址与父地址相同,一一映射」,这样 serial@40010000 的 0x40010000 就是 CPU 看到的物理地址。
有转换时写成三元组 <子地址 父地址 长度>:
ranges = <0x0 0x40000000 0x10000000>;意思是子总线上的 0x0 映射到父总线的 0x40000000,窗口长度 0x10000000。QEMU 的 vexpress 主板就是这样把「主板上的偏移」翻译成 CPU 物理地址的:设备树里 serial@a000 的寄存器偏移是 0xa000,经过几层 ranges 之后,内核看到的物理地址是 0x1000a000。5.8 里驱动的 probe 打印 映射到 [mem 0x1000a000-0x1000afff],就是这个结果。
3. 中断与引用
3.1 interrupts 的格式
一个设备的中断怎么描述,取决于它的中断控制器。控制器的节点上有 #interrupt-cells,说每条中断描述占几个单元格。ARM 上最常见的中断控制器是 GIC (Generic Interrupt Controller),#interrupt-cells = <3>,三个单元格依次是:
| 单元格 | 含义 |
|---|---|
| 1 | 中断类型:0 为共享外设中断 (Shared Peripheral Interrupt, SPI),1 为私有外设中断 (Private Peripheral Interrupt, PPI) |
| 2 | 中断号,SPI 从 0 起,PPI 从 0 起但对应内核的 16 起 |
| 3 | 触发方式:1 上升沿、2 下降沿、4 高电平、8 低电平 |
写成宏更可读,宏定义在 include/dt-bindings/interrupt-controller/arm-gic.h 与 irq.h 里:
#include <dt-bindings/interrupt-controller/arm-gic.h>
#include <dt-bindings/interrupt-controller/irq.h>
interrupts = <GIC_SPI 109 IRQ_TYPE_LEVEL_HIGH>;2
3
4
GIC_SPI 展开为 0,IRQ_TYPE_LEVEL_HIGH 展开为 4。头文件里的宏只是给 dtc 做文本替换,和 C 预处理无关,路径相对于内核源码的 include/ 目录。
3.2 interrupt-parent
设备节点不写 interrupt-parent 时,沿父节点往上找最近的一个带 interrupt-parent 的节点。根节点通常写一次,所有后代继承:
/ {
interrupt-parent = <&gic>;
...
};2
3
4
<&gic> 里的 &gic 是一个 phandle 引用,指向标签为 gic 的节点。写到哪个控制器,就用哪个控制器的 #interrupt-cells 解释后面的单元格。ST 的 MPU 上,GPIO 端口自己就是中断控制器,#interrupt-cells = <2>,两个单元格是引脚号与触发方式,interrupts-extended 可以在同一个属性里指向不同的控制器:
interrupts-extended = <&gpioa 12 IRQ_TYPE_EDGE_FALLING>;引脚 12 上一个下降沿中断,两个单元格正好对应 gpioa 的 #interrupt-cells = <2>。
interrupts-extended 与 interrupts 二选一,前者每条中断自带 phandle,不再受 interrupt-parent 约束。
3.3 标签、phandle 与提供者/使用者
标签 (label) 写在节点名前面,形如 uart0: serial@40010000。标签只在源文件里有效,编译后变成节点的 phandle,也就是一个 32 位整数,用来在别处引用这个节点。
uart0: serial@40010000 {
compatible = "mdr,demo-uart";
reg = <0x40010000 0x1000>;
};
aliases {
serial0 = &uart0;
};2
3
4
5
6
7
8
引用有两种写法:&uart0 用于给已有节点追加或修改属性(源文件层面),<&uart0> 用于属性值里存一个指向该节点的 phandle。aliases、chosen 里的引用属于后者。
设备树里大量属性遵循同一个模式:一个节点提供某种资源,另一个节点通过 phandle 消费它。
| 提供者属性 | 使用者属性 | 资源 |
|---|---|---|
#clock-cells | clocks、clock-names | 时钟 |
#reset-cells | resets、reset-names | 复位线 |
#gpio-cells | gpios、-gpios | GPIO |
#pinctrl-cells | pinctrl-0、pinctrl-names | 引脚复用配置 |
#dma-cells | dmas、dma-names | DMA 通道 |
#interrupt-cells | interrupts | 中断 |
规律是一样的:提供者声明「引用我需要几个单元格」,使用者按这个数量写。看到 clocks = <&oscclk 0>;,就去 oscclk 节点看 #clock-cells,是 1 的话后面的 0 就是时钟编号;是 0 的话 <&oscclk> 就够了。
节点 imu@68 的 reg = <0x68>; 只有一个单元格,这个格式由谁决定?
4. 文件组织
内核源码里的设备树分两层。SoC 级的 .dtsi (device tree source include) 描述这颗芯片内部有什么,板级的 .dts 用 #include 引入它,再补充板子上的差异。
以 QEMU 的 vexpress-a9 为例,arch/arm/boot/dts/arm/vexpress-v2p-ca9.dts 的结构是:
/dts-v1/;
#include "vexpress-v2m.dtsi"
/ {
model = "V2P-CA9";
compatible = "arm,vexpress,v2p-ca9", "arm,vexpress";
interrupt-parent = <&gic>;
#address-cells = <1>;
#size-cells = <1>;
aliases {
serial1 = &v2m_serial1;
};
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
device_type = "cpu";
compatible = "arm,cortex-a9";
reg = <0>;
};
};
memory@60000000 {
device_type = "memory";
reg = <0x60000000 0x40000000>;
};
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
memory@60000000 声明 1 GB 内存从 0x60000000 开始,cpu@0 声明一个 Cortex-A9 核。这两样是任何板子都必须告诉内核的:内存在哪、CPU 是什么。aliases 把 serial1 这个名字绑定到主板 DTSI 里的第二个串口,内核启动时按 serial0、serial1 的顺序给串口编号。
主板上的外设在 vexpress-v2m.dtsi 里,串口节点的形状是:
v2m_serial1: serial@a000 {
compatible = "arm,pl011", "arm,primecell";
reg = <0x0a000 0x1000>;
};2
3
4
compatible 有两个值:arm,pl011 是具体型号,arm,primecell 是 Arm 的通用外设总线型号。内核的 amba-pl011 驱动匹配第一个,primecell 那层用于通用属性处理。
4.1 板级覆盖
板级文件要改 SoC 文件里的节点,用的是 &标签 { … } 语法,不需要把整个节点重抄一遍:
&v2m_serial1 {
compatible = "mdr,serial";
status = "okay";
};2
3
4
&v2m_serial1 找到标签为 v2m_serial1 的节点,把大括号里的属性合并进去:已有的属性被覆盖,没有的加上。这是 5.8 里接管第二个串口的全部改动,reg 与中断都不用重写。
ST 的板级文件同理。arch/arm/boot/dts/st/stm32mp15xx-dkx.dtsi 里,Arduino 接口上的 I²C5 默认是关的:
&i2c5 {
pinctrl-names = "default", "sleep";
pinctrl-0 = <&i2c5_pins_a>;
pinctrl-1 = <&i2c5_pins_sleep_a>;
status = "disabled";
};2
3
4
5
6
要接 MPU6050,就在自己的板级文件里把 status 改成 "okay",再加子节点。Arduino 接口的 D14/D15 两根线是 PA12 与 PA11,它们在 stm32mp15-pinctrl.dtsi 里被复用成 I²C5 的 SDA 与 SCL,pinctrl-0 引用的就是那组配置。
删除用两个指令,都写在覆盖块里:
&i2c5 {
/delete-property/ clock-frequency;
/delete-node/ eeprom@50;
};2
3
4
/delete-property/ 删一个属性,/delete-node/ 删一个子节点。删不存在的属性和节点时 dtc 会报错,用之前先确认 SoC 文件里确实有。
&i2c5 {
compatible = "st,stm32f7-i2c";
reg = <0x40015000 0x400>;
interrupts = <GIC_SPI 109 IRQ_TYPE_LEVEL_HIGH>;
#address-cells = <1>;
#size-cells = <0>;
pinctrl-names = "default", "sleep";
status = "okay";
};2
3
4
5
6
7
8
9
&i2c5 {
status = "okay";
};2
3
左边每个属性都要和 SoC 文件保持一致,改一个漏一个就会出错,而且 SoC 升级时不会同步。右边只写与 SoC 不同的那一项,其余继承,改动量和出错面都小。
板级文件里写 &v2m_serial1 { compatible = "mdr,serial"; };,编译后会发生什么?
5. 绑定文档
一个节点该有哪些属性、每个属性什么格式、compatible 应该写什么,由绑定 (binding) 文档规定。内核对每个可匹配的 compatible 都有对应的绑定,写在 Documentation/devicetree/bindings/ 下。
绑定从 4.x 起改用 YAML 描述,文件后缀 .yaml,基于 JSON Schema。MPU6050 的绑定在 Documentation/devicetree/bindings/iio/imu/invensense,mpu6050.yaml,形状是这样:
properties:
compatible:
enum:
- invensense,mpu6050
- invensense,mpu6500
- invensense,mpu9150
...
reg:
maxItems: 1
interrupts:
maxItems: 1
vdd-supply: true
vddio-supply: true
mount-matrix:
description: |
Optional 3x3 mounting rotation matrix.
required:
- compatible
- reg2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
required 列出了必需属性:compatible 与 reg,少一个都不行。properties 里其余的是可选属性:中断、两路电源、安装方向矩阵。写节点时只写自己用得到的,不要凭感觉加属性。
make dt_binding_check 检查绑定文件本身写得对不对,make dtbs_check 拿绑定去核对所有编译出来的 DTB。两条命令都需要 pip install dtschema。CI 里通常会跑后者,它能把拼错的属性名、类型不对的值、缺失的必需属性都找出来:
$ make ARCH=arm dtbs_check DT_SCHEMA_FILES=Documentation/devicetree/bindings/iio/imu/invensense,mpu6050.yaml
DTC arch/arm/boot/dts/st/stm32mp157c-dk2.dtb
arch/arm/boot/dts/st/stm32mp157c-dk2.dtb: imu@68: 'interrupt-names' does not match any of the regexes: ...2
3
在实际工程实践中,严谨的驱动开发准则是:在动笔编写设备树节点之前,务必优先在内核绑定文档库中检索对应的 schema 规范。对于自研非标外设(如以 mdr, 为前缀的私有设备),内核官方库中固然没有对应的现成文档,此时更应当主动在项目工程规范中对自定义属性的名称、格式及物理量纲做出详尽的技术备忘,防止因口头约定模糊而给后续系统维护埋下隐患。
6. 为 MPU6050 写节点
MPU6050 挂在 I²C5 上,7 位地址 0x68,INT 引脚接到 PA8。按上一节的绑定,节点必须写 compatible 与 reg,中断是可选的,但我们要用 INT 触发数据就绪,所以写上。
&i2c5 {
status = "okay";
imu@68 {
compatible = "invensense,mpu6050";
reg = <0x68>;
interrupts-extended = <&gpioa 8 IRQ_TYPE_EDGE_RISING>;
};
};2
3
4
5
6
7
8
9
IRQ_TYPE_EDGE_RISING 来自 irq.h,板级文件开头要写上 #include <dt-bindings/interrupt-controller/irq.h>。实际的内核源码里这个文件还会引入 stm32mp15-pinctrl.dtsi 与 stm32mp151.dtsi,那些是 SoC 与外设的完整描述。
三处细节:
- 单元地址
68与reg的第一个值0x68一致。取舍时用十六进制、去掉0x。 reg写的是 7 位地址。8 位形式要把0x68左移一位再加读写位,得到0xD0/0xD1,直接写0xD0会让驱动找不到设备。interrupts-extended指向 GPIOA 的第 8 号引脚,IRQ_TYPE_EDGE_RISING表示上升沿触发。MPU6050 的 INT 默认是推挽、高有效、50 微秒脉冲,上升沿正好。
内核自带的驱动会先认领它
compatible = "invensense,mpu6050" 匹配内核自带的 inv_mpu6050 驱动。如果这个驱动编进了内核或作为模块加载,它会先 probe 成功,5.8 里自己写的驱动使用 mdr,mpu6050,就再也匹配不上。做实验时要么不启用 CONFIG_MPU6050,要么在 5.8 的练习里给自己的驱动换一个 compatible,并在节点里相应地改掉。两种写法不能同时存在。
改完编译 DTB,替换到启动介质上,开机后用 /sys 确认内核识别到了这颗芯片:
$ ls /sys/bus/i2c/devices/
i2c-0 i2c-1 5-0068
$ cat /sys/bus/i2c/devices/5-0068/name
mpu6050
$ dmesg | grep -i mpu
[ 2.418077] inv-mpu6050-i2c 5-0068: mounting matrix not found: using identity...2
3
4
5
6
目录名 5-0068 的 5 是 I²C 适配器编号,0068 是设备地址。这个命名规则由 I²C 核心生成,调试时很有用:看到 5-0068 就说明设备树里的节点被正确解析,总线编号与地址都对。
7. 设备树覆盖层
改板级 .dts 要重新编译整个 DTB,再重新烧写。产品量产之后这条路走不通:设备已经装在现场,DTB 在只读分区里。设备树覆盖层 (device tree overlay) 解决这个问题:一份小的 .dtbo,编译时不需要完整的源文件,加载时叠加到运行中的 DTB 上。
覆盖层的源文件开头两行与普通 DTS 不同:
/dts-v1/;
/plugin/;
&i2c5 {
status = "okay";
imu@68 {
compatible = "mdr,mpu6050";
reg = <0x68>;
interrupts-extended = <&gpioa 8 IRQ_TYPE_EDGE_RISING>;
};
};2
3
4
5
6
7
8
9
10
11
12
/plugin/ 告诉 dtc 这是一份覆盖层,编译时允许出现未定义的标签,把它们记成待解析的引用。编译命令要加 -@,基础 DTB 也要用 -@ 编译,否则里面没有符号表,覆盖层找不到 &i2c5 指向谁:
$ dtc -@ -I dts -O dtb -o mdr-imu.dtbo mdr-imu-overlay.dts
$ dtc -I dtb -O dts -o - mdr-imu.dtbo | head -20
/dts-v1/;
/ {
fragment@0 {
target = <0xffffffff>;
__overlay__ {
status = "okay";
imu@68 {
compatible = "mdr,mpu6050";
reg = <0x68>;
};
};
};
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
反编译出来的 fragment@0 就是编译后的形态:target 是一个 phandle,加载时按符号表解析成目标节点;__overlay__ 里的内容才是要合并的部分。0xffffffff 表示「尚未解析」。
加载有两条路。一是在 U-Boot 里,启动内核之前:
=> load mmc 0:1 0x43000000 mdr-imu.dtbo
=> fdt addr 0x40000000
=> fdt resize 0x2000
=> fdt apply 0x43000000
=> bootz 0x42000000 - 0x400000002
3
4
5
fdt resize 必须先执行,DTB 原来的空间塞不下新节点,fdt apply 会失败。二是运行中的系统,内核配置了 CONFIG_OF_OVERLAY 与 CONFIG_CONFIGFS_FS 时,把 .dtbo 写进 configfs:
$ mkdir /sys/kernel/config/device-tree/overlays/imu
$ cat mdr-imu.dtbo > /sys/kernel/config/device-tree/overlays/imu/dtbo
$ ls /sys/bus/i2c/devices/
i2c-0 i2c-1 5-0068
$ rmdir /sys/kernel/config/device-tree/overlays/imu2
3
4
5
写 dtbo 的那一刻应用,rmdir 时撤销。撤销会卸载对应的设备与驱动,这也是调试时反复改节点的便捷之处:不用重启。
覆盖层不适合放板子的基础描述,它适合放「同一块板子的不同配置」:一款硬件配上传感器,另一款不配;或者出厂后增加的扩展板。基础描述还是写在板级 .dts 里。
8. 检查与排错
8.1 编译与反编译
设备树的日常工具就是 dtc,用法只有两个方向:
| 命令 | 作用 |
|---|---|
dtc -I dts -O dtb -o x.dtb x.dts | 源码编译成二进制 |
dtc -I dtb -O dts -o x.dts x.dtb | 二进制反编译成源码 |
dtc -@ -I dts -O dtb -o x.dtbo x.dts | 编译覆盖层,带符号表 |
dtc -I fs -O dts /proc/device-tree | 从运行中的系统导出当前设备树 |
最后一条最实用:手头只有一个 DTB 或者一块已经启动的板子时,用它看内核实际拿到的树是什么样。编译器和引导程序都可能改过它,比如 U-Boot 会往 chosen 里加 bootargs,内核会补上一些缺失的属性。
编译时加上 -W 系列选项能多查一些东西,-Wno-unit_address_vs_reg 之类的反向选项用来关掉已知的警告。CI 里推荐 -Werror,把警告当错误。
8.2 在目标上查看
/proc/device-tree 是内核展开后的设备树,每个节点一个目录,每个属性一个文件:
$ ls /proc/device-tree/
#address-cells #size-cells aliases chosen compatible cpus memory@60000000 model soc
$ cat /proc/device-tree/model
V2P-CA9
$ cat /proc/device-tree/compatible | tr '\0' '\n'
arm,vexpress,v2p-ca9
arm,vexpress
$ ls /proc/device-tree/soc/
...2
3
4
5
6
7
8
9
字符串属性是连在一起、以 \0 结尾的,直接 cat 会连成一行,用 tr '\0' '\n' 拆开。整数属性是原始的大端字节,cat 出来是乱码,用 hexdump -C 或 od -An -tx4 看。
要列出所有串口节点并看它们的状态,可以在目标上跑一段 shell:
#!/bin/sh
for node in /proc/device-tree/soc/*serial*; do
[ -d "$node" ] || continue
status=$(tr -d '\0' < "$node/status" 2>/dev/null || echo okay)
compat=$(tr '\0' '\n' < "$node/compatible" | head -1)
echo "$(basename "$node"): $compat ($status)"
done2
3
4
5
6
7
8
输出大致是:
$ ./list_serial.sh
serial@9000: arm,pl011 (okay)
serial@a000: arm,pl011 (okay)
serial@b000: arm,pl011 (disabled)
serial@c000: arm,pl011 (disabled)2
3
4
5
没写 status 的节点在这里会走 echo okay 那条分支,因为 /proc/device-tree 里根本没有这个文件。
8.3 常见错误与排查顺序
probe 没被调用时,按下面这个顺序查,每一步都能排除一大片可能:
- 节点存在吗:
ls /proc/device-tree/...,或者在/sys/firmware/devicetree/base/下找。不在,说明 DTB 没更新,或者放错了分区。 - 状态是 okay 吗:
cat .../status。是disabled的话,内核压根不会创建设备。 - 设备被创建了吗:
ls /sys/bus/<总线>/devices/。I²C 设备是5-0068这种名字,platform 设备是a000.serial这种。 - 驱动匹配上了吗:看
/sys/bus/.../devices/<设备>/driver这个符号链接在不在。在,说明匹配成功;不在,说明compatible对不上。 - 驱动的
of_match_table写的是哪个字符串:和节点的compatible逐字符比对,大小写、逗号、连字符都算。
&i2c5 {
/* 1. 忘了改状态 */
imu@68 {
compatible = "invensense,mpu6050";
reg = <0x68 0x0>; /* 2. i2c 没有长度 */
};
};2
3
4
5
6
7
&i2c5 {
status = "okay";
imu@68 {
compatible = "invensense,mpu6050";
reg = <0x68>;
interrupts-extended = <&gpioa 8 IRQ_TYPE_EDGE_RISING>;
};
};2
3
4
5
6
7
8
9
第一处:I²C5 在 SoC 文件里是 disabled,覆盖块里不写 status = "okay",子节点写了也不会实例化。第二处:父节点 #size-cells = <0>,reg 只能有地址、不能有长度,多写一个单元格 dtc 会给出长度警告,内核也可能直接忽略这个节点。第三处是中断没写,能用轮询读数据,但数据就绪中断不会触发,5.9 的中断驱动实验会失败。
9. 习题
9.1 巩固
下面这棵小树语法完整,语义上有几处值得推敲。
/dts-v1/;
/ {
#address-cells = <2>;
#size-cells = <2>;
model = "MDR Syntax Demo";
aliases {
serial0 = &uart0;
};
memory@80000000 {
device_type = "memory";
reg = <0x0 0x80000000 0x0 0x40000000>;
};
soc {
compatible = "simple-bus";
ranges;
uart0: serial@40010000 {
compatible = "mdr,demo-uart";
reg = <0x0 0x40010000 0x0 0x1000>;
status = "okay";
};
};
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(1) 先算 memory@80000000:根节点写的是 2/2,reg 有四个单元格,写出地址与长度各自的十进制值。uart0 的父节点 soc 没写这两个属性,如果按根节点的 2/2 解释,四个单元格又拆成什么?
(2) 把这份文件交给 dtc,抄下它的全部输出。uart0 有没有被 reg_format 警告?有警告时,把括号里的 (#address-cells == x, #size-cells == y) 抄下来,这就是 dtc 在 soc 这一层实际采用的值;没有警告时,说明它沿父链取到了根节点的 2/2。
(3) 把根节点上的 #address-cells 与 #size-cells 两行删掉,其余不动,再编译一次。这次哪些节点被 reg_format 警告?再写三份变体,把 uart0 的 reg 分别写成 1 格、2 格、3 格,看哪一份不报警,由此推出整条父链都没有声明时 dtc 采用的地址格数与长度格数。把这一对默认值记下来,并与本页 2.3 节最后一句对照。
(4) 把根节点的两行恢复原样,再在 soc 上显式写 #address-cells = <2>; 与 #size-cells = <1>;,重新编译,抄下 uart0 那条 reg_format 警告。按 2+1 的拆法把 reg 改成合法写法(两格地址、一格长度),确认警告消失。这一步说明 reg 的格式由谁说了算?
(5) 用 dtc -I dtb -O dts 反编译正确版本,指出结果与源文件有哪三处不同。
(6) aliases 里的 serial0 = &uart0; 编译后变成什么?用 hexdump -C 在 DTB 里找到这个属性的值,说明它为什么正好 4 字节。
用 dtc 把正文第 2 节那份完整示例编译出来,再观察属性值的四种形态。
(1) 保存示例为 demo.dts,编译成 demo.dtb,再反编译。字符串属性、cells 属性、字节串属性、空属性各输出成什么样?
(2) 给根节点加一条 mac-address = [00 11 22 33 44 55];,反编译后用 hexdump -C 找到它在 DTB 里的原始字节,说明方括号与尖括号两种写法在二进制里的区别。
(3) 写 compatible = "mdr,demo-board", "mdr,demo-soc";,反编译后确认两个字符串在属性里以什么分隔。内核匹配时按哪个顺序?
(4) 故意把一处 ; 漏掉、把一处 = 写成 ==,各编译一次,抄下 dtc 的报错,说明它对行号的定位准不准。
reg 的 cells 数由父节点决定,这条规则可以用四份只差一个属性值的小文件试出来。
(1) 写四个 DTS,根节点分别声明 <1>/<0>、<1>/<1>、<2>/<1>、<2>/<2> 四组 #address-cells/#size-cells。每个文件里放同一个子节点,reg = <0x1000 0x100>(两个单元格)。
(2) 逐个编译,记录哪几份触发 reg_format 警告,把组合与结果列成表。警告里给出的 (#address-cells == x, #size-cells == y) 与你写的一致吗?
(3) 父节点完全不写这两个属性时,写第五份文件,把子节点的 reg 分别写成 2 格、3 格、4 格各试一次,哪些不报警?据此推出 dtc 在属性缺失时采用的地址格数与长度格数,把结论记下来。再在 Documentation/devicetree/bindings/ 下找一处父节点确实省略了这两个属性的例子,看它子节点的 reg 是几格,与你的结论是否吻合。
(4) reg_format 只是警告不是错误。说明为什么这样设计,以及内核遇到长度不对的 reg 时这个节点会怎样(结合第 8 节的排查顺序回答)。
9.2 编程
interrupts 的单元格数随中断控制器而变,用编译和反编译把这一点走通。
(1) 写 intc.dtsi:intc: interrupt-controller@a01000 带 interrupt-controller; 与 #interrupt-cells = <3>;gpio1: gpio@a02000 带 interrupt-controller;、#interrupt-cells = <2>。根节点上写 interrupt-parent = <&intc>;。
(2) 给两个设备各写一条中断:一个用 interrupts = <0 109 4>;(继承根节点的 interrupt-parent),一个用 interrupts-extended = <&gpio1 8 2>;。反编译后确认 interrupt-parent 与 interrupts-extended 里的 phandle 各落在哪个属性里。
(3) 改用宏 GIC_SPI、IRQ_TYPE_LEVEL_HIGH、IRQ_TYPE_EDGE_FALLING,用 dtc -i <内核源码>/include 编译,反编译后核对展开值。说明 dtc 的 #include 与 C 预处理有什么不同。
(4) 把 interrupts 改成两个单元格再编译,抄下诊断,说明 dtc 凭什么知道这里应该是 3 个。
给 MPU6050 写一份板级片段与一份覆盖层,并且只靠命令行验证结果。
(1) 按正文第 6 节写 mdr-imu.dts(板级片段)与 mdr-imu-overlay.dts(覆盖层),后者以 /dts-v1/; 与 /plugin/; 开头。
(2) 准备一份最小基础 DTS:含 i2c5 节点(status = "disabled"、#address-cells = <1>、#size-cells = <0>)与一个 gpioa 中断控制器。用 -@ 编译基础 DTB,再编译覆盖层。
(3) 用 fdtoverlay(与 dtc 同属 device-tree-compiler 包)把覆盖层叠到基础 DTB 上,反编译结果,确认 status 变成 okay、imu@68 出现在 i2c5 下,以及 interrupts-extended 的 phandle 已被解析成具体节点。
(4) 把覆盖层里的 reg 改成 <0x68 0x0> 再叠一次,记录 dtc 与 fdtoverlay 各自的反应,说明哪一步能拦住、哪一步拦不住。
(5) 对比基础 DTB 带 -@ 与不带 -@ 两种编译方式下的叠加结果,说明符号表在其中的作用。
写一个 dtree.sh,只依赖 dtc 与 coreutils,对一棵 DTS 或 DTB 做静态检查。
(1) 把输入统一反编译成 DTS 文本,检查四类问题:
- 单元地址与
reg的第一个值是否一致; - 每个节点的
reg长度是否符合父节点的#address-cells/#size-cells; - 每个
status的取值是否在okay、disabled、reserved、fail之内; - 每个
compatible是否形如厂商,型号,厂商前缀是否在内置的已知列表里(simple-bus、arm,primecell这类要单独处理)。
(2) 输出格式为 <文件>:<节点路径>: <问题>,有问题时返回 1,并打印问题总数。
(3) 用 demo.dts、上面那道 reg cells 实验里的四份文件、mdr-imu.dts 与一份故意写坏的文件做验收,确认每个问题都被抓到且没有误报。
(4) 说明这个脚本为什么做不到 make dtbs_check 能做的事,列出三条差距。
9.3 综合与挑战
/proc/device-tree 是排查的入口。写一个 dtinfo.sh,在运行中的 Linux 上(QEMU 里的 rootfs 即可)输出一份设备清单。
(1) 遍历 /proc/device-tree(或 /sys/firmware/devicetree/base),对每个节点输出 compatible 列表、status(缺省按 okay)以及有没有 reg 与 interrupts/interrupts-extended。
(2) 正确处理属性的三种编码:字符串连写(tr '\0' '\n')、大端整数(用 od -An -tx4 或 hexdump)、空属性(长度为 0 的文件)。
(3) 汇总一页统计:节点总数、按 status 分类的计数、有 compatible 但在 /sys/bus/*/devices/ 里找不到对应项的节点。
(4) 用第 8.3 节的顺序,为你关心的一个设备写一段排查记录:节点在不在、状态、设备是否创建、driver 链接在不在、of_match_table 的字符串。贴出每一步的命令与输出。
下面两份写法都能编译,只有一份在运行时能得到中断。
/* A */
&i2c5 {
status = "okay";
imu@68 {
compatible = "mdr,mpu6050";
reg = <0x68>;
interrupts = <8 1>;
};
};2
3
4
5
6
7
8
9
10
/* B */
&i2c5 {
status = "okay";
interrupt-parent = <&gpioa>;
imu@68 {
compatible = "mdr,mpu6050";
reg = <0x68>;
interrupts = <8 1>;
};
};2
3
4
5
6
7
8
9
10
11
(1) A 里 interrupts 的两个单元格由谁解释?结合根节点的 interrupt-parent = <&gic> 与 #interrupt-cells = <3>,说明 A 错在哪里。
(2) B 把 interrupt-parent 写在 &i2c5 的覆盖块里,对子节点 imu@68 有效吗?<8 1> 两个数分别是什么?
(3) 写一份最小 DTS 复现 A 与 B,用 dtc 反编译结果证明 interrupt-parent 最终落在哪个节点上,并用 dtc 的诊断说明 A 为什么会被警告或被内核拒绝。
(4) 正文第 6 节直接写 interrupts-extended,说明它比 B 的做法少依赖什么,以及在同一节点里挂多个不同控制器的中断时它为什么更方便。
把设备树覆盖层做成一条不重新烧写 DTB 的调试流程。第 (2)、(3) 问只要求设备被正确创建;有 5.8 的驱动时再往下看 driver 链接。
(1) 写一份基础 DTS 片段与一份覆盖层,覆盖层里放 mdr,mpu6050 的节点并打开 i2c5。两者都用 -@ 编译。
(2) 在目标板上用 configfs 加载:mkdir /sys/kernel/config/device-tree/overlays/imu,把 .dtbo 写进去,用 ls /sys/bus/i2c/devices/ 与 readlink .../driver 确认设备与驱动。
(3) rmdir 卸载后确认设备消失、驱动的 remove 被调用(在 dmesg 里找对应行)。把加载与卸载写成 reload-imu.sh,支持 load、unload、reload 三个子命令,每步打印结果。
(4) 故意让覆盖层里的 compatible 与驱动不一致(写成 mdr,mpu6050x),加载后确认设备被创建但 driver 链接不存在,按第 8.3 节的顺序定位到是哪一项。
(5) 说明覆盖层为什么不适合放板子的基础描述,以及 U-Boot 的 fdt apply 与 configfs 两种加载方式各适合什么场景。
把 5.8 里自写的 mdr,mpu6050 绑定写成一份 YAML schema,并让 dtbs_check 用上它。
(1) 读 Documentation/devicetree/bindings/iio/imu/invensense,mpu6050.yaml,按同样的结构写 mdr,mpu6050.yaml:compatible 只留 mdr,mpu6050,reg 的 maxItems 为 1,interrupts 与 interrupts-extended 可选,给自定义属性(如数据就绪引脚)写 description。
(2) 用 make dt_binding_check DT_SCHEMA_FILES=... 检查 schema 本身,修掉所有报错。
(3) 准备三份 DTS:一份正确、一份把 reg 写成两个单元格、一份拼错属性名。运行 make dtbs_check,确认只有第一份通过,另外两份各报出什么。
(4) 说明为什么自定义的厂商前缀不会被官方绑定的 compatible 枚举接受,做实验时该怎样临时处理(提示:改 schema 里的 enum 或 pattern)。
10. 参考资料
- Devicetree Specification v0.4,节点名、属性、
reg、ranges、phandle 的规范定义:https://www.devicetree.org/specifications/ - Linux 内核文档,Linux and the Devicetree:https://docs.kernel.org/devicetree/usage-model.html
- Linux 内核文档,Devicetree Overlay Notes:https://docs.kernel.org/devicetree/overlay-notes.html
- Linux 内核文档,Writing Devicetree Bindings in json-schema:https://docs.kernel.org/devicetree/bindings/writing-schema.html
invensense,mpu6050.yaml,MPU6050 的必需与可选属性:https://github.com/torvalds/linux/blob/master/Documentation/devicetree/bindings/iio/imu/invensense,mpu6050.yaml- Thomas Petazzoni,Device Tree 101,Bootlin 2021 年网络研讨会:https://bootlin.com/pub/conferences/2021/webinar/petazzoni-device-tree-101/petazzoni-device-tree-101.pdf
- Elixir Cross Referencer, Linux v6.18,
arch/arm/boot/dts/arm/vexpress-v2p-ca9.dts与vexpress-v2m.dtsi:https://elixir.bootlin.com/linux/v6.18/source/arch/arm/boot/dts/arm/vexpress-v2p-ca9.dts - Elixir Cross Referencer, Linux v6.18,
arch/arm/boot/dts/st/stm32mp15xx-dkx.dtsi与stm32mp151.dtsi:https://elixir.bootlin.com/linux/v6.18/source/arch/arm/boot/dts/st/stm32mp15xx-dkx.dtsi - STMicroelectronics,STM32MP157x-DKx - hardware description,Arduino 接口与 I²C5 的引脚:https://wiki.st.com/stm32mpu/wiki/STM32MP157x-DKx_-_hardware_description
dtc(1)与fdt(1)手册页:https://manpages.debian.org/testing/device-tree-compiler/dtc.1.en.html