PCIe 配置空间与地址映射:配置寄存器、BAR 空间、Memory/IO 空间
为什么必须搞懂配置空间?
我刚开始做 PCIe 驱动时,觉得配置空间就是一堆寄存器,读读 Vendor ID 就完事了。结果第一次调 DMA,设备死活不响应,才发现 BAR 地址根本没配对。嗯,从那以后我养成了一个习惯——先完整地 dump 一遍配置空间,再开始写驱动。
说白了,配置空间是 CPU 与 PCIe 设备沟通的第一道门。系统启动时,BIOS 或操作系统通过配置空间枚举设备、分配地址、设置中断。驱动开发者要做的,就是读懂这些寄存器,然后让设备正确映射到系统地址空间。
配置寄存器的关键字段
我个人习惯把配置空间分成三个区域去看:
- 头部区域(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 就够。
这里列一下我每次必看的几个寄存器:
| 偏移 | 名称 | 作用 |
|---|---|---|
| 0x00 | Vendor ID / Device ID | 识别设备身份,每个 Vendor 有唯一 ID |
| 0x04 | Command / Status | 控制 I/O、Memory、Bus Master 等使能 |
| 0x08 | Class Code / Revision ID | 设备类型(网卡、存储、桥…) |
| 0x10–0x24 | BAR0 – BAR5 | 设备请求的地址空间大小和类型 |
| 0x2C | Subsystem Vendor/Device ID | 板卡级标识,辅助驱动匹配 |
| 0x34 | Capabilities 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 个,后期加了额外功能很被动。
ioremap_wc() 才解决。所以,务必确认预取属性,不要想当然。
地址映射的完整流程
系统上电后,PCIe 枚举器(BIOS 或 Linux 内核)会做这几步:
- 扫描 Bus 0,发现设备,读取每个 Function 的配置空间。
- 对每个 BAR,写入全 1 并回读,计算出设备要求的地址区间大小和对齐。
- 在系统物理地址域中寻找一片连续的未用空间,将基址写入 BAR。
- 使能 Memory/IO 访问(Command 寄存器的 Bit 0 和 Bit 1)。
- 将分配好的物理地址记录到内核资源表,驱动通过
pci_iomap()或pci_resource_start()获取。
下面这张图概括了配置空间到物理地址的映射关系:
驱动开发中的实践要点
当你拿到了板子,第一步不是写 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 等标志。
再说说 Memory 映射的坑
很多设备提供门铃(Doorbell)寄存器,通常放在 BAR0 中。这个寄存器只能写不能读,而且写操作会触发硬件动作。如果驱动用 Memory 映射方式访问,没问题。但如果你图省事用了 readl() 去读它,返回的值是未定义的——我见过有的芯片读回0,有的读回全1。正确的做法是只用 writel(),并且不要校验返回值。
另外,IO 空间虽然淘汰了,但有些老设备或特殊嵌入式平台还在用。如果你遇到 pci_resource_flags 返回 IORESOURCE_IO,千万别直接用 ioremap,得用 ioport_map 或者通过 inb()/outb() 操作。