1. 设备树是什么?为什么RK3568开发必须掌握它?

大家好,我是老张,一个在嵌入式Linux和智能硬件领域摸爬滚打了十多年的老码农。今天咱们不聊虚的,直接上干货,聊聊在RK3568平台上做驱动开发时,那个你绝对绕不开、又让很多新手头疼的东西——Linux设备树。

我刚开始接触设备树那会儿,也是一头雾水。心想,以前在单片机或者老版本Linux上,硬件信息不都是直接写在驱动代码里的吗?怎么现在又搞出个“.dts”文件,还得学一套新语法?后来踩过几次坑才明白,设备树这玩意儿,简直是现代Linux驱动开发的“基石”,尤其是对于像RK3568这样功能复杂的多核应用处理器。

简单来说,设备树就是一个描述硬件板级信息的配置文件。你可以把它想象成你电脑的“硬件清单”或者“配置说明书”。这份清单用结构化的文本(.dts文件)写成,告诉Linux内核:“咱们这块RK3568开发板上,CPU是四核Cortex-A55,内存从这里开始,I2C0控制器在这里,上面挂了个触摸屏,I2C1上挂了个EEPROM,GPIO0的第三个引脚连着个用户LED……”

那么,为什么非要引入设备树呢?这得从Linux内核的“黑历史”说起。在设备树出现之前,ARM架构的板级信息(比如上面说的那些)都硬编码在内核源码的arch/arm/mach-xxx目录下。每出一款新板子,就要在内核里加一堆描述它硬件的C文件。结果就是内核源码急剧膨胀,充斥着大量冗余、板卡特定的代码。Linux之父Linus Torvalds对此非常不满,甚至发出了著名的“吐槽”。于是,社区借鉴了PowerPC架构的经验,引入了设备树,成功地将硬件描述从内核中剥离出来。

对于使用RK3568的我们来说,好处是显而易见的:

  1. 内核通用化:同一份Linux内核,搭配不同的设备树文件(.dtb),就能启动在不同的RK3568开发板上(比如野火的LubanCat、正点原子的ATK-DLRK3568等)。
  2. 配置灵活:改硬件?不用重新编译内核,改改设备树就行。比如把I2C1上的传感器从MPU6050换成BMP280,只需要在设备树里改几行。
  3. 结构清晰:设备树采用树形结构,硬件拓扑一目了然,比散落在大量C文件里的#define和结构体好维护多了。

所以,如果你想在RK3568上玩转Linux驱动,无论是点个灯、读个传感器,还是驱动屏幕、网卡,第一关就是理解和修改设备树。别怕,它的语法其实比C语言简单,接下来咱们就一步步拆解。

2. 庖丁解牛:设备树文件(.dts/.dtsi)语法全解析

刚开始看RK3568的SDK,arch/arm64/boot/dts/rockchip/目录下那一堆.dts、.dtsi文件可能让人发怵。别慌,我们像读文章一样,先掌握它的基本“字词句”。

2.1 节点与属性:设备树的“骨骼”与“血肉”

设备树的核心就两个东西:节点(Node) 和 属性(Property)。节点代表一个设备或总线,属性则是这个设备的详细参数。

一个最简单的设备树长这样:

/dts-v1/;

/ {
    compatible = "rockchip,rk3568";
};
  • /dts-v1/;:声明版本,必须放在文件开头。
  • / { ... };:这是根节点。所有设备都“挂”在这棵树的根上。
  • compatible = "rockchip,rk3568";:这是根节点的一个属性。它是一个“键值对”,键是compatible,值是字符串"rockchip,rk3568",用来告诉内核这块板子兼容的硬件平台。

节点可以嵌套,形成父子关系。比如描述RK3568的CPU:

