1. 从“硬编码”到“软配置”:为什么我们需要设备树?
如果你是从单片机或者早期的嵌入式Linux开发转过来的,一定对这样的场景不陌生:为了点亮一块新的开发板,你需要一头扎进内核源码的arch/arm/mach-xxx/目录下,找到一个名为board-xxx.c的文件,然后在里面密密麻麻地定义各种平台设备(platform_device)、I/O内存资源、中断号,甚至还要手动注册一堆杂项设备。每换一块板子,哪怕只是同一个SoC的不同变种,你都得重新修改、编译内核。这种将硬件信息“硬编码”在内核源码里的方式,我们称之为“板级支持包”(Board Support Package, BSP)。它带来的问题显而易见:内核源码变得臃肿且与特定硬件强耦合,驱动代码里充斥着大量的#ifdef,代码复用性极差,一个内核镜像几乎只能对应一块特定的板子。
设备树(Device Tree)的出现,就是为了解决这个“硬编码”的顽疾。它的核心思想很简单:将硬件描述与内核代码分离。你可以把它想象成一份写给内核的“硬件配置清单”或者“地图”。这份清单用一种结构化的文本格式(.dts或.dtsi)写成,独立于内核源码。在内核启动的早期阶段,Bootloader(如U-Boot)会将这份编译好的清单(.dtb文件)加载到内存中并传递给内核。内核中的设备树解析器(OF, Open Firmware)会读取这份清单,动态地创建出对应的设备节点,驱动再根据这些节点信息去匹配和初始化硬件。
这样一来,内核源码就变得“纯净”了。同一份支持某款SoC(比如瑞芯微的RK3568)的内核源码,可以搭配无数份描述不同外围硬件配置的设备树文件,从而轻松适配从核心板到各种形态的终端产品。驱动开发者也不再需要关心“我的设备在哪个物理地址”、“中断号是多少”,这些信息都从设备树节点中获取,驱动代码的通用性和可移植性得到了质的提升。对于像RK3568这类在国产嵌入式领域应用广泛的芯片,完善的设备树支持更是加速产品迭代、降低开发门槛的关键。
2. 设备树“语法”入门:节点、属性与兼容性
设备树源文件(.dts, Device Tree Source)的语法并不复杂,它像一棵倒置的树,从根节点开始,逐级描述系统的硬件组成。
2.1 基础结构:节点与属性
一个最简单的设备树文件骨架如下:
/dts-v1/; / { model = "My Awesome Board"; compatible = "my-company,my-board", "generic-board"; #address-cells = <1>; #size-cells = <1>; cpus { #address-cells = <1>; #size-cells = <0>; cpu@0 { compatible = "arm,cortex-a55"; device_type = "cpu"; reg = <0x0>; }; }; memory@80000000 { device_type = "memory"; reg = <0x80000000 0x40000000>; // 起始地址 0x80000000, 大小 1GB }; soc { compatible = "simple-bus"; #address-cells = <1>; #size-cells = <1>; ranges; serial@fe650000 { compatible = "snps,dw-apb-uart"; reg = <0xfe650000 0x100>; interrupts = <GIC_SPI 117 IRQ_TYPE_LEVEL_HIGH>; clock-frequency = <24000000>; status = "okay"; }; }; };我们来拆解一下关键元素:
- 节点(Node):用花括号
{}定义,是描述设备的基本单位。比如/是根节点,cpus、memory@80000000、soc、serial@fe650000都是子节点。@后面的地址(如80000000)是单元地址,用于区分同一类型的多个设备。 - 属性(Property):是键值对,描述节点的具体信息。常见属性有:
compatible:这是最重要的属性,用于驱动匹配。它是一个字符串列表,第一个字符串通常格式为“制造商,型号”,精确匹配驱动;后续字符串用于更通用的匹配。reg:描述设备占用的地址空间资源。它的格式是<地址1 长度1 [地址2 长度2 ...]>。如何解析这些数字,由父节点的#address-cells和#size-cells决定。例如,父节点soc的#address-cells = <1>; #size-cells = <1>;意味着reg中的每个“地址-长度”对都由1个cell表示地址,1个cell表示长度。interrupts:描述设备的中断号。它的解析依赖于系统中定义的中断控制器。status:设备状态,常用"okay"(启用)或"disabled"(禁用)。model和compatible(在根节点):描述整块板子的型号和兼容性。
2.2 驱动如何与设备树“握手”
驱动开发者最关心的是:我的驱动怎么找到设备树里的这个设备?答案就是compatible属性。
在内核驱动中,我们不再使用传统的platform_device_register来静态注册设备,而是定义一个of_device_id的结构体数组,并通过MODULE_DEVICE_TABLE(of, ...)导出,最后在驱动结构体(如platform_driver)的.driver.of_match_table成员中指向它。
// 驱动代码示例 static const struct of_device_id my_serial_of_match[] = { { .compatible = "snps,dw-apb-uart" }, { .compatible = "my-company,my-serial" }, // 可以匹配多个兼容字符串 {}, }; MODULE_DEVICE_TABLE(of, my_serial_of_match); static struct platform_driver my_serial_driver = { .probe = my_serial_probe, .driver = { .name = "my-serial", .of_match_table = of_match_ptr(my_serial_of_match), // 关键:匹配表 }, }; module_platform_driver(my_serial_driver);当内核解析设备树时,会为每个节点生成一个device_node结构。在总线(如 platform bus)进行设备与驱动匹配时,会比对设备节点(device_node)的compatible属性值与驱动提供的of_device_id表。一旦匹配成功,就会调用驱动的.probe函数。在.probe函数里,我们可以通过一系列OF(Open Firmware) API来获取设备树中的资源。
static int my_serial_probe(struct platform_device *pdev) { struct device_node *np = pdev->dev.of_node; // 获取对应的设备树节点 void __iomem *base; int irq; // 1. 获取内存资源(reg属性) base = devm_platform_ioremap_resource(pdev, 0); // 推荐使用这个API,它封装了获取和映射 if (IS_ERR(base)) return PTR_ERR(base); // 2. 获取中断资源(interrupts属性) irq = platform_get_irq(pdev, 0); if (irq < 0) return irq; // 3. 获取其他自定义属性 u32 baud_rate; if (of_property_read_u32(np, "current-speed", &baud_rate)) { // 如果属性不存在或读取失败,使用默认值 baud_rate = 115200; } // ... 后续的驱动初始化操作 return 0; }注意:
devm_platform_ioremap_resource是现在更推荐使用的API,它替代了旧的platform_get_resource+devm_ioremap组合,能自动管理资源释放,避免内存泄漏。
3. 进阶:设备树的重用、覆盖与调试技巧
当你的项目有多个产品型号,或者使用同一个SoC的不同模块时,直接复制粘贴设备树文件会是一场维护噩梦。设备树提供了强大的代码复用机制。
3.1 包含与继承:.dtsi 文件
公共的、不常变动的部分,尤其是SoC芯片本身的硬件描述,应该被提取到.dtsi(Device Tree Source Include) 文件中。.dts文件则描述具体的板级差异。
例如,芯片厂商会提供一个rk3568.dtsi,里面定义了CPU、内存控制器、各种内置外设(如VOP显示框架、GPU、NPU等)的节点。而你的产品设备树my-product.dts则这样写:
/dts-v1/; #include "rk3568.dtsi" // 包含SoC基础定义 #include <dt-bindings/gpio/gpio.h> #include <dt-bindings/pinctrl/rockchip.h> / { model = "My Product Board"; compatible = "my-company,my-product", "rockchip,rk3568"; chosen { stdout-path = "serial2:115200n8"; // 指定内核控制台输出串口 }; // 板级特定的配置,比如使能某个外设、修改时钟、配置引脚 &uart2 { // 引用rk3568.dtsi中定义的uart2节点,并添加/覆盖属性 status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&uart2m0_xfer>; }; &vop { // 配置显示输出框架 status = "okay"; assigned-clocks = <&cru DCLK_VOP0>, <&cru DCLK_VOP1>; assigned-clock-parents = <&pmucru PLL_HPLL>, <&cru PLL_VPLL>; }; // 添加板载的外设,比如一个GPIO连接的LED gpio-leds { compatible = "gpio-leds"; status-led { label = "status"; gpios = <&gpio0 RK_PA6 GPIO_ACTIVE_HIGH>; linux,default-trigger = "heartbeat"; }; }; };通过&符号引用已有节点并添加属性,是设备树配置中最常用的操作。这实现了对基础定义的“覆盖”或“补充”。
3.2 引脚控制(Pinctrl)与时钟的配置
在现代SoC中,一个引脚往往有多个功能(复用功能,即Mux)。设备树通过pinctrl子系统来描述引脚的复用和电气属性。以配置UART2的TXD和RXD引脚为例:
&uart2 { status = "okay"; pinctrl-names = "default"; // 状态名 pinctrl-0 = <&uart2m0_xfer>; // 指向具体的引脚配置组 }; // 在 pinctrl 节点中(通常在 .dtsi 里已定义) pinctrl { uart2 { uart2m0_xfer: uart2m0-xfer { rockchip,pins = <1 RK_PC2 1 &pcfg_pull_up>, // TXD, 复用功能1,上拉 <1 RK_PC3 1 &pcfg_pull_up>; // RXD, 复用功能1,上拉 }; }; };时钟配置也类似,通过引用时钟树中的节点来为设备指定时钟源和频率。assigned-clocks和assigned-clock-parents/rates是常用的属性。
3.3 设备树编译与内核传递
设备树源文件(.dts/.dtsi)需要被编译成二进制格式(.dtb, Device Tree Blob)才能被内核使用。编译工具是设备树编译器(DTC)。
# 在内核源码目录下,通常可以这样编译特定板子的dtb make dtbs # 或者单独编译 dtc -I dts -O dtb -o my-board.dtb my-board.dtsBootloader(如U-Boot)负责将.dtb文件加载到内存中,并在启动内核时通过特定的寄存器(如ARM的r2寄存器)将.dtb在内存中的起始地址传递给内核。在内核命令行中,你也可以通过devicetree参数指定,但更常见的是由Bootloader固定传递。
3.4 调试:当设备树不工作时
设备树配置错误是嵌入式Linux启动失败的常见原因。以下是一些调试手段:
查看解析后的设备树:系统启动后,可以在
/sys/firmware/devicetree/base/下以目录结构查看内核实际解析到的设备树。你可以用tree命令或find命令浏览。ls -la /sys/firmware/devicetree/base/ cat /sys/firmware/devicetree/base/model使用
of_*系列API的返回值:在驱动代码中,仔细检查of_property_read_*,platform_get_irq,devm_platform_ioremap_resource等函数的返回值,很多问题在这里就能发现。分析内核启动日志:使用
dmesg查看内核启动信息。关注以下关键词:OF:或DT:开头的日志,是设备树解析的核心输出。probe of ...成功或失败的信息。[ 0.000000] Machine model: ...这一行确认了使用的设备树模型。
检查U-Boot传递的dtb:在U-Boot命令行中,使用
fdt命令系列可以检查、甚至修改将要传递给内核的设备树。printenv查看fdtaddr等环境变量,确认dtb加载地址正确。反编译dtb:如果你只有一个
.dtb文件,可以用DTC工具反编译回.dts文本格式,方便查看。dtc -I dtb -O dts -o extracted.dts my-board.dtb
实操心得:最隐蔽的坑往往是“覆盖”不生效。比如在
.dts中引用&i2c1并设置status = "okay",但发现设备依然没起来。这时要检查.dtsi里该节点的原始定义,如果它被标记为status = "disabled";,你的覆盖是有效的。但如果.dtsi里根本没有status属性,那么内核默认可能就是不启用。另外,确保你的覆盖语句写在正确的位置,没有因为包含顺序或语法错误(如缺少分号)而被忽略。
4. 结合RK3568 VOP框架看设备树实战
瑞芯微RK3568的VOP(Video Output Processor)框架是显示输出的核心,它的设备树配置是一个很好的综合案例,涉及时钟、电源、引脚复用、远程端点(Remote Endpoint)等复杂概念。
一个简化的VOP设备树配置可能如下所示:
// 在 rk3568.dtsi 中定义的基础框架 vop: vop@fe040000 { compatible = "rockchip,rk3568-vop"; reg = <0x0 0xfe040000 0x0 0x3000>; interrupts = <GIC_SPI 148 IRQ_TYPE_LEVEL_HIGH>; clocks = <&cru ACLK_VOP>, <&cru HCLK_VOP>, <&cru DCLK_VOP0>, <&cru DCLK_VOP1>; clock-names = "aclk", "hclk", "dclk_vp0", "dclk_vp1"; resets = <&cru SRST_A_VOP>, <&cru SRST_H_VOP>; iommus = <&vop_mmu>; status = "disabled"; // 默认禁用,由板级文件使能 vop_out: port { #address-cells = <1>; #size-cells = <0>; // 这里定义输出端口,用于连接显示接口(如HDMI, MIPI-DSI) }; }; // 在板级 .dts 文件中的配置 &vop { status = "okay"; // 使能VOP assigned-clocks = <&cru DCLK_VOP0>, <&cru DCLK_VOP1>; assigned-clock-parents = <&pmucru PLL_HPLL>, <&cru PLL_VPLL>; // 为两个显示管道指定像素时钟的父时钟 }; // 配置HDMI控制器,并将其连接到VOP的输出端口 &hdmi { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&hdmitx_scl &hdmitx_sda &hdmitxm0_cec>; ports { #address-cells = <1>; #size-cells = <0>; port@0 { reg = <0>; hdmi_in_vp0: endpoint { remote-endpoint = <&vop_out_hdmi>; // 连接到VOP的某个输出 }; }; }; }; // 在VOP节点内部,定义具体的输出映射 &vop_out { vop_out_hdmi: endpoint@0 { reg = <0>; remote-endpoint = <&hdmi_in_vp0>; // 与HDMI的输入端口对应 }; };这个配置清晰地展示了:
- 节点引用与覆盖:通过
&vop和&hdmi引用SoC基础定义,并覆盖status等属性。 - 时钟配置:使用
assigned-clocks和assigned-clock-parents为显示管道指定精确的时钟源,这对显示稳定性至关重要。 - 端口与连接:使用
port和endpoint子节点来描述VOP与HDMI控制器之间的数据流连接。remote-endpoint属性像一根“线”将两者连接起来,这是Linux内核显示、视频等子系统中描述内部连接的标准方式。 - 引脚控制:HDMI节点的
pinctrl-0属性指定了HDMI相关引脚(I2C和CEC)的复用配置。
调试这类复杂外设时,除了看内核日志,还可以查看/sys/kernel/debug下的调试文件系统(需内核配置CONFIG_DEBUG_FS),例如debug/dri/0/下的文件可以查看DRM(Direct Rendering Manager, Linux显示核心框架)的状态,包括每个CRTC(对应VOP的管道)、连接器(对应HDMI)的状态和信息。
设备树是现代嵌入式Linux开发的基石,它将硬件描述从内核代码中解放出来,带来了前所未有的灵活性。掌握设备树,意味着你能够更高效地适配硬件、调试驱动,并理解内核与硬件交互的底层逻辑。从读懂一份.dts文件开始,到能为自己的外设编写正确的节点,再到能娴熟地调试设备树引起的问题,这条学习路径是每一位嵌入式Linux驱动开发者都必须扎实走过的。