起因

自研的 GD32F4+FPGA 运动控制卡和 NVMe 固态盘都挂在 PCIe 总线上,用之前得先把它们找出来。PCIe 本身就是"被逼出来"的总线:1992 年的 PCI 是共享并行总线,设备一多频率就上不去,2003 年 PCI-SIG 用串行点对点加交换结构把它整个重做——今天枚举的这棵树就是那场重做的遗产。枚举用的两件工具也各有来历:老配置空间访问走 CF8/CFC 端口,天生 256 条总线的天花板,PCI 固件规范为此补了内存映射的 ECAM;MSI 是 PCI 2.2 引入的消息式中断,设备写一笔内存就算发中断,比拉物理中断线省引脚也好扩展。这篇做 PCIe 枚举:从 MCFG 拿 ECAM 基址、深度优先走拓扑、探测 BAR、使能设备、配好 MSI。枚举产物是一张设备清单,后面 NVMe 驱动和运动控制卡驱动都从这里起步。

一、配置空间怎么访问:ECAM

现代 UEFI 平台走 ECAM(内存映射配置空间)。基址不是猜的——从 ACPI MCFG 表读:移交的 RSDP → XSDT → 找签名 "MCFG" 的表,表体是若干条目,每条给 (段号, 起始总线, 结束总线, 基址)

ECAM 的地址拼法一句话记牢:

1
2
3
4
fn cfg_addr(base: u64, bus: u8, dev: u8, func: u8, off: u8) -> u64 {
    base | ((bus as u64) << 20) | ((dev as u64) << 15)
        | ((func as u64) << 12) | (off as u64 & 0xFFC)
}

每个设备功能分到 4KB 的配置空间窗口。配置空间常用偏移:

偏移 字段 说明
0x00 Vendor ID(16 位) 0xFFFF = 空槽,枚举的判据
0x02 Device ID(16 位) 具体型号;自研卡的 Vendor/Device 对是驱动匹配的记号
0x04 Command bit1 内存空间使能,bit2 总线主控(DMA 必开)
0x08 Revision/Class Code 类码区分 NVMe 控制器(0x010802)等
0x0E Header Type 低 7 位:0=端点,1=PCI-PCI 桥;bit7=多功能
0x10–0x24 BAR0–BAR5 基址寄存器
0x34 Capabilities 指针 MSI/MSI-X 能力链的入口

二、枚举:深度优先走桥

从总线 0 开始,每总线 32 设备 × 8 功能,判空靠 Vendor ID:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
fn scan_bus(reg: &mut Registry, base: u64, bus: u8) {
    for dev in 0..32 {
        let multi = cfg16(base, bus, dev, 0, 0x0E) & 0x80 != 0;
        for func in 0..(if multi {8} else {1}) {
            if cfg16(base, bus, dev, func, 0x00) == 0xFFFF { 
                if func == 0 { break; } else { continue; }
            }
            let ht = cfg16(base, bus, dev, func, 0x0E) & 0x7F;
            if ht == 1 {   // PCI-PCI 桥:分配下一个总线号,递归下行
                let secondary = reg.alloc_bus_number();
                write_bridge_bus_nums(base, bus, dev, func, secondary);
                scan_bus(reg, base, secondary);
            }
            reg.add_device(base, bus, dev, func);   // 记入设备清单
        }
    }
}

深度优先是为了桥的下游先编好号再回填。桥的总线号寄存器有三个:0x18 Primary(上游)、0x19 Secondary(下游)、0x1A Subordinate(下游最大总线号)——必须先把 Secondary/Subordinate 开出来,下游设备的配置空间才映射得进来;扫完下游把 Subordinate 收紧到实际最大值,不然桥会替不存在的总线转发配置访问。工控板拓扑简单(根口直挂 NVMe + 运动卡,最多一层桥),但代码按通用写法走,QEMU 和真机行为一致。

三、BAR 探测:写入全 1 读回

BAR 的尺寸不能问,只能探:保存原值 → 写 0xFFFFFFFF → 读回 → 按类型清掉低位标志位 → 取反加一就是尺寸 → 恢复原值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fn probe_bar(base: u64, bus: u8, dev: u8, func: u8, idx: usize) -> (u64, u64) {
    let reg_off = (0x10 + idx * 4) as u8;
    let orig = cfg32(base, bus, dev, func, reg_off);
    cfg32_write(base, bus, dev, func, reg_off, 0xFFFF_FFFF);
    let mut val = cfg32(base, bus, dev, func, reg_off);
    if val & 0x1 == 0 {                              // 内存 BAR
        val &= !0xF;                                 // 清低 4 位标志
        // bit2..3=可预取/类型;类型=0b10 时是 64 位 BAR,下一个寄存器是高 32 位,要连着探
    } else {
        val &= !0x3;                                 // IO BAR
    }
    cfg32_write(base, bus, dev, func, reg_off, orig); // 恢复!忘了必炸
    (!(val as u64) + 1, orig as u64 & !0xF)          // (尺寸, 固件分配的基址)
}