/ {
    cpus {
        #address-cells = <2>;
        #size-cells = <0>;
        cpu0: cpu@0 {
            device_type = "cpu";
            compatible = "arm,cortex-a55";
            reg = <0x0 0x0>;
        };
        cpu1: cpu@100 {
            device_type = "cpu";
            compatible = "arm,cortex-a55";
            reg = <0x0 0x100>;
        };
    };
};
  • cpus是根节点/的子节点,它下面又有cpu0和cpu1两个子节点。
  • cpu0: cpu@0:这里引入了两个概念。
    • cpu0是标签(label)。后面我们可以用&cpu0来引用这个节点,非常方便。
    • cpu@0是节点名。格式通常是<name>[@<unit-address>],name是设备类型(如cpu, i2c, spi),unit-address通常是该设备的首地址或ID,这里0表示第一个CPU核心。
  • #address-cells和#size-cells:这是数据格式说明属性。它们定义了子节点reg属性中“地址”和“大小”字段各占用几个32位数(称为cell)。这里cpus节点的设置表示,它的子节点的reg属性中,地址占2个cell(64位),大小占0个cell(因为CPU核心没有长度概念)。
  • reg = <0x0 0x0>:这是寄存器区域属性。根据父节点的#address-cells=<2>,这里两个0x0共同组成了CPU0的ID或地址。对于cpu1,它的reg是<0x0 0x100>。

2.2 你必须掌握的六大核心属性

设备树属性很多,但掌握下面这几个,你就能解决80%的问题。

1. compatible:驱动的“身份证” 这是最重要的属性,没有之一。内核靠它来为设备匹配正确的驱动程序。

i2c1: i2c@fe5a0000 {
    compatible = "rockchip,rk3568-i2c", "rockchip,rk3399-i2c";
    ...
};

值可以是一个或多个字符串。内核会按顺序尝试匹配。首先找rockchip,rk3568-i2c的驱动,如果没找到,就找兼容的rockchip,rk3399-i2c的驱动。这保证了驱动的向后兼容性。

2. reg:设备的“住址”和“地盘大小” 定义设备寄存器空间的起始地址和长度。

uart2: serial@fe660000 {
    compatible = "rockchip,rk3568-uart", "snps,dw-apb-uart";
    reg = <0x0 0xfe660000 0x0 0x100>;
    ...
};

reg = <地址1 长度1 [地址2 长度2 ...]>。这里的地址0xfe660000和长度0x100(256字节)就是UART2外设在内存映射中的位置和寄存器范围。驱动通过这个信息来访问硬件。

3. status:设备的“开关” 控制设备使能与否,最常用的就是okay和disabled。

&i2c1 {
    status = "okay"; // 启用I2C1控制器
    clock-frequency = <400000>; // 设置I2C时钟为400kHz

    sensor@1e {
        compatible = "fsl,fxls8471";
        reg = <0x1e>;
        status = "disabled"; // 暂时禁用这个传感器
    };
};

在调试时,你可以通过status = "disabled"来临时关闭某个设备,非常方便。

4. pinctrl:管脚复用的“指挥官” RK3568的引脚功能可以复用(比如一个引脚可以是GPIO,也可以是UART的TX)。pinctrl属性用来指定一组特定的引脚功能配置。

&uart2 {
    pinctrl-names = "default";
    pinctrl-0 = <&uart2m0_xfer>;
    status = "okay";
};

这里表示UART2使用名为uart2m0_xfer的那组引脚配置。这组配置通常在pinctrl节点中定义,由原厂提供,我们一般不需要修改,知道怎么引用就行。

5. interrupts:设备的“呼叫号码” 定义设备使用的中断号。

&uart2 {
    interrupts = <GIC_SPI 121 IRQ_TYPE_LEVEL_HIGH>;
    ...
};

这表示UART2使用SPI类型中断,编号121,高电平触发。这些宏(GIC_SPI, IRQ_TYPE_LEVEL_HIGH)一般在.dtsi头文件中定义。

6. clocks 和 clock-names:设备的“心跳” 现代SoC时钟系统复杂,设备需要知道自己的时钟来源。

&i2c1 {
    clocks = <&cru CLK_I2C1>, <&cru PCLK_I2C1>;
    clock-names = "i2c", "pclk";
};

这里I2C1需要两个时钟:i2c(功能时钟)和pclk(总线时钟)。&cru是时钟控制器的标签。

2.3 模块化设计:.dts、.dtsi与头文件

