4. MMIO 与 PIO 访问:ioremap、readl/writel、ioread32/iowrite32、内存屏障与顺序保证

在 PCIe 驱动开发中,访问设备寄存器是数据搬运的基础操作。MMIO(Memory-Mapped I/O)与 PIO(Port I/O)是两种核心的寄存器访问方式。本章深入剖析这两种方式的底层机制、Linux 内核提供的访问 API(ioremap、readl/writel、ioread32/iowrite32),以及保证访问顺序的内存屏障技术。

4.1 MMIO 与 PIO 概念对比

PCIe 设备通过配置空间中的 BAR(Base Address Register)向主机报告其寄存器映射地址。根据 BAR 的类型,驱动可以采用 MMIO 或 PIO 方式访问。

特性 MMIO(Memory-Mapped I/O) PIO(Port I/O)
地址空间 映射到 CPU 的物理内存地址空间 使用独立的 I/O 地址空间(x86 特有)
访问指令 普通内存访问指令(MOV、STR、LDR) 专用 I/O 指令(IN、OUT)
性能 高,CPU 缓存可加速(需谨慎配置) 低,每次访问需 CPU 参与指令解码
可移植性 跨架构通用(ARM、x86、RISC-V) 仅 x86 架构原生支持
PCIe 应用场景 主流方式,用于高性能寄存器访问 遗留设备或特定配置空间访问
工程建议: 现代 PCIe 驱动优先使用 MMIO。PIO 在 x86 平台仍用于访问 Legacy 设备配置空间,但 MMIO 在吞吐量和延迟上全面占优。

4.2 ioremap:建立 MMIO 映射

ioremap 是 Linux 内核提供的核心函数,用于将 PCIe 设备的物理 BAR 地址映射到内核虚拟地址空间,使驱动可以通过指针直接访问设备寄存器。

#include <linux/io.h>

void __iomem *ioremap(phys_addr_t offset, unsigned long size);
void iounmap(void __iomem *addr);
  • offset:BAR 报告的物理基地址(从 pci_resource_start() 获取)。
  • size:映射区域大小(从 pci_resource_len() 获取)。
  • 返回值:指向映射后虚拟地址的指针,类型为 void __iomem *。
  • iounmap:驱动卸载或移除设备时释放映射。
关键点: ioremap 默认不启用 CPU 缓存(ioremap_nocache),确保对设备寄存器的读写直接到达 PCIe 总线,避免缓存一致性问题。

4.3 readl/writel 与 ioread32/iowrite32

Linux 内核提供两套 MMIO 访问 API:传统 readl/writel 系列和通用 ioread32/iowrite32 系列。两者在功能上等价,但设计哲学不同。

API 系列 函数原型 特点
readl/writel u32 readl(const volatile void __iomem *addr);
void writel(u32 val, volatile void __iomem *addr);
传统 API,参数类型为 void __iomem *,隐含 32 位访问宽度
ioread32/iowrite32 u32 ioread32(const void __iomem *addr);
void iowrite32(u32 val, void __iomem *addr);
通用 API,支持不同宽度(8/16/32/64),可移植性更好
// 使用 readl/writel 访问设备寄存器
void __iomem *reg_base;
u32 status;

reg_base = ioremap(pci_resource_start(pdev, 0), pci_resource_len(pdev, 0));
status = readl(reg_base + 0x10);   // 读取偏移 0x10 的寄存器
writel(0x1, reg_base + 0x00);      // 写入偏移 0x00 的寄存器

// 使用 ioread32/iowrite32(推荐)
status = ioread32(reg_base + 0x10);
iowrite32(0x1, reg_base + 0x00);
避坑指南: 不要直接对 ioremap 返回的地址使用指针解引用(如 *(u32 *)addr)。这会导致编译器优化掉 volatile 语义,且无法保证访问宽度和顺序。必须使用 readl/writel 或 ioread32/iowrite32 系列函数。

4.4 内存屏障与顺序保证

PCIe 设备寄存器访问对顺序极其敏感。CPU 和编译器可能对内存访问进行重排序,导致驱动写入的顺序与设备实际接收的顺序不一致。内存屏障(Memory Barrier)用于强制访问顺序。

