Linux 与设备树

Linux 的设备树数据使用模型

作者:

Grant Likely <grant.likely@secretlab.ca>

本文描述了 Linux 如何使用设备树。有关设备树数据格式的概述,可以在 devicetree.org 的设备树使用页面上找到[1]

“Open Firmware 设备树”,或简称为设备树(Devicetree, DT),是一种用于描述硬件的数据结构和语言。更具体地说,它是操作系统可读的硬件描述,这样操作系统就不需要在代码中硬编码机器的细节。

在结构上,DT 是一棵树,或者说是一个带有命名节点的无环图,节点可以包含任意数量的命名属性,这些属性封装了任意数据。此外,还存在一种机制,可以在自然的树状结构之外,创建从一个节点到另一个节点的任意链接。

从概念上讲,为了描述典型硬件特征(包括数据总线、中断线、GPIO 连接和外设),定义了一套通用的使用惯例(称为“绑定”(bindings)),规定了数据应如何出现在树中。

尽可能使用现有的绑定来描述硬件,以最大限度地利用现有的支持代码;但由于属性和节点名称只是文本字符串,因此通过定义新的节点和属性来扩展现有绑定或创建新绑定非常容易。不过,在未先了解现有绑定情况之前,请谨慎创建新绑定。目前存在两个不同且不兼容的 i2c 总线绑定,原因就是创建新绑定时没有事先调查现有系统中是如何枚举 i2c 设备的。

1. 历史

DT 最初由 Open Firmware 创建,作为将数据从 Open Firmware 传递到客户端程序(如操作系统)的通信方法的一部分。操作系统在运行时使用设备树来发现硬件拓扑,从而在没有硬编码信息的情况下支持大部分可用硬件(假设所有设备都有可用的驱动程序)。

由于 Open Firmware 常用于 PowerPC 和 SPARC 平台,因此 Linux 对这些架构的支持长期以来一直使用设备树。

