4、设备树地址映射:地址范围描述与转换
地址映射这个话题,说简单也简单,说复杂也复杂。我刚开始接触设备树时,就被 #address-cells 和 #size-cells 这两个属性搞得晕头转向。后来在项目中调一个 PCIe 的地址映射问题,折腾了两天才发现是 ranges 写错了。嗯,今天咱们就把这块彻底讲清楚。
4.1 地址范围描述:#address-cells 和 #size-cells
这两个属性是设备树地址映射的基石。它们定义了一个节点中,地址和长度分别用几个 32 位整数来表示。
- #address-cells:地址字段占几个 cell(一个 cell = 32 位)
- #size-cells:长度字段占几个 cell
举个例子,32 位系统通常这样写:
soc {
#address-cells = <1>; // 地址用 32 位
#size-cells = <1>; // 长度用 32 位
uart@10000000 {
reg = <0x10000000 0x1000>; // 基址 0x10000000,长度 0x1000
};
};
64 位系统就不一样了:
soc {
#address-cells = <2>; // 地址用 64 位
#size-cells = <2>; // 长度用 64 位
memory@80000000 {
reg = <0x0 0x80000000 0x0 0x80000000>;
// 地址:0x00000000_80000000
// 长度:0x00000000_80000000
};
};
关键点:子节点中的 reg 属性,必须按照父节点的 #address-cells 和 #size-cells 来解析。这是很多人容易搞混的地方。
我的习惯:写设备树时,我总会在每个总线节点上显式声明这两个属性。哪怕默认值就是 1,也写出来。这样代码更清晰,别人看的时候不会猜。
4.2 ranges 属性详解
ranges 属性,说白了就是地址转换表。它告诉内核:子节点的地址空间,怎么映射到父节点的地址空间。
ranges 的格式是这样的:
ranges = <子地址 父地址 长度>;
每个字段的 cell 数由对应的 #address-cells 和 #size-cells 决定:
- 子地址:子节点的 #address-cells 个 cell
- 父地址:父节点的 #address-cells 个 cell
- 长度:子节点的 #size-cells 个 cell
来看一个实际例子:
soc {
#address-cells = <1>;
#size-cells = <1>;
bus@80000000 {
compatible = "simple-bus";
#address-cells = <1>;
#size-cells = <1>;
ranges = <0x0 0x80000000 0x10000000>;
// 子地址 0x0 映射到父地址 0x80000000,长度 0x10000000
device@0 {
reg = <0x0 0x1000>; // 子地址 0x0
};
};
};
这里 device 的物理地址是 0x80000000 + 0x0 = 0x80000000。内核看到 ranges 后,会自动做这个加法。
我曾经踩过的坑:有一次写 FPGA 桥接的 ranges,把父地址和子地址写反了。结果驱动加载时,ioremap 返回的地址完全不对,读出来的寄存器值全是 0xdeadbeef。查了两天才发现是 ranges 顺序搞反了。记住:子地址在前,父地址在后。
ranges 还有一个特殊用法:空 ranges。如果写成 ranges;(没有等号),表示子地址和父地址是 1:1 映射,地址完全一样。
soc {
#address-cells = <1>;
#size-cells = <1>;
bus@0 {
#address-cells = <1>;
#size-cells = <1>;
ranges; // 1:1 映射
device@1000 {
reg = <0x1000 0x100>;
};
};
};
这种情况下,device 的物理地址就是 0x1000,不需要转换。
4.3 dma-ranges 属性
dma-ranges 和 ranges 很像,但用途完全不同。它描述的是 DMA 地址映射,也就是设备做 DMA 时,设备看到的地址怎么映射到系统物理地址。
为什么需要这个?你想想看,有些设备只能访问 32 位地址空间,但系统内存可能在 64 位地址空间。这时候就需要 dma-ranges 来做地址转换。
格式和 ranges 完全一样:
dma-ranges = <设备地址 父地址 长度>;
来看一个典型场景:
pcie-controller {
#address-cells = <3>;
#size-cells = <2>;
dma-ranges = <0x02000000 0x0 0x00000000
0x0 0x80000000
0x0 0x10000000>;
// 设备 DMA 地址 0x00000000 映射到
// 系统物理地址 0x80000000,长度 0x10000000
};
核心区别:ranges 用于 CPU 访问设备(MMIO),dma-ranges 用于设备访问内存(DMA)。这两个千万别搞混。
我在项目中遇到过一个问题:一个老旧的 DMA 控制器只能寻址 32 位空间,但系统有 4GB 内存。如果不配 dma-ranges,DMA 写到高地址时就会出错。后来在设备树里加了 dma-ranges,把设备地址 0-1GB 映射到系统物理地址 3-4GB,问题就解决了。
4.4 实战:地址映射的完整示例
咱们把前面讲的知识点串起来,看一个完整的例子:
/dts-v1/;
/ {
#address-cells = <2>;
#size-cells = <2>;
memory@80000000 {
device_type = "memory";
reg = <0x0 0x80000000 0x0 0x80000000>;
};
soc {
compatible = "simple-bus";
#address-cells = <2>;
#size-cells = <2>;
ranges = <0x0 0x0 0x0 0x80000000 0x0 0x10000000>;
// 子地址 0x0 映射到父地址 0x80000000,长度 0x10000000
dma-ranges = <0x0 0x0 0x0 0x80000000 0x0 0x80000000>;
// DMA 地址 0x0 映射到物理地址 0x80000000,长度 0x80000000
ethernet@10000000 {
reg = <0x0 0x10000000 0x0 0x1000>;
interrupts = <0 33 4>;
};
};
};
这个例子中:
- ethernet 的 MMIO 基址是 0x80000000 + 0x10000000 = 0x90000000
- ethernet 做 DMA 时,设备地址 0x0 对应物理地址 0x80000000
- DMA 可访问范围是 0x80000000 到 0x100000000(4GB)
调试技巧:如果地址映射出了问题,可以在内核启动参数中加 devicetree=debug,内核会打印设备树的解析过程。我经常用这招来验证 ranges 是否写对了。
4.5 常见错误与避坑指南
- cell 数不匹配:子节点的 reg 必须和父节点的 #address-cells/#size-cells 一致。我曾经见过一个设备树,父节点是 2 个 cell,子节点却只写了 1 个 cell 的地址,内核直接报错。
- ranges 顺序搞反:再次强调,子地址在前,父地址在后。这个顺序和 reg 属性不同,容易记混。
- 忘记声明 #address-cells 和 #size-cells:如果不写,内核会使用默认值 1。但如果你实际用的是 64 位地址,那就全乱了。
- dma-ranges 和 ranges 混用:这两个属性服务于不同的地址空间,不能互相替代。
特别提醒:在写 PCIe 设备的设备树时,dma-ranges 尤其重要。PCIe 规范中,设备看到的地址空间和 CPU 看到的地址空间是独立的。如果不配 dma-ranges,很多 PCIe 设备无法正常工作。
好了,地址映射这块就讲到这里。记住三个核心点:#address-cells 和 #size-cells 定义地址格式,ranges 做 MMIO 地址转换,dma-ranges 做 DMA 地址转换。下一章咱们聊聊中断控制器,那个也是设备树里容易出问题的地方。