第3章:PCIe驱动开发实战:设备枚举与发现、配置空间读写、BAR映射与DMA传输、中断处理注册、电源管理回调、实战:NVMe SSD控制器驱动

好,咱们直接进入正题。这一章的内容,说白了就是PCIe驱动开发中最核心、最绕不开的几个环节。你想想看,一个PCIe设备插到主板上,操作系统怎么知道它存在?怎么跟它通信?数据怎么搬?中断怎么处理?电源怎么管?这些搞不定,驱动就是空中楼阁。

我个人习惯把PCIe驱动开发分成六个关键步骤:设备枚举、配置空间操作、BAR映射、DMA传输、中断处理、电源管理。咱们一个一个来啃。

3.1 设备枚举与发现:系统怎么找到你的设备?

设备枚举这事儿,其实在BIOS/UEFI阶段就开始了。但作为驱动开发者,我们更关心的是:在Linux内核里,怎么找到我们想要的那个PCIe设备?

内核提供了一套标准的API。最常用的就是 pci_get_device()pci_get_subsys()。嗯,这里要注意:pci_get_device() 需要你传入Vendor ID和Device ID。比如NVMe SSD的Vendor ID通常是0x8086(Intel)或0x144d(Samsung),Device ID就得查具体型号了。

// 一个典型的设备查找示例
struct pci_dev *pdev = NULL;
while ((pdev = pci_get_device(0x8086, 0xF1A5, pdev)) != NULL) {
    // 找到了!做点啥?
    pr_info("Found device: %04x:%04x\n", pdev->vendor, pdev->device);
}

我在项目中遇到过一个问题:用 pci_get_device() 循环查找时,忘了每次调用后检查返回值,结果死循环了。后来养成习惯,每次调用完都检查一下,并且记得在不需要时调用 pci_dev_put() 释放引用计数。

避坑指南:我曾经在热插拔场景下吃过亏。设备拔出后,pci_dev结构体可能已经失效。建议配合 pci_dev_get()pci_dev_put() 管理生命周期。

3.2 配置空间读写:跟设备“握手”的第一步

配置空间,说白了就是PCIe设备的“身份证”和“控制面板”。每个PCIe设备都有256字节的标准配置空间(Type 0设备),扩展配置空间可达4KB。

内核提供了 pci_read_config_*()pci_write_config_*() 系列函数。我个人习惯用 pci_read_config_dword() 来读取32位值,效率最高。

u32 vendor_device;
pci_read_config_dword(pdev, PCI_VENDOR_ID, &vendor_device);
pr_info("Vendor: 0x%04x, Device: 0x%04x\n", 
        vendor_device & 0xFFFF, vendor_device >> 16);

为什么要读写配置空间?举个例子:你想知道设备支持多大的BAR空间,就得读 PCI_BASE_ADDRESS_0PCI_BASE_ADDRESS_5。你想开启总线主控(Bus Master),就得设置 PCI_COMMAND 寄存器的第2位。

注意:配置空间读写是原子操作,但频繁读写会影响性能。我在调试一个高速网卡驱动时,发现配置空间读写的延迟竟然达到了微秒级,后来改用MMIO方式访问BAR空间才解决。

3.3 BAR映射与DMA传输:数据通道的搭建

BAR(Base Address Register)是PCIe设备暴露给主机的内存或I/O空间。驱动要做的事很简单:把BAR映射到内核虚拟地址空间,然后就能像访问普通内存一样访问设备寄存器了。

内核提供了 pci_iomap() 函数,一行代码搞定映射:

void __iomem *bar0 = pci_iomap(pdev, 0, pci_resource_len(pdev, 0));
if (!bar0) {
    pr_err("Failed to iomap BAR0\n");
    return -ENOMEM;
}
// 现在可以读写BAR0了
u32 reg_val = ioread32(bar0 + 0x100);
iowrite32(0x1, bar0 + 0x104);

DMA传输呢?说白了就是让设备直接读写主机内存,不经过CPU。这需要分配DMA缓冲区,获取物理地址,然后告诉设备“你就往这个地址写数据”。

dma_addr_t dma_handle;
void *cpu_addr = dma_alloc_coherent(&pdev->dev, 
                                     DMA_BUF_SIZE, 
                                     &dma_handle, 
                                     GFP_KERNEL);
if (!cpu_addr) {
    pr_err("DMA allocation failed\n");
    return -ENOMEM;
}
// 把dma_handle写入设备的DMA地址寄存器
iowrite32(lower_32_bits(dma_handle), bar0 + DMA_ADDR_LOW);
iowrite32(upper_32_bits(dma_handle), bar0 + DMA_ADDR_HIGH);

经验之谈:DMA缓冲区大小要按页对齐,否则某些硬件会报错。我曾经因为缓冲区没对齐,导致NVMe SSD在传输4KB数据时频繁超时,排查了两天才发现是DMA地址没对齐到4KB边界。

3.4 中断处理注册:别让CPU空转

PCIe设备支持多种中断方式:INTx(传统中断)、MSI(消息信号中断)、MSI-X(扩展MSI)。现在的主流设备都用MSI-X,因为它支持每个队列独立中断,性能最好。

注册中断的流程是这样的:

  1. 调用 pci_alloc_irq_vectors() 分配中断向量
  2. 调用 request_irq() 注册中断处理函数
  3. 在中断处理函数中,读取设备的中断状态寄存器,判断是谁触发了中断