你可能会问,为什么RK3568的设备树被拆成了那么多文件?这是为了代码复用和分层管理,和C语言用.c和.h文件一个道理。

文件类型作用类比示例
.dts板级设备树主程序(.c)rk3568-lubancat2.dts,描述具体开发板的硬件差异,比如用了哪个型号的屏幕、网卡,用户按键接在哪个GPIO上。
.dtsiSoC级设备树头文件/通用模块(.h)rk3568.dtsi,描述RK3568芯片本身的硬件资源,比如CPU、内存控制器、所有I2C/SPI/UART控制器的基地址等。所有基于RK3568的板子都会包含它。
.h宏定义头文件宏定义头文件(.h)dt-bindings/pinctrl/rockchip.h,里面定义了诸如GPIO_ACTIVE_HIGH、RK_PB0等常量,方便编写和阅读。

一个典型的板级.dts文件开头是这样的:

// 板级设备树:rk3568-my-board.dts
/dts-v1/;
#include <dt-bindings/gpio/gpio.h>
#include <dt-bindings/pinctrl/rockchip.h>
#include "rk3568.dtsi" // 包含SoC通用定义

/ {
    model = "My Custom RK3568 Board";
    compatible = "my-company,my-rk3568-board", "rockchip,rk3568";

    // 这里开始定义或覆盖板级特有的硬件
    leds {
        compatible = "gpio-leds";
        user_led: led-0 {
            label = "heartbeat";
            gpios = <&gpio0 RK_PB0 GPIO_ACTIVE_HIGH>;
            linux,default-trigger = "heartbeat";
        };
    };
};

// 追加或修改从rk3568.dtsi继承来的节点
&i2c1 {
    status = "okay";
    // 添加板子上的I2C设备
    temperature_sensor: lm75@48 {
        compatible = "national,lm75";
        reg = <0x48>;
    };
};

这种结构清晰明了:芯片原厂维护rk3568.dtsi,板卡厂商或开发者基于它编写自己的.dts,只关注板级差异。切记:不要直接修改rk3568.dtsi这类通用文件! 你的所有修改都应该在自己的板级.dts文件中,通过&标签的方式对原有节点进行追加或覆盖。

3. 实战演练:为RK3568添加一个I2C设备节点

光说不练假把式。假设我们要在自己的RK3568板子上,给I2C1总线添加一个温湿度传感器SHT30。以下是完整步骤。

3.1 第一步:查阅硬件连接与芯片数据手册

  1. 硬件连接:确认SHT30的I2C地址。SHT30的地址引脚通常可配置,假设我们接地,地址是0x44。
  2. 原理图:确认SHT30连接在RK3568的哪个I2C控制器上。假设是I2C1。
  3. 驱动支持:检查内核是否支持SHT30。可以grep -r "sht3" drivers/,或者去Documentation/devicetree/bindings/iio/humidity/目录下找绑定文档。我们假设内核已有驱动drivers/iio/humidity/sht3x.c,其兼容性字符串是"sensirion,sht3x"。

3.2 第二步:修改设备树文件

找到你的板级设备树文件(例如rk3568-my-board.dts),在末尾添加以下内容:

/* 在文件末尾,对I2C1控制器节点进行追加 */
&i2c1 {
    status = "okay"; // 确保I2C1控制器使能
    clock-frequency = <100000>; // 设置I2C总线速度为100kHz,SHT30支持

    /* 添加SHT30子节点 */
    sht3x: humidity-sensor@44 {
        compatible = "sensirion,sht3x";
        reg = <0x44>; // I2C设备地址
        // 以下是一些可选属性,根据传感器和驱动文档添加
        // vdd-supply = <&vcc_3v3>; // 如果供电可控,可关联稳压器
        // interrupt-parent = <&gpio0>;
        // interrupts = <RK_PA5 IRQ_TYPE_LEVEL_LOW>; // 如果使用中断引脚
    };
};

