简介:这份资源面向嵌入式Linux驱动开发学习者与FPGA高速通信实践者,聚焦Linux系统下FPGA经PCIe接口与主机通信的设备驱动实现,并集成DMA机制以降低CPU干预、提升大数据量传输效率。压缩包共11个文件,约27KB,以C源码与头文件为核心,辅以内核模块ko、Makefile构建脚本及加载、卸载、运行等shell脚本,覆盖设备探测初始化、中断处理、DMA通道配置与数据传输等关键环节,便于读者对照理解PCIe分层架构与DMA读写流程。已有4825人学习下载,适合具备一定内核基础、希望掌握PCIe设备驱动框架与DMA编程思路的开发者参考,可据此搭建验证环境并梳理驱动集成与测试方法。
1. 设备驱动:为什么 CPU 读写 PCIe 卡上的内存会这么慢
做过嵌入式 Linux 的人大概都有过这种体验:FPGA 卡插在 PCIe 插槽上,lspci能看到设备,BAR 空间也映射成功了,用ioremap拿到虚拟地址,然后一个readl()读寄存器——没问题,很快。可一旦想搬一块数据,比如从 FPGA 的 DDR 里读 1MB 到用户态,用循环一个个readl()去拷,速度立刻掉到几 MB/s,CPU 占用率还飙到 100%。这不是代码写错了,而是踩到了 PCIe 通信最核心的一个机制分界:MMIO(内存映射 I/O)适合搬控制字,不适合搬数据流。
这个反直觉的结论背后,是 PCIe 事务模型和 CPU 缓存一致性协议共同作用的结果。搞懂它,才能理解为什么带 DMA 的设备驱动和普通字符设备驱动在结构上完全是两回事,也才能看懂一份「Linux 与 FPGA PCIe 通信 + DMA」的驱动代码里,那些描述符环、中断下半部、一致性内存分配到底在解决什么问题。这篇就沿着「为什么慢 → DMA 怎么绕开 → 驱动怎么组织 → 哪里会翻车」这条线,把这份驱动资源里的底层机制拆开讲。
2. MMIO 与 DMA:一次数据搬运到底走了哪条路
2.1 MMIO 的代价:每次 readl 都是一次 PCIe 事务
先看最朴素的写法。FPGA 在 BAR0 上暴露一块寄存器区,驱动ioremap之后:
/* 反面教材:用 MMIO 逐字搬数据 */ void __iomem *bar = ioremap(pci_resource_start(pdev, 0), len); u32 *dst = kmalloc(size, GFP_KERNEL); for (i = 0; i < size / 4; i++) dst[i] = readl(bar + DATA_OFFSET + i * 4); /* 每次都是一次 PCIe 读事务 */readl()在 ARM/x86 上最终落到一条对 Device 内存类型的访问指令。关键点在于:编译器不能把它优化成批量读,CPU 也不能把它合并——因为 MMIO 语义要求「每次访问都必须真实发生、顺序不能乱」。于是每 4 字节就产生一个 PCIe TLP(Transaction Layer Packet),走一遍 Root Complex、Switch、Endpoint。一次 TLP 的往返延迟在几百纳秒量级,1MB 数据就是 26 万次事务,光协议开销就把带宽吃光了。
更隐蔽的代价在 CPU 侧。MMIO 访问是非posted、强序的,CPU 必须等这次读完成才能继续,流水线被打断,cache 完全帮不上忙(Device 内存类型默认 uncached)。所以你会看到top里那个进程 sys 时间接近 100%——CPU 全耗在等 PCIe 应答上了。这就是血泪经验里最常见的一条:控制面用 MMIO,数据面必须上 DMA。
2.2 DMA 的本质:让设备自己当总线主控
DMA 的思路是把「搬数据」这件事从 CPU 手里拿走。PCIe 是支持 Bus Master 的,FPGA 作为 Endpoint 可以主动发起 Memory Write TLP,直接写主机内存,全程不经过 CPU 寄存器。CPU 只需要做三件事:告诉设备「源地址、目的地址、长度」,然后等一个中断说「搬完了」。
这里有个容易混淆的点:DMA 地址不是 CPU 的虚拟地址,也不是物理地址,而是总线地址(bus address / DMA address)。在有 IOMMU 的系统上,设备看到的是 IOVA,由 IOMMU 翻译成物理地址;没有 IOMMU 时,总线地址基本等于物理地址。驱动里必须用dma_map_single()或dma_alloc_coherent()来拿到这个地址,绝不能把virt_to_phys()的结果直接塞给 FPGA——这是新手最常翻的车之一,后面避坑章节会细说。
常见的实现思路是:驱动在初始化时分配一块**一致性内存(coherent memory)**作为描述符环,FPGA 和 CPU 都能读写、且不需要手动 flush cache;数据缓冲区则用流式映射(streaming mapping),搬完一次 map/unmap 一次,兼顾性能和缓存效率。这份驱动资源里两种都用到了,分工很清晰。
2.3 描述符环:CPU 和 FPGA 之间的无锁队列
单次 DMA 只能搬一块连续内存,要持续高速传输就得靠描述符环(descriptor ring)。结构大致是这样:
/* 描述符:CPU 写、FPGA 读,或反之,取决于方向 */ struct fpga_dma_desc { __le64 src_addr; /* 总线地址 */ __le64 dst_addr; __le32 len; __le32 flags; /* OWN 位、中断使能位、结束位 */ } __packed; /* 环控制结构,驱动私有数据 */ struct fpga_dma_chan { struct fpga_dma_desc *ring; /* 一致性内存,CPU/设备共享 */ dma_addr_t ring_dma; /* 环的总线地址,写给设备 */ u32 ring_size; /* 描述符个数,通常是 2 的幂 */ u32 head, tail; /* 生产者/消费者指针 */ spinlock_t lock; };机制的核心是那个flags里的OWN 位:谁拥有这个描述符,谁就有权改写它。CPU 填好一个描述符、清掉 OWN 位、更新 doorbell 寄存器,FPGA 看到 doorbell 后从环里取描述符执行,完成后把 OWN 位置回 1 并触发中断。整个过程 CPU 和 FPGA 不需要任何锁——它们通过 OWN 位的原子翻转完成握手,这就是所谓的「无锁队列」。
ring_size取 2 的幂不是随便定的:head/tail 用位掩码(head + 1) & (ring_size - 1)就能回绕,省掉一次取模运算,在高速路径上这点开销也要抠。__packed保证描述符在内存里的布局和 FPGA 侧 Verilog 解析的字节序、对齐完全一致,少一个字节的 padding 都会让设备读到错位的数据。
3. 驱动骨架:probe 里到底该按什么顺序初始化
3.1 从 pci_driver 到 probe:资源获取的先后依赖
PCIe 设备驱动的入口是一个struct pci_driver,注册id_table匹配厂商/设备 ID,内核枚举到设备后回调probe()。probe 里的初始化顺序是有严格依赖的,顺序错了就是各种-ENODEV和 oops:
static int fpga_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; struct fpga_dev *fdev; /* 1. 使能设备,这是后续一切访问的前提 */ ret = pci_enable_device(pdev); if (ret) return ret; /* 2. 申请 BAR 资源,防止被别的驱动抢 */ ret = pci_request_regions(pdev, "fpga_dma"); if (ret) goto err_disable; /* 3. 设置 DMA 掩码:告诉内核这个设备能寻址多宽的地址 */ 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_release; } /* 4. 映射 BAR,拿寄存器基址 */ fdev->regs = pci_iomap(pdev, 0, 0); if (!fdev->regs) { ret = -ENOMEM; goto err_release; } /* 5. 分配描述符环(一致性内存) */ fdev->ring = dma_alloc_coherent(&pdev->dev, RING_SIZE * sizeof(struct fpga_dma_desc), &fdev->ring_dma, GFP_KERNEL); if (!fdev->ring) { ret = -ENOMEM; goto err_unmap; } /* 6. 申请中断,注册 handler */ ret = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI | PCI_IRQ_LEGACY); if (ret < 0) goto err_free_ring; ret = request_irq(pci_irq_vector(pdev, 0), fpga_irq_handler, 0, "fpga_dma", fdev); if (ret) goto err_free_irq; pci_set_master(pdev); /* 打开 Bus Master,DMA 才能工作 */ pci_set_drvdata(pdev, fdev); return 0; /* ...错误路径逐层回滚... */ }几个顺序不能乱的地方值得单独说。pci_enable_device()必须在pci_iomap()之前,否则 BAR 可能还没被激活,映射出来的地址访问会直接总线错误。dma_set_mask_and_coherent()必须在dma_alloc_coherent()之前,因为分配器要根据掩码决定从哪个地址区间取内存——先分配再设掩码,设备可能拿到一个它寻址不到的地址,DMA 写出去就丢了,而且不报错,这种问题排查起来极其痛苦。pci_set_master()打开 Bus Master 位,是 FPGA 能主动发起 TLP 的开关,忘了它,描述符环填得再对,设备也一个字节都搬不过来。
3.2 中断处理:上半部只做最少的事
DMA 完成中断来了之后,handler 里绝对不能做耗时操作。标准做法是上半部只清中断状态、记录完成情况,把真正的收尾工作丢给下半部:
static irqreturn_t fpga_irq_handler(int irq, void *data) { struct fpga_dev *fdev = data; u32 status = readl(fdev->regs + REG_IRQ_STATUS); if (!(status & IRQ_DMA_DONE)) return IRQ_NONE; /* 不是我的中断,让内核继续找 */ writel(IRQ_DMA_DONE, fdev->regs + REG_IRQ_STATUS); /* 清中断,必须做 */ fdev->dma_done = true; wake_up_interruptible(&fdev->waitq); /* 唤醒等待的读/写进程 */ return IRQ_HANDLED; }这里IRQ_NONE的返回很关键:MSI 模式下多个设备可能共享向量,如果 handler 对不属于自己的中断返回IRQ_HANDLED,内核会以为处理完了,真正该处理的那个设备就饿死了。清中断寄存器也必须在上半部做,否则中断线一直拉着,系统会陷入中断风暴,表现为「一插卡系统就卡死」——这是踩坑记录里高频出现的一条。
等待队列waitq把中断上下文和进程上下文连起来:用户态read()阻塞在wait_event_interruptible()上,中断来了wake_up唤醒,进程继续把数据拷给用户。整个链路里,数据搬运由 FPGA 完成,CPU 只负责握手和拷贝,这才是 DMA 该有的样子。
3.3 用户态接口:mmap 还是 read/write
驱动对上层暴露接口有两种典型选择。一种是实现read/write,用户态调一次系统调用搬一块数据,驱动内部走 DMA,简单直接,但每次都有一次内核到用户的内存拷贝。另一种是mmap,把 DMA 缓冲区直接映射到用户态,零拷贝,适合大流量场景,但要求缓冲区在设备生命周期内固定,且用户态要自己管理同步。
这份资源里的驱动两种都提供了:控制寄存器走ioctl,数据流走mmap映射一块预分配的 coherent buffer,用户态拿到指针后直接读写,配合ioctl的「启动 DMA」「等待完成」命令做同步。这种设计把拷贝次数压到零,代价是用户态必须遵守「DMA 进行中不能碰缓冲区」的约定——驱动管不了用户态的行为,只能靠文档和ioctl的状态机约束。常见的实现思路是在ioctl里维护一个busy标志,DMA 期间拒绝新的启动请求,返回-EBUSY。
4. 避坑与排查:那些让驱动「看起来能跑」的陷阱
4.1 现象:DMA 传输偶发数据错乱,重传又好了
原因几乎总是缓存一致性。用dma_map_single()做流式映射时,如果方向是DMA_FROM_DEVICE(设备写内存、CPU 读),map 之前 CPU 可能已经把这块内存的旧数据读进了 cache;设备 DMA 写完物理内存后,CPU 读到的还是 cache 里的旧值。反过来DMA_TO_DEVICE时,CPU 写的数据可能还躺在 cache 里没落盘,设备读到的就是旧数据。
解决:严格按方向调用dma_map_single()/dma_unmap_single(),不要手动flush_cache_all()糊弄——那个函数在 SMP 上语义不对,而且性能极差。更稳妥的做法是数据缓冲区直接用dma_alloc_coherent()分配,虽然带宽略低,但省掉所有 cache 维护的麻烦,调试期先用它把逻辑跑通,再优化成流式映射。
4.2 现象:设备能收到 doorbell,但描述符内容全是 0
原因通常是地址给错了。驱动把virt_to_phys(ring)的结果写进了 doorbell 寄存器,但系统开了 IOMMU,设备需要的是 IOVA 而不是物理地址,于是它去读了一块无关内存。另一种情况是 32 位掩码下分配到了 4GB 以上的物理内存,设备根本寻址不到。
解决:永远用dma_alloc_coherent()返回的第三个参数(dma_addr_t)作为给设备的总线地址,绝不自己算。排查时可以在dmesg里打开 IOMMU 的 debug 输出,或者用cat /sys/kernel/debug/iommu/...看映射表,确认设备拿到的地址确实在映射范围内。
4.3 现象:中断只来一次,之后再也不来
原因多半是中断状态没清干净,或者清的方式不对。有些 FPGA 的中断是电平触发,清状态寄存器后需要等一个周期才真正释放;有些是「写 1 清零」(W1C),驱动却写了 0,等于没清。还有一种情况是 MSI 模式下设备重复触发同一个 vector,驱动没做去重。
解决:先确认中断类型(电平/边沿、MSI/MSI-X),再对照 FPGA 寄存器手册确认清中断的写值语义。调试时在 handler 里打印status的原始值,看第二次中断进来时状态位是不是还是置起的——如果是,说明清操作没生效,问题在写值或时序上。
4.4 现象:卸载模块时系统 hang 住或 oops
原因是资源释放顺序反了,或者 DMA 还在跑就去释放缓冲区。典型错误是先free_irq()再停 DMA,结果中断没了但设备还在往已经释放的内存里写,直接踩坏内核数据。
解决:卸载路径严格按初始化的逆序来,且第一步必须是「停设备」——写寄存器让 FPGA 停止 DMA,等当前传输完成(轮询状态位或等一个超时),再free_irq()、dma_free_coherent()、pci_iounmap()、pci_release_regions()、pci_disable_device()。每一步之间加msleep()留出设备响应时间,别指望寄存器写完立刻生效。
4.5 现象:小数据量正常,大数据量跑一会儿就超时
原因通常是描述符环满或空判断写错。head/tail 相等时到底是「全空」还是「全满」,取决于你留没留一个空槽。经典的无锁环实现会牺牲一个描述符槽来区分空和满:(head + 1) & mask == tail表示满,head == tail表示空。如果没留这个槽,环满和环空无法区分,驱动会在满的时候继续填,覆盖掉设备还没处理的描述符。
解决:统一用「留一槽」的约定,并在 doorbell 之前检查环是否满,满了就阻塞等待或返回-EAGAIN。压力测试时把环大小调到很小(比如 4 个描述符),跑大流量,能很快暴露这类边界 bug。
5. 设计取舍:为什么这份驱动不追求「通用」
把这份驱动的结构和 Linux 内核里成熟的 DMA 框架(dmaengine)对比,会发现它没有走通用抽象,而是直接操作自己的描述符环和寄存器。这不是偷懒,而是一个明确的取舍:通用框架带来复用性,也带来抽象开销和适配成本。
dmaengine要求实现一套struct dma_device回调,把设备的通道、描述符、残值查询都套进统一模型,好处是能复用内核的dma_request_chan()、dmaengine_prep_slave_sg()等接口,坏处是 FPGA 侧的描述符格式往往和框架预期不一致,中间要做一层转换,每次传输多一次描述符拷贝和状态同步。对于「一块 FPGA 卡、一种固定传输模式」的场景,直接操作硬件环反而路径最短、延迟最低、调试时能一眼看到寄存器状态。
另一个取舍在缓冲区管理上。mmap零拷贝方案性能最好,但把同步责任推给了用户态;read/write方案安全但多一次拷贝。这份驱动选择两者并存,本质上是承认「不同使用场景对安全和性能的权重不同」,而不是替用户做决定。我一般会这么理解:驱动只负责把硬件的可能性暴露出来,策略留给上层。
验证这套设计是否 work,最直接的办法是写一个最小用户态程序,用mmap拿到缓冲区,ioctl启动一次 1MB 的 DMA,用clock_gettime()掐时间,再和readl循环版本对比。如果 DMA 版本能跑到接近 PCIe 链路的理论带宽(比如 Gen2 x4 大约 1.6GB/s 量级),且 CPU 占用接近 0,说明描述符环、中断、缓存一致性这几条链路都通了。反过来,如果带宽只有理论值的零头,优先查描述符环深度和 doorbell 的触发频率——环太浅会导致设备频繁空转等 CPU 填描述符。
从那以后我每次拿到一份新的 PCIe 驱动代码,都会先翻它的probe顺序和dma_set_mask的位置,再看卸载路径是不是严格逆序——这两处对了,八成的问题就已经避掉了。希望这份拆解帮到你,资源里的完整驱动和 FPGA 侧配套逻辑,值得对着源码再走一遍。
本文还有配套的精品资源,点击获取