4. SR-IOV 原理:PF、VF、配置空间与分配流程

我在做 NVMe SSD 虚拟化项目时,第一次接触 SR-IOV 就被它的硬件级虚拟化能力震撼了。你想,一个物理设备能直接给虚拟机“分裂”出多个独立的虚拟设备,而且每个虚拟设备都能绕过 Hypervisor 直接访问,性能几乎无损——这事儿在传统软件模拟时代根本不敢想。

SR-IOV 的全称是 Single Root I/O Virtualization,它是一套 PCIe 规范。嗯,这里要注意:它不是 NVMe 发明的,但 NVMe 是最适合 SR-IOV 的协议之一。因为 NVMe 的队列和中断机制天然支持多通道,跟 SR-IOV 的 VF 一一对应非常流畅。

那么 SR-IOV 到底怎么工作的?核心就三个概念:物理功能(PF)、虚拟功能(VF)、以及配置空间。我把它们拆开来讲。

4.1 物理功能(PF)与虚拟功能(VF)

PF 是全功能的 PCIe 设备,它拥有一切能力:配置管理、中断处理、DMA、错误恢复……说白了,它就是那张真实的 NVMe 控制器。PF 在系统中只有一个,偶尔厂商会做两个 PF 做冗余,但常见场景就一个。

VF 是 PF 通过硬件“裁剪”出来的轻量级设备。VF 没有完整的配置空间,只保留最基本的数据通路和 I/O 能力。每个 VF 都有一组独立的队列对、MSI-X 中断、Doorbell 寄存器。我见过的设计里,一个 PF 可以生成几十甚至上千个 VF,具体数量取决于硬件资源和厂商实现。

你想想看,如果把 PF 比作一栋大楼的物业管理处,VF 就是大楼里的一个个独立办公室。物业管理处能改整栋楼的结构、开闭水电总闸;而各个办公室只能开关自己房间的灯和空调。互不干扰,并且每个办公室都觉得自己独占了整栋楼。

关键理解: PF 负责管理,VF 负责干活。管理通道走 PF,数据传输走 VF。两者通过硬件隔离,软件层面互不知晓。
PF 与 VF 核心区别
特性 PF (Physical Function) VF (Virtual Function)
配置空间 完整 PCI 配置空间 + SR-IOV 扩展能力 精简配置空间(仅类型 1 + 必要的 BAR)
管理能力 可创建/销毁 VF,配置全局参数,执行 Firmware 升级 无管理能力,仅可发起 I/O
中断 所有 MSI-X 表项可用 分配独立的 MSI-X 表项(通常 4~16 个)
BAR 空间 所有 BAR 可见 仅暴露一个或多个 BAR 用于数据通路
生命周期 随物理设备存在 由 PF 动态创建/销毁

我个人习惯把 PF 到 VF 的映射想象成“复印机”——PF 是原稿,VF 是复印件。复印件的内容与原稿有关联,但每一份复印件都有自己的纸(队列、内存、中断线)。这也是为什么 VF 的配置空间必须“引用” PF 的某些字段。

4.2 配置空间——PF 的完整能力与 VF 的简化副本

PCIe 配置空间是设备暴露给主机的“身份证”。PF 的配置空间遵循 PCIe 规范,包含了 Vendor ID、Device ID、Class Code、BAR 等标准字段。但 SR-IOV 最特别的在于 SR-IOV 扩展能力结构(Capability Structure),这个结构位于 PF 配置空间的 Capability 链表中,偏移由 Capabilities Pointer 指向。

找扩展能力的方法: 从配置空间偏移 0x34 处读 Capabilities Pointer,然后遍历 Capability List,找到 Capability ID = 0x10(就是 SR-IOV 扩展能力)。我刚开始调试时忘了这步,直接去读固定偏移,死活读不到 VF 个数——嗯,那是走了弯路。

SR-IOV 扩展能力结构里定义了以下几个关键寄存器(我只列最重要的):

  • TotalVFs (偏移 0x20): 该 PF 最多能支持的 VF 总数,硬件写死的。比如某个 NVMe 控制器最多 64 个 VF。
  • NumVFs (偏移 0x24): 当前实际启用的 VF 数量。写这个寄存器就能创建 VF,注意必须先写 InitialVFs 再写 NumVFs。
  • VF BAR 基址 (偏移 0x28~0x38): 指定 VF 的 BAR 空间在 PCIe 地址空间中的位置。每个 VF 的 BAR 大小相同,系统会为所有 VF 分配一块连续地址。
  • Physical Function Dependency Link: 这个寄存器告诉软件当前 PF 与哪个物理端口关联,在多端口控制器上很有用。

VF 自己的配置空间就简单多了。它是一个 Type 1 配置头(没错,就是 PCIe 桥那种类型),包含了 Bus、Device、Function 编号,以及 Vendor ID(从 PF 复制)、Device ID(固定值,通常与 PF 不同)。VF 的 BAR 空间只有数据通路用的那几个,没有 SR-IOV 扩展能力——VF 不能生孩子。

