PCIe 配置空间与地址映射:配置寄存器、BAR 空间、Memory/IO 空间

为什么必须搞懂配置空间?

我刚开始做 PCIe 驱动时,觉得配置空间就是一堆寄存器,读读 Vendor ID 就完事了。结果第一次调 DMA,设备死活不响应,才发现 BAR 地址根本没配对。嗯,从那以后我养成了一个习惯——先完整地 dump 一遍配置空间,再开始写驱动。

说白了,配置空间是 CPU 与 PCIe 设备沟通的第一道门。系统启动时,BIOS 或操作系统通过配置空间枚举设备、分配地址、设置中断。驱动开发者要做的,就是读懂这些寄存器,然后让设备正确映射到系统地址空间。

核心要点:配置空间共 256 字节(Type 0)/ 4096 字节(Type 1),其中前 64 字节是标准头部,后 192 字节是设备专用。每个 PCIe 设备至少实现一个 Function,每个 Function 有一套独立的配置空间。

配置寄存器的关键字段

我个人习惯把配置空间分成三个区域去看:

  • 头部区域(0x00–0x3F):Vendor ID、Device ID、Command/Status、Class Code、BAR 等。
  • Capabilities 区域(0x40–0xFF):Power Management、MSI/MSI-X、PCIe Capability 等。
  • 扩展配置空间(Type 1 才有):桥片用的,我们普通端点设备 Type 0 就够。

这里列一下我每次必看的几个寄存器:

偏移名称作用
0x00Vendor ID / Device ID识别设备身份,每个 Vendor 有唯一 ID
0x04Command / Status控制 I/O、Memory、Bus Master 等使能
0x08Class Code / Revision ID设备类型(网卡、存储、桥…)
0x10–0x24BAR0 – BAR5设备请求的地址空间大小和类型
0x2CSubsystem Vendor/Device ID板卡级标识,辅助驱动匹配
0x34Capabilities Pointer指向 Capabilities 链表的首地址

BAR 空间——地址映射的核心

你想想看,设备要跟 CPU 交换数据,总得有个“门牌号”。这个门牌号就是 BAR(Base Address Register)。每个 BAR 可以请求一块 Memory 空间或者 I/O 空间,系统会分配一个物理地址,然后把这块空间映射到设备上。

BAR 的格式很讲究:

  • Bit 0:指示类型——0 表示 Memory 空间,1 表示 I/O 空间(现在几乎不用 I/O BAR 了,基本都是 Memory)。
  • Bit 2–1(Memory 时):地址宽度——00 表示 32 位,10 表示 64 位(需要占用两个连续的 BAR)。
  • Bit 3(Memory 时):预取属性——1 表示可预取,允许 CPU 缓存读取;0 表示不可预取,必须按序访问。
  • 高比特位:由硬件实现决定 BAR 映射的空间大小。驱动通过往 BAR 写全 1 再读回的经典方法,可以探测出设备实际需要的地址范围。

举个探测的例子(伪代码):

uint32_t bar_orig = pci_read_config_dword(dev, base + offset);
pci_write_config_dword(dev, base + offset, 0xFFFFFFFF);
uint32_t bar_mask = pci_read_config_dword(dev, base + offset);
pci_write_config_dword(dev, base + offset, bar_orig);
// bar_mask 的低位被硬件清 0,表示地址位宽;高位保留 1 的 bit 就是实际映射的大小。
uint32_t size = ~(bar_mask & 0xFFFFFFF0) + 1;

我在项目中就遇到过这种情况:一个设备的 BAR0 是 64 位不可预取 Memory,结果我的驱动代码只判断了 Bit 0 为 0,没有检查 Bit 2-1,直接把 BAR0 当 32 位处理,导致高地址被截断。数据写到一半就崩了……

Memory 空间 vs I/O 空间

现在的 x86 平台上,I/O 空间基本被淘汰了。为什么?因为 I/O 空间只有 64KB(一个 BAR 最多 256 字节),而且访问需要通过 IN/OUT 指令,性能差。Memory 空间则直接映射到系统物理地址域,CPU 可以用 mov、load/store 操作,DMA 引擎也能直接访问。

我个人开发过的 PCIe 设备,所有 BAR 全用 Memory 空间。既简单又高效。不过有几个点要注意:

  • 如果 BAR 设置成不可预取,驱动中访问这些内存时一定要使用 ioremap_nocache() 或 devm_ioremap_resource(),不能简单用 ioremap(),否则可能遇到乱序或缓冲问题。
  • 64 位 BAR 需要占用两个连续的 BAR 槽位(比如 BAR0+BAR1、BAR2+BAR3),资源紧张时可能会分配失败。我在一次 FPGA 板卡上就因为 BAR0 用了 64 位,导致可用 BAR 只剩 2 个,后期加了额外功能很被动。
