9.10 PCI 与外设总线架构
在个人计算机体系中,处理器与高速外设(如网卡、声卡、图形显示适配器、NVMe/SATA 硬盘控制器及 USB 主控制器)之间的大规模数据流转,均依赖外设总线系统连接。
早期计算机依赖 ISA、EISA 等老式总线,外设的 I/O 端口、中断引脚与 DMA 通道往往需要通过主板跳线手动指定,极易引发资源冲突。外设部件互连标准(Peripheral Component Interconnect,简称 PCI) 的确立,正式引入了标准化的总线枚举、即插即用(Plug and Play)与地址空间动态重定位机制,成为现代系统感知并驱动外部硬件的基础协议。
1. 总线拓扑与 PCIe 演进
随微处理器主频与外设吞吐需求的飞跃,传统的并行 PCI 总线逐渐面临信号反射、总线时钟偏斜(Clock Skew)以及所有设备共享总线带宽的物理瓶颈。
英特尔随后推出了第三代 I/O 总线技术 PCI Express(简称 PCIe):
- 物理层的串行跃迁:PCIe 在电气层面将共享式的并行总线彻底重塑为点对点(Point-to-Point)的双向全双工差分串行链路。各个设备独占通道(Lanes,如 x1、x4、x16),彻底消除了并行干扰;
- 软件视角的无缝兼容:虽然 PCIe 与传统 PCI 的电气特性截然不同,但系统软件所观察到的逻辑拓扑与配置空间(Configuration Space) 规范完全兼容。这意味着操作系统内核针对 PCI 编写的设备枚举、BAR 基址探测与状态寄存器解析代码,在 PCIe 设备上依然可以直接运转。
2. PCI 配置空间与 BDF 寻址
在 PCI 架构下,整机的所有外设通过“总线(Bus)- 设备(Device)- 功能(Function)”(简称 BDF)的三维坐标系进行唯一寻址:
- 总线号(Bus 0~255):系统中最多可存在 256 条逻辑总线;
- 设备号(Device 0~31):每条总线上最多连接 32 个物理设备;
- 功能号(Function 0~7):每个物理设备内部最多集成 8 个逻辑功能实体(即多功能设备 Multifunction Device)。
每个 PCI 功能实体均由硬件固化了一段 256 字节的标准配置空间(Configuration Space)(PCIe 扩展至 4096 字节)。在传统的 x86 架构中,CPU 通过芯片组预留的一对 32 位 I/O 端口来访问这一空间:
- 配置地址端口
0xCF8(CONFIG_ADDRESS):写入一个 32 位的寻址字,其中最高位为使能位(必须置 1),第 23~16 位为总线号,第 15~11 位为设备号,第 10~8 位为功能号,第 7~2 位为目标双字寄存器在配置空间内的偏移(字节偏移除以 4); - 配置数据端口
0xCFC(CONFIG_DATA):根据写入0xCF8的地址,向该端口读写具体的 32 位配置数据。
2.1 标准配置空间头部(Type 0 Header)
配置空间的前 16 字节对于所有常规设备具有标准统一的布局,内核借此完成设备自检与身份识别:
| 偏移(Offset) | 字段长度 | 字段含义 |
|---|---|---|
0x00 | 16 位 | 厂商号(Vendor ID):硬件厂商唯一标识。若读回全 1(0xFFFF),说明该 BDF 上无物理设备存在 |
0x02 | 16 位 | 设备号(Device ID):厂商赋予该型号芯片的专属产品代号 |
0x08 | 8 位 | 版本 ID(Revision ID):芯片硬件步进与改版标识 |
0x09 | 8 位 | 编程接口(Prog IF):进一步细化控制器所遵循的通用驱动规范(如 AHCI、UHCI/EHCI 等) |
0x0A | 8 位 | 子类别(Subclass):外设所属的细分类型(如 SATA 控制器) |
0x0B | 8 位 | 基类别(Base Class):外设所属的大分类(如 0x01 存储控制器、0x02 网络控制器、0x03 显示控制器) |
0x0E | 8 位 | 首部类型(Header Type):第 7 位为 1 表示该物理设备为多功能设备,软件需遍历其功能 1~7;若为 0 则可直接跳过后续功能探测 |
2.2 基址寄存器(BAR)与 MMIO 资源探测
在配置空间的 0x10 至 0x24 范围内,排列着 6 个 基址寄存器(Base Address Registers,简称 BAR)。BAR 是外设向操作系统声明其所需的物理内存(MMIO)或 I/O 端口地址空间大小的硬件中介:
- 类型判别:BAR 的最低位(bit 0)用于区分资源类型:为 1 表示 I/O 端口映射,为 0 表示内存映射(MMIO)。在 MMIO 模式下,bit 2~1 为
10时代表该 BAR 扩展为 64 位寻址,此时它与紧随其后的下一个 BAR 共同构成一个 64 位的物理地址基准。 - 硬件尺寸探测算法:操作系统为了探测某外设控制器究竟需要多大尺寸的内存映射区间,通常采用标准的写入反读算法:
- 临时禁用该设备在命令寄存器中的 I/O 与内存响应位,防止探测时发生虚假响应;
- 保存 BAR 的初始基址;
- 向目标 BAR 写入全 1(
0xFFFFFFFF); - 再次读出该 BAR 的数值:硬件内部会自动将自身不需要的低位地址线强制置 0,软件通过位屏蔽提取出为 0 的位数,执行按位取反加 1(
~(mask & ~0xF) + 1),即可计算出该 BAR 所要求的精确字节尺寸; - 探测完毕后,恢复设备的原有基地址与命令寄存器。
- MMIO 的页表映射属性:当内核为设备的 MMIO 寄存器(例如 AHCI 控制器的 ABAR)建立虚拟内存映射时,必须在页表项中显式关闭 CPU 的内部缓存(设置为不可缓存 Uncacheable 或写透模式)。如果允许 CPU 缓存 MMIO 区间,处理器的预取、写合并与延迟回写机制将导致硬件控制器的真实寄存器漏写,使驱动程序读取到过时的虚假硬件状态。
2.1 巩固
PCIe 用串行的点对点连接,PCI 用共享的并行总线,两者的电气形式完全不同,可软件看到的配置空间却是同一套布局。
(1) 说明配置空间保持不变对操作系统意味着什么:驱动里「按偏移读配置空间」的那部分代码,从 PCI 设备迁到 PCIe 设备上需要改吗?改的是哪一部分?
(2) 配置空间用一对 I/O 端口访问:先把 32 位地址写到 0xCF8,再从 0xCFC 读写一个 32 位字。地址格式如下,位号从 0 数起:
- 第 31 位:使能位,必须为 1;
- 第 23~16 位:总线号;
- 第 15~11 位:设备号;
- 第 10~8 位:功能号;
- 第 7~2 位:寄存器号,以 4 字节为单位,即字节偏移除以 4;
- 第 1、0 位:固定为 0。
写出 uint32_t pci_cfg_addr(unsigned bus, unsigned dev, unsigned func, unsigned off),off 以字节计。
(3) 用 main 按 %08x 打印下面四组输入的结果,与右列核对:
(0, 0, 0, 0x00) 80000000
(0, 2, 0, 0x10) 80001004
(0, 0x1f, 7, 0x3c) 8000ff0f
(0xff, 0, 0, 0x08) 80ff00022
3
4
(4) off 不是 4 的倍数时,这个地址指向哪个寄存器?函数应当怎么处理这种输入?
2.2 编程
枚举总线上的功能。配置空间的前 16 个字节存放标识信息:
| 偏移 | 宽度 | 内容 |
|---|---|---|
| 0x00 | 16 位 | 厂商号,读回 0xFFFF 表示该功能不存在 |
| 0x02 | 16 位 | 设备号 |
| 0x09 | 8 位 | 编程接口 |
| 0x0A | 8 位 | 子类 |
| 0x0B | 8 位 | 基类 |
| 0x0E | 8 位 | 首部类型,第 7 位为 1 表示该设备是多功能设备 |
(1) 写 pci_scan(void (*visit)(unsigned bus, unsigned dev, unsigned func)),遍历 0~255 号总线、0~31 号设备、0~7 号功能,只对厂商号不是 0xFFFF 的功能调用 visit。
(2) 功能 0 的首部类型第 7 位为 0 时,跳过该设备其余 7 个功能。说明这样能省掉多少配置读,以及这个优化在什么情况下会漏掉设备。
(3) 用 visit 打印 bus:dev.func vendor:device class/subclass。
(4) 在 QEMU 中启动你的内核(命令行里 -device e1000 或默认的 -nic model=e1000),确认输出里有 8086:100e,基类为 0x02。再与宿主机的 lspci -n 对照,说明两边设备表为什么不同。
(5) 只算读厂商号这一步,按最坏情况(每个设备都多功能)要读多少个配置字?用第 (2) 条的优化之后,单功能设备占多数时大约是多少?说明两个数字的倍数关系从哪里来。
确定基址寄存器 (Base Address Register, BAR) 的大小。配置空间偏移 0x10~0x24 是 6 个 BAR,每个 32 位。做法是向 BAR 写入全 1,再读回:能保持为 1 的位不可写,读回 0 的位属于地址空间,据此算出大小。存储器 BAR 的低 4 位是标志,其中位 0 为 0、位 2~1 为 10 时表示 64 位 BAR,此时高 32 位占用下一个 BAR 的位置;I/O BAR 的位 0 为 1,低 2 位是标志。探测前要保存原值,探测后写回。
实现下面的接口并写一个 main,对指定功能打印 6 个 BAR 的类型、基址和大小:
struct pci_bar {
int is_io;
int is_64;
uint64_t base;
uint64_t size;
};
int pci_bar_probe(unsigned bus, unsigned dev, unsigned func, unsigned index,
struct pci_bar *out);2
3
4
5
6
7
8
9
要求:
(1) 大小为 0 表示该 BAR 未实现,函数返回 0;index 指向 64 位 BAR 的低半部分时,返回 1 并跳过下一个 BAR。
(2) 探测前后各读一次 BAR,确认写回后原值不变。
(3) 在 QEMU 的 e1000 设备上验收:BAR0 是存储器 BAR,大小 128 KiB;BAR1 是 I/O BAR,大小 64 字节。核对办法是在 QEMU 监视器里执行 info pci,它会列出每个设备的 BAR 与大小。
(4) 探测时先清掉命令寄存器(偏移 0x04)的位 1 和位 0,探测完再恢复。说明不清这两位时,写入全 1 可能对正在工作的设备造成什么后果。
(5) 说明为什么读回值里为 0 的位表示「这一段地址由本 BAR 占用」,据此算出的 (~(mask & ~0xF) + 1) 为什么一定是 2 的幂。mask 里第 0~3 位是标志位,必须先用 & ~0xF 清掉,否则算出来的大小会多出几十字节。
2.3 综合与挑战
找到 AHCI 控制器并读出它的寄存器区。AHCI 控制器的基类为 0x01(大容量存储控制器)、子类为 0x06(SATA)、编程接口为 0x01(AHCI 1.0)。它的 BAR5 是 ABAR(AHCI Base Address Register),指向一段存储器映射的寄存器区。
要求:
(1) 在 pci_scan 的基础上加过滤,打印所有匹配的功能号,并取出其 BAR5 的基址与大小。
(2) 把 ABAR 映射到内核地址空间,按 32 位读偏移 0x00 的 CAP 与偏移 0x10 的 VS。CAP 的低 5 位加 1 是端口数量,VS 的高 16 位是主版本号,低 16 位是次版本号。在 QEMU 的 -device ich9-ahci 上验收:端口数量不少于你挂上去的磁盘数,主版本号为 1。
(3) 取到的 BAR5 是物理地址。说明为什么不能直接把它当虚拟地址解引用(除非这段已经恒等映射),以及映射时必须选不可缓存、不可写合并的属性。提示:如果 CPU 把两次相邻的寄存器写合并成一次,或者把一次读的结果留在缓存里,控制器会漏掉写、状态位会读到旧值。
(4) 区分 BAR5 与 9.11 里的 AHCI_BASE:前者是控制器寄存器区的物理地址,后者是驱动自己放命令表的内存起始地址。分别给出取得这两个地址的代码,并说明为什么前者必须从配置空间读,后者可以由驱动自己挑。
(5) CAP 的第 31 位是 S64A,表示控制器能否处理 64 位物理地址。读出这一位,并说明它为 0 时,9.11 里把 ctbau 写成高 32 位的做法会有什么问题。
(6) CAP 的位 12~8 是 NCS,存的是命令槽数减一;位 4~0 是 NP,存的是端口数减一。用同一个函数把这两个数解出来,并在 QEMU 上核对:NCS + 1 应当不小于 1,NP + 1 应当等于 ABAR 之后那段寄存器区里能看到的端口数。