嵌入式Linux驱动开发(8)------内存映射 I/O - 别拿物理地址当指针用
仓库已经开源!所有教程,主线内核移植,跑新版本imx-linux/uboot都在这里!欢迎各位大佬观摩!喜欢的话点个⭐!
仓库地址:https://github.com/Awesome-Embedded-Learning-Studio/imx-forge
静态网页:https://awesome-embedded-learning-studio.github.io/imx-forge/
上一章我们介绍了硬件的基本原理,知道了要控制 LED 需要读写哪些寄存器。如果你之前写过单片机程序(比如 STM32),可能会习惯性地写出这样的代码:
c
#define GPIO1_DR (*(volatile u32*)0x0209C000)
*GPIO1_DR = 0; // 点亮 LED
说实话,在裸机环境下这确实没问题。因为在裸机程序里,物理地址和虚拟地址是一一对应的(或者说根本没有虚拟地址的概念)。但在 Linux 这样的操作系统环境下,这行代码会让你收获一个非常漂亮的内核 panic。
我们得搞清楚为什么。首先,Linux 运行在虚拟地址空间里,CPU 访问内存前需要通过 MMU 把虚拟地址转换成物理地址。我们直接写一个物理地址 0x0209C000,在内核看来这可能指向任何地方------可能是某个内核数据结构,可能是用户空间内存,甚至可能是一个未映射的地址。其次,即使地址能对应上,我们也没有考虑缓存一致性问题。
所以我们需要一种机制,安全地把物理地址映射到内核虚拟地址空间,然后通过虚拟地址来访问寄存器。这个机制就是 ioremap()。
先别急着看代码,我们先搞清楚两个概念:MMIO(Memory-Mapped I/O)和 PMIO(Port-Mapped I/O)。这两种是 CPU 与外设交互的不同方式。PMIO 方式下,外设寄存器有独立的地址空间(称为 I/O 端口),需要用专门的指令(如 x86 的 in/out)来访问。而 MMIO 方式下,外设寄存器被映射到内存地址空间,可以像访问内存一样用普通的 load/store 指令访问。ARM 架构只支持 MMIO,没有独立的 I/O 端口空间,所以我们所有的寄存器访问都是通过内存映射来实现的。
现在我们来拆解 ioremap() 做了什么。它的功能可以概括为一句话:建立物理地址到虚拟地址的映射。但这个简单的功能背后,内核做了一系列复杂的工作。在 ARM 架构下,ioremap() 的调用链大致是这样的:ioremap() -> arch_ioremap_caller() -> __ioremap() -> get_vm_area() 分配虚拟地址空间 -> ioremap_page_range() 建立页表映射 -> ioremap_pte_range() 填充页表项。
内核首先需要在内核地址空间中找到一块连续的虚拟地址区域,这是通过 get_vm_area() 实现的,它会在 vmalloc 区域里找到合适的空间。找到虚拟地址空间后,内核需要建立页表项,把这块虚拟地址映射到我们指定的物理地址。这一步的核心工作是设置页表项的属性。对于设备寄存器映射,页表项会被设置为 MT_DEVICE 类型,这个类型有几个重要属性:非缓存、不可合并、强有序。非缓存意味着数据不会被 CPU 缓存,为什么?因为缓存可能延迟写回,而我们写寄存器时希望立即生效。更严重的是,如果 CPU 缓存了寄存器的读结果,下次再读时可能拿到的是旧值。不可合并意味着 CPU 不会把多次访问合并成一次,这对设备访问很重要,因为有些设备寄存器的读操作有副作用。强有序意味着访问严格按照程序顺序执行,不会被乱序执行优化打乱。页表修改完成后,需要刷新 TLB(Translation Lookaside Buffer,页表缓存),确保 CPU 使用新的映射关系。
iounmap() 是 ioremap() 的逆向操作,它负责清理映射关系。主要做的事情包括:查找对应的 vm_area 结构、清除页表映射、释放虚拟地址空间、刷新 TLB。这里有个常见的错误点,很多人知道要 ioremap(),但忘了在模块卸载时调用 iounmap()。长期积累下来会造成内核地址空间泄漏。虽然每次泄漏的空间不大(只有几页),但这是不合格的驱动代码应该避免的问题。
你可能会问,既然已经映射好了,为什么不直接用指针读写?为什么非要用 writel() 和 readl()?这就涉及到内存屏障的问题了。现代 CPU 和编译器都会进行各种优化,包括指令重排、缓存优化等。对于普通内存访问,这些优化是安全的,因为有缓存一致性协议保证。但对于设备寄存器访问,这些优化可能导致严重问题。
我们来看一个具体的场景:
c
*addr1 = value1; // 写寄存器1:启动 DMA
*addr2 = value2; // 写寄存器2:设置 DMA 地址
编译器或 CPU 可能会交换这两条指令的顺序。如果寄存器 2 的值必须在 DMA 启动前设置好,重排就会导致错误。编译器还可能会"优化"掉一些它认为没必要的访问。比如对于设备寄存器,有些写操作是有副作用的,不能被优化掉。writel() 和 readl() 就是用来解决这些问题的。
让我们看看 writel() 的实现(简化版):
c
static inline void writel(u32 value, volatile void __iomem *addr)
{
__io_bw(); // 写前屏障
__raw_writel(value, addr);
__io_aw(); // 写后屏障
}
__io_bw() 和 __io_aw() 就是内存屏障,它们做了两件事:阻止编译器优化,告诉编译器这条指令前后的内存访问不能乱序;阻止 CPU 乱序,在汇编层面插入屏障指令,确保 CPU 按顺序执行。在 ARM64 的实现中,这会插入 dmb(Data Memory Barrier)指令。writel() 和 readl() 还会处理端序转换,大多数外设使用小端序,而 ARM 架构可能是大端或小端,所以内部会调用 cpu_to_le32() 把 CPU 端序转换为小端序。
你可能注意到了,writel() 内部调用了 __raw_writel()。这个 "raw" 版本是什么?__raw_writel() 是不做任何屏障的原始写操作,它的 ARM 实现非常简洁:
c
static inline void __raw_writel(u32 val, volatile void __iomem *addr)
{
asm volatile("str %1, %0"
: : "Qo" (*(volatile u32 __force *)addr), "r" (val));
}
就是一条 str 指令,把值写到内存地址。这里的 "Qo" 约束确保地址是正确对齐的,并且不使用写回寻址模式。一般我们不直接使用 __raw_writel(),除非我们清楚自己在做什么,并且有自己管理内存屏障的方案。
现在我们结合实际的 LED 驱动代码,看看怎么用的:
c
static void __iomem* IMX6U_CCM_CCGR1 = NULL;
static void __iomem* GPIO1_DR = NULL;
// 映射寄存器
IMX6U_CCM_CCGR1 = ioremap(kCCM_CCGR1_BASE, sizeof(u32));
GPIO1_DR = ioremap(kGPIO1_DR_BASE, sizeof(u32));
// 访问寄存器
u32 val = readl(IMX6U_CCM_CCGR1); // 读
writel(val | 0b11 << 26, IMX6U_CCM_CCGR1); // 写
// 清理映射
iounmap(IMX6U_CCM_CCGR1);
iounmap(GPIO1_DR);
这里有几个需要注意的点。实际工程中一定要检查 ioremap() 的返回值,如果映射失败(比如物理地址无效),它会返回 NULL,直接使用 NULL 指针会导致内核崩溃。ioremap() 的第二个参数是映射大小,建议按实际需要映射,虽然多映射一点不会造成功能问题,但会浪费内核虚拟地址空间。对于单个 32 位寄存器,映射 sizeof(u32) 或 4 字节就足够了。Linux 内核提供了多种寄存器访问函数,有 8 位的 readb/writeb、16 位的 readw/writew、32 位的 readl/writel、64 位的 readq/writeq。根据寄存器位宽选择对应函数,我们的 LED 寄存器都是 32 位的,所以用 readl() 和 writel()。
到这里,我们已经理解了内存映射 I/O 的完整机制。知道了为什么不能直接用指针访问物理地址,知道了 ioremap() 如何建立映射,知道了 writel()/readl() 如何保证访问顺序和正确性。下一章,我们会把这些知识应用到实际代码中,看看硬件抽象层是如何设计并实现的。
相关阅读
- 深入理解Linux模块------第1章 Hello World内核模块:内核编程的第一步 - 相似度 80%
- 入门 · 环境搭建 · 00 · Qt6 安装踩坑指南 - 相似度 80%
- 现代Qt开发------0.1------如何在IDE中配置Qt环境? - 相似度 80%