避坑指南:我曾经在一个项目中,芯片手册说 BAR0 是 32 位 Memory,但写全 1 读回后发现 Bit 3 是 1(可预取)。开始没在意,结果 CPU 读 BAR0 地址总是返回以前缓存的数据,导致状态机错乱。后来加上 ioremap_wc() 才解决。所以,务必确认预取属性,不要想当然。

地址映射的完整流程

系统上电后,PCIe 枚举器(BIOS 或 Linux 内核)会做这几步:

  1. 扫描 Bus 0,发现设备,读取每个 Function 的配置空间。
  2. 对每个 BAR,写入全 1 并回读,计算出设备要求的地址区间大小和对齐。
  3. 在系统物理地址域中寻找一片连续的未用空间,将基址写入 BAR。
  4. 使能 Memory/IO 访问(Command 寄存器的 Bit 0 和 Bit 1)。
  5. 将分配好的物理地址记录到内核资源表,驱动通过 pci_iomap() 或 pci_resource_start() 获取。

下面这张图概括了配置空间到物理地址的映射关系:

PCIe 配置空间 → 系统地址映射 配置空间 Vendor/Device ID Command/Status BAR0 ~ BAR5 Capabilities 指针 扩展配置空间 BAR 寄存器 类型 / 地址 / 大小 预取属性 系统地址空间 物理地址区域 (Memory 或 I/O) 例如:0xA0000000 ~ 0xA000FFFF 读取 映射 系统写入 BAR = 分配的基址 驱动通过 pci_resource_start() 获取 探测后 关键点: • 每个 BAR 都有独立基址,系统负责分配不重叠区间。 • 64-bit BAR 占用两个连续的 BAR 寄存器,只有第一个写入基址。 • 驱动访问 BAR 区域时,务必使用 ioremap 或 MMIO 函数。

驱动开发中的实践要点

当你拿到了板子,第一步不是写 DMA,而是枚举它。Linux 下用 lspci -vvv 就能看到完整的配置空间内容。我习惯把 BAR 地址、大小、类型和 Flags 全部记下来,对照芯片手册确认。

然后就是驱动里的资源申请:

struct pci_dev *pdev = ...;
u32 bar_mask = 0;
int bar;

for (bar = 0; bar < 6; bar++) {
    unsigned long start = pci_resource_start(pdev, bar);
    unsigned long len   = pci_resource_len(pdev, bar);
    unsigned long flags = pci_resource_flags(pdev, bar);

    if (start == 0 || len == 0) continue;  // BAR 未使用

    if (flags & IORESOURCE_MEM) {
        // 分配 ioremap 区域
        void __iomem *addr = pci_iomap(pdev, bar, 0);
        if (!addr) dev_err(&pdev->dev, "BAR%d iomap failed\n", bar);
        // 保存 addr 供后续操作
    } else if (flags & IORESOURCE_IO) {
        // I/O 空间 —— 基本不会遇到,遇到了小心处理
    }
}

注意 pci_iomap() 内部会自动根据预取属性决定使用哪种映射方式。但如果你的硬件要求特殊的 Cache 策略,最好还是手动调用 devm_ioremap_resource() 并指定 IORESOURCE_CACHEABLE 等标志。

严重警告:永远不要在中断上下文里重新探测 BAR 的大小!我以前在一个板卡的 ISR 中为了热插拔需要,直接往 BAR 写全 1,结果导致 PCIe 总线事务超时,整个机器卡死。探测 BAR 只能在系统初始化阶段完成。

再说说 Memory 映射的坑

很多设备提供门铃(Doorbell)寄存器,通常放在 BAR0 中。这个寄存器只能写不能读,而且写操作会触发硬件动作。如果驱动用 Memory 映射方式访问,没问题。但如果你图省事用了 readl() 去读它,返回的值是未定义的——我见过有的芯片读回0,有的读回全1。正确的做法是只用 writel(),并且不要校验返回值。

另外,IO 空间虽然淘汰了,但有些老设备或特殊嵌入式平台还在用。如果你遇到 pci_resource_flags 返回 IORESOURCE_IO,千万别直接用 ioremap,得用 ioport_map 或者通过 inb()/outb() 操作。