第二讲:设备树(DTS)深入解析

各位同学,今天我们来啃一块硬骨头——设备树。说实话,我刚接触Zephyr那会儿,最头疼的就是DTS。明明写C代码好好的,突然冒出来一堆.dts文件,看着像C又不像C,语法怪得很。但后来我发现,不理解设备树,你根本没法真正驾驭Zephyr的驱动框架。

设备树是什么?说白了,它就是一块「硬件描述板」。你用文本的方式告诉系统:我这块板子上有哪些外设,它们挂在哪个总线上,地址是多少,中断号是多少。Zephyr在编译时会解析这些文件,自动生成C代码,驱动就能直接用了。

一、DTS语法基础

先看一个最简单的例子。我在一个项目里写过这样的设备树节点:

/ {
    model = "My Custom Board";
    compatible = "mycompany,myboard";

    chosen {
        zephyr,console = &uart0;
    };

    soc {
        uart0: uart@40002000 {
            compatible = "mycompany,uart";
            reg = <0x40002000 0x1000>;
            interrupts = <10 1>;
            status = "okay";
            current-speed = <115200>;
        };
    };
};

这里有几个关键点,我一个个说。

1. 节点与属性

每个设备树文件以根节点 / 开始。节点可以嵌套,就像文件夹一样。每个节点有属性,属性就是键值对。

  • 节点名:比如 uart@40002000,@后面是地址,用来区分同名设备
  • 标签:比如 uart0:,这是给节点起个别名,方便其他地方引用
  • 属性compatiblereginterrupts 这些都是属性

重点compatible 属性是驱动匹配的关键。Zephyr的驱动会通过这个字符串找到对应的设备。我见过有人拼写错误,结果驱动死活加载不上,排查了半天。

2. 地址编码

reg = <0x40002000 0x1000>; 表示这个UART外设的基地址是0x40002000,地址范围是0x1000字节。为什么要有范围?因为有些外设占用的地址空间比较大,比如USB控制器可能占用好几页。

嗯,这里要注意:地址和大小是成对出现的。如果有多个地址段,可以写多组:

reg = <0x40002000 0x1000>, <0x40003000 0x100>;

二、绑定(Binding)—— 连接DTS和驱动的桥梁

你可能会问:设备树里的属性,驱动怎么知道是什么意思?比如 current-speed 这个属性,驱动怎么知道它是波特率?

答案就是绑定文件。绑定文件是YAML格式的,它定义了设备树节点的属性类型和含义。Zephyr在编译时会根据绑定文件生成C结构体,驱动直接访问这些结构体就行了。

举个例子,上面那个UART的绑定文件大概长这样:

description: My Company UART

compatible: "mycompany,uart"

include: [uart-controller.yaml]

properties:
  current-speed:
    type: int
    required: true
    description: UART baud rate

看到没?compatible 字符串把设备树节点和绑定文件关联起来了。然后绑定文件告诉系统:current-speed 是一个整型属性,是必填的。

我的习惯:写新驱动时,我会先写绑定文件,再写设备树节点,最后写驱动代码。这样思路更清晰,不容易遗漏属性。

三、设备树覆盖(Overlay)

这是Zephyr里非常实用的功能。你想想看,如果你有一个开发板,想外接一个传感器,难道要修改原厂的设备树文件吗?当然不行。覆盖文件就是用来解决这个问题的。

覆盖文件可以「叠加」到原始设备树上,添加新节点或修改已有节点。比如:

/ {
    aliases {
        my-sensor = &sensor0;
    };

    &i2c0 {
        sensor0: my-sensor@48 {
            compatible = "mycompany,temperature";
            reg = <0x48>;
            label = "TEMP_SENSOR";
        };
    };
};

这个覆盖文件做了两件事:

  • 在I2C0总线上添加了一个温度传感器节点,地址是0x48
  • 给这个传感器起了个别名 my-sensor

我在实际项目中经常用覆盖文件来做产品定制。同一个主控板,不同型号的产品外设不一样,我就写不同的覆盖文件,编译时选不同的配置就行,不用动核心代码。

我曾经踩过的坑:覆盖文件里修改节点时,一定要确保节点路径完全正确。有一次我把 &i2c0 写成了 &i2c1,结果传感器挂在了错误的I2C总线上,读出来的数据全是0xFF。排查了整整一天才发现是路径写错了。

四、设备树宏与API

Zephyr提供了一套宏,让你在C代码里访问设备树信息。常用的有:

作用
DT_NODELABEL(label) 通过标签获取节点标识符
DT_PROP(node, property) 获取节点的属性值
DT_REG_ADDR(node) 获取节点的寄存器基地址
DT_INST(inst, compat) 获取兼容字符串对应的第inst个实例

举个例子,要获取上面那个UART的基地址:

#include <zephyr/devicetree.h>

void *base = (void *)DT_REG_ADDR(DT_NODELABEL(uart0));

是不是很简单?但要注意,这些宏是在编译时计算的,不会产生运行时开销。

五、实战技巧与避坑指南

最后分享几个我这些年积累的经验:

  1. 善用 status 属性:把 status 设为 "disabled" 可以临时关闭某个外设,调试时特别有用。我经常把所有外设都禁用,然后逐个打开,看哪个出问题。
  2. 别滥用覆盖文件:覆盖文件虽好,但太多层覆盖会让设备树变得难以理解。我一般控制在两层以内:一层是板级覆盖,一层是应用级覆盖。
  3. 编译时检查:Zephyr编译时会生成 build/zephyr/include/generated/devicetree_unfixed.h 文件,里面是所有设备树宏展开后的结果。如果驱动行为异常,先去看看这个文件,确认宏的值对不对。
  4. 命名规范:节点标签用有意义的名称,比如 uart0i2c1spi2。别用 dev1dev2 这种,三个月后你自己都看不懂。

总结一下:设备树是Zephyr的「硬件配置语言」。掌握DTS语法、理解绑定机制、会用覆盖文件,你就能灵活配置任何外设。下一讲我们会用SPI驱动实战来演示今天的内容,到时候你会看到设备树和驱动代码是如何配合工作的。

好,今天就到这里。有问题随时问我,咱们下节课见。