MMIO 访问中的内存屏障作用示意图 CPU 写入顺序(无屏障) 写 Reg A 写 Reg B PCIe 总线 PCIe 总线 可能乱序到达设备 CPU 写入顺序(带 wmb() 屏障) 写 Reg A wmb() 内存屏障 写 Reg B PCIe 总线 保证 Reg A 先于 Reg B 到达设备

4.4.1 屏障类型与使用场景

屏障宏 作用范围 典型场景
wmb() 写操作顺序保证 先写描述符再通知设备(Doorbell)
rmb() 读操作顺序保证 先读状态寄存器再读数据寄存器
mb() 读写双向顺序保证 同时需要读写顺序的场景
readl_relaxed() / writel_relaxed() 无屏障版本 性能敏感且顺序无关的批量访问
// 典型 DMA 描述符提交流程(带屏障)
void submit_dma_descriptor(struct dma_chan *chan, struct dma_desc *desc)
{
    // 1. 写入描述符内容到设备内存
    iowrite32(desc->src, chan->regs + DMA_SRC);
    iowrite32(desc->dst, chan->regs + DMA_DST);
    iowrite32(desc->len, chan->regs + DMA_LEN);

    // 2. 内存屏障:确保上述写入在 Doorbell 之前完成
    wmb();

    // 3. 写入 Doorbell 寄存器通知设备开始传输
    iowrite32(1, chan->regs + DMA_DOORBELL);
}
性能优化提示: 在批量写入多个寄存器时,可以使用 writel_relaxed() 避免每次写入都插入屏障,最后只调用一次 wmb() 保证整体顺序。这在高频寄存器访问路径上能显著提升性能。

4.5 完整 MMIO 访问工程模板

以下代码展示了一个 PCIe 驱动中 MMIO 映射与访问的完整工程模式:

static int my_pcie_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    struct my_device *dev;
    int ret;

    dev = kzalloc(sizeof(*dev), GFP_KERNEL);
    if (!dev)
        return -ENOMEM;

    pci_set_drvdata(pdev, dev);

    // 启用 PCIe 设备
    ret = pci_enable_device(pdev);
    if (ret)
        goto err_free;

    // 请求 BAR0 资源
    ret = pci_request_region(pdev, 0, "my_pcie_bar0");
    if (ret)
        goto err_disable;

    // ioremap BAR0 到内核虚拟地址空间
    dev->regs = ioremap(pci_resource_start(pdev, 0),
                        pci_resource_len(pdev, 0));
    if (!dev->regs) {
        ret = -ENOMEM;
        goto err_release;
    }

    // 读取设备 ID 寄存器验证映射
    u32 dev_id = ioread32(dev->regs + DEVICE_ID_OFFSET);
    dev_info(&pdev->dev, "Device ID: 0x%08x\n", dev_id);

    // 配置设备:先写控制寄存器,再写使能寄存器
    iowrite32(CFG_RESET, dev->regs + CTRL_OFFSET);
    wmb();  // 确保复位完成
    iowrite32(CFG_ENABLE, dev->regs + CTRL_OFFSET);

    return 0;

err_release:
    pci_release_region(pdev, 0);
err_disable:
    pci_disable_device(pdev);
err_free:
    kfree(dev);
    return ret;
}

static void my_pcie_remove(struct pci_dev *pdev)
{
    struct my_device *dev = pci_get_drvdata(pdev);

    iounmap(dev->regs);
    pci_release_region(pdev, 0);
    pci_disable_device(pdev);
    kfree(dev);
}

4.6 常见陷阱与调试建议

  • 未映射直接访问: 使用 ioremap 前不能直接使用 pci_resource_start() 返回的物理地址。物理地址必须经过映射才能被内核代码安全访问。
  • 屏障缺失导致设备挂死: 在 Doorbell 或 Trigger 寄存器写入前缺少 wmb(),设备可能读到过时的描述符数据。
  • 过度使用屏障: 每个寄存器访问都加屏障会严重降低性能。只在关键顺序点使用屏障,批量访问使用 relaxed 变体。
  • 调试工具: 使用 devmem 工具(用户态)或 ioread32 打印(内核态)验证寄存器值是否符合预期。
调试技巧: 在怀疑 MMIO 访问顺序问题时,可以在关键寄存器写入前后插入 ioread32() 读取同一个寄存器(Read-Back 技巧)。PCIe 的强排序模型保证读操作会等待之前所有写操作完成,从而隐式实现屏障效果。