4、MMIO基础:内存映射IO的概念、ioremap原理、读写屏障
好,咱们今天聊聊MMIO。这东西说白了,就是让CPU用访问内存的方式去操作外设寄存器。你想想看,CPU本来只会读写内存地址,但外设的寄存器也想让CPU管,怎么办?那就把外设的寄存器映射到内存地址空间里去。这就是MMIO——Memory Mapped IO。
4.1 内存映射IO的概念
我刚开始接触嵌入式时,总觉得MMIO很神秘。后来发现,其实它就是个地址转换游戏。外设的寄存器,比如UART的数据寄存器、GPIO的方向寄存器,它们本来在物理上是一段独立的地址空间。但CPU不认识这个空间,它只认内存地址。所以我们需要把外设的物理地址,映射到CPU能访问的虚拟地址上。
举个例子。假设你的开发板上有一个UART,它的数据寄存器物理地址是0x1000_0000。你想往这个寄存器写一个字节,直接写0x1000_0000是不行的。因为CPU跑在虚拟地址模式下,它看到的地址是经过MMU翻译的。你得先让MMU知道:虚拟地址X对应物理地址0x1000_0000。然后你写X,就等于写了那个寄存器。
核心概念:MMIO就是把外设的物理地址空间,映射到CPU的虚拟地址空间。这样CPU就能用普通的访存指令(如ldr/str)来读写外设寄存器了。
这里有个关键点:外设寄存器的访问,和普通内存的访问,行为是不一样的。普通内存你写一个值,读回来就是那个值。但外设寄存器不一定。比如你写一个控制寄存器,它可能立刻触发硬件动作,读回来的值可能已经变了。或者有些寄存器是只读的,你写它根本没反应。所以,操作MMIO时,一定要对照芯片手册,搞清楚每个寄存器的属性。
4.2 ioremap原理
好了,概念清楚了,那怎么在Linux内核里实现这个映射呢?答案就是ioremap。这个函数是内核提供的,专门用来把物理地址映射到内核虚拟地址空间。
它的原型很简单:
void __iomem *ioremap(phys_addr_t phys_addr, unsigned long size);
第一个参数是物理地址,第二个参数是映射的大小。返回值是一个__iomem类型的指针。这个__iomem是个标记,告诉编译器这个指针指向的是IO内存,不是普通内存。你别试图对它做解引用操作,要用专门的读写函数。
我记得在项目中遇到过一个问题。有个同事直接用ioremap返回的指针去读数据,结果读回来的值总是不对。我一看代码,他写的是:
void __iomem *base = ioremap(0x10000000, 0x1000);
uint32_t val = *base; // 错误!
嗯,这里要注意。你不能直接用*base去读,因为编译器可能会优化掉某些访问,或者使用不合适的指令。正确的做法是用ioread32和iowrite32这类函数:
void __iomem *base = ioremap(0x10000000, 0x1000);
uint32_t val = ioread32(base); // 正确
iowrite32(0x01, base + 0x10); // 正确
ioremap的原理其实不复杂。它会在内核的页表里,创建一个新的页表项,把物理地址和虚拟地址关联起来。同时,它会设置页表项的缓存属性为不可缓存。为什么?因为外设寄存器的值随时可能被硬件改变,如果CPU缓存了,读到的可能是旧值。所以MMIO区域必须是非缓存的。
小技巧:在设备树中,你通常会看到reg = <0x10000000 0x1000>这样的属性。驱动里用platform_get_resource拿到物理地址后,再用devm_ioremap_resource来映射。这个函数比ioremap更安全,因为它会帮你检查资源是否冲突。
4.3 读写屏障
说到MMIO,就绕不开读写屏障。这东西,说白了就是保证访问顺序的。CPU和编译器为了性能,可能会乱序执行指令。比如你写了两个寄存器,先写控制寄存器A,再写数据寄存器B。但CPU可能先执行了写B,再执行写A。对于外设来说,这个顺序可能是致命的。
为什么会这样?因为现代CPU有乱序执行、写缓冲、多级缓存等机制。你写的指令,在CPU眼里只是几个内存操作。它觉得先做哪个后做哪个,只要不影响单线程的正确性,就可以随便换。但对于外设,顺序就是生命。
我举个例子。假设你要操作一个网卡,先写一个命令寄存器启动DMA,再写一个描述符地址寄存器。如果CPU先写了描述符地址,再写启动命令,网卡可能看到描述符地址还没准备好就开始DMA了,结果读到了错误的数据。
所以,我们需要读写屏障。Linux内核提供了几个宏:
| 宏 | 作用 |
|---|---|
wmb() |
写屏障。保证屏障前的写操作,在屏障后的写操作之前完成。 |
rmb() |
读屏障。保证屏障前的读操作,在屏障后的读操作之前完成。 |
mb() |
全屏障。同时保证读写顺序。 |
__iowmb() |
IO写屏障。专门用于MMIO的写屏障,比wmb()更轻量。 |
用法很简单。比如上面的网卡例子:
iowrite32(cmd, base + CMD_REG);
__iowmb(); // 保证上面的写已经完成
iowrite32(desc_addr, base + DESC_REG);
这里__iowmb()会插入一条屏障指令,强制CPU把写缓冲里的数据刷出去,并且等待写操作完成。这样就能保证先写CMD_REG,再写DESC_REG。
注意:不要滥用屏障。每次屏障都会让CPU停顿一下,影响性能。只在真正需要保证顺序的地方加。我见过有人每个寄存器操作前后都加屏障,结果性能惨不忍睹。其实很多外设本身有FIFO或者握手机制,不需要你操心顺序。
另外,ioread32和iowrite32这些函数,在ARM架构上默认会包含一定的顺序保证。但为了保险,我建议在关键操作前后还是显式加上屏障。尤其是当你操作的是DMA相关的寄存器时,顺序问题特别容易出bug。
我曾经在一个项目中,调试一个SD卡控制器驱动。每次写数据时,卡都报错。折腾了两天,最后发现是写命令寄存器和写数据寄存器的顺序反了。加上一个wmb()之后,问题立刻解决。嗯,从那以后,我写MMIO代码时,都会仔细想想:这个操作有没有顺序依赖?
4.4 总结与建议
MMIO是嵌入式驱动开发的基础。你只要和外设打交道,就离不开它。我个人习惯是:
- 拿到芯片手册后,先把外设的物理地址和寄存器偏移量整理成宏定义。
- 在驱动初始化时,用
devm_ioremap_resource一次性映射所有需要的地址。 - 读写寄存器时,始终用
ioread32/iowrite32,不要直接解引用指针。 - 在需要保证顺序的地方,加上合适的屏障。但不要过度使用。
说白了,MMIO就是让CPU用内存的方式管外设。但外设不是内存,它有它的脾气。你顺着它的脾气来,用正确的函数、加正确的屏障,它就能乖乖工作。否则,bug就会找上门来。
下一章,我们会聊聊设备树中的reg属性,以及如何用of函数来解析它。到时候你会看到,MMIO和设备树的结合,才是真正的生产力。