ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Linux PCI驱动框架核心脉络:设备模型、匹配机制与probe详解

2026/9/27 13:58:12 拓冰建站 浏览量
Linux PCI驱动框架核心脉络:设备模型、匹配机制与probe详解 1. 项目概述与整体设计思路1.1 为什么专门要讲PCI驱动框架做Linux驱动开发的朋友应该都有这种感觉字符设备驱动、平台设备驱动都相对好上手唯独PCI驱动一上来就是一堆结构体、一堆注册函数、还有复杂的枚举和资源管理逻辑初学者很容易被绕晕。说实话我刚接触PCI驱动的时候也是这样翻内核源码翻了半天看了个一知半解真到自己写一个网卡或存储控制器的驱动时又得从头把框架捋一遍。这篇文章系列的第一篇就打算把Linux PCI驱动框架的核心脉络讲透从数据结构、匹配机制、probe/remove生命周期到总线枚举和常见排查手段一次性梳理清楚。它适合哪些人看一类是从零开始写PCI设备驱动的开发者一类是做嵌入式平台BSP、需要把FPGA逻辑或者自定义PCIe加速卡接进系统的工程师还有一类就是纯粹想深入理解Linux设备模型底层工作机制的人。不管你是哪一种看完之后至少能回答这几个问题一个PCI设备从上电到驱动probe成功中间发生了什么驱动里各个回调函数是什么时候被调用的设备资源怎么分配、怎么释放出了问题应该从哪里下手排查PCI驱动框架本质上是Linux内核设备-总线-驱动模型的典型实例。它把PCI设备抽象成struct pci_dev把驱动抽象成struct pci_driver再把总线本身抽象成struct pci_bus三者通过ID匹配机制绑定在一起。理解了这套三角关系后面所有的寄存器读写、中断申请、DMA映射都是在围绕它们做文章。这也是我在实际工作中反复强调的一个观点不要急着看某个特定驱动的实现细节先把框架的骨架搭明白细节问题随时可以查。1.2 PCI/PCIe设备的现实意义说完这个框架的重要性再回到设备本身。现在的计算机系统里PCIe已经成了绝对主流的总线形态。网卡、显卡、NVMe固态硬盘、USB控制器、SATA控制器、声卡几乎所有高性能外设都挂在PCIe总线上。哪怕你是在做ARM嵌入式平台大量SoC也会内置PCIe控制器Host Bridge用来外挂WiFi模组、SSD、FPGA加速卡等设备。所以从这个角度说PCI驱动框架不是x86时代的遗留知识而是现代系统开发中绕不开的基本功。而且PCIe设备的热插拔能力、MSI/MSI-X中断机制、BAR空间映射等等都让它的驱动模型比I2C、SPI这类简单的板级总线复杂得多。比如一个PCIe网卡它的收发包缓冲区要映射到主机内存就要用DMA映射接口它可能支持多个队列每个队列要独立的MSI-X中断向量它的BAR空间可能同时映射了控制寄存器和描述符环这些都需要驱动框架提供标准API来支撑。理解了这些现实需求你才能真正理解PCI驱动框架里每一个接口为什么存在。2. PCI子系统核心数据结构解析2.1 struct pci_dev设备在内核里的身份证先把最基础的数据结构讲清楚。struct pci_dev是Linux内核中用来表示一个PCI设备的对象它抽象了设备的所有信息包括总线地址、设备ID、厂商ID、类别码、资源配置、电源管理状态、中断信息等。struct pci_dev { struct list_head bus_list; // 总线上的设备链表节点 struct pci_bus *bus; // 所属总线 struct pci_bus *subordinate; // 桥设备的下级总线 void *sysdata; // 平台私有数据 struct proc_dir_entry *procent; // proc接口相关 unsigned int devfn; // 设备号和功能号编码了槽位信息 unsigned short vendor; // 厂商ID unsigned short device; // 设备ID unsigned short subsystem_vendor; unsigned short subsystem_device; unsigned int class; // 类别码 u8 revision; // 修订版本 u8 hdr_type; // 配置空间头类型 struct pci_slot *slot; struct resource resource[DEVICE_COUNT_RESOURCE]; // BAR资源数组 unsigned int irq; // 传统INTx中断号 struct pci_driver *driver; // 已绑定的驱动 ... };注意resource数组它就是用来描述设备BAR空间的。PCI设备可以有几个独立的地址空间窗口BAR0~BAR5每个BAR对应一段寄存器或内存区。内核在枚举阶段会把BAR的大小、属性内存/IO、能否预取等解析出来填充到对应的struct resource里。驱动在probe时拿到这些resource就能做ioremap映射然后访问设备寄存器。header_type也很关键它标识了配置空间的类型0x00表示普通设备0x01表示PCI-PCI桥0x02表示CardBus桥。桥设备有它特殊处理逻辑它不仅仅是被驱动的对象还负责管理下级总线这也是subordinate指针的由来。实际写驱动的时候你不需要自己构造pci_dev它是内核在枚举时创建的。你拿到的指针一定是合法、已经初始化好的。这一点和有些框架不一样。2.2 struct pci_driver驱动的注册入口与回调函数struct pci_driver是驱动开发者的核心视角你平时写的那些probe、remove、suspend、resume函数都要填充到这个结构体里。struct pci_driver { struct list_head node; const char *name; // 驱动名称 const struct pci_device_id *id_table; // 支持的设备ID表 int (*probe)(struct pci_dev *dev, const struct pci_device_id *id); void (*remove)(struct pci_dev *dev); int (*suspend)(struct pci_dev *dev, pm_message_t state); int (*resume)(struct pci_dev *dev); ... };这里最容易被忽略的就是id_table。它定义了驱动能匹配哪些设备。注意即使你注册了一个驱动如果没有合法的id_table或者表里的条目与设备ID对不上probe是不会被调用的。这几乎是新手第一坑。注册和注销驱动的接口分别是pci_register_driver(my_driver); pci_unregister_driver(my_driver);在老版本内核里还有一个pci_module_init宏现在基本都统一用pci_register_driver了。模块加载时可以这样写static int __init my_pci_init(void) { return pci_register_driver(my_pci_driver); } module_init(my_pci_init); static void __exit my_pci_exit(void) { pci_unregister_driver(my_pci_driver); } module_exit(my_pci_exit);2.3 struct pci_device_id匹配机制的钥匙struct pci_device_id是驱动支持设备列表的最小单元struct pci_device_id { __u32 vendor, device; // 厂商ID和设备IDFFFF即为任意 __u32 subvendor, subdevice; // 子系统厂商/设备ID __u32 class, class_mask; // 类别码和掩码 unsigned long driver_data; // 驱动私有数据 };实际使用中我们一般这样定义表static const struct pci_device_id my_pci_ids[] { { PCI_DEVICE(0x1234, 0x5678) }, { PCI_DEVICE(0xabcd, 0x1234), .driver_data SOME_FLAG }, { 0, } }; MODULE_DEVICE_TABLE(pci, my_pci_ids);PCI_DEVICE宏会把vendor和device填充进去其他字段置0。如果某个字段填了PCI_ANY_ID即FFFF就表示匹配任意值。class_mask用于按设备类别匹配比如你可以匹配所有VGA控制器而不必关心具体厂商和型号。MODULE_DEVICE_TABLE宏的作用非常关键它不只是为了好看它会把id_table导出到模块的符号信息里。这样insmod或者系统自动加载模块时内核对PCI设备进行匹配就能依据这张表决定要不要加载这个驱动。在编译后生成的modules.alias文件里你能看到类似pci:v00001234d00005678*这样的别名就是从这张表生成的千万别以为这只是IDE提示用的。3. 驱动生命周期从probe到remove3.1 probe流程设备与驱动怎么对上眼当一个PCI设备被枚举出来并且内核在总线上查找驱动时会依次遍历每个注册在PCI总线上的pci_driver用id_table与设备的vendor/device/subclass等信息做匹配。匹配成功的动作就是调用驱动的probe函数。probe函数内部的标准操作顺序大致如下先pci_enable_device(dev)把设备从断电/禁用状态唤醒。这里做的事情包括确保设备能响应IO请求、分配/激活设备的电源状态等。很多新手忘记这个步骤导致后续访问BAR空间或配置寄存器异常。然后pci_request_regions(dev, driver_name)向内核声明要独占这个设备的所有BAR资源。这一步相当于占座防止别的驱动或代码冲突访问。接着读取BAR资源长度、属性并做ioremap把物理地址转成虚拟地址供CPU访问。如果设备支持MSI/MSI-X此时还要申请中断向量。如果设备要做DMA就需要调用dma_set_mask_and_coherent(dev, DMA_BIT_MASK(64))设置DMA掩码然后才能正常使用dma_alloc_coherent或者dma_map_single等接口。初始化硬件写寄存器复位、设置工作模式、分配描述符环、启动DMA引擎等。这一步完全看你的设备设计没有统一模板。最后注册子设备或者字符设备接口比如用misc_register、cdev_add之类的把设备暴露给用户空间。我这里给一个简化的probe框架代码示例便于大家对照理解static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_dev *mdev; int ret; // 使能设备 ret pci_enable_device(pdev); if (ret) { dev_err(pdev-dev, enable device failed\n); return ret; } // 独占BAR资源 ret pci_request_regions(pdev, mydriver); if (ret) { dev_err(pdev-dev, request regions failed\n); goto err_disable; } // 映射BAR0 mdev-regs pci_iomap(pdev, 0, pci_resource_len(pdev, 0)); if (!mdev-regs) { dev_err(pdev-dev, iomap bar0 failed\n); goto err_release; } // 设置DMA掩码区域设备支持64位寻址 ret dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(64)); if (ret) { dev_err(pdev-dev, set dma mask failed\n); goto err_unmap; } // 申请MSI-X中断 ret pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI | PCI_IRQ_MSIX); if (ret 0) { dev_err(pdev-dev, alloc irq vectors failed\n); goto err_unmap; } pdev-irq pci_irq_vector(pdev, 0); ret request_irq(pdev-irq, my_pci_isr, 0, mydriver, mdev); if (ret) { dev_err(pdev-dev, request irq failed\n); goto err_free_vectors; } // 初始化设备硬件寄存器启动DMA引擎... writel(0x1, mdev-regs CTRL_REG); // 注册misc设备接口、或者network device、block device等 // ... pci_set_drvdata(pdev, mdev); return 0; err_free_vectors: pci_free_irq_vectors(pdev); err_unmap: pci_iounmap(pdev, mdev-regs); err_release: pci_release_regions(pdev); err_disable: pci_disable_device(pdev); return ret; }仔细观察这段代码就会发现内核中绝大多数PCI驱动的probe函数结构都是这个范式。你在看drivers/net/ethernet/intel/igb、drivers/nvme/host/pci.c这些主流驱动时都能看到类似的影子。3.2 资源申请与BAR空间映射的正确姿势再单独展开说一下pci_request_regions和pci_iomap这两个东西因为在实践中很多人在这里会踩坑。pci_request_regions(dev, name)的作用是遍历dev-resource[]中的每一个非空项判断它是否与已有的系统资源区例如iomem_resource冲突如果不冲突就把这个资源区标记为已经被此驱动占用。换句话说它其实是在帮你维护资源所有权的合法性。拿到的资源最终会体现在/proc/iomem里方便用户空间排查分配情况。需要注意pci_request_regions会一次申请所有BAR。如果你的驱动只需要用到其中某一个BAR另一个BAR留给别的用途比如VFIO那就不适合用这个全量接口而是应该用pci_request_selected_regions(dev, mask, name)按位选择要申请的BAR。pci_iomap(pdev, bar, maxlen)的功能是把BAR对应的物理地址映射到内核虚拟地址空间。maxlen为0时表示映射整个BAR区域。映射完成后你就可以通过readl/writel去访问设备寄存器了。注意用完一定要调用pci_iounmap释放否则在热插拔场景下会留下隐患。这里插一个经验某些PCIe设备的BAR空间在硬件复位之后会重新计算大小或者地址在驱动加载之前pci_dev-resource[]里保存的大小信息在大多数情况下是准确的。但如果你的设备在驱动里被重新配置过比如重新分配了BAR的地址窗口那么以你自己的配置结果为准不要再依赖内核枚举时的信息。3.3 中断申请从INTx到MSI-XPCI中断是一个很有意思的话题。传统PCI设备使用INTx引脚中断控制器把它们统一路由到CPU。这样做的缺点是设备多的时候需要共享中断且每次中断都要读ISR状态寄存器才能确认是否是自己设备产生的中断效率低。现代PCIe设备基本都用MSIMessage Signaled Interrupt或者MSI-X。MSI本质上是通过写一个特定的内存地址来触发中断不再依赖物理引脚性能好很多。MSI-X则是对MSI的增强允许设备拥有多达2048个独立中断向量每个向量可以单独配置目的地址和中断号。这就是网卡多队列的基础。内核提供的接口是pci_alloc_irq_vectorsint pci_alloc_irq_vectors(struct pci_dev *dev, unsigned int min_vecs, unsigned int max_vecs, unsigned int flags)flags可以是PCI_IRQ_MSI、PCI_IRQ_MSIX或三者按位或。内核会按你给的范围尽量配置MSI-X失败则回退到MSI再不行就用传统INTx。所以严格来说这个接口并不是强制设备使用MSI它会自动降级。这也意味着你的中断处理函数要写得通用一点不能完全依赖MSI-X独有的特性比如每个队列一个中断这种假设。更加细粒度的方法是用pci_enable_msi_range、pci_enable_msix_range这些老接口但在新内核中pci_alloc_irq_vectors是推荐做法。如果你不确定设备支持哪种方式先看配置空间里MSI_CAP_ID是否存在或直接通过lspci -vvv查看Capabilities: MSI-X: Enable Count...这样的输出。顺带提一句申请到中断号后你要用request_irq或request_threaded_irq来注册中断处理函数。对于吞吐要求高的设备多半还会用napi或者类似机制在软中断上下文里做收包而不是在硬中断里做繁重的处理。3.4 remove流程卸载驱动的反向操作remove回调是probe的镜像操作顺序严格按照后申请的先释放的原则。我们直接看代码static void my_pci_remove(struct pci_dev *pdev) { struct my_dev *mdev pci_get_drvdata(pdev); // 停止设备硬件活动关中断、停DMA writel(0x0, mdev-regs CTRL_REG); free_irq(pdev-irq, mdev); pci_free_irq_vectors(pdev); pci_iounmap(pdev, mdev-regs); pci_release_regions(pdev); pci_disable_device(pdev); // 注销用户态接口释放私有数据结构 kfree(mdev); }有一点需要特别强调在remove里第一个要做的就是停止设备的活动而不是先释放资源。如果资源释放了设备却还在疯狂写内存或触发中断系统很容易崩溃或者数据损坏。举个真实例子内核开发中比较常见的一个bug是在PCIe驱动里remove函数先调用pci_release_regions释放了BAR空间但DMA引擎还在跑结果设备继续向已释放的虚拟地址写数据触发IOMMU page fault或者Address size is not 64-bit compatible之类的报错。这种问题非常难查。3.5 电源管理suspend/resume该做什么很多PCI驱动不实现suspend/resume也能工作但设备挂起时会表现得很奇怪。比如笔记本合盖后再打开网卡就可能丢了或者DMA状态错乱。标准做法是在suspend里保存设备寄存器的必要状态、停止DMA、释放非必要的内存映射在resume里重新使能设备、恢复配置空间和BAR、重新映射DMA描述符环。一个常见的误区是suspend/resume函数没有被调用。这通常是因为内核没有在dev-driver-pm域里配置对应的pm_ops或者驱动没有用SIMPLE_DEV_PM_OPS宏去构造pm结构体。实际编码中建议static const struct dev_pm_ops my_pm_ops { SET_SYSTEM_SLEEP_PM_OPS(my_suspend, my_resume) };然后填充到struct pci_driver的driver.pm字段里。4. Linux内核如何枚举PCI设备4.1 从配置空间到设备识别讲完了驱动侧我们再看系统侧Linux内核在启动时是怎么发现PCI设备的。这个过程由体系结构相关的初始化代码发起最终进入通用的PCI核心代码。以x86为例内核通过pcibios_init完成PCI配置空间访问机制的初始化比如使用CF8/CFC IO端口还是MMCONG内存映射配置空间随后就调用pci_scan_bus或pci_scan_root_bus来扫描总线。扫描的起点是Host Bridge根端口下的总线0。扫描过程中对每个发现的设备内核会读取它的配置空间头部得到Vendor ID、Device ID、Class Code、Header Type等信息。如果读取到的Vendor ID是0xFFFF说明这个槽位上没有设备外部设备不存在或者没有上电就跳过。配置空间头和BAR相关区域是扫描逻辑的基础。BAR的设计决定了设备需要多少地址空间寄存器写入0xFFFFFFFF后再读回来根据写入清零的位来判断长度和对齐要求。这个探测BAR大小的过程内核在pci_read_bases里就完成了。它会为每个非空的BAR创建并初始化一个struct resource设置在pci_dev-resource[]里。扫描完总线0后内核会检查是否存在PCI-PCI桥如果存在则继续扫描下一级总线形成一棵完整的PCI拓扑树。这也解释了为什么一个系统里可以支持多层PCIe交换机。4.2 从pci_scan_bus到pci_bus_add_devicespci_scan_bus完成的是设备识别和资源分配但它还不算真正把设备激活。真正让设备与驱动绑定的是后续的pci_bus_add_devices。这一步会为每个pci_dev在sysfs中创建对应的kobject同时触发设备模型中的自动匹配驱动流程。这一阶段里pci_bus_match会遍历总线上注册的pci_driver用id_table和设备信息比对匹配就调用pci_call_probe。这里有个容易被忽略的点资源和中断的分配其实并不是一步到位的。在早期扫描阶段BAR地址只是被探测出来真正的地址分配是pcibios_assign_resources或者pci_assign_unassigned_resources在后续完成的。如果你的设备BAR地址一直是0xFFFFF...之类的无效值多半是资源分配阶段出问题了。热搜词里提到的insufficient pci resources detected!!!pci out of resources condition正是资源分配阶段最常见的报错。配置请求的动作顺序大致是主机控制器发配置请求 - PCI核心读取配置寄存器 - 创建dev对象 - 填充bus/资源信息 - 设备挂到bus上 - 触发匹配驱动 - 调用probe。4.3 PCI与平台设备框架的交互例如AXI映射到PCIe的场景在嵌入式或FPGA系统中常会遇到AXI memory mapped to PCI Express这种桥接场景。具体说就是主机侧把FPGA片上AXI总线的地址空间映射成为了一个PCIe BAR窗口驱动侧看起来就是一个普通的PCIe设备访问BAR就等同于访问FPGA内部寄存器。这种情况下PCI驱动框架依旧通用但要注意BAR的地址长度和属性描述要跟硬件映射一致否则很容易出现访问后读回全F或者总线异常挂死。比如某FPGA PCIe桥把AXI地址空间0x00000000~0x0FFFFFFF映射到BAR2那驱动侧的pci_resource_len(pdev, 2)就应该是256MB。如果硬件设计上没对齐你在pci_request_regions时可能得到较大的resource长度这时就要仔细阅读产品手册确认到底是软件需要裁剪窗口还是硬件设计本身就有问题。另外在这类桥接场景里DMA映射常常不只是内核API的使用问题而是要考虑硬件是否支持SMMU/IOMMU地址变换。如果开启IOMMU驱动收发的DMA地址和设备侧看到的地址不是一回事一定要用dma_map_single/dma_unmap_single这套接口不能直接把虚拟地址或者物理地址塞给硬件描述符。否则你会在日志里看到类似IOMMU fault的错误。5. 常见问题与排查技巧实录5.1 probe怎么就没被调用这是最常遇到的问题之一。你确认驱动加载成功了pci_register_driver也返回了0但probe就是没跑。排查方向按顺序来查id_table是否填对了。对比lspci -nn输出的设备ID和驱动表里的vendor/device。最容易犯的错误是只写了vendor但device用了PCI_ANY_ID结果误匹配了一堆本不该匹配的设备而目标设备反而没匹配上被其他驱动先占了。查设备是否处于不可发现状态。比如有的PCIe设备默认禁用需要先通过平台固件或者setpci写入配置空间才能被枚举到。查是否被其他驱动抢先绑定了。lspci -k可以看当前设备绑定的driver。如果看到driver被另一个模块占用请先卸载或者黑名单掉对方。查/sys/bus/pci/devices/0000:xx:00.0/driver_override和/sys/bus/pci/devices/0000:xx:00.0/uevent的相关信息。driver_override设了某个驱动名就意味着只允许该驱动绑定。查有没有开启CONFIG_PCI_DEBUG之类的debug选项日志里可能不会直接给出匹配失败原因但至少有设备枚举的记录。调这类问题我建议第一步永远是lspci -vvv和dmesg | grep pci把设备到底有没有被系统发现、中断和BAR配置是什么样都搞清楚再说。曾经有同事花了半天时间查driver为什么没probe最后发现设备ID在ID表里确实有但是设备的Subsystem Vendor ID和表里不一致驱动做的精确匹配带了subvendor/subdevice字段把设备拒了把表改成全匹配立马就好了。5.2 资源不足和BAR地址冲突报错PCI: Cannot allocate resource region N of device ...或者PCI out of resources condition说明系统里没有足够的地址空间来容纳设备的BAR请求。常见于多个PCIe设备都申请了大块BAR空间超过了根端口能提供的可用范围内剩余总量。BIOS/固件没有正确预留IO窗口、总线范围或MMIO窗口特别是PCIe桥后面的0号总线范围被占满。设备BAR的size探测异常比如某些硬件在复位后默认BAR size特别小但实际运行时需要几个GB运行中被OS重新分配就失败了。排查时先看/proc/iomem里的PCI Bus ...区域有多大。注意有的平台默认只给32位MMIO预留了2~4GB你插4张需要1GB BAR的显卡资源必然不够。对策可以是在BIOS里开启Above 4G Decoding、调整根端口的Bus:Device:Function占用、或者通过内核参数pcirealloc强制重新分配资源。不过pcirealloc是有代价的使用后部分设备可能改变BAR地址有些老驱动可能不适应。BAR地址冲突则往往在热插拔场景下出现新插入的设备请求的BAR窗口和已有设备冲突。现代内核里PCI核心会尽量做动态分配但如果你内核没开CONFIG_HOTPLUG_PCI或者设备桥不支持这个自动调配流程就跑不起来。提示如果硬件平台允许尽量在硬件设计阶段就控制每块BAR的大小。一个经验法则是除非必要不要把整个FPGA的存储空间都映射成一个BAR可以拆分成几个小BAR控制寄存器、DMA描述符、数据缓冲既能降低资源申请失败的概率也能提高访问效率。5.3 中断不工作或者频繁报错中断相关的错误表现多种多样驱动request_irq失败、中断一直不触发、或者中断风暴。针对MSI/MSI-Xrequest_irq失败常见原因是设备没真正启用MSI Enable位。虽然pci_alloc_irq_vectors内部会处理但如果你在它之前自己去配置了MSI寄存器就可能和它冲突。另外一个常见问题是中断向量申请数量太多超过硬件上限这时pci_alloc_irq_vectors返回向量数会比请求的min_vecs少你后续按固定数量去初始化队列就会越界。中断风暴要怎么查先用cat /proc/interrupts看看中断计数是否疯狂增长。如果是多半是ISR里没有正确清除设备的Pending状态位。这种问题最崩溃的在于代码语法完全正确逻辑却总是差一步——你可能要返回IRQ_NONE给中断子系统如果总是返回IRQ_HANDLED而实际并没有处理本设备的中断内核就认为中断挂死自动屏蔽该中断线。经典的排查办法是把中断处理函数里能缩短的都缩短先只读ISR寄存器和清中断看计数是否归零确认正常之后再逐步加逻辑。INTx共享中断还多一个设备中断挂死的问题。如果你在共享中断线的一个设备ISR里长时间循环等待自己设备的某个状态位而那个位从来就不该变成你要的那个值就可能导致中断线被阻塞所有共享该线的设备都无法工作。对于MSI还好一点因为每个向量独立。5.4 DMA映射与IOMMU带来的坑在PCIe设备驱动里DMA是绕不开的环节。常见的错误有没有设置DMA掩码就直接调用dma_alloc_coherent返回值虽不会报错但后续如果设备地址超过32位就会产生无法访问的地址。一般建议在probe开头就做dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(64))。用kmalloc的内存当作DMA缓冲区使用。kmalloc返回的内存不保证物理连续到任意大小对于超过页面的内存更为明显。正确做法是用dma_alloc_coherent或者dma_map_single配合IOMMU。DMA方向的设置搞反。dma_map_single(dev, addr, size, DMA_FROM_DEVICE)和DMA_TO_DEVICE用反了后果是数据一致性问题而且是偶发性的很难复现。建议在代码注释里写清楚这个缓冲区是设备写内存还是设备读内存。没有调用dma_unmap_single导致IOMMU映射表泄漏。设备反复打开关闭几次之后系统慢得跟蜗牛一样。IOMMU开启的平台上DMA地址和物理地址不是直线映射。你可以通过dma_alloc_coherent拿到的地址直接写进设备描述符不需要自己去转换。但如果你是从物理地址手动构造DMA地址很多老驱动会这么干对不起在IOMMU环境下基本必挂。另外一个隐蔽的坑是某些SoC平台的PCIe控制器支持多个DMA窗口但窗口大小有限你用dma_map_single映射的缓冲区如果超过窗口上限就会触发IOMMU page fault。这类问题需要参考具体平台的TRM不能只靠通用经验。5.5 热插拔场景下的异常PCIe热插拔在服务器上是常见特性。驱动要兼容热插拔关键点是probe时不要假定设备永远在remove时要妥善处理所有并发访问。常用的保护手段是用引用计数pci_get_device之后用pci_dev_put管理pci_dev的使用期避免在remove过程中模块被释放掉。另外sysfs里提供的/sys/bus/pci/slots/节点可以在用户空间做echo 1 power来模拟电源控制方便测试。我遇到过一个很现实的坑一个NVMe盘通过U.2转接卡接入主板系统里同时存在设备节点/dev/nvme0n1当设备被移除后上层文件系统还在频繁访问结果内核直接oops。后来排查发现是驱动在remove里没有先通知上层停止IO导致DMA还在进行中就释放了BAR空间。这个教训告诉我们热插拔支持不是光靠驱动代码就能保障的还需要上层文件系统、块层、IO路径的配合。对一个PCI驱动来说remove里先停IO、再停DMA、最后释放资源这个顺序绝不能乱。6. 实操中的几个心得与后续学习路径这里分享几点我个人写PCI驱动总结出来的经验。第一多利用内核现成的工具链。在调试阶段lspci -vvv、setpci、cat /proc/iomem、cat /proc/interrupts、dmesg这几个命令基本能覆盖90%的问题定位。不要一上来就抓内核日志死磕代码大部分资源类和中断类问题通过这些接口一查就明白了。第二pci_set_drvdata和pci_get_drvdata这种小接口虽然看起来简单但是在probe和remove之间传递核心数据结构时是唯一合理的选择千万不要用全局变量去保存每个设备的私有数据。如果你的驱动被加载了多份比如一个主机上有两块相同的PCIe卡全局变量会直接导致数据竞争。第三看驱动源码不用从底层宏开始啃我建议按这个顺序先找一个相对简单的设备驱动比如drivers/misc/eeprom/at24.c虽然是I2C但思想类似或者drivers/net/ethernet/realtek/r8169.c这类完整的PCI网卡驱动看一下它的id_table、probe/remove、中断回调的骨架然后再回到内核文档Documentation/PCI/目录下翻索引。文档不多但每篇都是干货。第四版本兼容问题。Linux内核API演进很快这种文章里提到的接口在4.x和6.x内核间可能有细微差异。比如老的内核可能有pci_enable_msi新内核统一到pci_alloc_irq_vectorspci_module_init已经废弃。写驱动之前先确认目标内核版本再结合当前版本的include/linux/pci.h和drivers/pci/pci-driver.c来核对API这是最靠谱的方法。由于这个系列是一后面我还会单独用实例来分析一个完整字符类型的PCIe设备驱动怎么写包括硬件寄存器的组织、中断底半部处理、还有与用户态的交互方式。等这些基础都牢固了你再去理解网卡、NVMe这类复杂驱动就会觉得顺理成章了。