// 分配MSI-X中断
int nr_irqs = pci_alloc_irq_vectors(pdev, 1, 4, PCI_IRQ_MSIX);
if (nr_irqs < 0) {
    pr_err("Failed to allocate MSI-X interrupts\n");
    return nr_irqs;
}

// 注册中断处理函数
for (int i = 0; i < nr_irqs; i++) {
    int irq = pci_irq_vector(pdev, i);
    request_irq(irq, my_interrupt_handler, 0, "my_driver", &my_dev);
}

中断处理函数里要做什么?记住一个原则:快进快出。只做最必要的事——读取状态、清除中断标志、唤醒等待队列。耗时的操作交给下半部(tasklet或workqueue)。

我曾经踩过的坑:在中断处理函数里调用了 printk(),结果在高负载下系统直接死锁。因为 printk() 可能触发调度,而中断上下文不允许调度。从那以后,我所有调试打印都改用 trace_printk() 或直接写内存日志。

3.5 电源管理回调:让设备该睡就睡

PCIe电源管理主要涉及D0(全速运行)和D3(休眠)状态的切换。内核通过 struct dev_pm_ops 结构体来管理:

static const struct dev_pm_ops my_pm_ops = {
    .suspend = my_pci_suspend,
    .resume = my_pci_resume,
    .runtime_suspend = my_pci_runtime_suspend,
    .runtime_resume = my_pci_runtime_resume,
};

在suspend回调里,你要做的是:保存设备寄存器状态、停止DMA传输、释放中断。在resume回调里,反过来:重新配置BAR映射、重新分配DMA缓冲区、重新注册中断。

嗯,这里有个细节:很多开发者忘了在suspend时保存设备的PCI配置空间。系统恢复后,配置空间可能被BIOS或内核重置,导致设备无法正常工作。用 pci_save_state()pci_restore_state() 可以一键搞定。

3.6 实战:NVMe SSD控制器驱动

好了,理论说完了,咱们来点真格的。写一个简化的NVMe SSD控制器驱动,把上面所有知识点串起来。

NVMe SSD的核心寄存器都在BAR0里。你需要映射BAR0,然后操作以下几个关键寄存器:

寄存器偏移作用
CAP0x00控制器能力,包含队列深度、支持的中断类型等
VS0x08版本号
CC0x14控制器配置,使能、复位等
CSTS0x1C控制器状态,就绪、致命错误等
AQA0x24管理队列属性
ASQ0x28管理提交队列基地址
ACQ0x30管理完成队列基地址

驱动初始化的核心流程:

  1. 设备枚举:用 pci_get_device() 找到NVMe设备
  2. 使能设备:调用 pci_enable_device(),设置 PCI_COMMAND 开启内存空间和总线主控
  3. BAR映射:用 pci_iomap() 映射BAR0
  4. 分配DMA缓冲区:为管理队列分配DMA内存
  5. 配置控制器:设置CC寄存器,等待CSTS就绪
  6. 注册中断:分配MSI-X,注册中断处理函数
  7. 发送管理命令:比如识别控制器、创建I/O队列
static int nvme_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    struct nvme_dev *dev;
    int ret;

    // 1. 分配设备结构体
    dev = kzalloc(sizeof(*dev), GFP_KERNEL);
    if (!dev)
        return -ENOMEM;

    // 2. 使能PCI设备
    ret = pci_enable_device(pdev);
    if (ret)
        goto err_free;

    // 3. 设置DMA掩码(NVMe需要64位)
    ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64));
    if (ret)
        goto err_disable;

    // 4. 映射BAR0
    dev->bar = pci_iomap(pdev, 0, 0);
    if (!dev->bar) {
        ret = -ENOMEM;
        goto err_disable;
    }

    // 5. 分配管理队列DMA缓冲区
    dev->admin_q = dma_alloc_coherent(&pdev->dev, 
                                       PAGE_SIZE, 
                                       &dev->admin_q_dma, 
                                       GFP_KERNEL);
    if (!dev->admin_q) {
        ret = -ENOMEM;
        goto err_unmap;
    }

    // 6. 配置控制器(简化版)
    writel(0, dev->bar + NVME_REG_CC);  // 先禁用
    // 等待CSTS就绪...
    // 设置AQA、ASQ、ACQ寄存器
    // 使能控制器

    // 7. 注册中断
    ret = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSIX);
    if (ret < 0)
        goto err_free_dma;
    
    ret = request_irq(pci_irq_vector(pdev, 0), 
                      nvme_irq_handler, 0, "nvme", dev);
    if (ret)
        goto err_free_irq;

    // 8. 发送识别命令...
    pr_info("NVMe device initialized\n");
    return 0;

err_free_irq:
    pci_free_irq_vectors(pdev);
err_free_dma:
    dma_free_coherent(&pdev->dev, PAGE_SIZE, dev->admin_q, dev->admin_q_dma);
err_unmap:
    pci_iounmap(pdev, dev->bar);
err_disable:
    pci_disable_device(pdev);
err_free:
    kfree(dev);
    return ret;
}

核心要点:NVMe驱动的难点不在于单个操作,而在于状态机的管理。控制器从复位到就绪需要时间,你必须轮询CSTS寄存器等待。我曾经在等待超时处理上偷懒,结果设备在热重启后无法识别,加了个超时重试机制才搞定。

好了,这一章的内容就到这里。设备枚举、配置空间、BAR映射、DMA、中断、电源管理——这六个环节是PCIe驱动的骨架。下一章咱们会深入NVMe的队列管理,看看怎么真正地读写数据。