我做过一个实验:在 Linux 下用 lspci -vvv 看 VF 的 Capabilities,除了 MSI-X 和 Power Management,其他基本没有。所以 VF 的初始化只靠 PF 驱动写入 VF BAR 和队列基址,然后 VF 自己就能跑了。

// 伪代码:读取 PF 的 SR-IOV 扩展能力中的 TotalVFs
uint16_t read_total_vfs(uint8_t bus, uint8_t dev, uint8_t func) {
    // 1. 读取 Capabilities Pointer
    uint8_t cap_ptr = pci_read8(bus, dev, func, 0x34);
    // 2. 遍历 Capability List
    while (cap_ptr != 0) {
        uint8_t cap_id = pci_read8(bus, dev, func, cap_ptr);
        if (cap_id == 0x10) { // SR-IOV
            // TotalVFs 在偏移 0x20 (相对于该 Cap 结构)
            return pci_read16(bus, dev, func, cap_ptr + 0x20);
        }
        cap_ptr = pci_read8(bus, dev, func, cap_ptr + 1);
    }
    return 0; // 不支持 SR-IOV
}

4.3 VF 分配流程——从 PF 创建到 Guest 使用

我记得第一次写 VF 分配脚本时,被“先写 InitialVFs 再写 NumVFs”的顺序坑过。有些厂商的硬件更严格:必须把 ARI Capable 等字段先设好,否则 VF 死活不出来。下面是标准的 VF 分配流程,我用步骤列清楚:

  1. 系统枚举 PF:BIOS/OS 发现 PCIe 设备,加载 PF 驱动。驱动读到 SR-IOV 扩展能力,知道 TotalVFs = 64。
  2. 分配总线/设备范围:根据 NumVFs 的大小,系统为未来的 VF 预留一段 PCIe 拓扑(通常 VF 分配在 PF 的下级总线上)。
  3. 设置 InitialVFs 寄存器:告诉硬件“我打算启用 N 个 VF”。很多控制器在这步就开始分配内部资源了。
  4. 设置 NumVFs 寄存器:写非零值,硬件真正创建 VF。这步执行完后,VF 的配置空间就变得可访问了(之前读不到)。
  5. 系统扫描到 VF:OS 枚举新出现的设备 Function(每个 VF 有独立的 BDF),为每个 VF 分配系统资源,比如 MSI-X 向量、中断号。
  6. VF 驱动加载:在宿主机上可以加载 vfio-pci 或原生 VF 驱动,然后将 VF 直通给虚拟机。
曾经遇到的坑: 某些 NVMe 控制器要求 VF 的 InitialVFs 必须等于 TotalVFs,否则 NumVFs 写入会被忽略。我调试了整整两天才在厂商的勘误表里发现这个 bug。所以当你发现 VF 总创建失败时,记得先查芯片的 errata。

图中我画了一个简化的 SR-IOV 结构流程,你看一眼就能记住:

SR-IOV 结构:PF 管理 VF,VF 直通 Guest 物理功能 (PF) 完整配置空间 SR-IOV Capability 管理 VF 生命周期 虚拟功能 0 (VF0) 队列对 + MSI-X 虚拟功能 1 (VF1) 队列对 + MSI-X ··· 最多 TotalVFs 个 配置空间分解 (PF 扩展能力结构) • TotalVFs (0x20): 硬件支持的 VF 最大数量 • NumVFs (0x24): 当前激活的 VF 数量 (写非零即创建) • VF BAR 基址 (0x28~0x38): VF 设备暴露的 MMIO 地址范围 • InitialVFs (0x1C): 预分配的内部资源数量 (某些硬件要求 == TotalVFs)

VF 分配完成后,虚拟机里的 Guest 看到的就是一个全新的 NVMe 控制器,有独立的 Vendor/Device ID。Guest 加载自己的 nvme 驱动,就可以正常读写。整个过程中 Hypervisor 不需要陷入 I/O 路径——这就是 SR-IOV 性能好的根本原因。

实用技巧: 在 Linux 上你可以用 echo N > /sys/bus/pci/devices/<PF BDF>/sriov_numvfs 快速创建 VF。但要注意,这个操作会直接写硬件的 NumVFs 寄存器,如果当前 VF 正在被使用,行为未定义。我习惯先在 PF 驱动的 sysfs 接口里确认状态,再写入。

另外,VF 创建后,它的 PCIe BDF 是动态分配的,每次主机重启或 VF 重建后可能变化。所以虚拟机配置里最好使用 <address type='pci' domain='0x0000' bus='0xXX' slot='0xXX' function='0x0'/> 静态指定,或者用 libvirt 的 <hostdev managed='yes'> 让 Hypervisor 自动绑定。

嗯,关于配置空间和分配流程,我就讲到这里。记住核心:PF 是接口,VF 是通道。理解了这个,SR-IOV 的其他细节——比如 VF BAR 地址如何分布、如何给每个 VF 分配独立的中断——就都能顺下来。