代码解读:

  • &i2c1:引用在rk3568.dtsi中定义的i2c1节点标签。
  • status = "okay":确保控制器启用。有时原厂配置可能是disabled。
  • clock-frequency:设置总线速率。不设置则用默认值(通常100kHz)。
  • sht3x::为这个节点定义一个本地标签,方便其他地方引用(虽然这里可能用不到)。
  • humidity-sensor@44:节点名。@后面最好跟设备地址,提高可读性。
  • compatible:必须与驱动中的of_device_id表匹配。
  • reg:I2C设备地址,0x44。

3.3 第三步:编译与更新设备树

修改保存后,在Linux内核源码根目录下编译设备树:

# 假设你的编译环境已配置好(如aarch64-linux-gnu-工具链)
# 方式1:编译所有设备树
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs

# 方式2:只编译你修改的板子对应的设备树
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- rockchip/rk3568-my-board.dtb

编译成功后,会在arch/arm64/boot/dts/rockchip/目录下生成rk3568-my-board.dtb文件。

将这个.dtb文件替换到开发板/boot目录下(具体路径因系统而异,可能是/boot/dtb/或/boot/),然后重启开发板。

3.4 第四步:验证节点是否生效

系统启动后,可以通过以下方式验证:

  1. 查看系统日志:dmesg | grep -i sht3x 或 dmesg | grep -i i2c1,看是否有驱动加载和设备发现的日志。
  2. 查看sysfs:如果驱动加载成功,在/sys/bus/iio/devices/目录下应该会出现新的设备目录,里面会有in_humidity_raw、in_temp_raw等文件,可以通过cat读取数据。
  3. 查看设备树展开:ls /proc/device-tree/i2c@fe5a0000/,应该能看到sht3x@44目录,进去cat compatible能看到sensirion,sht3x。

如果一切顺利,恭喜你,你已经成功通过设备树添加了一个硬件设备!这个过程是RK3568驱动开发的常态:查阅硬件手册 -> 修改设备树 -> 编译更新 -> 验证。

4. 驱动如何与设备树对话:OF函数实战指南

设备树描述好了硬件,那驱动代码怎么读取这些信息呢?这就是OF(Open Firmware)函数的用武之地。内核提供了一整套以of_、of_property_为前缀的函数,专门用来解析设备树。

4.1 在驱动中获取设备树信息的标准流程

我们以一个虚拟的“多功能传感器”驱动为例,演示如何读取设备树中定义的资源。

假设设备树节点如下:

&i2c2 {
    my_sensor: sensor@5a {
        compatible = "my-company,multi-sensor-v1";
        reg = <0x5a>;
        // 自定义属性
        sample-rate-hz = <100>; // 采样率
        channel-names = "temperature", "pressure", "humidity";
        // 中断
        interrupt-parent = <&gpio0>;
        interrupts = <RK_PC1 IRQ_TYPE_EDGE_RISING>;
        // 复位GPIO
        reset-gpios = <&gpio0 RK_PB2 GPIO_ACTIVE_LOW>;
        // 复杂属性:一个16位的数据数组
        calibration-data = <0x1234 0x5678 0x9abc>;
    };
};

对应的驱动探测(probe)函数中,可以这样操作:

#include <linux/of.h>
#include <linux/of_gpio.h>
#include <linux/interrupt.h>
#include <linux/i2c.h>

