3、内存映射(MMIO):PCIe BAR空间、MMIO地址映射原理、ioremap与mmap的区别
好,咱们今天聊点硬核的——内存映射,也就是 MMIO。说实话,很多做驱动的朋友,写了好几年代码,对这块还是一知半解。我当年刚入行时也踩过坑,总觉得不就是读个寄存器嘛,直接指针访问不就完了?结果呢?系统直接挂掉,连个报错都没给我留。
嗯,咱们一步步来。先搞清楚 PCIe 设备里的 BAR 空间是什么,再说 MMIO 映射的原理,最后聊聊 ioremap 和 mmap 到底有啥区别。这三个东西,说白了就是「设备怎么把它的内部资源暴露给 CPU」的完整链路。
3.1 PCIe BAR 空间:设备与 CPU 的「窗口」
每个 PCIe 设备,内部都有一些寄存器或者内存缓冲区。CPU 想访问它们,总得有个地址吧?BAR(Base Address Register)就是干这个的。
BAR 是 PCIe 配置空间里的寄存器,每个设备最多可以有 6 个 BAR(Function 0 到 Function 5)。每个 BAR 描述了一段地址空间,可以是内存空间,也可以是 I/O 空间。不过现在基本都用内存空间了,I/O 空间太慢,而且 x86 以外的平台支持得不好。
BAR 的类型:
- Memory BAR:映射到 CPU 的内存地址空间,支持 32 位或 64 位地址。
- I/O BAR:映射到 I/O 地址空间,现在基本淘汰了。
我在项目中遇到过一块 FPGA 加速卡,它的 BAR0 是 64 位地址,大小 256MB,用来映射板上的 DDR 内存。BAR2 是 32 位地址,大小 4KB,用来映射控制寄存器。你看,不同 BAR 用途完全不同。
关键点:BAR 的大小和类型,是在设备硬件设计时定死的。驱动通过读取配置空间里的 BAR 寄存器,才知道这段空间有多大、能不能预取(Prefetchable)。
举个例子,读取 BAR0 的代码片段:
// 读取 PCIe 设备的 BAR0
u32 bar0_low = pci_read_config_dword(pdev, PCI_BASE_ADDRESS_0);
u32 bar0_high = 0;
// 判断是否为 64 位地址
if (bar0_low & PCI_BASE_ADDRESS_MEM_TYPE_64) {
bar0_high = pci_read_config_dword(pdev, PCI_BASE_ADDRESS_0 + 4);
}
// 掩掉低 4 位(属性位),得到基地址
u64 bar0_addr = (bar0_low & PCI_BASE_ADDRESS_MEM_MASK);
if (bar0_high) {
bar0_addr |= ((u64)bar0_high << 32);
}
printk("BAR0 地址: 0x%llx\n", bar0_addr);
这里要注意,低 4 位不是地址,而是表示 BAR 类型、是否可预取等属性。我曾经犯过一个低级错误,直接拿 BAR 寄存器的值去访问,结果访问到了错误的地址,排查了半天才发现是没掩掉低 4 位。
3.2 MMIO 地址映射原理:CPU 怎么找到设备?
BAR 空间只是设备告诉系统「我这里有资源」。但 CPU 要访问它,还得经过一步——地址映射。
MMIO(Memory-Mapped I/O)的原理很简单:把设备的寄存器或内存,映射到 CPU 的物理地址空间中。这样 CPU 就可以用普通的 load/store 指令来访问设备了。你想想看,对 CPU 来说,读一个内存地址和读一个设备寄存器,指令是一样的,只是背后走的总线不同。
映射过程:
- BIOS 或内核枚举 PCIe 设备时,读取 BAR 寄存器。
- 系统分配一段物理地址空间给这个 BAR。
- 驱动调用
pci_ioremap_bar()或ioremap(),将物理地址映射到内核虚拟地址空间。 - 驱动通过虚拟地址访问设备寄存器。
我习惯用一张图来理解:
CPU 虚拟地址 --> MMU 页表 --> CPU 物理地址 --> PCIe 总线 --> 设备 BAR 空间
嗯,这里有个坑:MMIO 映射的物理地址,不能像普通内存那样用 memcpy 或直接解引用。因为设备寄存器可能有副作用(读一次就改变状态),或者对访问宽度有要求(必须 4 字节对齐)。
警告:千万不要用 memcpy 或 memset 操作 MMIO 区域!这些函数会优化成批量访问,可能破坏设备状态。必须使用 readl()、writel() 等专用函数。
3.3 ioremap 与 mmap 的区别:内核视角 vs 用户视角
这两个函数名字很像,但用途完全不同。我刚开始学的时候也搞混过,后来在项目里各用了一次,才彻底明白。
| 特性 | ioremap | mmap |
|---|---|---|
| 使用场景 | 内核驱动中映射 MMIO | 用户空间映射物理内存或 MMIO |
| 返回地址 | 内核虚拟地址 | 用户空间虚拟地址 |
| 权限 | 内核态访问 | 用户态访问 |
| 缓存策略 | 默认 uncached(不可缓存) | 可配置(通过 flags) |
| 典型调用 | ioremap(phys_addr, size) |
mmap(0, size, PROT_READ, MAP_SHARED, fd, offset) |
ioremap 是内核专用的。它的作用是把一段物理地址(比如 BAR 空间)映射到内核的虚拟地址空间。映射后的地址,驱动可以直接用 readl/writel 访问。
举个例子:
// 内核驱动中映射 BAR0
void __iomem *bar0_virt = pci_ioremap_bar(pdev, 0);
if (!bar0_virt) {
dev_err(&pdev->dev, "ioremap BAR0 failed\n");
return -ENOMEM;
}
// 读取寄存器
u32 val = readl(bar0_virt + REG_OFFSET);
writel(0x1, bar0_virt + REG_CTRL);
mmap 是用户空间的系统调用。它可以把一个文件描述符(比如 /dev/mem 或某个字符设备)映射到用户进程的地址空间。这样用户程序就能直接访问物理内存或 MMIO 区域,不需要经过内核。
用户空间映射 MMIO 的典型做法:
// 用户空间程序
int fd = open("/dev/mem", O_RDWR | O_SYNC);
// 假设物理地址 0xFE000000,大小 4KB
void *map_base = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0xFE000000);
if (map_base == MAP_FAILED) {
perror("mmap failed");
exit(1);
}
// 直接访问
volatile u32 *reg = (volatile u32 *)(map_base + 0x100);
*reg = 0x1234; // 写寄存器
个人经验:我建议在内核驱动里用 ioremap,因为安全可控。用户空间 mmap 虽然方便,但容易出问题——比如用户程序崩溃了,映射没释放,可能导致系统不稳定。我曾经在调试一个视频采集卡时,用户态程序 mmap 了 DMA 缓冲区,结果程序异常退出,缓冲区没 unmap,导致下次申请 DMA 时内核报错。
核心区别一句话总结:ioremap 是内核驱动用来访问设备寄存器的,mmap 是用户程序用来直接操作硬件资源的。两者底层都涉及页表修改,但使用场景和权限完全不同。
3.4 避坑指南:我踩过的几个坑
嗯,最后分享几个实战中容易犯的错误:
- 忘记掩掉 BAR 的低位:BAR 寄存器的低 4 位是属性位,不是地址。直接拿 BAR 值去 ioremap 会失败。
- 用 memcpy 操作 MMIO:设备寄存器可能有副作用,必须用 readl/writel 系列函数。
- 用户空间 mmap 后忘记同步:如果设备是 DMA 引擎,用户空间映射了 DMA 缓冲区,一定要用
msync或MAP_SHARED保证缓存一致性。 - ioremap 后没有检查返回值:映射可能失败,返回 NULL。不检查就直接访问,内核直接 oops。
我曾经在一个项目里,因为 BAR 地址是 64 位的,但我只读了低 32 位,结果映射到了错误的地址。设备没反应,我还以为是硬件坏了,折腾了两天才发现是代码问题。从那以后,我写 BAR 读取代码都会加上 64 位判断。
好了,MMIO 映射这块就聊到这儿。下一节咱们讲 DMA 传输,那才是真正考验功底的地方。