第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_0 到 PCI_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,因为它支持每个队列独立中断,性能最好。
注册中断的流程是这样的:
- 调用
pci_alloc_irq_vectors()分配中断向量 - 调用
request_irq()注册中断处理函数 - 在中断处理函数中,读取设备的中断状态寄存器,判断是谁触发了中断
// 分配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,然后操作以下几个关键寄存器:
| 寄存器 | 偏移 | 作用 |
|---|---|---|
| CAP | 0x00 | 控制器能力,包含队列深度、支持的中断类型等 |
| VS | 0x08 | 版本号 |
| CC | 0x14 | 控制器配置,使能、复位等 |
| CSTS | 0x1C | 控制器状态,就绪、致命错误等 |
| AQA | 0x24 | 管理队列属性 |
| ASQ | 0x28 | 管理提交队列基地址 |
| ACQ | 0x30 | 管理完成队列基地址 |
驱动初始化的核心流程:
- 设备枚举:用
pci_get_device()找到NVMe设备 - 使能设备:调用
pci_enable_device(),设置PCI_COMMAND开启内存空间和总线主控 - BAR映射:用
pci_iomap()映射BAR0 - 分配DMA缓冲区:为管理队列分配DMA内存
- 配置控制器:设置CC寄存器,等待CSTS就绪
- 注册中断:分配MSI-X,注册中断处理函数
- 发送管理命令:比如识别控制器、创建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的队列管理,看看怎么真正地读写数据。