ARTICLE DETAIL

资讯详情

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

Linux PCI设备驱动核心机制:匹配、BAR映射与DMA配置实战

Linux PCI设备驱动核心机制:匹配、BAR映射与DMA配置实战 做PCI设备驱动开发的人大概都有过这种体验照着范例把struct pci_driver填满在 probe 里写上一堆初始化代码编译加载然后心提到嗓子眼——设备到底有没有被正确挂上BAR 空间够不够中断会不会来我上周调一块 PCIe 采集卡时就撞上了pci out of resources这个经典报错排查过程让我意识到与其零散地搜日志不如把 Linux PCI 驱动框架的运转逻辑完整梳理一遍。这是系列的第二篇上一篇讲了 PCIe 拓扑和枚举的基本概念这次聚焦驱动开发真正绕不开的部分设备匹配流程、资源映射手法、DMA 与中断配置以及现场怎么排障。内容不追求覆盖每一个 API而是帮你在脑子里搭出一张设备从识别到跑数据的路线图。1. 先弄清谁来找谁PCI 设备与驱动的匹配机制刚接触 PCI 驱动的人最容易犯的一个错是把字符设备驱动的思路直接搬过来注册一个 file_operations然后等着用户 open 设备文件。但 PCI 驱动的主动权根本不在驱动这边。内核的pci_bus_type会在总线枚举和后续热插拔事件中主动扫描设备拿着设备信息去比对所有已注册的pci_driver找到匹配项之后才调用你的 probe。可以说设备是岗位驱动是候选人内核是 HR。1.1 一张 pci_device_id 表就是寻人启事struct pci_device_id是驱动和设备之间的约定。内核匹配时就是拿设备的 vendor、device、subvendor、subdevice、class 等字段逐条遍历驱动提供的 id_table只要任何一条能对上就认为这个驱动负责这个设备。struct pci_device_id { __u32 vendor, device; // 主ID必须匹配 __u32 subvendor, subdevice; // 子系统ID可设 PCI_ANY_ID __u32 class, class_mask; // 类别匹配带掩码 kernel_ulong_t driver_data; // 透传给 probe 的私有数据 };vendor 和 device 是主干匹配项几乎不会用PCI_ANY_ID去模糊匹配因为那等于对所有设备喊我是你爹会把别人的设备抢过来。subvendor 和 subdevice 描述的是具体板卡而不仅仅是芯片。同一颗芯片可能被多家厂商做成不同板卡如果你只写 vendor/device 而不约束 subdevice驱动就会错误绑定到不是你目标的板卡上。我自己就踩过这个坑。当时有两个子型号vendor/device 完全一样只有 subdevice 不同。我偷懒只写了PCI_DEVICE(0x1234, 0x5678)结果两个型号都被 probe第二个型号的寄存器布局不一样初始化直接崩。改成PCI_DEVICE_SUB(0x1234, 0x5678, 0x1234, 0x0002)之后才清净。所以如果你的硬件有子系统标识尽量用全约束。1.2 匹配成功之后driver_data 和 MODULE_DEVICE_TABLE 在忙什么driver_data是个kernel_ulong_t按内核惯例用来存放指向私有配置结构体的指针取整。probe 的第二个参数const struct pci_device_id *id会把这一项原样传给你这样同一个驱动支持多个型号时你可以在 probe 入口直接根据 id 拿到对应配置不用到处写 if/else。static const struct pci_device_id cap_ids[] { { PCI_DEVICE(0x1234, 0x5678), .driver_data (kernel_ulong_t)cap_a_config }, { PCI_DEVICE(0x1234, 0x5679), .driver_data (kernel_ulong_t)cap_b_config }, { } }; MODULE_DEVICE_TABLE(pci, cap_ids);MODULE_DEVICE_TABLE不是给人看的仪式它会在编译时生成模块的 alias 信息depmod后写入modules.alias。系统里 udev 在发现 PCI 设备时会读取设备 sysfs 下的 modalias 文件内容像一串编码比如pci:v00001234d00005678sv...然后用它去 modules.alias 里反查该加载哪个 .ko。所以哪怕你在板子上手动insmod没问题如果忘了写MODULE_DEVICE_TABLE开箱时驱动基本不会被自动加载——这不是内核 bug是你不小心砍掉了自动加载链路。1.3 class 匹配的适用场景除了精确 ID 匹配pci_device_id 还支持按设备类别匹配比如匹配某个网卡或者显卡类别下的所有设备。class 字段本身是编码形式class_mask用来屏蔽不需要比较的位。实操中 class 匹配用得少因为同一个类别下不同设备的寄存器差异巨大强行匹配进去probe 里还得再靠 vendor/device 二次区分不如直接精确匹配干净。真正的典型用途是某种通用类驱动比如某些 vendor 提供的通用 PCIe 驱动先按 class 兜底再在内部分流派。新手不建议模仿。一句话总结匹配机制id_table 是门禁driver_data 是进门后递给你的工牌MODULE_DEVICE_TABLE 是让门卫知道今天有你这个候选人的通讯录。三者配合probe 才会在一个设备真实存在的前提下被调用。2. probe 不是随便写写驱动的出生、存活与退休probe是 PCI 驱动的核心主战场但很多人把它当成初始化硬件的地方这个理解太窄。probe 更准确的定位是资源申请清单。设备枚举是内核做的驱动能做的只是把自己需要的资源申请下来做好映射然后让设备可被系统其他子系统使用。2.1 probe 里该干的七件事和对应释放我按自己写 PCIe 驱动的习惯给出 probe 的标准动作static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_dev *dev; int ret; dev kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev-pdev pdev; pci_set_drvdata(pdev, dev); ret pci_enable_device(pdev); if (ret) goto err_free; ret pci_request_regions(pdev, my_driver); if (ret) goto err_disable; dev-bar pci_iomap(pdev, 0, pci_resource_len(pdev, 0)); if (!dev-bar) goto err_release; pci_set_master(pdev); 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) goto err_unmap; } ret pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI | PCI_IRQ_INTX); if (ret 0) goto err_unmap; dev-irq pci_irq_vector(pdev, 0); ret devm_request_irq(pdev-dev, dev-irq, my_irq_handler, 0, my_driver, dev); if (ret) goto err_irq; ret misc_register(dev-miscdev); if (ret) goto err_irq; return 0; err_irq: pci_free_irq_vectors(pdev); err_unmap: pci_iounmap(pdev, dev-bar); err_release: pci_release_regions(pdev); err_disable: pci_disable_device(pdev); err_free: kfree(dev); return ret; }对应的 remove 就是把这套动作倒着做一遍首先注销 miscdev 这类对外接口确保不再有用户态路径能摸到设备然后 free 中断、释放中断向量、iounmap、release regions、disable device最后 kfree。顺序不能乱尤其要记住先断对外接口再断内部资源否则 remove 过程中有进程正在读写你这边把 ioremap 释放了那边 read 还在访问非法地址直接 oops。2.2 devm 资源偷懒的前提是搞清楚生命周期上面的错误处理链里有一堆 goto写多了容易漏。内核提供了一套 devmmanaged device resources机制注册的资源会在设备解除绑定或驱动移除时自动释放。比如devm_kzalloc、devm_ioremap、devm_request_irq以及较新内核里的devm_request_pci_regions和pcim_enable_device。用 devm 的好处是 probe 中途失败时不用一个个手动回滚代码里可以少写很多错误处理标签。但副作用也很明确这些资源按栈顺序逆序释放也就是后申请的反而先释放。如果你的资源之间有依赖关系就必须仔细设计申请次序。我现在的习惯是小驱动直接用 devm 一把梭减少出错大驱动则保持手动管理因为移除顺序我想完全掌控。有一点必须强调用了devm_request_irq之后remove 里绝对不要再手动free_irq否则会重复释放内核直接炸给你看。2.3 suspend/resume别让设备在睡眠中失忆现代 PCI 驱动很少直接在struct pci_driver里写 suspend/resume 回调而是用driver.pm my_pm_ops挂dev_pm_ops。挂起时主要做三件事停掉数据流DMA 停、中断屏蔽、保存需要恢复的寄存器状态、把设备放到低功耗状态。恢复时反过来先恢复 PCI 配置空间再重新初始化设备内部状态最后开中断、启动 DMA。这里有个最常见的误解很多人以为 resume 之后 BAR 地址、DMA 配置由内核全包了。内核确实会恢复标准 PCI 配置空间pci_restore_state但设备内部私有寄存器和 DMA 描述符状态属于设备固件上下文内核管不着。如果硬件固件设计得比较偷懒D3 之后内部状态全丢resume 里不重新初始化开起来的 DMA 会像脱缰野马一样访问无效地址。我碰到过一块板卡resume 后必须重新下发整个描述符环基地址否则中断永远不来数据也永远不更新。3. 地址空间真相BAR、MMIO 与pci out of resources的根因PCI 设备不直接知道CPU 物理地址这个概念。它只知道自己有几个 BARBase Address RegisterBAR 是设备向系统申报地址空间的窗口。固件在枚举时读取 BAR 的写入行为来判断窗口大小然后给每个窗口分配一段 PCI 域地址这段地址再经过 Host Bridge 映射到 CPU 物理地址空间。驱动要访问设备寄存器本质上就是访问这段被映射进来的内存或 IO 空间。3.1 从 BAR 描述符读出你要的资源驱动不要自己去读配置空间里的 BAR 原始值那是固件和内核枚举阶段的事。内核已经帮你解析好了直接使用pci_resource_*系列函数resource_size_t start pci_resource_start(pdev, bar); resource_size_t len pci_resource_len(pdev, bar); unsigned long flags pci_resource_flags(pdev, bar);flags 里需要注意两位IORESOURCE_IO表示 IO 端口空间IORESOURCE_MEM表示内存映射空间。内存空间里还有IORESOURCE_PREFETCH标志表示可预取。可预取的含义是读这个地址没有副作用、数据可以被 CPU 缓存合并。如果你的 BAR 实际是控制寄存器或者 FIFO读它本身会改变设备状态那就绝不该标成 prefetchable。之前调试一块回读 FIFO 的板卡readl 拿回来的数据偶尔乱序查到最后就是硬件工程师把 BAR 配成了 prefetchable软件这边读操作被 CPU 重排合并了。3.2 从 PCI 域地址到 CPU 指针ioremap 的三条路拿到pci_resource_start之后你还需要把它映射成内核虚拟地址才能用 C 语言访问。最省事的是pci_iomap它内部会根据资源类型自动选择ioremap还是ioport_mapvoid __iomem *bar0 pci_iomap(pdev, 0, pci_resource_len(pdev, 0)); if (!bar0) { ... } u32 val ioread32(bar0 REG_STATUS); iowrite32(0x1, bar0 REG_CTRL);这里我建议统一用ioread32/iowrite32而不是readl/writel。两者在 x86 上行为差别不大但ioread32的语义就是访问 MMIO移植到 ARM、RISC-V 上更安全。64 位 BAR 也没有玄学pci_resource_len返回的已经是resource_size_t64 位只要内核开启 64 位资源支持直接按同样方式访问即可。3.3 排障实战新卡在老机器上报 no space 的完整排查这个报错值得单独立一节因为它不是驱动的锅而是整个系统资源分配的锅。典型 dmesg 长这样pci 0000:03:00.0: BAR 0: no space for resource [mem size 0x01000000] pci 0000:03:00.0: BAR 0: cant assign mem (size 0x01000000)第一句的意思是设备想要 16MB 的 MMIO 窗口但系统当前的 PCI 域地址空间里找不到连续 16MB 的空闲区间。这在老平台和虚拟机上特别常见。PCI 地址空间不是无限的传统 PC 在 32 位时代只把 3G 到 4G 附近一段留给 PCI 设备多个设备瓜分完就没了。现代主板 BIOS 里有Above 4G Decoding选项打开之后才允许把 PCI 资源放到 4G 以上的高位地址空间。我那次遇到的具体情况是一块板卡 BAR0 要 16MB插上一台只有 8MB 空闲 MMIO 窗口的旧工控机报的正是这个错。排查顺序是那一次排查的节奏是先看lspci -vvv -s 03:00.0发现 Region 0 显示[size16M]而不是具体地址说明 BAR 没被分配再看dmesg | grep -i pci立刻定位到 no space 日志最后进 BIOS 确认平台上确实没有 Above 4G 选项。你要是也遇到同样日志处理手段按优先级来BIOS 里打开 Above 4G Decoding有些 BIOS 叫 Large BAR、SR-IOV 支持都得开换插槽避开集显或其他大 BAR 设备占用的窗口内核启动参数加pcirealloc让内核在启动阶段强行重新分配所有 PCI 资源虚拟机环境则检查虚拟机的 MMIO 窗口配置有些 hypervisor 默认给 PCIe 预留空间很小还有一个容易被忽略的细节pci_enable_device失败时也可能报cant enable device: BAR 0 ... not assigned。很多人以为 enable 只是开电源管理实际上它内部也会尝试为未分配资源的 BAR 争取空间。如果 BAR 压根没分到地址这里的报错只是把枚举阶段的问题延迟到了驱动加载阶段而已。4. 让数据流动起来DMA 设置与中断选择的若干细节设备识别了、寄存器能访问了但一块 PCIe 板卡真正的价值在于数据搬运。这一步绕不开 DMA 和中断也是驱动开发中出错率最高的部分。4.1 DMA 掩码先让设备够得着内存设备做 DMA 时要往内存总线地址上写数据但设备本身不知道自己 CPU 物理地址多宽。你需要用dma_set_mask_and_coherent告诉 DMA 层这个设备认得多少位地址if (dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(64))) { if (dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(32))) return -EIO; }先试 64 位失败再退化 32 位这是标准顺序。为什么失败可能是 IOMMU 限制、设备硬件本身只支持 32 位寻址或者总线地址宽度不足。注意dma_set_mask和dma_set_coherent_mask可以分开设置一般情况用dma_set_mask_and_coherent一起设更省事。忘了设掩码的后果是 DMA 映射层不知道设备能力可能交出 64 位地址而设备只能写低 32 位数据写入完全随机排查时让人抓狂。4.2 一致性内存与流式映射的分工DMA 内存分两大类使用场景完全不同。一致性内存用dma_alloc_coherent分配CPU 和设备看到的地址不需要额外同步。适合放描述符环、状态区域、控制块这类需要频繁被双方读写的小块数据struct ring { struct desc *cpu; dma_addr_t dma; }; ring-cpu dma_alloc_coherent(pdev-dev, ring_size, ring-dma, GFP_KERNEL);流式映射用dma_map_single/dma_map_sg一次映射一段缓冲区用完就 unmap。适合网络包、块数据这类一次性大块数据传输。速度更快但 CPU 访问前必须dma_sync_single_for_cpu设备访问前必须dma_sync_single_for_device。我吃过一次亏DMA 完成中断里直接读 data buffer发现数据是旧的折腾了半天根源就是忘了在 CPU 读之前做 synccache 里的旧数据挡住了新鲜结果。还要提醒一点dma_alloc_coherent分配的是页对齐内存别指望它能分配几十 MB。大块数据老老实实走流式映射或者自己管理 SG 列表。设备端用什么驱动端就用对应的映射 APIDMA 地址一定要用 API 返回的dma_addr_t不要自己拿 virt_to_phys 算有 IOMMU 的时候这两个值可能完全不一样。4.3 MSI 与 INTx 的取舍以及分配向量代码模板现代 PCIe 设备优先用 MSI/MSI-XINTx 是针脚共享中断的旧方案能不用就不用。MSI-X 的优势是每个队列可以分配独立中断向量多个 CPU 核分别处理不同队列吞吐量完全不一样。分配代码一般这样写int nvec pci_alloc_irq_vectors(pdev, 1, num_queues, PCI_IRQ_MSIX | PCI_IRQ_MSI); if (nvec 0) { nvec pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_INTX); if (nvec 0) return nvec; } for (i 0; i nvec; i) { unsigned int irq pci_irq_vector(pdev, i); devm_request_irq(pdev-dev, irq, my_irq_handler, 0, dev_name(pdev-dev), queue[i]); }pci_alloc_irq_vectors会按你给的类型优先级依次尝试返回值是实际分配到的向量数。拿到nvec后每个向量的 IRQ 号必须用pci_irq_vector查询不能假设它们是连续的。如果走到 INTx 兜底request_irq里要加IRQF_SHARED因为 INTx 本来就是共享中断线handler 里必须先读取设备中断状态寄存器确认是不是自己的中断不是就返回IRQ_NONE。中断 handler 本身的撰写原则是在原子上下文里只做最少的事。读状态、清中断、把数据搬进预分配缓冲、置一个标志位、唤起 workqueue然后返回IRQ_HANDLED。重活放到线程化中断或 workqueue 里硬中断里做得越少锁的问题越少系统延迟越可控。我甚至会把request_threaded_irq的线程化 handler 作为大块数据处理的主战场硬中断只负责 ack 和唤醒。5. 现场调试三板斧sysfs、dmesg 与 lspci 的配合写 PCI 驱动不是一次就能成功的准备一套高效的现场排查手段能省下大量时间。我在调试中用的最多的是这三个工具组合lspci -vvv看设备状态、dmesg看内核日志、sysfs 里的接口做动态试验。5.1 从 lspci -vvv 读设备当前状态lspci -nnvvv -s 03:00.0是查看单设备全貌的标准命令。重点看几个字段Region 0: Memory at ...如果显示的是实际地址说明 BAR 已分配如果显示[size16M]而没有地址说明资源没分配Interrupt: pin A routed to IRQ 51能看到中断路由状态Capabilities: ... MSI-X确认 MSI-X 能力是否可用LnkCap/LnkSta链路速率和宽度怀疑物理连接问题时先看这里每次加载驱动前后都跑一遍对比设备状态变化很多时候问题一眼就能看出来。5.2 模拟热插拔与强制绑定的正确姿势sysfs 提供了一套不需要断电的热插拔试验环境。把设备从总线逻辑上移除再重新扫描echo 0000:03:00.0 /sys/bus/pci/devices/0000:03:00.0/remove echo 1 /sys/bus/pci/rescan这样模拟一次完整的设备消失-重新枚举流程非常适合验证 probe/remove 的配对逻辑。手动绑定驱动则在驱动目录下操作echo 0000:03:00.0 /sys/bus/pci/drivers/my_driver/bind echo 0000:03:00.0 /sys/bus/pci/drivers/my_driver/unbind如果设备已经被别的驱动占用了会绑定失败。这时可以先去占用的驱动目录下 unbind再回来绑定。另外一个技巧是driver_override往设备 sysfs 里写驱动名可以强制指定某个驱动来匹配这个在测试多个候选驱动时非常管用。5.3 常见问题速查调试中反复出现的几类问题我整理成一个速查表省得每次现查现象可能原因优先排查手段probe 不调用id_table 没匹配lspci -nn 核对 vendor/device/subsystemmodinfo 看 aliasBAR 分配失败BIOS 窗口不足dmesg 搜 BAR开 Above 4Gpcirealloc读寄存器全是 0xFF设备未 enable、地址映射错误、设备没上电先 pci_enable_device核对 bar0 虚拟地址中断不触发中断向量配置错、设备中断源被 mask/proc/interrupts 看计数尝试 pcinomsi 排除 MSI 问题中断风暴没屏蔽挂起中断、共享误判probe 早期先把设备中断输出 disable清 pending再 request_irqDMA 数据全零DMA 掩码没设、映射错地址核对 dma_alloc_coherent 返回的 dma_addr 是否正确写入设备我再分享一个调试 DMA 时屡试不爽的小技巧在硬件还没有真正跑起来之前先让设备写一段已知 pattern 到一致性内存里CPU 这边轮询这段内存如果能看到 pattern说明 DMA 写路径和地址映射是通的再把 CPU 写好的 pattern 让设备读出去验证读路径。先打通地址对不对再去调数据对不对能少走很多弯路。最后说点实在的。Linux PCI 驱动框架表面上是一堆结构体和回调实际上它把设备发现、资源分配、驱动绑定、生命周期管理这些脏活全包了。真正考验开发者的往往不是 API 记不记得住而是对匹配规则、资源窗口、DMA 语义和中断模型的理解是否到位。如果你照着这个思路把 probe 当资源清单、把 remove 当逆序拆解、把 sysfs 当实验台绝大多数 PCI 驱动问题都能一步步拆到根因上。下一篇文章我会挑一个具体的 PCIe DMA 驱动示例从零到一把完整流程跑一遍。
返回列表