配置空间基础

配置空间,说白了就是 PCIe 设备的「身份证」加「控制面板」。每个设备都有一块这样的空间,主机通过读写它来发现设备、分配资源、启动运行。我刚开始接触 PCIe 驱动时,第一件事就是搞懂这个空间的结构。

你可能会问:为什么需要配置空间?因为 PCIe 是枚举总线。系统启动时,软件根本不知道有哪些设备,它必须通过一套标准机制去扫描。这个机制,就建立在配置空间之上。

核心要点:配置空间是 PCI/PCIe 设备与软件通信的「合同」。双方都遵守这个合同,才能正常协作。

配置空间结构

PCI 规范定义了 256 字节的配置空间。PCIe 扩展到了 4KB,但前 256 字节完全兼容 PCI。前 64 字节是标准头部,分为两种类型:Type 0 和 Type 1。

  • Type 0(类型 0):用于非桥设备——也就是我们常见的网卡、显卡、NVMe SSD。
  • Type 1(类型 1):用于桥设备——主要是 PCIe 交换机(Switch)和根端口(Root Port)。

这两种头部的区别在哪?简单说,Type 0 有 6 个 Base Address Register(BAR),用于设备自身地址映射。Type 1 有 2 个 BAR,剩下的是桥专用的:Primary Bus Number、Secondary Bus Number、Subordinate Bus Number,还有 Memory Base/Limit 和 I/O Base/Limit。这些桥专用寄存器用来管理下游总线的地址范围。

我曾经遇到过一个坑:某块定制板卡,写 Type 1 配置空间的 Secondary Bus Number 时总是不生效。查了好几天的硬件文档才发现——该桥的 Secondary Bus Number 寄存器的写使能位默认是关闭的,必须先设置一个配置锁定位才能写入。嗯,这里要注意:桥设备的配置空间通常有保护机制。

偏移 Type 0 字段 Type 1 字段
0x00–0x01 Vendor ID Vendor ID
0x02–0x03 Device ID Device ID
0x04–0x05 Command Register Command Register
0x06–0x07 Status Register Status Register
0x08–0x0B Revision ID + Class Code Revision ID + Class Code
0x10–0x27 BAR 0–5 BAR 0–1
0x18–0x1B — Primary/Secondary/Subordinate Bus Number
0x1C–0x1D — I/O Base/Limit
0x20–0x21 — Memory Base/Limit
0x2C–0x2F Subsystem Vendor/Device ID Subsystem Vendor/Device ID
0x34 Capabilities Pointer Capabilities Pointer
0x3C–0x3F Interrupt fields Interrupt fields

个人技巧:我习惯在调试板上先读 Vendor ID 和 Device ID。如果读到 0xFFFF,说明设备不存在或者配置访问有问题。这是最快速的 PCIe 设备存在性检测方法。

配置空间读取方法

读取配置空间有两种经典方法:CF8/CFC 和 MMCONFIG。你想想看,前者是 x86 平台的老办法,后者是 PCIe 引入的新机制。两者并存,但思路完全不同。

CF8/CFC 方法

CF8/CFC 利用两个 I/O 端口:

  • CF8h(地址端口):写入总线号、设备号、功能号、寄存器偏移。
  • CFCh(数据端口):读出或写入配置数据。

流程是这样的:写地址到 CF8,然后从 CF8+4 即 CFCh 读写数据。一次只能访问 4 字节。

// CF8/CFC 读取示例
unsigned int pci_conf_read(unsigned char bus, unsigned char dev,
                           unsigned char func, unsigned char offset)
{
    unsigned int address;
    unsigned int data;

    // 构造地址:Enable位(31) | 保留位 | 总线号 | 设备号 | 功能号 | 寄存器偏移
    address = (1 << 31) |
              (bus << 16) |
              ((dev & 0x1F) << 11) |
              ((func & 0x07) << 8) |
              (offset & 0xFC);  // 4字节对齐

    // 写地址到 CF8
    outl(address, 0xCF8);

    // 从 CFC 读数据
    data = inl(0xCFC);

    return data;
}

注意:CF8/CFC 是 x86 特有的。ARM、RISC-V 没有这个端口机制,只能用 MMCONFIG。如果你要写跨平台驱动,我会建议直接使用 MMCONFIG。

MMCONFIG 方法

MMCONFIG 把配置空间映射到内存地址空间。每一段配置空间对应一段物理地址。系统软件只需像访问普通内存一样读写即可。

PCIe 规范规定:每个设备的配置空间占用 4KB(256 字节 * 16 功能)。一段总线最多挂 32 个设备,每个设备 8 个功能。所以一段总线需要 4KB * 32 * 8 = 1MB 内存映射空间。

映射公式很简单:

// MMCONFIG 地址计算
#define MMCONFIG_BASE  0xE0000000  // 假设基地址

void *pci_get_mmconfig_addr(unsigned int bus, unsigned int dev,
                            unsigned int func, unsigned int offset)
{
    unsigned long addr;

    addr = MMCONFIG_BASE +
           (bus << 20) |       // 每段总线 1MB
           (dev << 15) |       // 每个设备 32KB
           (func << 12) |      // 每个功能 4KB
           (offset & 0xFFF);   // 偏移

    return (void *)addr;
}

// 读取示例
unsigned int pci_conf_read_mmconfig(unsigned int bus, unsigned int dev,
                                    unsigned int func, unsigned int offset)
{
    volatile unsigned int *addr;

    addr = (volatile unsigned int *)pci_get_mmconfig_addr(bus, dev, func, offset);
    return *addr;
}

我个人更喜欢 MMCONFIG——它更快,没有 I/O 端口保护模式的切换开销。在 Linux 内核中,PCIe 核心驱动优先使用 MMCONFIG,只有不支持时才回退到 CF8/CFC。

特性 CF8/CFC MMCONFIG
访问速度 慢(I/O 端口操作) 快(内存访问)
平台依赖 x86 特有 跨平台(ACPI 枚举)
地址空间 256 字节/设备 4KB/设备(支持扩展)
原子性 端口锁竞争 支持原子访问

为了让你更直观地理解整个配置空间的体系,我画了一张结构图:

PCIe 配置空间体系结构 CF8/CFC 方法 ① 写地址 → I/O 端口 0xCF8 ② 读/写数据 → I/O 端口 0xCFC ③ 一次访问 4 字节 ④ 需要端口权限 仅 x86 平台 配置空间头部 Type 0(非桥设备) - 6 个 BAR(32/64 位) - Capabilities Pointer - Interrupt 字段 Type 1(桥设备) - 2 个 BAR - Bus Number 寄存器 - Memory/I/O Base/Limit 前 64 字节标准头部 + 192 字节扩展空间 + PCIe 扩展(4KB) MMCONFIG 方法 ① 计算映射地址 ② 直接内存访问 ③ 支持 4KB 扩展空间 ④ 跨平台兼容 更快,推荐使用

看这张图,左侧是 CF8/CFC 的串行访问流程,中间是配置空间的头部细节,右侧是 MMCONFIG 的直接映射方式。两种方法最终都指向同一个目标:读写那个 256 字节(或 4KB)的配置空间。

实际开发中,我建议你在驱动初始化时先检测 MMCONFIG 是否可用。如果可用,就用它;否则回退到 CF8/CFC。Linux 内核的 pci_mmcfg_init() 就是这么干的。