4. 设备树地址编码:#address-cells、#size-cells、ranges属性、地址映射机制

地址编码这块,说白了就是告诉内核:你的外设挂在哪个地址上?地址范围有多大?中间有没有经过地址转换?

我刚开始接触设备树时,总觉得这些属性可有可无。直到有一次调试一个PCIe桥片,死活枚举不到下游设备……折腾了两天,最后发现是 ranges 写错了。嗯,从那以后我再也不敢小看这几个属性了。

4.1 地址编码的三要素

每个可寻址的设备节点,都需要回答三个问题:

  • 地址有多宽? —— 由 #address-cells 定义
  • 大小有多宽? —— 由 #size-cells 定义
  • 父总线怎么翻译? —— 由 ranges 定义

你想想看,一个32位ARM芯片,地址总线是32根线,那地址值用1个32位整数就能表示。但如果是64位PowerPC,地址就得拆成两个32位整数。这就是 #address-cells 存在的意义。

核心规则:

  • #address-cells 表示地址字段占几个32位单元
  • #size-cells 表示长度字段占几个32位单元
  • 这两个属性定义在父节点中,影响子节点的 reg 属性解析

4.2 reg 属性的编码规则

reg 是设备节点的核心属性,它告诉内核:这个设备的寄存器空间在哪。

格式很简单:reg = <地址1 长度1 地址2 长度2 ...>

但具体怎么拆分,取决于父节点的 #address-cells 和 #size-cells。

父节点配置 地址字段宽度 长度字段宽度 reg 示例 实际含义
#address-cells = <1>; #size-cells = <1> 1个32位 1个32位 <0x10000000 0x1000> 基址0x10000000,大小0x1000
#address-cells = <2>; #size-cells = <1> 2个32位 1个32位 <0x0 0x10000000 0x1000> 基址0x00000000_10000000,大小0x1000
#address-cells = <1>; #size-cells = <0> 1个32位 无 <0x10000000> 仅地址,无长度(常用于中断控制器)
// 实际案例:一个典型的ARM SoC设备树片段
soc {
    #address-cells = <1>;
    #size-cells = <1>;
    compatible = "simple-bus";
    ranges;

    uart0: serial@101f1000 {
        compatible = "arm,pl011";
        reg = <0x101f1000 0x1000>;
        interrupts = <0 5 4>;
    };

    gpio0: gpio@101e0000 {
        compatible = "arm,pl061";
        reg = <0x101e0000 0x1000>;
        interrupts = <0 8 4>;
    };
};

我的习惯:写 reg 时,我总会在注释里把地址和长度拆开写清楚。比如 reg = <0x101f1000 0x1000>; /* 基址 0x101f1000,长度 4KB */。半年后你自己回来看代码,绝对感谢这个注释。

4.3 ranges 属性:地址翻译的桥梁

ranges 是设备树里最容易写错、也最难排查的属性之一。它的作用很简单:告诉内核,子节点的地址空间怎么映射到父节点的地址空间。

格式:ranges = <子地址 父地址 长度>;

每个字段的宽度由对应的 #address-cells 决定:

  • 子地址:子节点的 #address-cells 宽度
  • 父地址:父节点的 #address-cells 宽度
  • 长度:子节点的 #size-cells 宽度

如果 ranges 属性为空(ranges;),表示子地址和父地址是一一对应的,不需要翻译。

如果节点没有 ranges 属性,表示子节点不在父节点的地址空间内,内核无法直接访问。

我曾经踩过的坑:有一次给FPGA挂一个自定义IP,ranges 写成了 ranges = <0x0 0x43c00000 0x10000>;,但FPGA桥的 #address-cells 是2,父节点是1。结果内核解析时把地址拆错了,驱动读到的寄存器全是0xFF。排查了整整一天……

教训:写 ranges 之前,先确认父子和自己的 #address-cells 和 #size-cells 各是多少。

4.4 地址映射机制:从设备树到物理地址

内核解析设备树地址的流程,其实就三步:

  1. 解析 reg:根据父节点的 #address-cells 和 #size-cells,从 reg 中提取出子地址和长度
  2. 逐级翻译:从当前节点向上遍历,每经过一个带 ranges 的节点,就做一次地址转换
  3. 得到物理地址:最终翻译到根节点,得到CPU可以直接访问的物理地址

举个例子,假设有这样的设备树层级:

/ {
    #address-cells = <1>;
    #size-cells = <1>;

    pcie@40000000 {
        #address-cells = <3>;  // PCIe地址格式:总线号+设备号+偏移
        #size-cells = <2>;     // PCIe长度格式:高32位+低32位
        ranges = <0x02000000 0x0 0x40000000
                  0x40000000
                  0x0 0x10000000>;

        ethernet@0,0 {
            reg = <0x02000000 0x0 0x0
                   0x0 0x1000>;
        };
    };
};

内核解析 ethernet 的地址时:

  • 先看父节点 pcie 的 #address-cells=<3>,#size-cells=<2>,从 reg 中提取出子地址 0x02000000 0x0 0x0,长度 0x0 0x1000
  • 然后看 pcie 的 ranges,发现子地址 0x02000000 0x0 0x0 落在第一个映射范围内,翻译成父地址 0x40000000
  • 继续向上到根节点,根节点没有 ranges(或者 ranges; 空映射),最终物理地址就是 0x40000000

一句话总结:设备树的地址映射,就是一层层剥洋葱。每层 ranges 告诉你这一层怎么翻译,直到剥到根节点得到物理地址。

4.5 知识体系总览

下面这张图,把地址编码的核心逻辑串起来了:

设备树地址编码与映射机制 设备树地址编码与映射机制 #address-cells #size-cells ranges 定义地址字段宽度 (几个32位单元) 定义长度字段宽度 (几个32位单元) 子地址 → 父地址映射 (地址翻译桥梁) reg = <地址 长度 ...> (设备寄存器空间描述) 逐级向上翻译:子地址 → ranges → 父地址 → ... → 物理地址 CPU 可直接访问的物理地址 ranges 为空 = 1:1映射 无 ranges = 不可直接访问

4.6 实战中的避坑指南

这些年调过的板卡,地址编码出问题的情况我见得多了。总结几个高频坑:

  • 坑一:#address-cells 写错位置 —— 记住,这个属性是定义在父节点上的,不是设备节点自己。我见过有人把 #address-cells 写在 uart 节点里,内核直接报错。
  • 坑二:ranges 的字段宽度不匹配 —— 子地址、父地址、长度三个字段的宽度必须严格对应各自的 #address-cells 和 #size-cells。少写一个32位,整个映射就偏了。
  • 坑三:64位地址只用了一个cell —— 有些SoC的地址空间超过4GB,但设备树里只写了一个32位地址。内核解析时高位被截断,驱动访问的地址完全不对。
  • 坑四:忘记写 ranges 导致设备不可见 —— 如果子节点在父节点的地址空间内,但父节点没写 ranges,内核会认为子设备不可直接访问,驱动 probe 直接失败。

我的调试小技巧:遇到地址相关的问题,先在内核启动日志里搜 of_address_to_resource 或者 of_translate_address 的输出。内核会把每一步地址翻译的结果打印出来,对照设备树一看,问题基本就现形了。

地址编码这块,说白了就是约定和翻译。只要把 #address-cells、#size-cells、ranges 这三个属性的关系理清楚,再复杂的地址映射也能拆解明白。嗯,下次调板卡遇到地址问题,不妨先从这三个属性查起。