4、设备树地址映射:reg属性、ranges属性、dma-ranges属性、地址转换

地址映射,说白了就是让CPU能找到外设。你写驱动时,总得知道寄存器在哪吧?设备树里就是靠reg、ranges、dma-ranges这几个属性来描述的。我刚开始接触时也觉得绕,后来调过几个板子就明白了——其实核心就三件事:描述地址、转换地址、告诉DMA怎么走。

4.1 reg属性:描述设备占用的地址空间

reg是最基础的属性。它告诉内核:这个设备用哪段地址空间,长度是多少。格式很简单:reg = <地址 长度, 地址 长度, ...>。每个地址和长度,在32位系统里是1个cell(32位),在64位系统里是2个cell(64位)。

核心规则:reg中的地址和长度,其cell数量由父节点的#address-cells和#size-cells决定。

举个例子,一个UART控制器挂在内存总线上:

soc {
    #address-cells = <1>;
    #size-cells = <1>;
    
    uart@10000000 {
        compatible = "ns16550";
        reg = <0x10000000 0x1000>;
        interrupts = <33>;
    };
};

这里0x10000000是起始地址,0x1000是长度(4KB)。内核解析后,驱动就能通过platform_get_resource()拿到这段IO内存。

我的习惯:写reg时,长度尽量对齐页大小(4KB)。有些IP核只用了几个寄存器,但给整页映射能避免后续扩展时改设备树。

4.2 ranges属性:实现父子节点间的地址转换

这是很多人容易搞混的地方。ranges解决的是地址翻译问题——子节点看到的地址空间,和父节点看到的地址空间,往往不是一回事。

比如,一个PCIe控制器内部有独立的配置空间和IO空间,这些地址在CPU视角下可能被映射到不同的基地址。ranges就是做这个映射的。

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

看个实际例子:

bus@20000000 {
    compatible = "simple-bus";
    #address-cells = <1>;
    #size-cells = <1>;
    ranges = <0x0 0x20000000 0x10000000>;
    
    ethernet@0 {
        reg = <0x0 0x10000>;
    };
};

这里ranges的意思是:子地址0x0映射到父地址0x20000000,映射长度0x10000000(256MB)。所以以太网控制器看到的reg = <0x0 0x10000>,实际物理地址是0x20000000 + 0x0 = 0x20000000。

我曾经踩过的坑:如果ranges属性为空(ranges;),表示父子地址空间是1:1映射。但如果父节点没有定义ranges,子节点的地址就无法被CPU直接访问。有一次我忘了给桥接总线加ranges,驱动ioremap死活不成功,查了两天才发现是设备树的问题。

4.3 dma-ranges属性:DMA地址转换

dma-ranges和ranges很像,但用途完全不同。ranges是给CPU访问外设用的,dma-ranges是给外设做DMA访问内存用的。

为什么需要这个?因为有些设备(比如老旧的32位DMA控制器)只能访问低32位地址空间,但系统内存可能超过4GB。或者,IOMMU/SMMU做了地址重映射,设备看到的DMA地址和物理地址不一致。

格式和ranges一样:dma-ranges = <设备地址 父地址 长度, ...>

举个例子:

pcie@40000000 {
    #address-cells = <3>;
    #size-cells = <2>;
    dma-ranges = <0x02000000 0x0 0x00000000 
                  0x0 0x80000000 
                  0x0 0x80000000>;
};

这个配置告诉PCIe设备:你的DMA地址0x00000000到0x7FFFFFFF,对应物理地址0x80000000到0xFFFFFFFF。说白了,就是把DMA地址空间整体偏移了2GB。

我建议:如果你的系统有IOMMU,dma-ranges可以简化驱动逻辑。驱动不需要知道物理内存布局,只管用设备看到的DMA地址就行,IOMMU会帮你做转换。

4.4 地址转换的完整流程

内核解析设备树地址时,会从子节点向上逐级查找ranges,直到根节点。这个过程叫地址翻译(address translation)。

我画了张图,帮你理清这个流程:

设备树地址转换流程 根节点 (/) 总线节点 (ranges属性) 设备A (reg属性) 设备B (reg属性) 内核OF地址解析:逐级查找ranges,累加偏移 of_translate_address() → of_get_address() → of_read_number() 最终物理地址 → ioremap() / of_iomap() 关键:ranges为空表示1:1映射;无ranges表示子地址不可被CPU直接访问 dma-ranges同理,但用于DMA方向(设备→内存)

内核里,of_translate_address()函数就是干这个活的。它会从当前节点开始,向上遍历父节点,每遇到一个ranges就做一次偏移累加,直到根节点。最终得到的就是CPU能直接用的物理地址。

4.5 实战对比:三种属性的差异

属性 方向 典型场景 不配置的后果
reg 设备→CPU视角 描述设备自身寄存器地址 驱动无法访问硬件
ranges 子节点→父节点 桥接总线地址映射 子设备地址不可达
dma-ranges 设备→内存 DMA地址空间转换 DMA操作可能访问错误内存

特别注意:dma-ranges和ranges虽然格式一样,但内核处理它们的时机不同。ranges在of_translate_address()中处理,dma-ranges在of_dma_configure()中处理。别搞混了。

4.6 避坑指南

我这些年调过的板子,地址映射出问题的情况真不少。总结几个常见坑:

  • address-cells和size-cells不匹配:父节点和子节点必须一致,否则解析出来的地址全是错的。我见过有人父节点用<2>,子节点用<1>,结果地址被截断。
  • ranges长度不够:子设备地址范围超出了ranges定义的映射长度,内核不会报错,但访问时会挂掉。
  • dma-ranges忘记配置:64位系统上,如果DMA控制器只能访问32位地址,不配dma-ranges的话,DMA可能写到高地址内存去,数据损坏。
  • 空ranges的含义:ranges;(空)表示1:1映射,但ranges不存在时,子地址完全不可翻译。这两个情况差别很大。

我的调试技巧:在驱动里加dev_info()打印res->start和res->end,对比设备树里的值。如果地址不对,八成是ranges解析出了问题。另外,/sys/firmware/devicetree/base/下可以查看解析后的设备树,非常方便。

地址映射这块,说白了就是搞清楚「谁看谁」的问题。CPU看外设用ranges,外设看内存用dma-ranges,设备自己用reg。理清这个关系,设备树地址部分就通了。