static int my_sensor_probe(struct i2c_client *client)
{
    struct device *dev = &client->dev;
    struct device_node *np = dev->of_node; // 关键!获取本设备对应的设备树节点
    int ret, sample_rate;
    u32 cal_data[3];
    const char *channel_name;
    int irq, reset_gpio;

    // 1. 检查兼容性 (通常框架已做,这里演示)
    if (!of_device_is_compatible(np, "my-company,multi-sensor-v1")) {
        dev_err(dev, "Device tree node not compatible!\n");
        return -EINVAL;
    }

    // 2. 读取整数属性
    ret = of_property_read_u32(np, "sample-rate-hz", &sample_rate);
    if (ret) {
        dev_warn(dev, "No sample-rate-hz property, using default 10Hz\n");
        sample_rate = 10;
    } else {
        dev_info(dev, "Sample rate set to %d Hz from DT\n", sample_rate);
    }

    // 3. 读取字符串属性(获取第一个)
    ret = of_property_read_string_index(np, "channel-names", 0, &channel_name);
    if (!ret) {
        dev_info(dev, "First channel name: %s\n", channel_name);
    }

    // 4. 读取数组属性
    ret = of_property_read_u32_array(np, "calibration-data", cal_data, 3);
    if (!ret) {
        dev_info(dev, "Calibration data: 0x%04x, 0x%04x, 0x%04x\n",
                 cal_data[0], cal_data[1], cal_data[2]);
    }

    // 5. 获取中断号(标准方法)
    irq = of_irq_get(np, 0); // 获取第一个中断
    if (irq <= 0) {
        dev_err(dev, "Failed to get IRQ\n");
        // 可能是不使用中断的设备
    } else {
        dev_info(dev, "IRQ number: %d\n", irq);
        // 后续可以 request_irq(irq, ...);
    }

    // 6. 获取GPIO描述符(标准方法)
    reset_gpio = of_get_named_gpio(np, "reset-gpios", 0);
    if (reset_gpio < 0) {
        dev_err(dev, "Failed to get reset GPIO\n");
    } else {
        dev_info(dev, "Reset GPIO: %d\n", reset_gpio);
        // 后续可以 gpio_request(reset_gpio, ...); gpio_direction_output(...);
    }

    // 7. 直接内存映射(对于寄存器空间)
    // 对于I2C设备,我们通常用client->addr,而不是直接映射。
    // 但对于platform设备,如果有reg属性,可以:
    // struct resource *res;
    // void __iomem *base;
    // res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    // base = devm_ioremap_resource(dev, res);
    // 或者直接用 of_iomap(np, 0);

    return 0; // 探测成功
}

static const struct of_device_id my_sensor_of_match[] = {
    { .compatible = "my-company,multi-sensor-v1" },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, my_sensor_of_match);

关键点总结:

  • dev->of_node是通往设备树节点的桥梁。
  • of_property_read_*系列函数是读取属性的主力,安全且带错误处理。
  • 对于中断、GPIO、时钟、DMA等标准资源,强烈建议使用of_irq_get、of_get_named_gpio、of_clk_get等专用函数,而不是手动解析interrupts、gpios属性。这些函数处理了底层细节,更可靠。
  • of_device_is_compatible可以用来做额外的兼容性检查。
  • of_find_node_by_*系列函数用于在驱动中主动查找节点,但在标准的probe函数中,通常不需要,因为框架已经通过of_match_table把匹配好的节点传给了你。

4.2 进阶:处理reg属性与地址转换

对于有内存映射寄存器(如SPI控制器、内部硬件加速器)的设备,reg属性的解析至关重要。

// 假设设备树中:reg = <0x0 0xfe610000 0x0 0x1000>;
struct resource res;
void __iomem *base;

// 方法1:使用platform设备框架提供的API(推荐)
res = platform_get_resource(pdev, IORESOURCE_MEM, 0); // 获取第0个内存资源
base = devm_ioremap_resource(&pdev->dev, &res); // 映射并管理资源
if (IS_ERR(base)) {
    return PTR_ERR(base);
}

// 方法2:直接使用OF函数
base = of_iomap(np, 0); // 映射节点第0个reg区域
if (!base) {
    dev_err(dev, "Failed to iomap\n");
    return -ENOMEM;
}
// 记得在remove或error路径用 iounmap(base);

devm_ioremap_resource是首选,因为它自动管理资源申请和释放,并且会进行必要的安全检查(比如资源是否已被占用)。

5. 避坑指南:RK3568设备树开发常见问题

在我多年的开发经验里,新手在设备树上踩的坑大同小异。这里列几个高频问题,帮你提前排雷。

