配置空间基础
配置空间,说白了就是 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/设备(支持扩展) |
| 原子性 | 端口锁竞争 | 支持原子访问 |
为了让你更直观地理解整个配置空间的体系,我画了一张结构图:
看这张图,左侧是 CF8/CFC 的串行访问流程,中间是配置空间的头部细节,右侧是 MMCONFIG 的直接映射方式。两种方法最终都指向同一个目标:读写那个 256 字节(或 4KB)的配置空间。
实际开发中,我建议你在驱动初始化时先检测 MMCONFIG 是否可用。如果可用,就用它;否则回退到 CF8/CFC。Linux 内核的 pci_mmcfg_init() 就是这么干的。