如果你是做嵌入式 Linux、驱动、虚拟化,那么 MMU、IOMMU、MMIO、mmap、ioremap 这五个概念,就是你的九九乘法表。不懂 MMU,就很难理解进程隔离和缺页中断;对 IOMMU 没有概念,GPU 直通基本靠运气;分不清 MMIO 和 ioremap,连最简单的字符设备驱动都很难写出来;如果不会用 mmap,遇到用户态零拷贝和共享内存时就会无从下手。
这篇文章会围绕 CPU 访问内存 / 外设的完整链路来写。
CPU 执行一条 load 或 store 指令时,使用的是虚拟地址。MMU 把它翻译成物理地址之后,系统再判断这个物理地址对应 DRAM,还是 PCIe 设备的 MMIO 窗口。
设备做 DMA 时,不经过 CPU 的 MMU,而是把 DMA 地址交给 IOMMU。IOMMU 再把设备看到的 I/O 虚拟地址(IOVA)转换为实际物理地址。没有 IOMMU 时,很多设备会直接把地址送上内存总线。
一、MMU
1.1 没有 MMU 会怎么样?
没有 MMU 的系统也能跑。早期单任务嵌入式系统以及一些非常小的 MCU,常常直接使用物理地址。程序里写下 0x80000000,总线就真的去访问这个地址附近的存储器。
这种方式简单、启动也快,一旦系统复杂起来,麻烦就接踵而至:
- 进程之间无法隔离:进程 A 可以随便读写进程 B 的数据。
- 程序难以重定位:每个程序都得提前分配一段固定的物理地址。
- 内存碎片利用率低:连续物理内存不够时,即使总空闲内存足够,也很难分配。
- 内核保护薄弱:用户程序误写一个地址,可能直接把内核数据改掉。
- 共享库和按需加载困难:代码、数据不能灵活映射到每个进程的地址空间。
在一个只有裸机循环和几个中断的产品里,这些问题还能忍。到了 Linux,几十个进程、驱动、文件缓存、共享内存一起跑,直接物理寻址基本就是把系统交给运气。
实时控制追求的是确定性——没有缺页异常这种不可预测的延迟,程序执行时间可以严格计算。但通用操作系统不行,必须有 MMU。
1.2 MMU 核心原理
MMU 的核心工作,就是把 CPU 产生的虚拟地址(Virtual Address,VA)翻译成物理地址(Physical Address,PA)。现代系统通常按页进行映射,常见页大小是 4 KiB,也可能使用 2 MiB、1 GiB 等大页。
以 64 位系统的典型四级页表为例,一个虚拟地址会被拆成多段索引:
| PGD index | PUD index | PMD index | PTE index | page offset |
| 9 bit | 9 bit | 9 bit | 9 bit | 12 bit |
最后 12 位是页内偏移。MMU 通过前面的索引逐级查找页表,找到页表项(PTE)后,把物理页号和页内偏移拼起来,形成物理地址。
虚拟地址 VA = 虚拟页号 VPN + 页内偏移 offset
页表项 PTE = 物理页号 PFN + R/W/X/U 等属性
物理地址 PA = PFN 对应的物理页基址 + offset
如果每次访问都从顶层页表开始查,性能肯定不行。所以 CPU 里有一块小而快的缓存:TLB(Translation Lookaside Buffer)。TLB 保存最近使用的“虚拟页号 → 物理页号”映射,命中时,地址翻译几乎可以在很短的路径内完成。
下面是一段地址拆分的思路演示,它不是为了直接操作真实页表,而是用来表达拆分逻辑。实际内核会根据架构、页表级数和地址宽度做不同处理。
#include <stdint.h>
#include <stdio.h>
#define PAGE_SHIFT 12 // 4 KiB 页
#define PAGE_SIZE (1UL << PAGE_SHIFT)
#define PAGE_MASK (~(PAGE_SIZE - 1))
static void decode_virtual_address(uint64_t va)
{
uint64_t vpn = va >> PAGE_SHIFT; // 虚拟页号
uint64_t off = va & (PAGE_SIZE - 1); // 页内偏移
printf("VA = 0x%llx\n", (unsigned long long)va);
printf("VPN = 0x%llx\n", (unsigned long long)vpn);
printf("offset = 0x%llx\n", (unsigned long long)off);
}
实际的页表项还会包含访问权限、是否存在、是否可执行、是否可缓存、是否为脏页等信息。不同架构在位的定义上不完全一样,但“从虚拟页号找到物理页号,同时偏移量原样保留”这个基本逻辑不会改变。
1.3 内存保护机制
MMU 不只是翻译地址,它还要把访问控制放到硬件处理的那条路上去。常见的保护属性包括:
- Present / Valid:页是否存在于当前地址空间。
- Read / Write:是否允许写入。
- Execute Disable(NX/XN):数据页是否被禁止执行程序代码。
- User / Supervisor:用户态代码能不能访问这页。
- Accessed / Dirty:硬件记录页是否被访问、写脏。
- Cache 属性:普通缓存、写合并、设备内存等。
用户态程序访问未映射地址时,CPU 会触发缺页异常,把控制权交给内核。内核收到信号后,可能从磁盘加载数据页,也可能分配匿名内存页,或者判定这是非法访问,向进程发送 SIGSEGV 信号。
当用户写入只读内存页时,CPU 会触发写保护异常。如果页面是私有映射,内核可能执行写时复制(COW),为当前进程单独拷贝一份可写数据页;如果是真正意义的只读页,系统会直接报错,而不是给修改的机会。
这就是普通进程无法随意读写内核地址的原因。不是大家约定好了不去碰它,而是页表权限和 CPU 特权级在硬件层面把路挡住了。
1.4 MMU 工作流程图解
缺页异常并不一定是错误。很多时候它只是“这页还没准备好”,内核把页面准备好后,返回用户态,原来的指令重新执行,看起来就像访问从来没有被打断过。
1.5 适用场景
Linux 为每个进程维护独立的虚拟地址空间。不同进程可以使用相同的虚拟地址,比如都使用 0x7f... 附近的地址,但它们对应的物理页可能完全不同。
用户态常见的 malloc() 最终通常会走到 brk() 或 mmap(),得到一段虚拟地址范围。物理页可能要等到第一次访问时才真正分配,这种现象通常称为按需分配。
#include <stdlib.h>
#include <string.h>
int main(void)
{
size_t len = 16 * 1024 * 1024;
char *buf = malloc(len); // 拿到虚拟地址时,物理页可能还没全部分配
if (buf == NULL)
return 1;
memset(buf, 0, len); // 第一次触碰每个页面,可能触发缺页分配
free(buf);
return 0;
}
做性能调试时,不能只看 malloc() 返回的速度,还要看第一次访问内存时的缺页开销。很多程序在初始化阶段突然变慢,往往就是缺页分配造成的。
二、IOMMU
2.1 DMA 的痛点
DMA(Direct Memory Access)允许设备绕过 CPU 直接读写内存。网卡收包、NVMe 读盘、音视频采集、GPU 传输,几乎都离不开 DMA。
早期设备在内存寻址能力上非常有限。部分设备只有 32 位地址线,只能访问 4 GiB 以下的空间;还有些设备只能接触一段连续的物理内存区域。让人头疼的是,这些设备一旦拿到错误的地址,数据就可能被写进内核页表、其他进程的存储区,甚至固件所在的区域。
对于传统系统而言,在一般情况下,通过利用 DMA API 做缓冲区分配和地址转换,例如:
void *cpu_addr;
dma_addr_t dma_addr;
cpu_addr = dma_alloc_coherent(dev, 4096, &dma_addr, GFP_KERNEL);
if (!cpu_addr)
return -ENOMEM;
/* 把 dma_addr 写进设备的 DMA 描述符,而不是把 cpu_addr 直接写进去。 */
writel(lower_32_bits(dma_addr), regs + DMA_ADDR_LOW);
writel(upper_32_bits(dma_addr), regs + DMA_ADDR_HIGH);
这里的 dma_addr_t 并不是 CPU 能直接使用的虚拟地址。这个地址后面到底有没有经过 IOMMU 转换,取决于平台和内核配置,并不是固定的。
2.2 IOMMU 核心作用
IOMMU 可以理解成设备侧的地址翻译与访问控制单元。设备发出 IOVA 后,IOMMU 查找属于该设备或设备组的页表,把 IOVA 转换成物理地址,再把请求送到内存系统。
这条链路的好处主要有两个:
- 设备可以使用连续的 IOVA,而实际的物理页可以是离散的,IOMMU 负责拼接起来。
- 设备的访问范围能够被限制在指定页之内,一旦发生越界 DMA,就会在硬件层被拦截。
2.3 IOMMU 两大能力
能力一:地址转换
设备看到的地址并不需要与物理地址一致。内核可以把一系列分散的物理页映射为连续的 IOVA,从而解决设备地址位宽不够、内存碎片,以及缓冲区布局等问题。这就好比 CPU 使用虚拟地址一样,只不过 MMU 服务于指令执行,而 IOMMU 服务于设备主动发起的 DMA 事务。
能力二:隔离和保护
IOMMU 能够针对具体设备、同一个 IOMMU group 的设备组,或者安全域,分别构建独立的页表。这样一来,任何特定设备都只能访问明确分配给自己的内存页面,不会因为某个描述符损坏或篡改就把数据写到系统其他区域。
IOMMU 通常还会跟中断重映射一起使用。设备发出的 MSI/MSIX 中断信息也需要限制和转发,防止设备伪造中断目标地址。
不过,IOMMU 并非无懈可击。如果驱动程序主动把全部物理内存映射给设备,IOMMU 仍然会照做;如果设备固件、驱动逻辑或权限配置有问题,数据照样可能遭到破坏。
2.4 IOMMU 与 MMU 的本质区别图解
| 对比项 |
MMU |
IOMMU |
| 服务对象 |
CPU、进程 |
PCIe 设备、DMA 引擎 |
| 输入地址 |
虚拟地址 VA |
IOVA / DMA 地址 |
| 输出地址 |
物理地址 PA |
物理地址 PA |
| 页表归属 |
进程地址空间、内核地址空间 |
设备域、IOMMU group |
| 主要目的 |
地址翻译、进程隔离、权限保护 |
DMA 地址翻译、设备隔离、越界拦截 |
| 触发异常 |
缺页异常、保护异常 |
IOMMU fault、DMA remapping fault |
| 典型使用 |
malloc()、用户态代码 |
网卡、GPU、NVMe、VFIO |
两者最后都可能把地址送到物理内存,但发起者、权限模型和页表上下文完全不一样。把 IOMMU 说成“给设备用的 MMU”,作为入门比喻没问题,停在这里就不够了。
2.5 典型场景
GPU 直通
在虚拟化场景里,虚拟机里的 GPU 驱动认为自己拿到了设备。宿主机通过 IOMMU 把这块 GPU 放进一个独立的 DMA 域,让它只能访问分配给虚拟机的内存。
VFIO
VFIO 把设备安全地交给用户态或虚拟机使用。用户态可以通过 VFIO 获取设备的 BAR、配置空间和中断,但真正的 DMA 映射仍由 IOMMU 约束。
DMA 防越界
在没有 IOMMU 的老平台上,驱动必须格外小心 DMA 地址和长度。一处整数溢出,就可能让设备把数据写到完全不相关的区域。IOMMU 能在硬件层拦截很多错误,但映射策略仍然取决于驱动。
排查 IOMMU 问题时,经常会在日志里看到 DMAR fault、IOMMU page fault 或 SMMU translation fault。它们不一定说明硬件坏了,更多时候是设备访问了没有建立映射的 IOVA,或者驱动在设备停机前就释放了 DMA 缓冲区。
三、MMIO
3.1 IO 端口 vs MMIO
CPU 访问外设寄存器,传统上有两种模型。
IO 端口(Port I/O):x86 早期提供独立的 I/O 地址空间,通过 in / out 指令访问。I/O 端口地址和内存地址是两套体系。内存映射 I/O(MMIO):MMIO 把设备寄存器映射到物理地址空间的一段范围里。CPU 执行普通的 load/store 路径,但地址最终被总线转发给设备,而不是 DRAM。
ARM、RISC-V 和很多 SoC 都主要使用 MMIO。PCIe 设备的 BAR(Base Address Register)也会把寄存器窗口呈现成一段 MMIO 资源。
| 项目 |
IO 端口 |
MMIO |
| 地址空间 |
独立 I/O 空间 |
物理内存地址空间的一部分 |
| 典型指令 |
in / out |
load / store 或 readl / writel |
| 常见平台 |
x86 传统设备 |
ARM、RISC-V、PCIe、SoC 外设 |
| 缓存要求 |
由 I/O 语义控制 |
通常映射为 Device / Uncached |
| Linux 访问接口 |
inb/outb |
ioread32/iowrite32、readl/writel |
3.2 MMIO 原理
假设一个 PCIe 设备的 BAR0 被分配到物理地址 0xE0000000,长度为 64 KiB:
物理地址 0xE0000000 ~ 0xE000FFFF
├── 0x0000 设备控制寄存器
├── 0x0004 设备状态寄存器
├── 0x0100 DMA描述符基址
└── ...
当 CPU 访问 0xE0000004 时,地址数据不会跑到 DRAM,而是被内存控制器或 PCIe Root Complex 识别为设备窗口区域,再转化成总线读写协议帧。设备收到事务后,根据偏移量读写对应寄存器。
寄存器读写往往带有副作用。读取状态寄存器可能清除中断,写入控制寄存器可能启动 DMA。所以,MMIO 访问方式不能与普通内存访问混为一谈。
3.3 MMIO 内存特性
MMIO 区域不能当普通内存用,一般具备下面几种属性:
- 不可普通缓存:缓存旧寄存器值会让软件读到过期状态。
- 访问有顺序要求:写启动寄存器前,描述符内容必须已经对设备可见。
- 访问宽度敏感:一个 32 位寄存器可能不允许拆成两个 16 位访问。
- 读写操作可能产生副作用:读取操作不一定是单纯取值。
- 不能随便预取或投机执行:设备寄存器不适合像数组一样被 CPU 猜测访问。
Linux 提供 readb/readw/readl/readq 和 writeb/writew/writel/writeq,以及更通用的 ioread32/iowrite32。这些接口会配合架构需要,处理访问宽度、屏障和 I/O 内存语义。
#define REG_CONTROL 0x00
#define REG_STATUS 0x04
#define CTRL_START BIT(0)
#define STATUS_READY BIT(0)
static int device_start(void __iomem *regs)
{
u32 status;
/* 先读取设备状态寄存器,确认硬件已经准备好。 */
status = readl(regs + REG_STATUS);
if (!(status & STATUS_READY))
return -EBUSY;
/* 通过 MMIO 写控制寄存器,启动设备。 */
writel(CTRL_START, regs + REG_CONTROL);
return 0;
}
这里使用 void __iomem * 是有意的。__iomem 是专门给 sparse 静态检查用的地址空间标注,提醒开发者:这个指针指向 I/O 内存,绝不能当成普通 RAM 指针去解引用。
3.4 MMIO 物理地址从哪里来
MMIO 物理资源不是驱动自己猜的,通常来自下面三类:
- PCI/PCIe BAR:设备枚举时由固件或 PCI 核心分配。
- 设备树(Device Tree):SoC 外设在
reg 属性中描述基址和长度。
- ACPI:x86 和服务器平台通过
_CRS 等表描述资源。
PCIe 驱动通常这样获取 BAR 资源:
struct resource *res;
res = pci_resource_start(pdev, 0); // 获取 BAR0 物理起始地址
len = pci_resource_len(pdev, 0); // 获取 BAR0 长度
实际驱动更推荐使用 pcim_iomap()、devm_ioremap_resource() 等带资源管理的接口,不要手工拼接物理地址。设备换一台机器或者换一个 PCIe 插槽后,BAR 地址可能完全不一样。
3.5 图解 CPU 访问 MMIO 寄存器的流程
一般来说,普通 RAM 可以被缓存、合并访问,甚至打乱顺序执行。但 MMIO 区域必须严格遵循设备规定的协议规范。写完某个寄存器后,设备究竟在哪个时间点看到这次写入,取决于系统架构、总线类型和访问接口的语义定义。
遇到启动顺序问题时,不要急着加一大堆 udelay(),先看看读写屏障是否更合适。
我刚开始独立负责维护 PCIe 采集卡驱动时,遇到过一次典型的“设备像死了一样”的故障。探测函数执行成功,ioremap() 也成功,writel() 返回之后没有任何报错,可中断信号始终没响应,读状态寄存器一直维持在 0。我一开始怀疑 DMA,接着怀疑 IOMMU,最后开始怀疑人生。机房空调开得非常冷,示波器显示的只是一条平直的线,旁边同事已经问了三遍“是不是线插反了”。
后来把寄存器表一项项对照,才发现问题在于使用了错误的访问方式,而且状态寄存器还是“读后清除”。第一次调试打印就把状态清掉了,后面的日志当然全是 0。改成 readl(),再理顺启动寄存器和 DMA 描述符的写入顺序,设备立刻开始吐数据。
这个坑让我明白,MMIO 并不是一块带编号的普通内存块。对寄存器的读写操作本身就是协议的一部分。很多驱动问题表面看起来像硬件故障,最后查出来往往只是某一行代码惹毛了硬件。
四、mmap
4.1 mmap 是什么?
mmap() 是把一个文件、匿名内存,或者驱动提供的设备资源映射到进程的虚拟地址空间。调用完成后,用户态就能拿到一个指针,把它当成普通内存来访问。
它做的是建立虚拟地址区间和后端对象之间的关系,并不一定在调用返回时就分配好所有物理页。真正访问页面时,可能通过缺页异常来完成装载或分配。
#include <sys/mman.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
void *map_file(int fd, size_t len)
{
void *addr = mmap(NULL, len,
PROT_READ | PROT_WRITE,
MAP_SHARED,
fd, 0);
if (addr == MAP_FAILED) {
perror("mmap");
return NULL;
}
return addr;
}
用户态虚拟地址是 mmap() 返回的地址,它和物理地址、内核虚拟地址都不一样。所以不能因为它数值看起来像回事,就拿去做设备的 DMA 操作。
4.2 mmap 常见两种用法
用法一:映射文件
文件映射可以减少用户态和内核态之间的显式拷贝。多个进程使用 MAP_SHARED 映射同一个文件时,可以共享页面缓存中的数据。
int fd = open("data.bin", O_RDWR);
void *p = mmap(NULL, 4096,
PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
/* 直接读写映射区域,内核负责与文件页缓存协调。 */
((char *)p)[0] = 'L';
msync(p, 4096, MS_SYNC); // 需要把修改同步到文件时显式调用
munmap(p, 4096);
close(fd);
用法二:映射匿名内存
匿名映射经常用于大块内存、共享内存以及运行时分配。MAP_PRIVATE | MAP_ANONYMOUS 通常得到进程私有的匿名页面,MAP_SHARED | MAP_ANONYMOUS 可以和亲缘进程共享。
4.3 mmap 映射 MMIO
驱动可以实现 file_operations 中的 .mmap,把设备寄存器、显存、DMA 缓冲区等资源映射给用户态。这样用户程序就能少走一层系统调用,直接访问硬件共享区域。
下面是一个简化的驱动示例:
static int demo_mmap(struct file *filp, struct vm_area_struct *vma)
{
unsigned long size = vma->vm_end - vma->vm_start;
phys_addr_t phys = demo_mmio_phys_base;
/* 检查用户请求的长度,防止越过设备资源边界。 */
if (size > demo_mmio_size)
return -EINVAL;
/* 设备 MMIO 不能按普通可缓存 RAM 映射。 */
vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);
vma->vm_flags |= VM_PFNMAP | VM_DONTEXPAND | VM_DONTDUMP;
/* 把物理页框映射进调用进程的用户虚拟地址空间。 */
return remap_pfn_range(vma,
vma->vm_start,
phys >> PAGE_SHIFT,
size,
vma->vm_page_prot);
}
这段代码只是说明机制。实际项目要根据架构和内核版本选择合适的页属性接口,优先使用设备模型和 DMA API 提供的映射方法。把任意物理地址交给用户态,是非常危险的设计。
4.4 缺页、写时复制、MAP_SHARED/MAP_PRIVATE
mmap 调用本身不会分配物理页,也不会拷贝数据。它只在内核里创建一个 vm_area_struct(VMA),记录这段虚拟地址映射到了哪个文件、什么权限、什么标志。
真正的物理页分配发生在第一次访问映射区域的时候。MMU 发现 PTE 的 present 位是 0,触发缺页异常,内核的 handle_mm_fault 去处理:如果是文件映射,从磁盘读数据;如果是匿名映射,分配零页。
MAP_SHARED:多个进程映射同一个文件,一个进程的写对另一个进程可见,而且会写回文件。适合进程间共享内存、文件读写。
MAP_PRIVATE:写时复制(COW)。写的时候内核为你复制一份私有的物理页,修改不会影响其他进程,也不会写回文件。fork 之后父子进程的内存就是 COW 的。
对于 MMIO,通常不能简单套用文件映射的直觉。硬件寄存器没有普通的页缓存语义,驱动必须明确设置页属性,并控制允许映射的偏移和长度。
// MAP_PRIVATE + 缺页的经典观察
char *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 此时没有物理页被分配
// p[0] = 'A'; // 第一次写 → 触发缺页 → 内核分配一个物理页 → COW 建立
// 如果只读不写,多个进程会共享同一个零页(zero page)
// 全局零页是内核预分配的,所有匿名只读映射都指向它
4.5 底层调用链路
不同内核版本和架构的函数细节会变化,但整体调用链大致是:
用户态 mmap()
└─ sys_mmap / ksys_mmap_pgoff
└─ vm_mmap_pgoff()
└─ do_mmap()
└─ 创建或合并 vm_area_struct
└─ 文件映射:调用 file_operations->mmap()
└─ 驱动实现 remap_pfn_range / dma_mmap_*
vm_area_struct(VMA)描述一段连续的用户虚拟地址区间,包括起止地址、权限、页属性、映射文件和操作函数。它不是每一页的详细页表,而是管理“这一大段地址具有什么规则”。
可以用下面这段用户态代码观察映射结果:
#include <stdio.h>
#include <stdlib.h>
static void dump_maps(void)
{
FILE *fp = fopen("/proc/self/maps", "r");
char line[512];
if (!fp)
return;
while (fgets(line, sizeof(line), fp))
fputs(line, stdout);
fclose(fp);
}
/proc/self/maps 能看到进程的 VMA 范围,但看不到完整的硬件页表细节。想继续追下去,需要结合 /proc/self/pagemap、内核调试接口或架构相关工具。
五、ioremap
5.1 ioremap 定位
ioremap() 的作用,就是把设备的物理 MMIO 资源映射成内核虚拟地址。驱动拿到这个地址后,再通过 readl()、writel() 等 I/O 访问接口操作寄存器。
void __iomem *regs;
regs = ioremap(mmio_phys, mmio_len);
if (!regs)
return -ENOMEM;
writel(0x1, regs + REG_CONTROL);
/* 手工映射时必须在设备移除或错误路径中解除映射。 */
iounmap(regs);
在设备模型的驱动程序里,更推荐下面这个做法:
struct resource *res;
void __iomem *regs;
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
regs = devm_ioremap_resource(&pdev->dev, res);
if (IS_ERR(regs))
return PTR_ERR(regs);
devm_ioremap_resource() 会对资源进行审查,同时设置符合要求的 I/O 访问属性,而且在设备释放时自动清理,能明显减少出错处理流程。
5.2 ioremap 与 vmalloc、kmalloc 的区别
| 接口 |
典型用途 |
后端 |
是否对应设备物理资源 |
kmalloc() |
小块、物理连续内存 |
RAM |
否 |
vmalloc() |
大块、虚拟连续内存 |
多个离散物理页 |
否 |
ioremap() |
设备寄存器、显存窗口 |
MMIO 物理地址 |
是 |
几个常见误解可以这样拆解:
kmalloc() 返回的是内核虚拟地址,一般用于普通内存,跟 DMA 地址不是一个东西。vmalloc() 拿到的内存虽然虚拟地址连续,但物理页面通常并不连续,因此不能直接当作设备的 DMA 缓冲区。ioremap() 得到的是 I/O 内存映射,不能当作普通 RAM 使用。
如果硬件必须使用 DMA,应当调用 dma_alloc_coherent()、面向流的 DMA 映射接口,或者上层框架提供的专用内存区域。万万不可先用 kmalloc() 申请内存,再把返回结果强转成设备需要的物理地址。
5.3 ioremap 的缓存属性
设备寄存器通常需要 Device 属性或 Uncached 属性,为的是避免 CPU 缓存、预取和错误合并带来的问题。帧缓冲、显存或写频率很高的区域,有时适合 Write-Combining(写合并),但能不能用必须参考硬件手册和架构语义。
Linux 中常见的相关接口包括:
ioremap():普通设备 I/O 映射。
ioremap_wc():写合并映射,常见于帧缓冲等场景。
ioremap_np():在支持的架构上使用非投机访问属性。
devm_ioremap_resource():设备资源管理版本,驱动里更常用。
不要为了追求性能就随意把寄存器改成可缓存状态。寄存器读写的顺序问题和副作用,远比那点缓存命中率的提升重要。
5.4 ioremap 与 mmap 的对比(内核态 vs 用户态)
| 对比项 |
ioremap() |
mmap() |
| 调用者 |
内核驱动 |
用户态程序,也可由驱动实现回调 |
| 结果地址 |
内核虚拟地址 |
用户虚拟地址 |
| 典型目标 |
设备 MMIO 物理资源 |
文件、匿名内存、DMA 缓冲区、MMIO |
| 访问接口 |
readl/writel/ioread32 |
普通指针访问,需遵守映射属性 |
| 资源清理 |
iounmap() 或 devm 自动清理 |
munmap() |
| 安全边界 |
驱动完全控制 |
需要检查权限、长度、偏移和生命周期 |
驱动先 ioremap() 访问寄存器,用户态再通过驱动的 .mmap 访问共享缓冲区,这是很常见的组合。不过这两个映射通常并不是同一块地址,当然更不存在“mmap 调用了 ioremap”这种说法。
5.5 驱动中 ioremap 使用规范与常见坑
第一个坑——错误路径出现泄漏
探测函数执行过程中出错,如果忘记调用 iounmap(),内核模块多次加载后会遗留未取消的映射和被占用的资源。使用 devm_ioremap_resource() 能明显减少这类问题。
第二个坑——反复映射同一个资源
同一 BAR 被多条路径重复 ioremap(),不仅消耗内核虚拟地址空间,还可能让资源释放过程变得复杂。应当在设备私有结构体里保存映射结果,统一管理。
*第三个坑——直接解引用 `void __iomem `**
下面的写法看起来能跑,实际上不推荐:
/* 不推荐:绕过 I/O 访问语义,可能触发静态检查和架构问题。 */
*(volatile u32 *)(regs + REG_CONTROL) = value;
应该写成:
/* 推荐:使用架构感知的 MMIO 写接口。 */
writel(value, regs + REG_CONTROL);
第四个坑——把 MMIO 当成 memcpy 源或目标
memcpy() 只能用于普通内存区域。设备寄存器可能有访问宽度、访问顺序和副作用方面的要求。批量拷贝会让硬件看到一连串不可控的访问行为。需要搬运 I/O 内存时,使用 memcpy_toio()、memcpy_fromio(),并确认硬件手册允许这种访问方式。
第五个坑——映射长度和寄存器偏移不匹配
驱动里最让人头疼的一类 bug,就是看起来 BAR 长度足够,但实际访问时偏移量超出界限。最好把寄存器偏移定义成宏,在探测过程中专门检查资源长度,必要时在每条访问路径上做好边界保护。
六、完整数据流图解
假设有一块 PCIe 采集卡,BAR0 被设定为控制寄存器,设备通过 DMA 把采集数据写入内存。下面是一段简化的驱动初始化流程:
static int demo_probe(struct pci_dev *pdev,
const struct pci_device_id *id)
{
struct demo_dev *d;
int ret;
d = devm_kzalloc(&pdev->dev, sizeof(*d), GFP_KERNEL);
if (!d)
return -ENOMEM;
/* 1. 启用 PCI 设备并申请 BAR 资源。 */
ret = pcim_enable_device(pdev);
if (ret)
return ret;
ret = pcim_iomap_regions(pdev, BIT(0), "demo_pci");
if (ret)
return ret;
/* 2. 获取 BAR0 对应的内核 MMIO 虚拟地址。 */
d->regs = pcim_iomap_table(pdev)[0];
if (!d->regs)
return -ENOMEM;
/* 3. 分配 DMA 缓冲区,dma_handle 是设备要使用的地址。 */
d->cpu_buf = dma_alloc_coherent(&pdev->dev,
DMA_BUF_SIZE,
&d->dma_handle,
GFP_KERNEL);
if (!d->cpu_buf)
return -ENOMEM;
/* 4. 把 DMA 地址和长度写入设备寄存器。 */
writel(lower_32_bits(d->dma_handle),
d->regs + REG_DMA_ADDR_LO);
writel(upper_32_bits(d->dma_handle),
d->regs + REG_DMA_ADDR_HI);
writel(DMA_BUF_SIZE, d->regs + REG_DMA_LEN);
/* 5. 最后写启动位,避免设备提前开始 DMA。 */
writel(CTRL_START, d->regs + REG_CONTROL);
pci_set_drvdata(pdev, d);
return 0;
}
这段代码背后的链路可以拆成五个步骤:
- PCI 核心枚举设备,从 BAR 中得到 MMIO 物理资源。
pcim_iomap_regions() 内部完成资源申请和 I/O 映射,驱动得到 void __iomem *。
dma_alloc_coherent() 返回 CPU 可访问的虚拟地址和设备使用的 DMA 地址。
- 驱动通过 MMIO 寄存器,把 DMA 地址告诉设备。
- 设备开始 DMA 时,IOMMU 先检查并翻译 DMA 地址,最后访问物理内存。
CPU 访问控制寄存器时经过 CPU MMU → MMIO 窗口 → PCIe 设备;设备写采集数据时经过 设备 DMA → IOMMU → 物理内存。这两条路交汇在同一块硬件,但地址翻译单元不是同一个。
6.2 一张大图串起:MMU、IOMMU、MMIO、mmap、ioremap
把每个名词放回到数据流里,各自的位置就很清楚了:
- MMU:当 CPU 访问用户态或内核态虚拟地址时工作。
- IOMMU:当设备发起 DMA 传输时工作。
- MMIO:指设备寄存器在物理地址空间中的呈现方式。
- ioremap:驱动把 MMIO 物理资源映射成内核虚拟地址。
- mmap:把文件、缓冲区或设备资源映射成用户虚拟地址。
6.3 概念对比总表
| 名词 |
解决的问题 |
输入 |
输出 |
常见调用者 |
典型坑 |
| MMU |
CPU 地址翻译和进程隔离 |
CPU 虚拟地址 |
物理地址 |
CPU 硬件、内核页表 |
忽略 TLB、权限和缺页 |
| IOMMU |
DMA 地址翻译和设备隔离 |
IOVA/DMA 地址 |
物理地址 |
设备、虚拟化框架 |
误以为开启后自动安全 |
| MMIO |
让设备寄存器进入地址空间 |
设备寄存器资源 |
总线读写事务 |
CPU、驱动 |
当普通 RAM 缓存或解引用 |
mmap() |
建立用户虚拟地址映射 |
文件/匿名/设备资源 |
用户虚拟地址 |
用户态、驱动回调 |
长度、权限、生命周期失控 |
ioremap() |
建立内核 I/O 映射 |
MMIO 物理资源 |
内核虚拟地址 |
内核驱动 |
泄漏、重复映射、误用 memcpy |
kmalloc() |
分配普通内核内存 |
大小与 GFP 标志 |
内核虚拟地址 |
内核代码 |
误当 DMA 地址 |
vmalloc() |
分配虚拟连续内存 |
大小 |
内核虚拟地址 |
内核代码 |
误以为物理连续 |
七、高频面试/开发坑点总结
7.1 误区 1:IOMMU 就是 MMU,只是给设备用?
这其实是个只能拿一半分的回答。两者确实都做地址翻译,但上下文不一样。MMU 处理的是 CPU 当前所在的地址空间,页表和进程切换、特权级、TLB 刷新绑定在一起;IOMMU 处理的是设备发出的 DMA 事务,页表和设备域、IOMMU group、虚拟化隔离绑定在一起。
更完整的回答应该是:
IOMMU 是设备侧的地址转换和访问控制硬件。它和 CPU MMU 在功能上有相似之处,但服务对象、页表上下文、异常模型和配置入口都不同。
如果面试官接着问“设备地址是否一定都要经过 IOMMU?”答案往往是不一定。平台本身可能就没有 IOMMU,内核也可能按直通模式配置。针对各种硬件和软件平台的差异,DMA API 会尽量把这些差别隐藏起来,所以驱动开发人员不要自己拍脑袋假定它肯定存在或肯定不存在,而应该按规范处理。
7.2 误区 2:ioremap 得到的地址可以当成普通内存 memcpy
这是不行的,至少不能直接这样做。MMIO 访问需要考虑:
- 当前访问宽度是不是硬件允许的宽度;
- 读写顺序是否符合设备要求;
- 访问操作会不会清除状态、清零中断或触发动作;
- 架构是否需要 I/O 屏障;
- 这个区域能不能被缓存或预取。
访问寄存器时用 readl/writel,搬运 I/O 内存时用 memcpy_toio/memcpy_fromio,访问 DMA 缓冲区则使用普通内存接口或与 DMA API 对应的同步接口。工具选错了,代码有时也能跑,但换平台、换编译器优化级别时,就很容易出问题。
7.3 误区 3:mmap 只能映射文件,不能映射硬件 MMIO
很多人对 mmap() 的最初印象集中在文件映射上。但 Linux 允许驱动实现 .mmap,把设备资源映射到用户态。常见做法是:
- 用户态打开
/dev/demo0。
- 调用
mmap()。
- 驱动在
.mmap 中检查偏移、长度和权限。
- 通过
remap_pfn_range()、dma_mmap_coherent() 等接口建立映射。
- 用户态访问映射后的地址,直接读写硬件或共享 DMA 缓冲区。
这个能力很强,但也容易把系统暴露出去。如果驱动允许用户态把任意物理页映射出来,权限检查又很松,那基本就是给别人发了一把内存钥匙。
7.4 误区 4:开启 IOMMU 之后 DMA 一定安全
IOMMU 只会按照当前的映射规则执行。下面这些情况照样会出问题:
- 驱动器把不应该映射的页面加进了设备域;
- 计算 DMA 长度时出现整数溢出;
- 设备重用过程中没有及时撤销旧的 IOVA 映射;
- 设备还没停下来之前,就释放或重用 DMA 缓冲区;
- 多功能设备共享 IOMMU group,隔离边界实际上没有想象中细致;
- 用户态 VFIO 程序拿到设备后,自己配置了错误的 DMA 描述符。
所以,IOMMU 提供硬件级别的地址翻译和内存隔离能力。最终安全性到底靠不靠谱,取决于平台配置、内核策略、驱动实现和设备固件是否同时可靠。
一个 DMA 生命周期检查清单
/* 伪代码:设备提交 DMA 前的关键顺序。 */
prepare_buffer(buf); // CPU 填充描述符和数据
dma_sync_single_for_device(dev, dma_addr, len, DMA_TO_DEVICE);
writel(dma_addr, regs + REG_DMA_ADDR);
writel(len, regs + REG_DMA_LEN);
writel(CTRL_GO, regs + REG_CONTROL); // 最后启动设备
/* 等待中断或轮询完成。 */
wait_for_completion(&done);
dma_sync_single_for_cpu(dev, dma_addr, len, DMA_FROM_DEVICE);
consume_buffer(buf); // CPU 读取设备写入的数据
上面只是表达顺序,真实代码要根据 dma_alloc_coherent() 或流式 DMA 的具体类型选择接口。很多 DMA bug 不是地址错了,而是 CPU 和设备对缓存内容的理解不一致。这类底层问题在云栈社区的技术讨论中也经常出现。