问题一:修改了.dts,但编译后系统没变化。

  • 检查点:
    1. 编译命令是否正确?确认输出了新的.dtb文件,且文件时间戳已更新。
    2. 是否将正确的.dtb文件拷贝到了开发板的正确位置?不同启动加载器(U-Boot)的加载路径可能不同,常见的是/boot/或/boot/dtb/。有时需要重命名为dtb或dtb.img。
    3. U-Boot是否真的加载了你更新的文件?查看U-Boot启动日志,看它加载的是哪个dtb文件。
    4. 最容易被忽略的一点:内核是否缓存了设备树?确保开发板是冷启动(彻底断电再上电),而不是热重启。有些系统热重启可能不会重新从存储设备加载dtb。

问题二:驱动probe函数没被调用。

  • 检查点:
    1. compatible属性是否拼写正确? 大小写、逗号、引号。必须与驱动中的of_device_id表里的字符串完全一致。用dmesg | grep -i compatible看看内核解析出来的值是什么。
    2. 父总线控制器是否使能? 如果你的设备挂在I2C1上,确保&i2c1节点里status = "okay"。我见过太多人只加了子设备节点,忘了打开父控制器。
    3. 驱动本身是否编译进内核或作为模块加载?lsmod查看一下。
    4. 检查内核日志是否有OF相关的错误,比如of_i2c: probe of sensor@44 failed with error -6,这能提供线索。

问题三:GPIO或中断申请失败。

  • 检查点:
    1. GPIO编号是否正确?RK3568的GPIO编号是分组的(如GPIO0, GPIO1)。在设备树中,应使用<&gpio0 RK_PA0 GPIO_ACTIVE_HIGH>这样的形式,而不是一个全局编号。驱动中应使用of_get_named_gpio来获取,它会正确转换。
    2. GPIO是否被其他驱动占用?cat /sys/kernel/debug/gpio可以查看GPIO使用情况。
    3. 中断号是否正确?使用of_irq_get获取。检查设备树中interrupt-parent指向的中断控制器节点是否正确。
    4. 引脚复用冲突:这个引脚是否被pinctrl配置成了其他功能(比如UART的TX)?检查设备树中该设备的pinctrl属性,以及系统中其他节点是否配置了相同的引脚。RK3568的引脚功能是唯一的。

问题四:设备树语法错误导致编译失败或内核启动卡住。

  • 检查点:
    1. 使用dtc工具进行语法检查:dtc -I dts -O dtb -o /dev/null your.dts。这会输出具体的语法错误行。
    2. 确保节点标签唯一。同一个.dtsi文件可能被包含多次,如果标签重复,合并时会出错。
    3. 属性值格式要匹配。比如reg = <0x1000>是整数,reg = "value"是字符串,不能混用。
    4. 内核启动卡在“Loading Device Tree...”或类似地方,通常是设备树二进制(.dtb)本身损坏或格式错误。回退到上一个能启动的版本进行对比。

问题五:如何调试设备树?

  • 三板斧:
    1. /proc/device-tree/:这是内核中设备树结构的直观反映。它是一个目录树,直接对应设备树节点和属性。cat属性文件可以查看值,ls可以看子节点。这是最直接的调试手段。
    2. 内核启动参数:在U-Boot中修改内核启动参数,添加devicetree相关调试选项,如earlycon(虽然主要用于串口,但有时能看出问题)。
    3. 内核配置:确保内核配置了CONFIG_PROC_DEVICETREE=y和CONFIG_OF_DEBUG=y(后者可能需要手动在make menuconfig中开启,位于Device Drivers -> Device Tree and Open Firmware support),可以开启更详细的OF调试信息。

最后,也是最重要的建议:多看官方示例和绑定文档。Rockchip SDK中arch/arm64/boot/dts/rockchip/目录下有大量参考设计。内核源码Documentation/devicetree/bindings/目录下的txt文件,是设备树属性的“权威字典”,描述了每个兼容设备所支持的标准属性及其含义。养成查阅这些文档的习惯,能让你事半功倍。

设备树是连接硬件和Linux驱动的桥梁,初学时有门槛,但一旦掌握,你就会发现它带来的灵活性和可维护性是无与伦比的。在RK3568这样强大的平台上,从点灯到驱动复杂的多媒体外设,都离不开对设备树的熟练运用。希望这篇长文能帮你打通任督二脉,在RK3568的驱动开发之路上走得更顺。

更多推荐