2005 年,当 PowerPC Linux 开始进行大规模清理并合并 32 位和 64 位支持时,决定在所有 powerpc 平台上强制要求支持 DT,无论它们是否使用 Open Firmware。为此,创建了一种称为展平设备树(Flattened Device Tree, FDT)的 DT 表示形式,它可以作为二进制块(binary blob)传递给内核,而无需真正的 Open Firmware 实现。U-Boot、kexec 和其他引导加载程序经过修改,既支持传递设备树二进制文件(dtb),又支持在引导时修改 dtb。DT 还被添加到了 PowerPC 引导包装器(arch/powerpc/boot/*)中,以便将 dtb 与内核镜像打包在一起,从而支持引导现有的不支持 DT 的固件。

一段时间后,FDT 基础设施被泛化,以适用于所有架构。在撰写本文时,6 个主流架构(arm、microblaze、mips、powerpc、sparc 和 x86)以及 1 个非主流架构(nios)都具备了一定程度的 DT 支持。

2. 数据模型

如果你还没阅读过《设备树使用》(Device Tree Usage)[1]页面,现在就去看吧。没关系,我等你……

2.1 高级概览

需要理解的最重要的事情是,DT 只是一个描述硬件的数据结构。它没有什么神奇之处,也不会神奇地让所有硬件配置问题消失。它真正做的是提供了一种语言,用于将硬件配置与 Linux 内核(或其他任何操作系统)中的板级和设备驱动程序支持解耦。使用它允许板级和设备支持变为数据驱动的;即根据传递给内核的数据来做初始化决策,而不是基于每台机器硬编码的选择。

理想情况下,数据驱动的平台设置应该减少代码重复,并使单个内核镜像更容易支持广泛的硬件。

Linux 将 DT 数据用于三个主要目的:

  1. 平台识别,

  2. 运行时配置,以及

  3. 设备填充(device population)。

2.2 平台识别

首先也是最重要的是,内核将使用 DT 中的数据来识别特定的机器。在理想世界中,特定的平台对内核来说应该无关紧要,因为所有的平台细节都将由设备树以一致且可靠的方式完美描述。然而,硬件并不完美,因此内核必须在早期引导期间识别机器,以便它有机会运行特定于机器的修正(fixups)。

在大多数情况下,机器身份是无关紧要的,内核将改为根据机器的核心 CPU 或 SoC 选择设置代码。例如,在 ARM 上,arch/arm/kernel/setup.c 中的 setup_arch() 将调用 arch/arm/kernel/devtree.c 中的 setup_machine_fdt(),后者会搜索 machine_desc 表并选择最匹配设备树数据的 machine_desc。它通过查看根设备树节点中的“compatible”属性,并将其与 struct machine_desc 中的 dt_compat 列表进行比较来确定最佳匹配(如果你好奇的话,它定义在 arch/arm/include/asm/mach/arch.h 中)。

“compatible”属性包含一个排序的字符串列表,以机器的确切名称开头,后面跟着与其兼容的可选板卡列表,按兼容性从高到低排序。例如,TI BeagleBoard 及其后继产品 BeagleBoard xM 板的根 compatible 属性可能分别如下所示:

compatible = "ti,omap3-beagleboard", "ti,omap3450", "ti,omap3";
compatible = "ti,omap3-beagleboard-xm", "ti,omap3450", "ti,omap3";

其中,“ti,omap3-beagleboard-xm”指定了确切的型号,它还声明它与 OMAP 3450 SoC 以及整个 omap3 系列 SoC 兼容。你会注意到列表是按从最具体(确切的板卡)到最不具体(SoC 系列)进行排序的。

敏锐的读者可能会指出,Beagle xM 也可以声明与最初的 Beagle 板兼容。然而,在板级这样做应当谨慎,因为即使在同一产品线内,从一块板到另一块板通常也有很大的变动,当一块板声称与另一块板兼容时,很难确切界定其含义。对于顶层来说,最好保持谨慎,不要声称一块板与另一块板兼容。一个明显的例外是,当一块板是另一块板的载板(carrier)时,例如附加在载板上的 CPU 模块。

关于 compatible 值的另一个注意事项。compatible 属性中使用的任何字符串都必须在文档中说明其含义。请在 Documentation/devicetree/bindings 中为 compatible 字符串添加文档。

再次以 ARM 为例,对于每个 machine_desc,内核会检查 dt_compat 列表中的任何条目是否出现在 compatible 属性中。如果出现,则该 machine_desc 成为驱动该机器的候选者。在搜索完整个 machine_desc 表后,setup_machine_fdt() 根据每个 machine_desc 匹配 compatible 属性中的哪个条目,返回“最兼容”的 machine_desc。如果未找到匹配的 machine_desc,则返回 NULL。

这一方案背后的原理是:在大多数情况下,如果大量板卡使用相同的 SoC 或相同的 SoC 系列,单个 machine_desc 就可以支持它们。然而,总会有一些例外情况,某些特定的板卡需要通用的设置代码中没有的特殊初始化代码。可以通过在通用设置代码中显式检查有问题的板卡来处理特殊情况,但如果情况不止一几种,这样做很快就会变得丑陋且/或难以维护。

相反,compatible 列表允许通用的 machine_desc 通过在 dt_compat 列表中指定“兼容性较低”的值来支持广泛的通用板卡集合。在上面的例子中,通用的板卡支持可以声明与“ti,omap3”或“ti,omap3450”兼容。如果在最初的 beagleboard 上发现了一个 bug,需要在早期引导期间使用特殊的变通(workaround)代码,那么可以添加一个新的 machine_desc 来实现这些变通代码,并且只匹配“ti,omap3-beagleboard”。

PowerPC 使用了略有不同的方案,它会调用每个 machine_desc 中的 .probe() 钩子,并使用第一个返回 TRUE 的钩子。然而,这种方法没有考虑 compatible 列表的优先级,在支持新架构时可能应该避免这种做法。

2.3 运行时配置

在大多数情况下,DT 将是将数据从固件传递到内核的唯一方法,因此它也被用来传入运行时和配置数据,例如内核参数字符串和 initrd 镜像的位置。

这些数据大多包含在 /chosen 节点中,当引导 Linux 时,它看起来大致如下:

chosen {
        bootargs = "console=ttyS0,115200 loglevel=8";
        initrd-start = <0xc8000000>;
        initrd-end = <0xc8200000>;
};

bootargs 属性包含内核参数,而 initrd-* 属性定义了 initrd 二进制块的地址和大小。请注意,initrd-end 是 initrd 镜像之后的第一个地址,因此这与 struct resource 的通常语义不匹配。chosen 节点还可以可选地包含任意数量的附加属性,用于特定于平台的配置数据。

在早期引导期间,架构设置代码在设置分页之前会多次调用 of_scan_flat_dt() 并传入不同的辅助回调函数来解析设备树数据。of_scan_flat_dt() 代码扫描设备树,并使用辅助函数提取早期引导所需的信息。通常,使用 early_init_dt_scan_chosen() 辅助函数来解析包含内核参数的 chosen 节点,使用 early_init_dt_scan_root() 来初始化 DT 地址空间模型,以及使用 early_init_dt_scan_memory() 来确定可用 RAM 的大小和位置。

在 ARM 上,函数 setup_machine_fdt() 负责在选择了支持该板的正确 machine_desc 后对设备树进行早期扫描。

2.4 设备填充

在识别出板卡并解析了早期配置数据之后,内核初始化就可以按正常方式继续进行。在此过程的某个时间点,会调用 unflatten_device_tree() 将数据转换为更高效的运行时表示形式。这也是将调用特定于机器的设置钩子的时候,例如 ARM 上的 machine_desc .init_early()、.init_irq() 和 .init_machine() 钩子。本节的其余部分将使用 ARM 实现中的示例,但在使用 DT 时,所有架构所做的事情都差不多。

顾名思义,.init_early() 用于需要在引导过程早期执行的任何特定于机器的设置,而 .init_irq() 用于设置中断处理。使用 DT 并不会实质性改变这两个函数中任何一个的行为。如果提供了 DT,那么 .init_early() 和 .init_irq() 都可以调用任何 DT 查询函数(include/linux/of*.h 中的 of_*)来获取有关平台的附加数据。

在 DT 背景下最有趣的钩子是 .init_machine(),它主要负责用平台数据填充 Linux 设备模型。历史上,这在嵌入式平台上是通过在板级支持 .c 文件中定义一组静态时钟结构、platform_devices 和其他数据,并在 .init_machine() 中集体注册来实现的。当使用 DT 时,无需为每个平台硬编码静态设备,而是可以通过解析 DT 来获取设备列表,并动态分配设备结构。

最简单的情况是 .init_machine() 仅负责注册一组 platform_devices。platform_device 是 Linux 用于内存或 I/O 映射设备(无法由硬件检测到)以及“复合(composite)”或“虚拟(virtual)”设备(稍后会详细介绍)的概念。虽然 DT 中没有“平台设备(platform device)”术语,但平台设备大致对应于树根处的设备节点以及简单内存映射总线节点的子节点。

现在正是给出示例的好时机。以下是 NVIDIA Tegra 板的设备树的一部分:

/{
      compatible = "nvidia,harmony", "nvidia,tegra20";
      #address-cells = <1>;
      #size-cells = <1>;
      interrupt-parent = <&intc>;

      chosen { };
      aliases { };

      memory {
              device_type = "memory";
              reg = <0x00000000 0x40000000>;
      };

      soc {
              compatible = "nvidia,tegra20-soc", "simple-bus";
              #address-cells = <1>;
              #size-cells = <1>;
              ranges;

              intc: interrupt-controller@50041000 {
                      compatible = "nvidia,tegra20-gic";
                      interrupt-controller;
                      #interrupt-cells = <1>;
                      reg = <0x50041000 0x1000>, < 0x50040100 0x0100 >;
              };

              serial@70006300 {
                      compatible = "nvidia,tegra20-uart";
                      reg = <0x70006300 0x100>;
                      interrupts = <122>;
              };

              i2s1: i2s@70002800 {
                      compatible = "nvidia,tegra20-i2s";
                      reg = <0x70002800 0x100>;
                      interrupts = <77>;
                      codec = <&wm8903>;
              };

              i2c@7000c000 {
                      compatible = "nvidia,tegra20-i2c";
                      #address-cells = <1>;
                      #size-cells = <0>;
                      reg = <0x7000c000 0x100>;
                      interrupts = <70>;

                      wm8903: codec@1a {
                              compatible = "wlf,wm8903";
                              reg = <0x1a>;
                              interrupts = <347>;
                      };
              };
      };

      sound {
              compatible = "nvidia,harmony-sound";
              i2s-controller = <&i2s1>;
              i2s-codec = <&wm8903>;
      };
};

在 .init_machine() 时,Tegra 板级支持代码需要查看此 DT 并决定为哪些节点创建 platform_devices。然而,看着这棵树,每个节点代表哪种设备并不是一眼就能看出来的,甚至一个节点是否代表设备也不一定。/chosen、/aliases 和 /memory 节点是不描述设备的信息性节点(尽管可以说内存可以被视为设备)。/soc 节点的子节点是内存映射设备,但 codec@1a 是一个 i2c 设备,而 sound 节点代表的不是一个设备,而是其他设备如何连接在一起以创建音频子系统的。我知道每个设备是什么,但是内核如何知道对每个节点做什么呢?

诀窍在于,内核从树的根部开始,寻找具有“compatible”属性的节点。首先,通常假设任何带有“compatible”属性的节点都代表某种设备;其次,可以假设树根处的任何节点要么直接连接到处理器总线,要么是无法用其他方式描述的杂项系统设备。对于这些节点中的每一个,Linux 都会分配并注册一个 platform_device,进而可能会将其绑定到 platform_driver。

为什么为这些节点使用 platform_device 是一个安全的假设?嗯,就 Linux 对设备建模的方式而言,几乎所有的 bus_types 都假设其设备是总线控制器的子设备。例如,每个 i2c_client 都是 i2c_master 的子设备。每个 spi_device 都是 SPI 总线的子设备。USB、PCI、MDIO 等也是如此。在 DT 中也可以发现相同的层次结构,其中 I2C 设备节点仅作为 I2C 总线节点的子节点出现。SPI、MDIO、USB 等也是如此。唯一不需要特定类型父设备的设备是 platform_device(以及 amba_device,稍后会详细介绍),它们可以愉快地存在于 Linux /sys/devices 树的底部。因此,如果一个 DT 节点位于树的根部,那么它作为 platform_device 注册确实可能是最好的选择。

Linux 板级支持代码调用 of_platform_populate(NULL, NULL, NULL, NULL) 来启动对树根处设备的发现。这些参数全部为 NULL,因为当从树的根部开始时,不需要提供起始节点(第一个 NULL)、父 struct device(最后一个 NULL),并且我们(目前)没有使用匹配表。对于只需要注册设备的板卡,.init_machine() 可以完全为空,除了 of_platform_populate() 调用之外。

在 Tegra 示例中,这对应于 /soc 和 /sound 节点,但 SoC 节点的子节点怎么办?它们不也应该注册为平台设备吗?对于 Linux DT 支持,通用行为是子设备由父设备的设备驱动程序在驱动程序 .probe() 期间注册。因此,i2c 总线设备驱动程序将为每个子节点注册一个 i2c_client,SPI 总线驱动程序将注册其 spi_device 子节点,其他 bus_types 也是如此。根据该模型,可以编写一个驱动程序,它绑定到 SoC 节点并为其每个子节点简单地注册 platform_devices。板级支持代码将分配并注册一个 SoC 设备,一个(理论上的)SoC 设备驱动程序可以绑定到 SoC 设备,并在其 .probe() 钩子中为 /soc/interrupt-controller、/soc/serial、/soc/i2s 和 /soc/i2c 注册 platform_devices。很简单,对吧?

实际上,事实证明将某些 platform_devices 的子节点注册为更多的 platform_devices 是一种常见的模式,设备树支持代码反映了这一点,并使上面的示例更简单。of_platform_populate() 的第二个参数是一个 of_device_id 表,任何匹配该表中条目的节点的子节点也将被注册。在 Tegra 的情况下,代码可能看起来像这样:

static void __init harmony_init_machine(void)
{
      /* ... */
      of_platform_populate(NULL, of_default_bus_match_table, NULL, NULL);
}

“simple-bus”在设备树规范(Devicetree Specification)中被定义为表示简单内存映射总线的属性,因此 of_platform_populate() 代码可以写成直接假设兼容 simple-bus 的节点将始终被遍历。然而,我们将其作为参数传入,以便板级支持代码始终可以覆盖默认行为。

[需要添加关于添加 i2c/spi 等子设备的讨论]

附录 A:AMBA 设备

ARM Primecells 是连接到 ARM AMBA 总线的一种特定设备,它们包含对硬件检测和电源管理的一些支持。在 Linux 中,struct amba_device 和 amba_bus_type 用于表示 Primecell 设备。然而,棘手的一点是,并非 AMBA 总线上的所有设备都是 Primecells,并且对于 Linux 而言,amba_device 和 platform_device 实例通常是同一总线段的兄弟节点。

当使用 DT 时,这给 of_platform_populate() 带来了问题,因为它必须决定是将每个节点注册为 platform_device 还是 amba_device。这不幸地使设备创建模型稍微复杂化了一些,但事实证明解决方案并不是太具侵入性。如果一个节点与“arm,primecell”兼容,那么 of_platform_populate() 将把它注册为 amba_device 而不是 platform_device。