坚持高质量原创,拒绝内容堆砌,喜欢的话点击上方星标,更新第一时间收到提醒,谢谢关注!
很多同学在接触 Linux 驱动之前,可能会接触过 STM32、NXP 等 MCU 的裸机程序。在 MCU 工程中,寄存器访问经常就是下面这样:
#define GPIO_BASE 0x40020000UL
#define GPIO_DATA (*(volatile uint32_t *)(GPIO_BASE + 0x04))
GPIO_DATA = 0x1;
在这段 MCU 代码中,我们的驱动是可以直接使用物理地址访问硬件寄存器的。
然而到了 Linux 驱动中,同样是访问一段已经知道物理地址的寄存器,代码却要先调用 ioremap():
#define GPIO_PHYS_BASE 0x09030000
#define GPIO_REG_SIZE 0x1000
void __iomem *base;
base = ioremap(GPIO_PHYS_BASE, GPIO_REG_SIZE);
if (!base)
return -ENOMEM;
GPIO_PHYS_BASE 已经指出了 GPIO 控制器在芯片中的位置,驱动想要操作寄存器却要先 ioremap() ,再通过返回的 base作为后续的控制器基地址使用。
但是寄存器的物理位置没有变化,为什么访问它的地址和方式变了?
其根本原因在于处理器有没有负责地址转换的 MMU(Memory Management Unit,内存管理单元)。
MCU 面向的硬件规模和业务模型通常相对简单,很多产品采用裸机程序,或者只运行 RTOS 来完成实时控制,并不需要 Linux 这类通用操作系统提供的多进程虚拟地址空间。
STM32F4、STM32H7、NXP i.MX RT 等常见 MCU 采用的 Cortex-M 处理器核心通常不集成 MMU,程序访问外设时使用的地址可以直接进入芯片的物理地址空间。
相比之下,面向复杂业务的 SoC 往往需要同时承载图形、多媒体、网络以及多个应用进程,因此通常运行 Linux 这类通用操作系统。
主流 Linux SoC 多采用 Cortex-A 等应用级处理器核心,这些核心集成了 MMU,Linux 会利用它建立和管理虚拟地址空间。
这篇文章我们就来深入了解一下 MMU 以及 Linux 中的虚拟地址映射机制。
1. 无 MMU 环境下的寄存器访问
在 MCU 或 SoC 的芯片手册中,通常都能找到一张物理地址空间分布图。
片上 SRAM、Flash 和各种外设控制器分别占据其中一段地址,0x40020000 表示的就是 GPIO 控制器在这张地址图中的起始位置。
STM32F40xxx 物理地址空间分布图(来源:STMicroelectronics DS8626 Figure 18)外设寄存器虽然不是普通内存,却可以像内存一样通过地址访问,CPU 访问 SRAM 所在的地址时,片上总线把请求送到 SRAM。
当地址落入例如 GPIO 的物理窗口时,同一套地址译码逻辑会把请求送到 GPIO 控制器。
这种设计称为 Memory-Mapped I/O,也就是 MMIO。
对于没有 MMU 的 Cortex-M,开头那次寄存器访问会沿下面的路径到达硬件:
没有 MMU 时,程序使用的地址原样进入片上总线从 CPU 发起访问到片上总线完成译码,0x40020004 这个地址始终没有变化。
对于这类 Cortex-M,芯片手册给出的物理地址也就可以直接成为程序访问硬件时使用的地址。
2. MMU 与 Linux 内核地址空间
前面说的这种直接使用物理地址的方式比较适合由一个简单的程序控制整颗芯片的 MCU,这类程序一般都是独占系统资源,只需要按照芯片固定的物理地址图安排代码、数据和外设访问。
然而 Linux 面对的并不是一个程序独占整颗芯片的运行环境,内核需要对进程进行资源隔离来防止相互之间的影响。
因此 Linux 不能把芯片的物理地址图直接交给各个进程使用,进程看到并使用的是自己的虚拟地址空间,代码中的地址只表示它在当前虚拟地址空间中的位置,并不直接表示芯片上的物理位置。
不过处理器最终仍然需要用物理地址访问硬件,CPU 发出的虚拟地址在进入片上总线之前,必须先转换成对应的物理地址,而负责转换的就是 MMU。
Linux 会在启动的过程中建立并维护一个名为页表的东西,通过页表在虚拟地址与物理地址之间建立映射关系,再把当前使用的页表配置给 MMU。
CPU 发起访问时,MMU 根据页表中的映射完成地址转换;如果当前地址没有映射或者不允许访问,CPU 就不会把这次访问继续送往目标物理位置。
进入内核正常运行阶段以后,驱动使用的就是内核虚拟地址。
假设驱动访问虚拟地址 VA_A,而页表记录它对应物理地址 PA_B,MMU 会先根据页表完成 VA_A 到 PA_B 的转换,再把 PA_B 送入片上总线。
Linux 维护页表映射,MMU 根据页表把虚拟地址转换成物理地址3. ioremap() 的地址映射过程
为了完成寄存器物理地址到内核虚拟地址的映射,Linux 内核提供了 ioremap() 接口,驱动把寄存器的物理地址和范围传给它,内核建立对应的映射,再把得到的虚拟地址返回给驱动。
ioremap() 背后连接的是 Linux 完整的内存管理体系,其中还会涉及虚拟地址区间管理和多级页表建立,具体实现很难在这里一一展开。
我们这里重点关注映射过程,在 Linux 6.18 的 ARM64 的实现中,这些核心步骤都集中在 generic_ioremap_prot() 中,所以我们重点看一下这个函数的内部实现逻辑来了解一下ioremap()映射的核心逻辑。
generic_ioremap_prot() 的完整实现如下:
void __iomem *generic_ioremap_prot(phys_addr_t phys_addr, size_t size,
pgprot_t prot)
{
unsignedlong offset, vaddr;
phys_addr_t last_addr;
structvm_struct *area;
/* An early platform driver might end up here */
if (WARN_ON_ONCE(!slab_is_available()))
returnNULL;
/* Disallow wrap-around or zero size */
last_addr = phys_addr + size - 1;
if (!size || last_addr < phys_addr)
returnNULL;
/* Page-align mappings */
offset = phys_addr & (~PAGE_MASK);
phys_addr -= offset;
size = PAGE_ALIGN(size + offset);
area = __get_vm_area_caller(size, VM_IOREMAP,
IOREMAP_START, IOREMAP_END,
__builtin_return_address(0));
if (!area)
returnNULL;
vaddr = (unsignedlong)area->addr;
area->phys_addr = phys_addr;
if (ioremap_page_range(vaddr, vaddr + size, phys_addr, prot)) {
free_vm_area(area);
returnNULL;
}
return (void __iomem *)(vaddr + offset);
}
函数开头的几项判断还没有开始建立映射,它们先确认内核内存分配器已经可用,并排除长度为零和物理地址范围溢出的请求,只有这些条件都满足,后面的映射流程才会继续执行。
3.1 页边界对齐:把寄存器范围扩展为完整页面
进入主流程以后,第一步不是立即寻找虚拟地址,而是先把驱动传入的物理起点和长度整理成页表能够处理的完整页面范围。
/* Page-align mappings */
offset = phys_addr & (~PAGE_MASK);
phys_addr -= offset;
size = PAGE_ALIGN(size + offset);
页表按照页粒度建立映射,而寄存器物理地址和映射长度不一定正好落在页边界上。
如果寄存器地址落在某个页面的中间,函数会先用 offset 记录它距离该页起始地址有多少字节,再将 phys_addr 向下对齐到页首。
原来的 size 只覆盖从寄存器地址开始的范围,现在映射起点向低地址方向移动了 offset 个字节,因此需要映射的总长度也变成 size + offset。
最后,PAGE_ALIGN() 再把这个长度向上取整到完整页面,保证页表建立的映射能够覆盖驱动最初请求的全部物理范围。
generic_ioremap_prot 将未对齐的寄存器范围扩展为页表能够处理的完整页面经过这一步,phys_addr 表示向下对齐后的物理页起点,size 表示从这个起点开始、能够覆盖原请求的整页长度。
3.2 虚拟区间预留:为页表映射找到一段可用地址
上一小节已经得到了页对齐后的寄存器物理范围,但仅有物理地址还不能建立映射。
页表记录的是“某段虚拟地址对应某段物理地址”,因此内核还要先确定这次映射准备使用哪段虚拟地址。
这里需要强调一下,虚拟地址虽然不直接表示硬件位置,但也不能随意重复使用。
内核虚拟地址空间中的很多区间已经有了各自的用途,其中也包括其他驱动建立的寄存器映射。
同一段虚拟地址在当前页表中只能对应一套物理地址关系,新的映射如果占用已有区间,就会破坏原来的映射。
因此,在建立新的页表关系之前,内核需要先找到一段尚未使用、而且长度足够的连续虚拟地址。
__get_vm_area_caller() 完成的就是这项工作:它根据上一小节得到的映射长度查找可用区间,并把这段虚拟地址登记为已使用,避免后续映射再次占用相同的位置。
这里的“预留”只是内核对虚拟地址的管理,它没有给寄存器分配新的物理空间,也没有把寄存器中的内容搬到这段虚拟地址中,只是为本次映射选定了一段虚拟地址,并将它的起点保存为 vaddr。
执行到这里,寄存器所在的物理范围已经确定,映射准备使用的虚拟范围也已经选好。
两段地址都已经准备好了,接下来要在页表中建立它们之间的转换关系。
3.3 页表映射建立:ioremap_page_range() 连接虚拟地址与物理地址
ioremap_page_range() 会把这两个地址范围之间的转换关系写入内核页表,这个函数是整个页表映射阶段的入口。
ioremap_page_range() 的底层实现原理会涉及 vmap、多级页表以及不同架构的映射粒度处理。
为了避免进入非常复杂的内存管理细节,我们这里就关注关键流程,看看内核怎样把这条关系建立起来。
建立映射要做的事情,就是在页表中记录这段虚拟地址应该通向哪段物理地址,以及使用怎样的访问属性。以后 MMU 收到这段虚拟地址,才能从页表中得到这次访问真正要到达的物理位置。
建立映射的思路其实并不复杂,可以把页表理解为一张保存地址转换关系的表:MMU 以虚拟地址进行查找,从对应的记录中得到物理地址和访问属性。
然而现代处理器的虚拟地址空间非常大,真正使用的区域却只占其中一部分,如果为每一个可能的虚拟地址范围都预留记录,页表本身就会非常庞大,其中绝大多数位置还会长期为空。
Linux 因此实现了多级页表组织这些转换关系,虚拟地址被分成几个部分,查找时用其中一部分找到下一级页表,再用下一部分继续缩小范围,直到找到最终保存映射关系的位置。
只有某段虚拟地址真正需要建立映射时,内核才为它创建查找过程中所需的下级页表。
大片未使用的虚拟地址可以在较高层直接保持未映射,不必提前为它们准备后续的页表结构。多级页表由此把庞大的虚拟地址空间拆开管理,同时避免保留大量没有实际用途的空记录。
沿着这套结构,ioremap_page_range() 从内核页表的顶层开始,根据 vaddr 中的不同部分逐级向下查找。
如果途中缺少所需的下级页表,内核就先创建它,再继续寻找能够保存这条转换关系的位置,这里新分配的内存只用来保存页表,寄存器仍然位于原来的物理地址。
找到最终位置后,内核把目标物理范围的基址和本次 I/O 映射需要的属性写入映射项。
需要注意的是,ioremap_page_range() 处理的是一段地址范围内核沿着虚拟区间向后推进时,物理地址也按照已经处理的长度同步前进。
假设 vaddr 是映射后的虚拟起点,phys_addr 是寄存器物理范围的起点,那么范围内相同的偏移会落到相同的物理偏移:vaddr + x 最终到达 phys_addr + x。
强调一下,这里描述的是这一次 ioremap() 形成的地址转换结果。
内核最终需要写入多少个映射项,取决于处理器架构、映射长度和地址对齐条件。
有的映射项可以覆盖更大的连续范围,但这只会改变页表内部组织这段映射的方式,范围内的偏移关系不会改变。
下面这张图展示了页表、多级页表的查找过程以及最终形成的地址关系:
虚拟地址通过多级页表定位映射项,最终到达寄存器物理范围映射建立成功以后,页对齐的 vaddr 已经对应页对齐的 phys_addr。
generic_ioremap_prot() 将此前保存的 offset 加回到 vaddr,最终返回能够准确对应原始寄存器位置的 vaddr + offset。
驱动后续访问这个地址时,CPU 会把虚拟地址交给 MMU,MMU 根据这套页表关系找到目标物理地址范围,再保留地址中原有的偏移,最终到达驱动想要访问的寄存器物理位置。
4. devm_ioremap() 与设备生命周期
如果做过 BSP 开发,大家可能还会在 platform 驱动的 probe() 中见到 devm_ioremap()、devm_platform_ioremap_resource() 这类接口。
那它们和直接使用 ioremap() 有什么区别呢?
实际上,devm_ioremap() 内部仍然调用 ioremap(),两者建立寄存器映射的过程并没有本质区别。
devm_ 真正增加的是资源管理:直接使用 ioremap() 时,映射需要由驱动手工释放;使用 devm_ioremap() 时,这段映射会交给内核按照设备的生命周期统一管理。
直接调用 ioremap() 得到映射以后,驱动还要在不再使用它时调用 iounmap(),撤销映射并释放对应的 I/O 虚拟地址区间和页表资源。
如果驱动结构比较简单,保证 ioremap() 和 iounmap() 成对出现并不困难,然而真实驱动的 probe() 在完成寄存器映射以后,通常还要继续初始化时钟、复位和中断。
每多一个可能失败的步骤,就可能多出一条需要清理资源的退出路径,驱动不仅要在正常解绑时执行 iounmap(),还要保证后续任何一步初始化失败时,都能沿对应的错误路径撤销前面已经建立的映射。
当退出路径越来越多时,只要其中一处漏掉了 iounmap(),这段映射占用的虚拟地址区间和页表资源就会一直保留下来,也就形成了工程中常说的内存泄漏。
devm_ioremap() 解决的正是这个痛点,调用它时驱动会传入当前设备对应的 struct device。映射成功以后devm_ioremap() 会把映射地址以及相应的释放动作登记到这个设备的 devres 中。
如果 probe() 后续失败,或者设备与驱动解除绑定,设备核心就会统一释放登记在当前设备上的资源,其中也包括这段寄存器映射。devres 中登记的释放动作最终仍然会调用 iounmap() 撤销映射,只是不再需要驱动为每一条退出路径分别安排释放操作。
所以对于映射生命周期本来就与设备一致的常规 platform 驱动,我建议优先使用 devm_platform_ioremap_resource() 或相应的 devm 映射接口,可以明显减少错误路径中的清理负担。
5. 总结
这一节我们深入学习了 Linux 的地址映射机制,也理解了这套机制背后的核心原理。
常用的 MCU 通常只运行一套相对简单的控制程序,程序直接面对固定的硬件资源,处理器本身也没有 MMU,所以芯片手册中的寄存器物理地址可以直接用于访问硬件。
运行 Linux 的 Cortex-A SoC 需要同时承载多个进程和更加复杂的业务,内核必须隔离不同进程的地址空间,并对内存访问进行统一管理。
Linux 因此会建立虚拟地址空间和页表,再由 MMU 把 CPU 使用的虚拟地址转换成真正送往硬件的物理地址,到了驱动中,芯片手册给出的寄存器物理地址也就不能再直接作为指针使用。
ioremap() 正是在这样的背景下出现的,它把寄存器所在的物理地址范围映射到内核虚拟地址空间,驱动使用返回的虚拟地址访问寄存器,MMU 再根据页表找到原来的物理 MMIO 窗口。
分析它的底层实现以后也可以看到,ioremap() 并不是简单修改一个地址数值,而是由内核分配虚拟地址区间,逐级查找并建立真实的页表映射关系。
这其中内部涉及到比较复杂的内存管理机制,我们之后再进行系统的讲解。
喜欢的话请点赞、推荐、关注,这对我真的很重要!!!