三个实战坑:恢复原值是纪律(枚举完设备就不可用了,固件分的基址全乱);64 位 BAR 占两个寄存器要连着探;固件已经分好基址了,我们读出来登记就行,不重新分配——专用系统信固件的地基。

identity 分页下 MMIO 物理地址可以直接访问,驱动拿到 (BAR 基址, 尺寸) 就能 as *mut u32 开始干活。最后开 Command 的 bit1|bit2(内存空间 + 总线主控),DMA 设备不开 bus master 发不出任何传输。

四、MSI:中断的接线

设备能力链从 0x34 的指针开始,每个能力有"下一指针"串成链,按能力 ID 找:0x05 = MSI,0x11 = MSI-X。MSI 能力结构的关键字段:消息地址寄存器(+4 起,32 位)、消息数据寄存器(+8 起,16 位有效)、控制寄存器(+2,bit0 使能,bit7 64 位地址支持)。

接线三步:

1
2
3
4
5
6
// 1. 消息地址 = Local APIC 默认投递地址
cfg32_write(..., msi_off + 0x04, 0xFEE0_0000);
// 2. 消息数据 = 中断向量号(布局见 APIC 篇的优先级表,设备区 0x20+)
cfg16_write(..., msi_off + 0x08, vector);
// 3. 使能
cfg16_write(..., msi_off + 0x02, ctl | 1);

一次普通内存写就变成一个中断——没有共享线、没有电平维持。自研卡值得把 MSI 的多消息能力也用上:控制寄存器 bit4..6 写入要启用的向量个数(log2 计数,1/2/4/8,上限看 bit1..3 的能力位),设备中断时改写消息数据低位选向量——比如运动卡分 4 个:放电完成、状态就绪、故障、门铃,驱动一眼分得清来源,省掉"先读状态寄存器再分派"的来回。向量号从设备区 0x20+ 分(APIC 篇的优先级表),天然低于插补 0xD0——NVMe 完成中断再忙也插不进放电节拍,这个隔离不用写一行代码,优先级硬件自带。

五、DMA 内存的前置约束

  • 物理连续 + 可寻址:无 IOMMU 假设下,DMA 缓冲必须物理连续,且落在设备的可寻址范围内(保守做法:全部 4GB 以下)。DMA 池在内存管理器篇已经建好——低 4GB 一刀切出来的那块,NVMe 的 PRP 页、EHCI 的队列头都从这里出
  • 缓存一致性白送:x86 平台 PCIe DMA 天然 cache 一致(snoop),不用手动刷缓存——这是相对 ARM 最大的减负

六、枚举清单之后:设备框架的接口

枚举的终点不是一张打印清单,是驱动注册表。系统层把它做成通用框架,不写死任何一块卡:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pub struct PciDev {
    pub bdf: (u8, u8, u8),
    pub bars: [Option<Bar>; 6],     // (基址, 尺寸, IO/内存, 32/64 位)
    pub irq_vector: u8,             // MSI 接好的向量
}

pub trait PciDriver {
    fn match_id(&self, vendor: u16, device: u16, class: u32) -> bool;
    fn probe(&mut self, dev: &mut PciDev, dma: &DmaPool) -> Result<(), ProbeErr>;
}

NVMe、EHCI、运动控制卡在框架眼里一律平等:注册一个 PciDriver,按 Vendor/Device 或类码匹配,probe 拿到 BAR 元组、MSI 向量和 DMA 池句柄自己开工。这也是两层架构在这篇的落点:框架归系统层,具体的卡——包括 GD32F4+FPGA 运动控制卡——在应用篇的适配层里实现,换卡换机床不动这里一行。

总结

实战清单:MCFG 取 ECAM 基址、bus<<20|dev<<15|fn<<12 拼地址、Vendor 0xFFFF 判空、Header Type 分桥递归(0x18/0x19/0x1A 开窗口、Subordinate 收紧)、写全 1 探 BAR 并恢复原值、Command 开 bit1|bit2、能力链找 MSI 写地址/数据/使能(多消息按 2 的幂启用)、DMA 用内存管理器篇的低 4GB 池、枚举产物挂进 PciDriver 注册表。跑通标志:枚举清单里能看到 NVMe 控制器(类码 0x010802)和自研卡的 Vendor/Device ID,QEMU 里 lspci 对得上号;注册一个空 probe 的测试驱动能被自研卡的 ID 命中。下一篇啃 USB:EHCI 主机控制器驱动。

参考链接