ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux PCI设备驱动开发实战:从枚举、BAR映射到DMA与中断处理

Linux PCI设备驱动开发实战:从枚举、BAR映射到DMA与中断处理 1. 从一张“掉卡”工单说起PCI设备驱动到底在管什么前阵子帮朋友排查一台工控机的故障现象很典型系统跑着跑着一块通过PCIe插槽扩展的网卡就“消失”了lspci里还能看到设备但ifconfig里网口没了dmesg里刷出一串 AER 报错。他第一反应是硬件坏了换了块卡问题依旧。最后定位下来是驱动在链路训练和错误恢复上的处理不够健壮加上主板 BIOS 里 ASPM 配置和卡本身兼容性有冲突。这个案例几乎把 PCI 设备驱动开发里最容易踩的坑都串起来了枚举、配置空间、BAR 映射、中断、DMA、错误处理、热插拔。很多人学 Linux 驱动是从字符设备入手的觉得 PCI 驱动“高不可攀”其实它只是多了一层“设备发现与资源分配”的机制核心的 file_operations、中断处理、DMA 那一套是相通的。这篇文章我就按一个从业者的视角把 Linux PCI 设备驱动从原理到实操完整拆一遍尽量把那些文档里不写、但实际调试中一定会遇到的东西讲透。适合谁看如果你已经写过简单的字符设备驱动想往 PCI/PCIe 方向进阶或者你是嵌入式、服务器运维、内核调试方向经常要和lspci、dmesg、AER 报错打交道这篇内容应该能帮你把零散的知识点串成一条线。我会尽量用生活化的类比解释协议概念同时给出可以直接参考的代码骨架和调试命令。2. PCI/PCIe 驱动整体设计与思路拆解2.1 为什么 PCI 驱动不能像字符设备那样“自己注册自己”字符设备驱动的套路是模块加载时主动register_chrdev然后等用户空间open。设备是“静态存在”的驱动知道自己在管谁。PCI 驱动完全不是这个逻辑——设备是总线枚举出来的驱动是被“匹配”上去的。打个比方字符设备驱动像是你开了一家店挂上招牌等客人上门PCI 驱动像是你是一个维修工坐在劳务市场里总线相当于中介拿着设备清单挨个问“谁会修这个型号”你举手说“我会”然后才被派活。这个“举手”的动作就是pci_driver结构体里的id_table和probe回调。所以 PCI 驱动的骨架天然是“注册驱动 → 总线匹配 → probe 被调用 → 申请资源 → 初始化硬件 → 注册用户接口”这条链路。理解这一点后面所有代码结构就顺了。2.2 三个核心结构体的分工写 PCI 驱动绕不开三个结构体我把它们的分工列成表方便对照记忆结构体角色关键成员生命周期struct pci_driver驱动“身份证”name、id_table、probe、remove模块加载到卸载struct pci_dev内核眼中的设备vendor、device、irq、resource[]设备插入到移除struct pci_device_id匹配规则vendor、device、subvendor、class静态定义pci_device_id是匹配的“暗号”。内核枚举到设备后拿设备的 vendor/device ID 去和每个驱动的 id_table 比对匹配上了就调用对应驱动的 probe。这里有个细节PCI_DEVICE宏只匹配 vendordevice而PCI_DEVICE_CLASS是按类别匹配后者适合写“通用型”驱动比如你要管所有类型的存储控制器。2.3 方案选型为什么优先用pci_register_driver而不是手动遍历有些老代码会手动pci_get_device去遍历总线找设备然后自己初始化。这种写法现在基本不推荐原因有三热插拔支持差手动遍历只在加载那一刻找一次设备后插入就漏了pci_register_driver是事件驱动的新设备插入会自动触发 probe。资源竞争多个驱动抢同一个设备时总线匹配机制有锁保护手动遍历容易出竞态。电源管理割裂pci_driver里可以挂.suspend/.resume回调手动遍历的驱动很难和系统电源管理框架对接。所以除非你有非常特殊的场景比如要在一个驱动里管多个不同类设备否则老老实实用pci_register_driver。3. 核心细节解析与实操要点3.1 配置空间设备的“简历”和“控制面板”PCI 设备的配置空间是 256 字节PCIe 扩展到 4KB前 64 字节是标准头部后面是能力结构Capability。标准头部里最关键的是这几个字段Vendor ID / Device ID厂商和设备型号匹配用。Command / Status控制位比如使能 IO、Memory、Bus Master。BAR0~BAR5基地址寄存器决定设备要多少地址空间。Interrupt Line / Pin中断信息。读配置空间用pci_read_config_byte/word/dword写用pci_write_config_*。这里有个新手常踩的坑写 Command 寄存器使能 Bus Master 之前DMA 是跑不起来的。很多人 probe 里忘了这一步结果 DMA 传输一直超时查半天以为是地址映射问题。/* 使能 Memory 空间和 Bus MasterDMA 必需 */ pci_set_master(pdev); /* 内部就是置位 Command 的 Bus Master 位 */pci_set_master这个封装函数建议直接用它比手动读改写更安全内部处理了锁和状态检查。3.2 BAR 映射把设备的“窗口”接到内核地址空间BAR 是设备向系统申请的地址窗口。设备上的寄存器、FIFO、DMA 描述符都挂在这个窗口里。驱动要做的是把这个物理地址映射到内核虚拟地址之后用readl/writel访问。映射流程用pci_resource_start(pdev, bar)拿到物理起始地址。用pci_resource_len(pdev, bar)拿到长度。用pci_resource_flags判断是 IO 还是 Memory 空间。用ioremap或pci_iomap映射。res_start pci_resource_start(pdev, 0); res_len pci_resource_len(pdev, 0); if (!res_start || !res_len) { dev_err(pdev-dev, BAR0 invalid\n); return -ENODEV; } /* 先申请资源防止被别的驱动抢 */ if (pci_request_region(pdev, 0, my_pci_driver)) { dev_err(pdev-dev, BAR0 request failed\n); return -EBUSY; } base pci_iomap(pdev, 0, res_len); if (!base) { pci_release_region(pdev, 0); return -ENOMEM; }注意pci_request_region一定要在pci_iomap之前调用。我见过有人先映射再申请结果两个驱动映射了同一块 BAR访问时数据错乱排查了很久。3.3 中断处理从 INTx 到 MSI/MSI-XPCI 设备的中断经历了 INTx传统引脚中断→ MSI → MSI-X 的演进。MSI-X 支持最多 2048 个独立中断向量对多队列网卡、NVMe 这类高吞吐设备几乎是标配。申请中断的推荐写法/* 优先尝试 MSI-X失败退 MSI再退 INTx */ nvec pci_alloc_irq_vectors(pdev, 1, MAX_VECTORS, PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_LEGACY); if (nvec 0) return nvec; for (i 0; i nvec; i) { irq pci_irq_vector(pdev, i); ret request_irq(irq, my_isr, 0, my_pci, priv); if (ret) goto err_free_vectors; }pci_alloc_irq_vectors这个接口比老的pci_enable_msix好用太多它自动处理了降级逻辑。中断处理函数里要注意MSI-X 的每个向量是独立的不要假设所有中断都走同一个 handler多队列场景下每个队列一个向量handler 里要通过dev_id区分。3.4 DMA 与一致性映射DMA 是 PCI 驱动性能的关键。核心概念是“设备看到的地址”和“CPU 看到的地址”可能不一样中间隔着 IOMMU。所以不能直接把kmalloc返回的虚拟地址丢给设备必须用 DMA API 转换。两种典型用法一致性映射dma_alloc_coherent适合长期存在的描述符环CPU 和设备都能访问不需要手动同步。流式映射dma_map_single/dma_map_sg适合一次性数据传输用完要dma_unmap。/* 一致性映射用于 DMA 描述符环 */ desc_ring dma_alloc_coherent(pdev-dev, ring_size * sizeof(struct desc), desc_phys, GFP_KERNEL); if (!desc_ring) return -ENOMEM; /* 把 desc_phys 写进设备寄存器设备就能找到描述符环了 */ writel(desc_phys, base REG_DESC_BASE);实操心得dma_alloc_coherent分配的内存默认是写合并的如果你需要强顺序保证比如门铃寄存器要用writel配合wmb()内存屏障别指望编译器帮你保序。4. 实操过程与核心环节实现4.1 完整驱动骨架从模块加载到设备探测下面给一个可以直接编译的最小 PCI 驱动骨架我把它拆成几个部分讲。#include linux/module.h #include linux/pci.h #include linux/interrupt.h #define DRV_NAME my_pci_drv #define BAR_INDEX 0 struct my_priv { struct pci_dev *pdev; void __iomem *base; int irq; struct dma_ring *ring; dma_addr_t ring_phys; }; static struct pci_device_id my_pci_ids[] { { PCI_DEVICE(0x1234, 0x5678) }, /* 替换成实际 vendor/device */ { 0, } }; MODULE_DEVICE_TABLE(pci, my_pci_ids); static irqreturn_t my_isr(int irq, void *dev_id) { struct my_priv *priv dev_id; u32 status readl(priv-base REG_INT_STATUS); if (!(status INT_MASK)) return IRQ_NONE; /* 清中断处理业务 */ writel(status, priv-base REG_INT_STATUS); return IRQ_HANDLED; } static int my_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_priv *priv; int ret, nvec; ret pci_enable_device(pdev); if (ret) return ret; pci_set_master(pdev); /* 使能 Bus MasterDMA 必需 */ ret pci_request_region(pdev, BAR_INDEX, DRV_NAME); if (ret) goto err_disable; priv devm_kzalloc(pdev-dev, sizeof(*priv), GFP_KERNEL); if (!priv) { ret -ENOMEM; goto err_release; } priv-pdev pdev; pci_set_drvdata(pdev, priv); priv-base pci_iomap(pdev, BAR_INDEX, 0); if (!priv-base) { ret -ENOMEM; goto err_release; } nvec pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_LEGACY); if (nvec 0) { ret nvec; goto err_unmap; } priv-irq pci_irq_vector(pdev, 0); ret request_irq(priv-irq, my_isr, 0, DRV_NAME, priv); if (ret) goto err_vectors; /* 硬件初始化复位、配置、启动 */ writel(0x1, priv-base REG_CTRL); dev_info(pdev-dev, probe ok, irq%d\n, priv-irq); return 0; err_vectors: pci_free_irq_vectors(pdev); err_unmap: pci_iounmap(pdev, priv-base); err_release: pci_release_region(pdev, BAR_INDEX); err_disable: pci_disable_device(pdev); return ret; } static void my_remove(struct pci_dev *pdev) { struct my_priv *priv pci_get_drvdata(pdev); writel(0x0, priv-base REG_CTRL); /* 先停硬件 */ free_irq(priv-irq, priv); pci_free_irq_vectors(pdev); pci_iounmap(pdev, priv-base); pci_release_region(pdev, BAR_INDEX); pci_disable_device(pdev); } static struct pci_driver my_pci_driver { .name DRV_NAME, .id_table my_pci_ids, .probe my_probe, .remove my_remove, }; module_pci_driver(my_pci_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(your name); MODULE_DESCRIPTION(Minimal PCI driver skeleton);这个骨架里probe的错误处理路径是重点。注意看err_*标签的顺序资源申请和释放必须严格逆序先申请的enable_device最后释放后申请的irq先释放。这是驱动开发里最容易出内存泄漏和资源残留的地方。4.2 参数计算BAR 大小怎么确定BAR 的大小不是猜的是设备硬件决定的。配置空间里 BAR 的低位有标志位bit0 表示 IO/Memorybit1/2 表示类型高位是地址。确定大小的标准做法是往 BAR 写全 1再读回来能置 1 的位就是可寻址范围。内核已经帮我们封装好了pci_resource_len返回的就是设备实际申请的长度。但调试时你可能需要自己算比如判断设备是不是要了 64KB 空间# 在用户空间看 BAR 信息 lspci -vv -s 01:00.0 | grep -A2 Region 0 # 输出类似Region 0: Memory at f7c00000 (64-bit, non-prefetchable) [size64K]如果size显示的是[size16M]但你只映射了 64KB访问超出部分就会触发异常。所以pci_iomap时长度参数建议传 0让内核自动用pci_resource_len的值避免手算错误。4.3 实操现场用 QEMU 验证驱动没有真实硬件也能练。QEMU 支持-device参数模拟 PCI 设备配合edu设备QEMU 自带的教学用 PCI 设备非常适合练手qemu-system-x86_64 \ -kernel bzImage \ -append consolettyS0 root/dev/sda \ -drive filerootfs.img,formatraw \ -device edu \ -nographicedu设备的 vendor/device ID 是0x1234:0x11e8把它填进id_table就能匹配上。edu有 BAR0寄存器、BAR1DMA 测试、中断麻雀虽小五脏俱全拿来验证 probe、中断、DMA 全流程非常合适。启动后在系统里执行lspci -nn | grep 1234 # 01:00.0 Class 00ff: 1234:11e8 dmesg | grep my_pci_drv # [ 12.3] my_pci_drv 0000:01:00.0: probe ok, irq24看到probe ok就说明匹配和初始化成功了。5. 常见问题与排查技巧实录5.1 掉卡、降速、AER 报错怎么查这是 PCIe 稳定性问题里最高频的一类。现象是设备时好时坏dmesg里出现AER: Corrected error或Uncorrected error。排查思路按这个顺序走现象可能原因排查命令设备消失链路训练失败lspci -vv看 LnkSta降速到 Gen1信号完整性差lspci -vv | grep LnkStaAER 报错硬件/BIOS 兼容dmesg | grep -i aerDMA 超时Bus Master 未使能检查pci_set_masterlspci -vv里的LnkSta字段会显示当前链路速度和宽度比如Speed 8GT/s, Width x4。如果设备标称 Gen3 x8实际跑在 Gen1 x1那基本是物理层问题——金手指氧化、插槽接触不良、走线太长。我遇到过一块卡插在转接卡上转接卡质量差导致降速直插主板就正常了。AER 报错的处理要分 Corrected 和 Uncorrected。Corrected 是可纠正错误硬件自动恢复了但频繁出现说明链路质量在恶化Uncorrected 是致命错误通常会导致设备不可用。内核有pcinoaer参数可以关掉 AER 报告但这只是掩盖问题不建议在生产环境用。5.2 probe 没被调用先查匹配新手最常见的问题驱动加载了lsmod能看到但probe死活不执行。九成是id_table没匹配上。排查步骤lspci -nn确认设备的 vendor:device ID。检查id_table里的宏用的是PCI_DEVICE还是PCI_DEVICE_CLASS两者匹配字段不同。看dmesg有没有no driver found之类的提示。确认设备没有被其他驱动先占用了lspci -k看Kernel driver in use。lspci -k -s 01:00.0 # Kernel driver in use: my_pci_drv # Kernel modules: my_pci_drv如果Kernel driver in use显示的是别的驱动说明你的驱动没抢到要么改 id_table 更精确要么先卸载那个驱动。5.3 中断收不到的几个隐蔽原因中断不触发除了硬件问题软件上常见这几个坑中断没使能设备侧的REG_INT_EN没打开或者 Command 寄存器的中断禁用位没清。MSI-X 向量没配对pci_alloc_irq_vectors申请了 4 个向量但只给第一个request_irq后面三个没注册设备往那些向量发中断就丢了。中断共享冲突INTx 是共享的request_irq时IRQF_SHARED标志要带上且 handler 里必须判断是不是自己的中断不是就返回IRQ_NONE。清中断顺序错先清设备侧状态再返回IRQ_HANDLED如果先返回再清可能丢中断。独家技巧调试中断时先看/proc/interrupts里对应 IRQ 的计数有没有涨。涨了说明中断到了 CPU问题在 handler不涨说明中断根本没上来问题在设备侧或路由配置。5.4 卸载模块时卡死或报错rmmod卡死通常是remove回调里有死锁或等待。常见原因在remove里等一个永远不会完成的中断或 DMA。忘了free_irq中断还在触发访问已释放的内存。pci_iounmap之后还有代码访问base。正确的remove顺序是先停硬件写控制寄存器→ 关中断free_irq→ 释放中断向量 → 取消映射 → 释放 region → disable device。每一步都要确保没有后续访问。6. 进阶话题热插拔与电源管理6.1 热插拔支持不只是加个回调PCIe 热插拔Hot-Plug在服务器和存储场景很常见。驱动要支持热插拔核心是正确处理remove和probe的对称性——设备拔出时remove被调用重新插入时probe再来一遍。听起来简单但实际有几个坑用户态接口要能感知如果你注册了字符设备设备拔出后open的 fd 怎么办标准做法是在remove里标记设备下线让后续read/write返回-ENODEV而不是直接崩溃。引用计数remove被调用时可能还有用户态在操作要用引用计数或completion等待操作结束。资源清理要彻底热插拔会反复 probe/remove任何一次泄漏累积起来都会出问题。内核提供了pci_dev_put/pci_dev_get来管理设备引用配合pci_stop_and_remove_bus_device可以主动触发移除流程。6.2 电源管理回调suspend/resume 怎么写pci_driver里的.suspend和.resume回调在系统休眠时被调用。写这两个回调的原则是suspend里保存设备状态停 DMA关中断让设备进入低功耗态。resume里恢复寄存器重新使能中断和 DMA恢复设备状态。static int my_suspend(struct pci_dev *pdev, pm_message_t state) { struct my_priv *priv pci_get_drvdata(pdev); /* 停 DMA */ writel(0, priv-base REG_DMA_CTRL); /* 保存关键寄存器 */ priv-saved_ctrl readl(priv-base REG_CTRL); pci_save_state(pdev); pci_set_power_state(pdev, PCI_D3hot); return 0; } static int my_resume(struct pci_dev *pdev) { struct my_priv *priv pci_get_drvdata(pdev); pci_set_power_state(pdev, PCI_D0); pci_restore_state(pdev); writel(priv-saved_ctrl, priv-base REG_CTRL); return 0; }注意pci_set_power_state切到 D3hot 后配置空间还能访问但 BAR 里的寄存器可能就读不到了。所以保存寄存器状态要在切电源状态之前做。7. 调试工具链与实战命令速查7.1 用户空间排查命令这些命令我几乎每天都会用到整理成速查表# 查看所有 PCI 设备及 ID lspci -nn # 查看详细配置空间、链路状态、驱动绑定 lspci -vv -s 01:00.0 # 查看设备树形结构 lspci -t # 查看内核驱动绑定情况 lspci -k # 查看配置空间原始数据 hexdump -C /sys/bus/pci/devices/0000:01:00.0/config # 查看中断分布 cat /proc/interrupts # 查看 AER 错误统计 cat /sys/bus/pci/devices/0000:01:00.0/aer_dev_correctable/sys/bus/pci/devices/下面每个设备目录里都有丰富的属性文件比如resourceBAR 映射、enable使能状态、driver绑定的驱动符号链接。调试时直接读这些文件比翻代码快得多。7.2 内核侧调试技巧动态调试echo module my_pci_drv p /sys/kernel/debug/dynamic_debug/control配合pr_debug可以按需打开日志不用重编译。ftraceecho function /sys/kernel/debug/tracing/current_tracer然后cat trace_pipe能看到 probe/remove 的调用栈。KASAN如果怀疑内存越界编译内核时开CONFIG_KASAN驱动里的越界访问会被精确报出来。我个人的习惯是probe 里关键节点都加dev_dbg平时不开出问题时用动态调试打开既不污染日志又能快速定位。8. 写在最后几个踩坑换来的经验PCI 驱动开发最忌讳“想当然”。我见过太多人栽在几个看似不起眼的地方忘了pci_set_master导致 DMA 不工作、remove里资源释放顺序错导致 rmmod 卡死、id_table匹配太宽泛抢了别的设备。这些问题在文档里往往一笔带过但实际调试时能耗掉你一整天。我的建议是每写一个 PCI 驱动先把probe和remove的对称性画出来申请了什么资源就对应释放什么顺序严格逆序。然后用 QEMU 的edu设备把全流程跑通再上真实硬件。真实硬件上先验证枚举和 BAR 映射再加中断最后加 DMA一层一层来出问题容易定位。另外lspci -vv和dmesg是你最好的朋友。任何 PCIe 稳定性问题先看链路状态和 AER 日志八成的问题都能从这两个地方找到线索。至于那些“掉卡”“降速”的玄学问题很多时候不是驱动代码的锅而是硬件兼容性和 BIOS 配置该换插槽换插槽该调 ASPM 调 ASPM别死磕代码。
返回列表