4. PCIe WiFi设备枚举:PCI子系统基础、设备探测流程、BAR空间映射与中断注册

好,我们进入第四章。这一章讲的是WiFi驱动和硬件打交道的第一个关键环节——设备枚举。说白了,就是操作系统怎么发现你插上去的那张WiFi网卡,然后怎么把它的资源(内存地址、中断号)交到驱动手里。

很多刚入行的朋友觉得这步很简单,不就是pci_register_driver吗?其实不然。我见过太多因为BAR空间没配好、中断触发方式选错,导致WiFi吞吐死活上不去的案例。这一章,我们就把它彻底讲透。

4.1 PCI子系统基础:设备是怎么被“看见”的

先问一个问题:你的WiFi网卡插在PCIe槽上,CPU怎么知道它在哪里?

答案是通过PCI配置空间。每个PCIe设备(包括WiFi网卡)都有一组256字节的配置寄存器,里面包含了厂商ID、设备ID、类代码、BAR(基地址寄存器)等信息。Linux内核在启动时,会通过PCI总线扫描,读取这些寄存器,建立起一颗完整的PCI设备树。

核心数据结构:struct pci_dev

内核用struct pci_dev来描述一个PCI设备。驱动开发中,你打交道最多的就是它。

struct pci_dev {
    u32 vendor;        // 厂商ID,比如0x8086是Intel
    u32 device;        // 设备ID,WiFi芯片的具体型号
    u32 class;         // 类代码,网卡通常是0x028000
    struct pci_bus *bus; // 所属总线
    unsigned int irq;  // 中断号
    struct resource resource[DEVICE_COUNT_RESOURCE]; // BAR空间资源
    // ... 还有很多
};

我个人习惯在驱动入口处先打印一下这些信息,确认设备确实被枚举到了:

static int my_wifi_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    dev_info(&pdev->dev, "Found WiFi device: vendor=0x%04x device=0x%04x\n",
             pdev->vendor, pdev->device);
    dev_info(&pdev->dev, "IRQ=%d, class=0x%06x\n", pdev->irq, pdev->class);
    // ...
}

嗯,这里要注意:class字段是24位的,打印时记得用%06x,不然高位会被截掉。我刚开始写驱动时就犯过这个错,查了半天为什么设备没匹配上。

4.2 设备探测流程:从pci_register_driver到probe

设备探测的流程,可以用下面这张图来概括:

PCIe WiFi设备探测流程 pci_register_driver() pci_match_id() 匹配设备 probe() 回调函数 pci_enable_device() pci_request_regions() pci_iomap() / ioremap() request_irq() 注册中断 驱动初始化完成,设备就绪 关键点 probe() 中必须按顺序 调用 enable → request → iomap → request_irq

流程看起来不复杂,但每一步都有坑。我们逐个拆解。

4.2.1 pci_device_id:你的设备匹配表

驱动里必须定义一个pci_device_id数组,告诉内核你支持哪些设备:

static const struct pci_device_id my_wifi_ids[] = {
    { PCI_DEVICE(0x8086, 0x24F3) },  // Intel AX200
    { PCI_DEVICE(0x14C3, 0x7961) },  // MediaTek MT7921
    { 0, }  // 结束标志
};
MODULE_DEVICE_TABLE(pci, my_wifi_ids);

这里有个细节:MODULE_DEVICE_TABLE宏会把这张表导出到模块的.modinfo段里。当内核发现新设备时,会遍历所有已注册驱动的这张表,找到匹配的就调用probe。

小技巧:调试阶段可以用modinfo命令查看模块支持的设备列表。如果发现设备ID没在列表里,先检查MODULE_DEVICE_TABLE有没有写对。

4.2.2 probe函数的标准骨架

一个合格的WiFi驱动probe函数,至少要做这几件事:

  1. 使能PCI设备——调用pci_enable_device()
  2. 设置DMA掩码——dma_set_mask_and_coherent()
  3. 请求BAR资源——pci_request_regions()
  4. 映射BAR空间——pcim_iomap()或ioremap()
  5. 读取MAC地址——从EEPROM或OTP中读取
  6. 注册中断——request_irq()或request_threaded_irq()
  7. 初始化WiFi核心——调用ieee80211_alloc_hw()等

我曾经接手过一个项目,probe函数里把pci_enable_device放在了pci_request_regions后面。结果呢?资源冲突,设备死活起不来。查了两天才发现是顺序问题。所以,顺序很重要。

4.3 BAR空间映射:驱动和硬件的“通信管道”

BAR(Base Address Register)是PCI设备暴露给CPU的寄存器或内存区域。WiFi网卡通常有2~3个BAR:

BAR编号 典型用途 映射类型
BAR0 MAC寄存器、控制状态寄存器 Memory-Mapped I/O (MMIO)
BAR1 DMA描述符环、数据缓冲区 Memory (可预取)
BAR2 扩展配置空间(部分芯片) MMIO

映射BAR空间的典型代码:

// 先使能设备
ret = pci_enable_device(pdev);
if (ret) {
    dev_err(&pdev->dev, "Cannot enable PCI device\n");
    return ret;
}

// 设置DMA掩码,WiFi设备通常需要64位
ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64));
if (ret) {
    ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(32));
    if (ret) {
        dev_err(&pdev->dev, "No suitable DMA mask\n");
        goto err_disable;
    }
}

// 请求BAR0和BAR1的资源
ret = pci_request_regions(pdev, "my_wifi");
if (ret) {
    dev_err(&pdev->dev, "Cannot request PCI regions\n");
    goto err_disable;
}

// 映射BAR0到CPU虚拟地址空间
void __iomem *bar0 = pcim_iomap(pdev, 0, pci_resource_len(pdev, 0));
if (!bar0) {
    dev_err(&pdev->dev, "Cannot iomap BAR0\n");
    goto err_release;
}

// 映射BAR1
void __iomem *bar1 = pcim_iomap(pdev, 1, pci_resource_len(pdev, 1));
if (!bar1) {
    dev_err(&pdev->dev, "Cannot iomap BAR1\n");
    goto err_release;
}

注意:映射完成后,读写BAR空间必须使用ioread32()/iowrite32()等访问函数。不要直接解引用__iomem指针,否则在ARM等非x86架构上会触发段错误。

我个人习惯在probe里打印BAR的物理地址和大小,方便调试:

for (i = 0; i < 6; i++) {
    if (pci_resource_len(pdev, i) == 0)
        continue;
    dev_info(&pdev->dev, "BAR%d: phys=%pap size=%#llx\n",
             i, &pci_resource_start(pdev, i),
             (u64)pci_resource_len(pdev, i));
}

4.4 中断注册:让硬件能“喊”你

WiFi设备的中断处理,是驱动性能的关键。PCIe设备通常支持MSI-X(Message Signaled Interrupts eXtended),比传统的INTx中断效率高得多。

4.4.1 中断方式对比

中断方式 特点 WiFi驱动推荐
INTx (传统引脚中断) 共享中断线,容易冲突,性能差 不推荐
MSI 消息中断,最多32个向量 可用,但队列多时不够用
MSI-X 每个队列独立中断,支持亲和性 强烈推荐

注册中断的代码示例:

// 先尝试MSI-X,不行再回退到MSI
int nvec = pci_alloc_irq_vectors(pdev, 1, 4, PCI_IRQ_MSIX | PCI_IRQ_MSI);
if (nvec < 0) {
    dev_err(&pdev->dev, "Failed to allocate MSI/MSI-X vectors\n");
    goto err_release;
}

// 为每个队列注册中断处理函数
for (i = 0; i < nvec; i++) {
    int irq = pci_irq_vector(pdev, i);
    ret = request_irq(irq, my_wifi_isr, 0, "my_wifi", &priv->queue[i]);
    if (ret) {
        dev_err(&pdev->dev, "Failed to request IRQ %d\n", irq);
        goto err_free_irq;
    }
}

避坑指南:我曾经在调试一款WiFi6网卡时,发现中断处理函数里读寄存器总是返回0。查了半天,原来是pci_enable_device之后没有调用pci_set_master。没有总线主控能力,设备根本发不了中断。加上这一行就解决了。

4.4.2 中断处理函数的设计原则

WiFi设备的中断处理函数,要尽量短。为什么?因为中断上下文不能睡眠,而且长时间关中断会导致其他设备饿死。

我一般这样设计:

  • 上半部(ISR):只做最紧急的事——读中断状态寄存器,清除中断标志,然后唤醒下半部。
  • 下半部(Tasklet/Workqueue):处理数据包接收、发送完成、链路状态变化等耗时操作。
static irqreturn_t my_wifi_isr(int irq, void *data)
{
    struct wifi_queue *q = data;
    u32 cause;

    // 读中断原因寄存器
    cause = ioread32(q->bar0 + INT_CAUSE_REG);
    if (!cause)
        return IRQ_NONE;  // 不是我们的中断

    // 清除中断标志
    iowrite32(cause, q->bar0 + INT_CAUSE_REG);

    // 调度下半部
    tasklet_schedule(&q->tx_tasklet);

    return IRQ_HANDLED;
}

嗯,这里有个细节:ioread32和iowrite32是带内存屏障的,能保证读写顺序。如果你用readl/writel,效果一样,但ioread32更符合PCI驱动惯例。

4.5 本章小结

PCIe WiFi设备的枚举,说白了就是三步:

  1. 匹配——通过pci_device_id让内核找到你的驱动
  2. 资源获取——使能设备、映射BAR空间、设置DMA
  3. 中断注册——让设备能通知CPU有事情要处理

每一步都有对应的内核API,顺序不能乱,参数不能错。我见过太多驱动死在probe阶段,原因无非就是这几个:忘了pci_set_master、BAR映射长度写错、中断向量数分配不够。