ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Linux驱动开发:iomem_resource资源树如何防止物理地址冲突?

Linux驱动开发:iomem_resource资源树如何防止物理地址冲突? 前阵子排查一个诡异死机问题折腾了两天才定位到根因两个内核驱动同时对同一段物理地址空间调用 ioremap互相把对方的寄存器写坏了。这类问题在 Linux 驱动开发里非常典型而内核里早就准备了一把“尺子”——iomem_resource 树用来对物理地址空间做统一管理。这篇文章我想把这棵树的来龙去脉、常用接口和驱动开发里的避坑经验一次讲清楚不管你是做嵌入式驱动、内核移植还是系统调试都能从中找到能直接用的东西。1. 物理地址也能“撞车”iomem_resource 到底在防什么1.1 物理地址空间不是“内存条”它的排布远比想象混乱很多人刚接触 Linux 驱动时潜意识里会把“物理地址空间”等同于“内存条”。实际上处理器看到的物理地址空间是一张被分割得七零八落的“地皮”一部分是真实的系统内存System RAM一部分是外设控制器的寄存器区间MMIO一部分是 PCI/PCIe 设备的 BAR 映射窗口还有固件保留区、中断控制器、SMMU、GIC、BootROM、Crash Kernel 预留区等。以 x86 为例物理地址最底部的 1MB 里就藏着 VGA 显存、BIOS、各种 legacy 设备而在 ARM SoC 上片内寄存器通常挂在固定的高地址段DDR 又可能被限制在某几个区间里外设通过片选信号映射到不同的地址窗口。这些区域不是按顺序整齐排列的而是由芯片设计、固件配置和设备树共同决定。更关键的是物理地址空间是有限资源没有任何虚拟地址机制可以帮你在这一层做隔离。虚拟内存解决的是“进程之间互相看不到对方的地址”它通过页表把虚拟地址翻译成物理地址。但一旦进入内核态驱动拿到一段物理地址调用 ioremap 建立映射这段物理地址就被该驱动“名正言顺”地占用了。如果另一个驱动也去映射同一段地址内核此时完全不知情两个驱动会同时读写同一块硬件寄存器轻则功能异常重则整机死机。1.2 ioremap 只解决“能不能访问”不解决“该不该访问”这里要明确一个概念ioremap 做的事情非常“底层”它只负责把一段物理地址映射到内核虚拟地址空间让你能通过指针访问这段物理区域。它不检查这段地址属于谁、是否已经被别人占用、是不是合法资源。换句话说ioremap 回答的是“能不能访问”的问题而“该不该访问”属于资源管理层面的事。我之前遇到的那次事故就是这样驱动 A 负责管理存储控制器驱动 B 是某个外设的驱动外设地址段和存储控制器寄存器区间恰好部分重叠。驱动 B 在 probe 阶段直接 ioremap 了自己解析到的物理地址没有经过任何登记。表面上看它的读写都“正常”但某些操作会悄悄修改存储控制器的关键寄存器。等压力测试跑到某个特定流程时系统突然挂死现场 dmesg 干干净净没有任何报错。这类问题最难查因为它不是必现而是取决于两个驱动是否恰好同时访问重叠区间。1.3 内核解决冲突的抓手全局资源树为了统一管理这种冲突Linux 内核维护了一棵全局的物理地址资源树树的根节点就是iomem_resource。这棵树覆盖整个物理地址空间所有需要占用某段物理地址的功能模块——系统内存、设备寄存器、PCI 桥窗口、保留区域——都通过特定接口向这棵树登记。登记之后内核就能在另一个驱动试图请求同一段地址时直接拒绝或者至少让你在/proc/iomem里看到到底是谁占了这坑。这棵树的设计思路其实特别像小区物业对公共区域的“地皮登记本”每块地谁在用、用了多大范围、是长期占用还是临时借用都记录在案。这样新住户想申请一块地时物业可以马上告诉你“这里已经有人用了你不能进”而不是等两家打起来再去拉架。2. 从 struct resource 看内核如何描述一段物理地址区间2.1 一个节点对应一个闭区间资源树的基本元素是struct resource它描述一段连续的物理地址区间。源码位于include/linux/ioport.h核心字段如下struct resource { resource_size_t start; // 起始物理地址 resource_size_t end; // 结束物理地址闭区间 const char *name; // 资源名称会显示在 /proc/iomem unsigned long flags; // 资源属性标志 unsigned long desc; // 资源描述符用于区分 System RAM 等类型 struct resource *parent; // 父节点 struct resource *sibling; // 下一个兄弟节点 struct resource *child; // 第一个子节点 };注意start和end是闭区间。也就是说一段大小 4096 字节、起始地址为 0x10000000 的区域end应为 0x10000fff而不是 0x10001000。内核提供了resource_size()宏来计算区间大小#define resource_size(res) ((res)-end - (res)-start 1)很多新手在这里容易写错直接用end - start导致请求和释放的字节数不一致后面会引发非常隐蔽的问题这个坑我留在第 6 章详细说。2.2 父、子、兄弟树形结构的组织规则iomem_resource是整棵树的根定义在kernel/resource.cstruct resource iomem_resource { .name PCI mem, .start 0, .end -1, .flags IORESOURCE_MEM, };根节点的范围从 0 到地址总线的最大值32 位下是 0xffffffff64 位下是 0xffffffffffffffff。名称写成“PCI mem”是历史遗留实际上是整个物理地址空间。树的组织规则是每个节点可以有自己的子节点子节点之间按起始地址从小到大排序通过sibling指针串成链表。一个父节点下的所有子节点理论上都必须落在父节点的[start, end]区间内。这种嵌套关系用文字描述可能有点抽象看一个伪结构就清楚了iomem_resource (0x0 - 0xffffffffffffffff) ├── System RAM (0x1000 - 0x9fbff) ├── reserved (0x9fc00 - 0x9ffff) ├── PCI Bus 0000:00 (0x20000000 - 0x2fffffff) │ ├── 0000:00:01.0 BAR0 (0x20000000 - 0x20000fff) │ └── 0000:00:02.0 BAR0 (0x30000000 - 0x30000fff) └── mydriver regs (0x10000000 - 0x10000fff)child指向最左边的子节点sibling指向右边紧挨着的兄弟。这种组织方式让内核可以高效地判断“某段地址和现有资源是否重叠”因为同一层级的兄弟节点是排序的查找时只要顺着链表往下走发现第一个end 目标start的节点再判断是否有交集即可。2.3 flags 中藏着的信息量flags字段不只是摆设它直接参与冲突判定。常见的有标志位含义IORESOURCE_MEM表示这是一段内存映射资源MMIO 或 RAMIORESOURCE_IO表示这是一段 IO 端口资源IORESOURCE_BUSY表示该资源已被占用驱动请求资源后会自动置位IORESOURCE_EXCLUSIVE独享资源不允许其他请求共享IORESOURCE_SHARED表示该资源允许共享例如某些中断资源IORESOURCE_DISABLED资源当前被禁用在驱动代码里判断某段资源是否被别人占用最简单的办法就是看这个节点有没有IORESOURCE_BUSY。因为request_mem_region成功时内核会把新节点标记为IORESOURCE_BUSY而遍历/proc/iomem时你看到的所有条目绝大多数都是 BUSY 状态的节点。3. 请求、释放、分配与遍历资源树上的四个基本动作3.1 request_mem_region给驱动“圈地”的正规入口驱动在使用一段物理地址之前应该先调用request_mem_region()向内核申请这片区域。它的定义在include/linux/ioport.h#define request_mem_region(start,n,name) \ __request_region(iomem_resource, (start), (n), (name), IORESOURCE_MEM)参数很直白start是起始物理地址n是长度name是资源名称。底层函数__request_region的逻辑大致如下检查请求的区间是否落在父节点范围内如果越界返回NULL。遍历父节点的子节点链表查找是否存在重叠区间。如果没有重叠创建新节点设置start/end/name/flags并置IORESOURCE_BUSY然后按地址顺序插入树中。如果存在重叠进一步判断现有资源是否允许共享。只有极少资源声明了可共享例如某些共享中断否则直接返回NULL。所以驱动里最常见的使用姿势是这样的if (!request_mem_region(res-start, resource_size(res), mydriver)) { dev_err(pdev-dev, resource already in use\n); return -EBUSY; }如果返回NULL说明这段地址已经被别的驱动或子系统登记了这时候不要强行继续 ioremap正确做法是返回错误码让上层知道资源冲突。这也是我之前那起事故应该走的流程如果驱动 B 当时先调用了request_mem_region内核会直接告诉它“这段地址有人了”不至于一路写到存储控制器头上。3.2 release_mem_region闭区间差一就出事的释放逻辑有请求就有释放对应的接口是#define release_mem_region(start,n) \ __release_region(iomem_resource, (start), (n))__release_region的逻辑要复杂一些它不仅支持整体释放还支持部分释放。比如一个节点是[0x1000 - 0x1fff]你只释放[0x1400 - 0x17ff]内核会把原节点拆成[0x1000 - 0x13ff]和[0x1800 - 0x1fff]两个节点。这个能力在某些场景下很有用但也意味着释放时对区间边界的描述必须精确。如果你释放一个从未请求过的区域内核会打印一条类似Trying to free nonexistent resource [0x1000-0x1fff]的警告并附带调用栈。这其实是好事它说明你的释放逻辑和请求逻辑不对称早点暴露出来比留着隐患强。我在调试一些老驱动时就靠这种警告立刻定位到某段代码用了end - start导致区间算窄最终请求和释放对不上。3.3 allocate_resource在地址空间里“找洞”的高级操作request_mem_region是“给指定地址圈地”适合你已经知道确切物理地址的场景。但有些场景下你并不知道哪段地址可用而是希望内核帮你在一片范围内找一个空闲区间。比如 PCI 桥的窗口分配、某些需要动态映射的专用硬件用的就是allocate_resourceint allocate_resource(struct resource *root, struct resource *new, resource_size_t size, resource_size_t min, resource_size_t max, resource_size_t align, resource_size_t (*alignf)(void *, const struct resource *, resource_size_t, resource_size_t), void *alignf_data);调用时传入根节点、要分配的大小size、允许的地址范围[min, max]、对齐要求align。内核会在范围内扫描现有资源树找出一个满足大小和对齐要求的“空洞”把新区间分配给new再挂到树上。PCI 枚举阶段给每个 BAR 分配地址时底层就是类似机制在运作。这个接口普通驱动用得不多但理解它有助于明白 PCI 热插拔时“新设备往哪放”是怎么解决的。3.4 遍历资源树用手写循环还是 walker 接口有时为了调试需要遍历整棵资源树看看某段地址附近到底挂了哪些节点。内核提供了for_each_resource宏#define for_each_resource(_root, _p, _sibling) \ for (_p (_root)-child; _p; _p _sibling)注意它不会自动帮你保存下一个节点通常需要自己准备一个变量保存sibling否则在循环里如果做了释放节点的操作就会出问题。我在调试冲突时经常写一段临时代码打印与目标区间有交叠的所有资源static void dump_conflict(resource_size_t start, resource_size_t size) { struct resource *p, *n; resource_size_t end start size - 1; for_each_resource(iomem_resource, p, n) { if (p-start end p-end start) pr_info(conflict: %pR\n, p); } }除了手写循环内核还提供了一批 walker 接口比如walk_iomem_res_desc()和walk_system_ram_res()。它们可以按资源描述类型过滤只遍历System RAM或某类特定资源常用于统内存布局、kexec 加载等场景。3.5 devm 系列把资源生命周期交给设备模型上面这些接口都属于“手动挡”请求了就必须记得释放尤其是在 probe 函数里有多条错误出口时很容易漏。内核后来提供了一套基于设备资源管理的“自动挡”接口devm_request_mem_region()devm_ioremap_resource()devm_release_mem_region()其中devm_ioremap_resource()是我现在写平台驱动时的首选。它的内部逻辑相当于“校验资源类型 请求资源 ioremap 把释放动作挂到设备生命周期上”一条龙处理。设备移除或 probe 失败时内核会自动释放资源不用你手动挂钩。void __iomem *devm_ioremap_resource(struct device *dev, const struct resource *res);返回IS_ERR指针就表示失败可能是资源类型不对、请求失败或映射失败。这个设计极大减少了资源泄漏类 bug后面第 6 章我会给完整代码范式。4. 设备树、PCI 与系统 RAM 是怎么挂到这颗树上的4.1 设备树 reg 属性到 resource 的转换链路在设备树驱动的标准流程里硬件地址信息写在设备节点的reg属性中。内核在创建设备时会通过of_address_to_resource()把reg解析成一个struct resource并存到platform_device的resource[]数组里。驱动在 probe 阶段调用platform_get_resource()把它取出来res platform_get_resource(pdev, IORESOURCE_MEM, 0);这时res 只是“设备描述里的资源信息”还没有真正挂到 iomem_resource 树上。它什么时候挂上去答案是驱动调用request_mem_region()或devm_ioremap_resource()的那一刻。这也是很多人的认知误区以为设备树里写了reg内核就自动把这段地址登记了。实际上设备树只是告诉内核“这个设备打算用这块地址”至于这块地址是否可用、是否被占用还是要靠驱动主动请求才知道。设备树里两段地址重叠不会报错只有真正请求时才会发现冲突。4.2 PCI 设备的 BAR 与桥窗口如何动态占用PCI 设备和平台设备不太一样平台设备的物理地址通常是芯片厂商在设备树/固件里定死的而 PCI 设备的 BAR 地址一般是在枚举阶段由 PCI core 动态分配的。整个分配过程就是典型的资源树应用场景。为每个 PCI-PCI 桥分配 MEM 窗口时PCI core 会在父总线的资源范围内调用类似allocate_resource的逻辑找到一个足够大且对齐的空洞作为桥的转发窗口。窗口分配完成后这个窗口资源会被挂到 iomem_resource 树下。之后桥下面每个设备的 BAR 再从这个窗口内继续细分。所以你在/proc/iomem里看到的缩进结构往往能清楚反映 PCI 拓扑PCI Bus 0000:00 ├── 0000:00:01.0 BAR0 (0x20000000 - 0x20000fff) └── 0000:00:02.0 BAR0 (0x30000000 - 0x30000fff)热插拔场景下新设备被枚举时需要为它的 BAR 找洞这时如果之前释放的窗口空间仍然以空闲节点形式留在树上新设备就可以直接复用。资源树在这里承担了“地址空间记账本”的角色让动态分配有了依据。4.3 系统 RAM 的注册与 E820/设备树内存节点物理地址空间里最大的一块通常就是系统内存。在 x86 平台上固件通过 E820 表报告内存布局内核初始化时会把每个 E820 条目注册到资源树中标记为System RAM或reserved。在 ARM64 平台内存来自设备树/ACPI 的 memory 节点早期由 memblock 管理之后也会以System RAM的身份登记到树上。这也是/proc/iomem里能看到成片System RAM条目的原因。有了这些登记内核和用户态工具就能明确区分哪些物理区间是可用内存、哪些是固件保留、哪些已经被某个驱动占用。kexec 工具加载 crash kernel 时就依赖/proc/iomem找到预留的加载区域内存热插拔时移除内存前也要先从资源树上摘除对应节点。可以说这棵树不只是给驱动用的它还是整个系统物理地址布局的“总账”。5. /proc/iomem 实战从树上读取布局、排查冲突5.1 输出格式与缩进的含义/proc/iomem是内核将 iomem_resource 树导出到用户态的接口格式非常直观00000000-00000fff : reserved 00001000-0009fbff : System RAM 0009fc00-0009ffff : reserved 000a0000-000bffff : PCI Bus 0000:00 10000000-10000fff : mydriver regs每一行的第一个字段是起始地址-结束地址冒号后面是资源名称。缩进表示层级关系缩进越多说明嵌套越深。比如某段资源下面挂了子资源子资源就会比父资源多缩进一个层级。看这个文件时有两点值得注意地址字段是闭区间也就是0x1000-0x1fff表示大小是 0x1000而不是 0xfff。资源名称由驱动请求时传入的name参数决定。如果驱动传了一个很随便的名字比如空的字符串在排查冲突时你会很难受。所以给资源起一个清晰、可检索的名字是很有价值的习惯。5.2 用 /proc/iomem 回答“这块地址被谁占了”排查冲突最直接的场景就是驱动调用request_mem_region返回NULL你想知道到底是谁占着这块地。例如目标地址是0x10000000可以直接cat /proc/iomem | grep -i 10000000如果命中的是reserved那大概率是固件或早期启动代码保留的区域你需要去查设备树或固件配置。如果命中的是另一个驱动的名字那就要看那个驱动为什么会占用这段地址是reg配错了还是两个设备确实映射到了同一段物理区间。还有个实用技巧在板卡 bringup 时先保存一份“无设备驱动”的/proc/iomem等所有驱动加载完再存一份用 diff 对比两份文件。新增加的节点就是各驱动占用的地址区间谁占了哪里一目了然。这个习惯帮我排查过好几次地址重叠问题。5.3 在 dmesg 中通过 %pR 快速确认自己的资源内核给struct resource提供了专门的打印格式符%pR在驱动里打印资源信息非常方便dev_info(pdev-dev, regs %pR\n, res);输出大致是mydriver regs [mem 0x10000000-0x10000fff]如果资源没有IORESOURCE_BUSY标志打印出来可能会显示成没有mem前缀的裸区间。这个格式的好处是一眼就能看出资源范围、类型和名称。配合 dmesg 的调用栈能很快判断资源是否成功挂树、范围是否符合预期。我在调试时经常在 probe 里临时加一行这种打印确认设备树解析结果和自己预期一致然后再去查/proc/iomem验证最终登记状态。6. 驱动里最常见的几个 iomem_resource 事故与安全写法6.1 忘记请求直接映射能跑但后患无穷我见过不少驱动probe 代码里直接这样写void __iomem *regs; regs ioremap(res-start, resource_size(res)); if (!regs) return -ENOMEM;没有request_mem_region也没有任何登记。问题在于在当前这个硬件上它大概率能正常工作所以代码能通过测试直到哪天换了一块板子、地址布局变了或者另一个驱动占用了同一段地址系统开始出现间歇性故障。这种 bug 的隐蔽性极强因为它不会在代码审查时被看出“明显错误”也不会在单板测试时必现。所以我的建议很简单凡是 ioremap 前先问自己一句这段物理地址我有没有让内核知道如果回答是否就先request_mem_region或者直接用devm_ioremap_resource把登记动作和映射动作一起做掉。6.2 释放不匹配导致的内核警告闭区间带来的一个经典错误是释放时的长度计算请求时用了request_mem_region(0x10000000, 0x1000, foo)表示地址范围是0x10000000 - 0x10000fff。释放时如果写成release_mem_region(0x10000000, 0xfff)那释放的就是0x10000000 - 0x10000ffe。这样一来最后一个字节没被释放资源树里会残留一个[0x10000fff-0x10000fff]的 1 字节节点。这种“差一”的错误非常容易出现在手写代码里。更夸张的情况下如果请求用的长度和释放用的长度来自不同的宏或手工计算还会触发Trying to free nonexistent resource警告。要彻底避免这个问题统一使用resource_size(res)来计算长度不要自己写end - start或手写固定数值。6.3 遇到 request 失败怎么快速定位占用方如果你确认自己请求的地址和大小没问题但request_mem_region还是返回NULL完整排查链路应该是看 dmesg 尾部有没有内核打印的失败原因和调用栈确认是哪个函数、哪一行触发的失败。打开/proc/iomem用目标地址或周边地址 grep找到占用节点和它的名称。如果占用节点叫reserved去查设备树里有没有对应的reserved-memory节点或者固件有没有保留这块地址。如果占用节点是另一个驱动的名字检查那个驱动加载了没有、它的资源范围是否正确必要时屏蔽它再测试。如果/proc/iomem里看不出来可以在调试模块里写一个遍历函数把目标区间附近的所有资源节点打出来。第 3.4 节那段dump_conflict代码就是我常用的小工具把它编译进一个临时模块加载后能立刻看到和冲突区间有交叠的所有节点省得在 dmesg 里大海捞针。6.4 我推荐的安全映射代码范式对于平台驱动我现在的标准写法是这样static int mydrv_probe(struct platform_device *pdev) { struct resource *res; void __iomem *regs; res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENXIO; regs devm_ioremap_resource(pdev-dev, res); if (IS_ERR(regs)) return PTR_ERR(regs); /* 到这里资源已登记到 iomem_resource 树映射也成功了 */ return 0; }这段代码里devm_ioremap_resource自动完成了类型校验、资源请求、ioremap 和生命周期绑定probe 失败时也不需要手动调 release。资源名称会继承res-name所以最好保证设备树或者platform_get_resource拿到的 resource 有一个可读的 name这样 /proc/iomem 里能看到mydriver regs而不是空白。如果你确实需要手动控制流程比如想自己设置IORESOURCE_EXCLUSIVE之类的标志那么至少要做到每个错误出口都释放资源。手动版本长这样static int mydrv_probe(struct platform_device *pdev) { struct resource *res; void __iomem *regs; int ret; res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENXIO; if (!request_mem_region(res-start, resource_size(res), mydriver)) { dev_err(pdev-dev, failed to request region\n); return -EBUSY; } regs ioremap(res-start, resource_size(res)); if (!regs) { ret -ENOMEM; goto err_release; } /* 后续初始化如果出错也要 goto err_unmap */ return 0; err_unmap: iounmap(regs); err_release: release_mem_region(res-start, resource_size(res)); return ret; }看出来了吗手动版本一旦失败分支变多很容易漏掉一个释放。这也是我优先推荐 devm 版本的原因少写一个释放分支就少一个资源泄漏的机会。对于 PCI 驱动类似的建议是用pcim_iomap_regions()或pci_request_region()总之都是先登记、再映射的路子。最后分享一个我自己的习惯新板卡 bringup 时我会先跑一遍/proc/iomem把整棵资源树存个档等设备驱动全部加载后再 diff 一次谁多了、谁少了、有没有谁乱占一眼就能看出来。这个习惯帮我省掉过好几次“神秘死机”的排查时间。希望这篇关于 iomem_resource 的拆解也能让你在下次遇到物理地址冲突时少走一点弯路。
返回列表