4、设备树(Devicetree):语法、节点、属性、绑定,如何通过设备树描述硬件并生成配置
设备树(Devicetree,简称 DT)是 Zephyr 乃至整个嵌入式 Linux 生态中描述硬件资源的核心语言。在 Zephyr 中,设备树不仅用于描述“有哪些硬件”,更关键的是它通过 节点(Node)、属性(Property) 和 绑定(Binding) 机制,在编译期生成 C 语言的静态配置结构体,从而驱动设备驱动模型的初始化。本章将深入剖析设备树的语法结构、节点与属性的组织方式、绑定文件的作用,并通过一个完整的实战案例展示如何从零编写设备树并生成最终配置。
设备树在 Zephyr 中扮演着“硬件描述 + 配置生成”的双重角色。它不同于传统的 Kconfig 配置(主要控制软件功能开关),设备树更关注硬件拓扑、地址映射、中断连接、引脚复用等底层细节。理解设备树的工作机制,是进行 Zephyr 驱动开发、板级移植和系统裁剪的必备技能。
4.1 设备树语法与节点结构
设备树采用树形结构描述硬件,其基本语法类似于 C 语言中的花括号嵌套。每个硬件外设、总线、GPIO 控制器、中断控制器等都被抽象为一个节点(Node),节点通过属性(Property)描述其具体特征。节点之间通过父子关系体现硬件拓扑。
一个典型的设备树节点包含以下要素:
- 节点名:形如
uart0、i2c1,通常与硬件实例对应。 - 单元地址:在节点名后通过
@指定,如uart@40001000,表示寄存器基地址。 - 属性:键值对形式,如
compatible = "st,stm32-usart";,用于匹配驱动。 - 子节点:嵌套在父节点内部,描述总线上的从设备或子模块。
- 标签(Label):通过
label属性或节点前的别名(如uart0: uart@40001000)提供引用句柄。
以下是一个简化的 STM32 设备树片段:
soc {
uart0: uart@40001000 {
compatible = "st,stm32-usart";
reg = <0x40001000 0x400>;
interrupts = <37 0>;
clocks = <&rcc STM32_CLOCK_BUS_APB2 0x00000001>;
pinctrl-0 = <&uart0_tx_pins>;
pinctrl-names = "default";
status = "okay";
};
};
其中 reg 属性定义了寄存器地址和长度,interrupts 指定中断号与触发方式,clocks 关联时钟控制器,pinctrl-0 引用引脚复用配置。这些属性最终会被解析为 C 宏,供驱动代码直接使用。
4.2 属性类型与绑定(Binding)机制
设备树属性本身是弱类型的,但 Zephyr 通过绑定文件(YAML 格式)为每个 compatible 值定义了严格的属性模式。绑定文件位于 dts/bindings/ 目录下,其作用包括:
- 定义哪些属性是必需的,哪些是可选的。
- 指定属性的数据类型(如
int、string、array、phandle等)。 - 提供默认值,当设备树中未显式声明时使用。
- 生成类型安全的 C 宏,避免直接操作原始设备树数据。
以 st,stm32-usart 的绑定文件为例(简化):
compatible: "st,stm32-usart"
properties:
reg:
required: true
type: array
description: "Register base address and size"
interrupts:
required: true
type: array
description: "IRQ number and flags"
clocks:
required: true
type: phandle-array
description: "Clock controller reference"
pinctrl-0:
required: false
type: phandle-array
description: "Pin control configuration"
status:
required: false
type: string
default: "okay"
description: "Device enablement state"
绑定文件与设备树源文件在编译时共同输入给 edtlib 解析器,生成 devicetree_generated.h。该头文件包含大量宏定义,例如:
#define DT_NODELABEL_UART0_NODE_ID DT_NODELABEL_UART0
#define DT_NODELABEL_UART0_BASE_ADDRESS 0x40001000
#define DT_NODELABEL_UART0_SIZE 0x400
#define DT_NODELABEL_UART0_IRQ_0 37
#define DT_NODELABEL_UART0_CLOCKS_CONTROLLER DT_NODELABEL_RCC
#define DT_NODELABEL_UART0_STATUS_OKAY 1
这些宏可以直接在 C 代码中使用,例如 DEVICE_DT_GET(DT_NODELABEL_UART0) 获取设备实例。
4.3 设备树到配置的生成流程
Zephyr 的设备树处理流程分为四个阶段,每个阶段都有明确的输入输出:
- 预处理阶段:使用 C 预处理器(
cpp)处理.dts文件中的#include和宏定义,生成.dts.pre.tmp。 - 编译阶段:设备树编译器(
dtc)将预处理后的文件编译为二进制.dtb,同时输出合并后的.dts_compiled.dts供调试。 - 绑定解析阶段:
gen_defines.py脚本结合绑定文件,遍历设备树节点,生成devicetree_generated.h。 - 宏映射阶段:生成
devicetree_legacy_unfixed.h,提供旧式宏兼容层,并建立节点路径与宏的映射关系。
整个流程由 CMake 在构建时自动执行,开发者通常无需手动干预。但理解这一链路有助于排查“设备树改了但配置未生效”的问题。
4.4 实战案例:为自定义传感器添加设备树支持
假设我们有一款 I2C 接口的温度传感器 tmp117,挂载在 I2C1 总线上,地址为 0x48。我们需要完成以下步骤:
第一步:编写绑定文件 dts/bindings/sensor/ti,tmp117.yaml:
compatible: "ti,tmp117"
properties:
reg:
required: true
type: int
description: "I2C slave address"
label:
required: true
type: string
description: "Device name"
vdd-supply:
required: false
type: phandle
description: "Power supply regulator"
第二步:在板级设备树中声明节点(以 boards/arm/xxx/xxx.dts 为例):
&i2c1 {
tmp117@48 {
compatible = "ti,tmp117";
reg = <0x48>;
label = "TMP117";
vdd-supply = <&vreg>;
status = "okay";
};
};
第三步:在驱动中使用生成的宏:
#include <zephyr/device.h>
#include <zephyr/drivers/i2c.h>
#define DT_DRV_COMPAT ti_tmp117
static int tmp117_init(const struct device *dev)
{
const struct tmp117_config *cfg = dev->config;
// 使用 DT_INST_REG_ADDR(0) 获取地址
// 使用 DT_INST_BUS(0) 获取 I2C 总线设备
return 0;
}
#define TMP117_INIT(n) \
static struct tmp117_config tmp117_config_##n = { \
.i2c = DEVICE_DT_GET(DT_INST_BUS(n)), \
.addr = DT_INST_REG_ADDR(n), \
}; \
DEVICE_DT_DEFINE(n, tmp117_init, NULL, NULL, &tmp117_config_##n, \
POST_KERNEL, CONFIG_SENSOR_INIT_PRIORITY, NULL);
DT_INST_FOREACH_STATUS_OKAY(TMP117_INIT)
第四步:验证生成结果。构建后检查 build/zephyr/include/generated/devicetree_generated.h,应能看到:
#define DT_N_S_soc_S_i2c1_40005400_S_tmp117_48_BUS DT_N_S_soc_S_i2c1_40005400
#define DT_N_S_soc_S_i2c1_40005400_S_tmp117_48_REG_ADDR 0x48
#define DT_N_S_soc_S_i2c1_40005400_S_tmp117_48_LABEL "TMP117"
通过 DT_INST_FOREACH_STATUS_OKAY 宏,驱动可以自动遍历所有状态为 okay 的实例,无需手动维护设备列表。
4.5 常见误区与调试技巧
status = "okay" 节点的配置,若忘记设置状态,驱动将无法匹配。此外,绑定文件中的类型必须与设备树中的实际值严格对应,例如 reg 为 int 时不能写成数组。
以下是几个高频问题的排查思路:
- 宏未生成:检查节点是否设置了
status = "okay",以及compatible是否与绑定文件名匹配。 - 地址错误:使用
reg = <0x48>时,确保绑定中reg类型为int或array,且长度与硬件一致。 - 总线关联失败:确认父节点(如
i2c1)本身已启用,且status为okay。 - 引脚复用冲突:检查
pinctrl-0引用的引脚节点是否被其他外设占用。
调试时,可以运行 ninja devicetree 目标,它会输出合并后的设备树文件和生成的宏文件,便于人工检查。
4.6 设备树与 Kconfig 的协同
设备树与 Kconfig 并非替代关系,而是互补。Kconfig 控制驱动是否编译(CONFIG_SENSOR=y),设备树控制驱动实例化时的硬件参数。例如,即使 Kconfig 启用了 TMP117 驱动,若设备树中没有对应节点,驱动也不会被实例化。反之,设备树中有节点但 Kconfig 未启用驱动,则节点会被忽略。
这种设计带来的好处是:同一份板级设备树可以配合不同的 Kconfig 配置,实现“硬件描述与软件功能解耦”。例如,在调试阶段可以启用所有外设节点,而在量产固件中通过 Kconfig 裁剪掉不需要的驱动,设备树无需修改。
4.7 设备树覆盖(Overlay)与多板支持
Zephyr 支持通过 .overlay 文件在构建时动态修改设备树,而无需修改原始 .dts 文件。这在以下场景中非常有用:
- 为同一块开发板适配不同的传感器扩展板。
- 在应用层临时调整引脚功能或外设参数。
- 实现“应用级配置”与“板级配置”分离。
Overlay 文件的语法与 .dts 完全一致,通过 &node 引用原始节点并修改属性。例如:
&tmp117@48 {
status = "disabled"; // 禁用板载传感器
};
&i2c1 {
bme280@76 {
compatible = "bosch,bme280";
reg = <0x76>;
label = "BME280";
status = "okay";
};
};
构建时通过 -DOVERLAY_CONFIG=my.overlay 指定覆盖文件。Zephyr 会先合并所有 .dts 和 .dtsi,再应用 overlay 的修改,最终生成统一的设备树。
4.8 设备树宏的进阶使用
除了基础的 DT_NODELABEL_* 宏,Zephyr 还提供了一套强大的宏 API,用于在编译期操作设备树数据:
| 宏类别 | 示例 | 说明 |
|---|---|---|
| 节点引用 | DT_NODELABEL(uart0) |
通过标签获取节点标识符 |
| 属性访问 | DT_PROP(node, reg) |
获取属性值(编译期常量) |
| 实例遍历 | DT_INST_FOREACH_STATUS_OKAY |
遍历所有启用状态的实例 |
| 总线信息 | DT_INST_BUS(n) |
获取实例所在总线的设备节点 |
| 中断信息 | DT_IRQN(node) |
获取中断号 |
| 时钟信息 | DT_CLOCK_INDEX_BY_NAME |
按名称获取时钟索引 |
这些宏在驱动开发中极为常用,它们将设备树数据转化为类型安全的编译期常量,避免了运行时解析的开销和错误风险。
4.9 设备树调试工具与日志
Zephyr 提供了多种调试手段来验证设备树配置:
- 构建时输出:在
build/zephyr/目录下查看zephyr.dts(合并后的设备树)和devicetree_generated.h。 - 运行时日志:启用
CONFIG_DEVICE_SHELL=y和CONFIG_DEVICE_SHELL_DEVICE_LIST=y,可在 shell 中执行device list查看已注册设备。 - 设备树转储:使用
dtc -I dtb -O dts将编译后的.dtb反编译为可读文本。 - 宏检查:在代码中临时添加
#ifndef DT_NODELABEL_UART0等条件编译,确认宏是否生成。
.dtsi 文件开始,只添加板级差异部分。同时,保持节点标签命名规范(如 uart0、i2c1),便于后续使用 DT_NODELABEL 宏快速引用。
4.10 设备树与多核/异构系统
在支持多核或异构计算的 Zephyr 系统中(如 Cortex-M4 + Cortex-A7),设备树通过 chosen 节点和 cpu 节点区分不同核心的硬件资源。例如:
chosen {
zephyr,console = &uart0;
zephyr,shell-uart = &uart0;
zephyr,sram = &sram0;
};
cpus {
cpu0: cpu@0 {
compatible = "arm,cortex-m4";
reg = <0>;
};
cpu1: cpu@1 {
compatible = "arm,cortex-a7";
reg = <1>;
};
};
每个核心可以拥有独立的外设节点,通过 status 属性控制哪些外设属于哪个核心。Zephyr 的 zephyr,memory-attr 属性还可以定义内存区域的缓存属性、安全属性等,为异构系统提供精细控制。
4.11 设备树性能与资源占用
设备树在 Zephyr 中完全在编译期处理,运行时不会占用额外的 RAM 或 Flash 来存储设备树数据。所有节点信息都被转换为 C 结构体常量,存储在只读区。这意味着设备树的复杂度不会影响运行时性能,但会增加编译时间和生成的宏数量。
对于资源受限的 MCU,建议遵循以下原则:
- 只启用实际使用的外设节点,将未使用的节点设为
status = "disabled"。 - 避免在设备树中定义冗余属性,尤其是大型数组或字符串。
- 合理使用
DT_INST_FOREACH_STATUS_OKAY宏,减少重复代码。
4.12 设备树测试与验证策略
为了确保设备树配置的正确性,建议在开发流程中加入以下验证环节:
- 静态检查:使用
dtc -Werror编译设备树,将警告视为错误。 - 单元测试:编写驱动测试用例,验证设备树宏生成的地址、中断号等参数是否符合硬件手册。
- 硬件回环测试:通过读写寄存器验证外设是否被正确初始化。
- 多板回归:使用
twister工具在多个板级配置上运行测试,确保设备树修改不影响其他平台。
Zephyr 的 twister 工具支持 --device-testing 选项,可以自动检测设备树配置并运行对应的测试用例,是 CI/CD 流程中不可或缺的一环。
4.13 设备树与驱动模型的关系
设备树最终服务于 Zephyr 的设备驱动模型。每个设备树节点对应一个 struct device 实例,其 config 结构体由设备树宏填充,data 结构体由驱动在运行时初始化。驱动通过 DEVICE_DT_DEFINE 宏将设备树节点与驱动函数绑定。
这种设计使得驱动代码完全与具体硬件参数解耦。例如,同一个 UART 驱动可以服务于多个不同基地址的 UART 实例,只需在设备树中声明多个节点即可。驱动内部通过 dev->config 获取寄存器地址、中断号等参数,无需硬编码。
4.14 设备树常见错误模式总结
| 错误模式 | 典型症状 | 解决方案 |
|---|---|---|
| 节点状态未设置 | 驱动未初始化,设备列表为空 | 添加 status = "okay" |
| compatible 不匹配 | 编译报错“no binding found” | 检查绑定文件名与 compatible 值 |
| 属性类型错误 | 宏生成值异常或编译失败 | 对照绑定文件修正类型 |
| 父节点未启用 | 子节点宏未生成 | 确保父节点 status 为 okay |
| 中断号冲突 | 运行时中断异常 | 检查中断控制器分配 |
| 引脚复用冲突 | 外设功能异常 | 检查 pinctrl 配置 |
掌握设备树的核心机制,不仅能够高效完成板级移植,还能深入理解 Zephyr 驱动模型的底层原理。建议读者在开发过程中,经常查看生成的 devicetree_generated.h 文件,将设备树源码与宏输出对照学习,这是最快提升设备树技能的方法。