PCIe 基础:拓扑、配置空间、BAR 与中断

要搞定 NVMe 驱动,得先把 PCIe 摸熟。NVMe 设备本质上就是一个 PCIe 端点(Endpoint),挂在系统总线上。你写驱动时的第一步——探测设备、分配资源、注册中断——全得跟 PCIe 打交道。我不止一次看到新手拿着 datasheet 翻 BAR 寄存器,却连设备在树上挂在哪都不知道。我习惯先理清框架,再抠细节。

1. PCIe 拓扑:一棵树,三个角色

PCIe 的拓扑结构是树形的。根节点叫 Root Complex(RC),往下接 Switch,Switch 再挂各种 Endpoint(EP)。RC 通常集成在 CPU 里,负责和内存、南桥通信。Switch 说白了就是一颗扩展芯片,能把一个上行口拆成多个下行口。EP 是我们的 NVMe SSD,一个物理设备可以包含多个 Function(比如 NVMe 控制器本身 + 管理功能)。

为什么要关心拓扑?因为你访问设备配置空间之前,必须知道它的 BDF(Bus:Device.Function)。我见过一次奇怪的问题:一台机器插了两块 NVMe,系统只认了一块。最后发现是 BIOS 在 Switch 下枚举时只给了一块分配了 Bus Number——嗯,那是另一回事。这里记住一点:每个 EP 都有独立的 BDF,驱动通过 BDF 索引设备。

核心概念: PCIe 的链接是点对点的,不像 PCI 共享总线。这也意味着配置空间访问是通过路由事务完成的。

2. 配置空间:驱动注册的门票

每个 PCIe Function 都有 4KB 的配置空间。但操作系统一般只暴露前 256B(PCI 兼容区域),剩下的 PCIe 扩展空间(256~4095)需要用特定的 MMIO 方式访问,比如通过 ECAM(Enhanced Configuration Access Mechanism)。

配置空间的结构很清晰:前 64B 是 PCI 标准 Header,包含 Vendor ID、Device ID、Command、Status、Class Code 等。你用 lspci -x 看到的原始字节就在这里。如果 Vendor ID 不是 0xFFFF,就说明设备存在。

我自己的习惯:在 probe 中第一件事就是读 Vendor ID 确认设备响应。曾经踩过一个坑:某个平台 PCIe 链路训练没完成,读回来的 Vendor ID 全为 FFFF,我以为是驱动没加载——其实是硬件没准备好。所以在访问配置空间前,最好先确认链路状态(Link Status 寄存器)。

一个小技巧: 使用 pci_read_config_byte/word/dword 函数族。但如果要读 4KB 扩展空间,大部分内核版本建议用 pci_read_config 系列配合 PCI_DEVICE_ID 遍历,或者直接用 sysfs 的 config 接口。

3. BAR 空间:CPU 怎么跟 NVMe 通信?

BAR(Base Address Register)就是 PCIe 设备向系统请求的 MMIO 或 IO 空间。NVMe 控制器通常只占 1~2 个 BAR,且绝大多数使用 Memory Space(IO space 已经快绝迹了)。BAR 的宽度可以是 32 位或 64 位,NVMe 规范推荐使用 64 位 BAR 避免地址空间紧张。

读 BAR 之前,记得把 Command 寄存器的 Memory Space Enable 和 Bus Master Enable 置位。我刚开始写驱动时忘了这一步,读出来的 BAR 地址全是 0,折腾了大半天才发现是 command 没配置好。

BAR 的大小怎么确定?系统会向 BAR 写入全 1 然后读回,被硬件 Mask 掉的不为 1 的位就是 BAR 实际占用的地址范围。比如 NVMe 的 BAR 大小一般是 8KB(有的实现是 4KB)。驱动里用 pci_ioremap_bar 把物理地址映射到虚拟地址空间,然后就能直接读写寄存器了。

BAR 属性NVMe 典型值说明
Memory/IO 类型Memory(0x0)NVMe 只使用 MMIO
Prefetchable通常否寄存器空间不可预取
地址宽度64 位避免 32 位地址空间耗尽
大小8KB / 4KB包含队列寄存器、Doorbell 等

4. MSI/MSI-X 中断:告别 IRQ 共享

NVMe 原生支持 MSI-X,这是最推荐的中断方式。MSI(Message Signaled Interrupt)和 MSI-X 的核心区别:MSI 最多 32 个中断向量(且必须连续),MSI-X 最多 2048 个(可以稀疏)。NVMe 驱动需要每条队列一个独立中断,队列数动辄几十上百,所以必须用 MSI-X。

配置 MSI-X 的流程很简单:

  • 调用 pci_alloc_irq_vectors 申请向量数。
  • 系统返回可用的向量列表,每个向量对应一个硬件中断号。
  • 驱动将每个向量绑定到某个队列的完成中断。

我曾经犯过一个低级错误:申请了 64 个 MSI-X 向量,却只初始化了 32 个队列。结果是后 32 个队列一直得不到中断处理,IO 超时。后来我养成了习惯:request_irq 的数量必须和申请的向量数严格匹配,不要偷懒用循环+计数。

避坑指南: 使用 MSI-X 时,要确保设备端开启了 MSI-X Enable 位(配置空间 Command 寄存器 + 0x10 偏移)。另外,MSI-X 的 Table 和 PBA(Pending Bit Array)通常放在 BAR 空间内,不要和普通寄存器访问搞混。

5. 一张图理清 PCIe 与 NVMe 的关系

下面这张 SVG 图把 PCIe 拓扑、配置空间、BAR 和中断都串在一起。你可以直观看到 Root Complex、Switch、NVMe EP 之间的结构,以及每个 EP 内部配置空间和 BAR 的对应关系。

PCIe 拓扑与 NVMe 核心要素 Root Complex (RC) PCIe Switch Endpoint 1 NVMe SSD Endpoint 2 GPU / NIC 设备配置空间(4KB) • BDF 定位:Bus:Device.Function • 前 256B 包含 Vendor/Device ID、Class • 扩展空间 256~4095 用于 PCIe Capability • 访问方式:PIO (传统) 或 ECAM (推荐) • 常用 Capability: MSI, MSI-X, AER, PM BAR 空间 & 中断 • BAR 0/1 通常映射 NVMe 寄存器(Doorbell) • 64 位 BAR 避免地址空间紧张 • MSI-X:可多达 2048 个中断向量 • 每条 NVMe 队列绑定一个独立 MSI-X 向量 • 注册前确认 MSI-X Enable 位已置位

6. MSI 与 MSI-X 对比

选哪种中断方案?绝大多数情况用 MSI-X。下表一清二楚。

特性MSIMSI-X
最大向量数32(限制严格)2048(可扩展)
向量连续性必须连续可稀疏分配
Table 位置在配置空间固定偏移自行指定 BAR+偏移
NVMe 推荐仅用于旧平台官方推荐
驱动配置复杂度低稍高(需读写 PBA)
我的经验: 写 NVMe 驱动时直接用 pci_alloc_irq_vectors(dev, 1, num_queues, PCI_IRQ_MSIX)。系统会自动回退到 MSI 或 INTx,但你不想那样。顺便一提,有些主板的 MSI-X 表放在了 BAR1 里而不是 BAR0,别想当然。

总结一下

这一趟走下来,你应该能画出 PCIe 拓扑,知道怎么读配置空间和 BAR,也清楚 MSI-X 为什么是 NVMe 的最佳搭档。驱动 probe 函数背后的逻辑其实就是这些:找到设备、获取资源、注册中断。下次碰到设备不工作,按这个链条检查——拓扑通了没?配置空间读对了没?BAR 映射了没?中断来了么?