简介:面向嵌入式Linux与FPGA异构开发者的PCIe通信驱动示例工程,聚焦主机与FPGA之间高速数据通路的搭建。包内以驱动源码为核心,头文件与C源文件配合,覆盖PCIe设备探测与初始化、中断处理注册、DMA通道申请与内存映射、数据收发以及卸载时的资源释放等完整环节;配套Makefile用于内核模块编译,另附install、unload、run等脚本与用户态测试程序,模块加载后即可直接验证传输性能。压缩包共11个文件、约27KB,体量精简但链路闭环,适合已具备Linux内核基础、需要对照实现DMA读写的中高级读者,目前已有4834人学习下载。可据此理解PCIe分层架构与单向、双向DMA的取舍,掌握从模块编译、加载到用户空间访问设备的排错思路,也可作为高速数据采集类项目的驱动骨架参考。
1. PCIe 通信的本质:为什么 CPU 不该逐个搬字节
一块 FPGA 插在 PCIe 插槽上,主机侧写个read()就能读到采样数据——看起来平平无奇,但如果把这条链路拆开看,中间横着配置空间枚举、BAR 地址映射、中断路由、DMA 描述符环、缓存一致性五道关卡。真正决定这套设备驱动好不好用的,不是「能不能通信」,而是「数据由谁搬」。嵌入式 Linux 里最常见的翻车现场就是:驱动能跑通,cat /dev/xxx也能出数,但一上高采样率,CPU 单核直接打满,吞吐卡在几十 MB/s 上不去。根因往往不是 FPGA 慢,而是驱动让 CPU 一个字节一个字节地做ioread32。这份资源要讲的,就是把这五道关卡逐一打通、并把搬运工作从 CPU 手里抢过来交给 DMA 控制器的完整设备驱动实现。
2. 从枚举到 BAR:内核是怎么「看见」这块 FPGA 的
在写任何一行读写逻辑之前,先得搞清楚内核眼中的 FPGA 长什么样。PCIe 设备不是「一块内存」,它是一组配置空间加若干可映射区域,驱动要做的第一件事是把这些区域翻译成 CPU 能访问的虚拟地址。
2.1 配置空间:设备树之外的第二套「身份证明」
PCIe 设备的识别走的是标准配置空间,前 64 字节是所有人共有的头部,其中Vendor ID和Device ID是驱动匹配的钥匙。内核在枚举阶段会遍历总线,把每个设备的配置空间读出来,建立起pci_dev结构,然后拿这对 ID 去比对已注册的pci_driver.id_table。常见的实现思路是给 FPGA 厂商申请一个 Vendor ID,再为不同固件功能分配不同 Device ID;如果是原型阶段来不及申请,也可以用一个临时 ID 加class匹配兜底,但量产固件一定要改回正式 ID,否则同一台机器插两块不同功能的板子会互相抢驱动。
驱动注册的核心代码通常长这样:
static const struct pci_device_id fpga_pci_ids[] = { { PCI_DEVICE(0x1234, 0x5678) }, /* 厂商 ID + 设备 ID,最稳妥的匹配方式 */ { 0, } }; MODULE_DEVICE_TABLE(pci, fpga_pci_ids); static int fpga_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; /* 唤醒设备:PCIe 设备上电后可能处于 D3 低功耗态,必须先切到 D0 */ ret = pci_enable_device(pdev); if (ret) return ret; /* 申请 BAR 区域所有权,防止其他驱动或用户态程序抢占同一段地址 */ ret = pci_request_regions(pdev, "fpga_dma"); if (ret) goto err_disable; /* 打开总线主控能力——这一步是后面 DMA 能工作的前提 */ pci_set_master(pdev); return 0; err_disable: pci_disable_device(pdev); return ret; }pci_enable_device()不只是「打开开关」,它会把设备的电源状态从 D3 拉回 D0,并且给 BAR 分配总线地址;pci_request_regions()在/proc/iomem里圈出一段独占区间,后面ioremap才不会和别人撞车;pci_set_master()设置命令寄存器里的 Bus Master 位,没有这一位,设备发起的 DMA 请求会被根复合体直接丢弃——这是新手最常漏的一步,现象是寄存器读写都正常,一开 DMA 就超时。
2.2 BAR 映射:MMIO 与 Prefetchable 的区别
FPGA 通常会把 BAR0 做成寄存器区(不可预取),BAR1 或 BAR2 做成大块数据缓冲区(可预取)。这个划分不是随便定的:Prefetchable BAR 允许 CPU 做读合并和预取,适合大块顺序数据;而寄存器区必须严格一次一访问,因为读操作本身可能有副作用(比如读清零、读弹出 FIFO)。如果把寄存器区错误地标成可预取,CPU 乱序预读就会把 FIFO 里的数据悄悄吃掉,这种 bug 极难定位。
映射的典型写法:
/* BAR0: 控制寄存器区,长度 64KB,ioremap 后得到 __iomem 指针 */ ctrl_base = pci_iomap(pdev, 0, 0); if (!ctrl_base) return -ENOMEM; /* BAR2: 数据缓冲区,长度由固件上报,这里从配置空间读实际长度 */ data_base = pci_iomap(pdev, 2, 0);映射完成后,寄存器访问必须用ioread32/iowrite32系列,不能直接用*(u32 *)ptr。原因在于编译器会把普通指针访问重排、合并,而 MMIO 访问有严格顺序要求;__iomem这个标注就是给 sparse 检查用的,漏掉它编译器不报错,运行时才出玄学问题。
2.3 地址空间的三层翻译:CPU 地址、总线地址、设备地址
这里有个容易被忽略的概念:驱动里拿到的虚拟地址、dma_addr_t表示的总线地址、FPGA 内部看到的地址,是三套不同的东西。CPU 访问 BAR 时走的是「虚拟地址 → 物理地址 → PCIe 总线地址 → 设备内部偏移」;而 DMA 方向相反,设备发起访问时用的是总线地址,需要 IOMMU 或 1:1 映射翻译回物理内存。所以后面分配 DMA 缓冲区时,必须用dma_alloc_coherent拿到那对「CPU 侧虚拟地址 + 设备侧总线地址」,绝不能用kmalloc的返回值硬转——在没有 IOMMU 的平台上可能碰巧能跑,一换平台就翻车。
3. DMA 引擎:把搬运权从 CPU 手里交出去
把数据搬运交给 DMA,是这套驱动真正有价值的部分。但「开个 DMA」远不是调一个 API 那么简单,描述符环怎么排、方向和一致性怎么保证,决定了这套驱动能跑多快、能跑多稳。
3.1 一致性 DMA 与流式 DMA:两条路径的取舍
Linux 的 DMA API 分成两类。dma_alloc_coherent()分配的是「一致性内存」,CPU 和设备看到的内容永远同步,代价是这段内存通常被映射成 uncached,CPU 读写它慢得像蜗牛;dma_map_single()/dma_map_sg()走的是流式映射,内存还是普通可缓存内存,只在映射和解除映射的边界上做一次 cache 操作,性能高得多。
选择标准很直接:控制结构、描述符环这类「小、频繁、双向」的东西用一致性内存;大块数据缓冲区用流式映射。常见的实现思路是给 DMA 描述符环用dma_alloc_coherent,给数据 payload 用dma_map_single每次传输前映射、传完解除。
/* 描述符环:小而频繁,用一致性内存,CPU 和设备都能随时读写 */ desc_ring = dma_alloc_coherent(&pdev->dev, RING_NUM * sizeof(struct dma_desc), &desc_ring_dma, GFP_KERNEL); if (!desc_ring) return -ENOMEM; /* 数据缓冲区:大块、单向,用流式映射,走普通可缓存内存 */ dma_addr_t bus_addr = dma_map_single(&pdev->dev, buf, len, DMA_FROM_DEVICE); if (dma_mapping_error(&pdev->dev, bus_addr)) { /* 映射失败必须回滚,不能继续下发描述符 */ return -EIO; }DMA_FROM_DEVICE表示数据从设备流向内存,内核会在映射时 invalidate 对应 cache 行,防止 CPU 读到旧数据;DMA_TO_DEVICE则在映射前 flush,确保设备读到的是最新内容。方向标错是典型踩坑点:标反了不会报错,只会偶尔读到半新半旧的数据,压力测试下才暴露。
3.2 描述符环:环形队列如何避免锁竞争
FPGA 和驱动之间的高性能传输,几乎都用环形描述符队列。驱动维护一个head(生产者,CPU 下发)和一个tail(消费者,设备回写),两者都是单调递增的 32 位或 64 位计数器,索引时对环大小取模。这种设计的好处是天然无锁:CPU 只改head,设备只改tail,谁都不用抢对方的变量。
struct dma_desc { u64 src_addr; /* 设备侧总线地址,不是 CPU 虚拟地址 */ u64 dst_addr; u32 length; u32 flags; /* OWN 位:1 表示归设备所有,0 表示已回写给 CPU */ }; /* 下发一个描述符:先填内容,最后置 OWN 位,顺序不能反 */ static void submit_desc(struct fpga_dev *dev, dma_addr_t addr, u32 len) { struct dma_desc *d = &dev->ring[dev->head % RING_NUM]; d->src_addr = addr; d->length = len; /* wmb 保证上面的字段对设备可见之后,OWN 位才被置位 */ wmb(); d->flags = DESC_OWN; dev->head++; iowrite32(dev->head, dev->ctrl_base + REG_DMA_DOORBELL); }这里的wmb()是整个机制的关键。现代 CPU 和编译器都会重排写操作,如果不加屏障,可能出现「OWN 位已经置 1,但src_addr还没写进去」的窗口,设备一看 OWN 就抢走描述符,读到垃圾地址,轻则传输错误,重则 DMA 写到不该写的物理内存上。wmb()之后紧跟一次 doorbell 寄存器写,通知 FPGA 有新描述符可消费——这一次 MMIO 写本身也是天然的内存屏障,所以有些实现会省掉显式的wmb(),但依赖这个隐含语义属于血泪经验级的坑,不建议省。
3.3 中断收尾:为什么要下半部
设备消费完描述符后会触发 MSI/MSI-X 中断。硬中断上下文里不能做耗时操作,所以标准做法是硬中断里只清中断状态、记录完成数量,然后return IRQ_WAKE_THREAD唤醒线程化的下半部去唤醒等待队列、回收缓冲区、重新提交描述符。
static irqreturn_t fpga_irq(int irq, void *data) { struct fpga_dev *dev = data; u32 status = ioread32(dev->ctrl_base + REG_IRQ_STATUS); if (!(status & IRQ_DMA_DONE)) return IRQ_NONE; /* 先清中断,否则电平触发的中断会立刻重入 */ iowrite32(status, dev->ctrl_base + REG_IRQ_STATUS); dev->done_count += (status >> 8) & 0xff; return IRQ_WAKE_THREAD; }注意中断状态要先读后清、一次清干净。如果固件用的是电平触发中断而驱动只清了部分位,中断线会一直拉着,系统表现为「中断风暴」,top里能看到某个 CPU 核的si占用长期 100%,但业务线程一点进展都没有。MSI-X 相对好一些,因为它是消息信号,但清状态的顺序依然要对。
4. 驱动实现细节:从字符设备到 mmap 的完整链路
机制讲清楚之后,落到具体实现。这套驱动的对外形态通常是一个字符设备,上层应用通过open/ioctl/read/mmap使用,每一层都有对应的坑。
4.1 字符设备注册与 ioctl 命令设计
字符设备用alloc_chrdev_region动态申请主次设备号,配合cdev_init/cdev_add注册,再用class_create+device_create自动在/dev下生成节点,避免手动mknod。ioctl 命令的编码必须用_IOR/_IOW/_IOWR宏,把方向、类型、序号、大小打包进 32 位命令字,这样 32 位应用跑在 64 位内核上也不会因为结构体大小不一致而传错参数。
#define FPGA_IOC_MAGIC 'F' #define FPGA_IOC_START_DMA _IOW(FPGA_IOC_MAGIC, 1, struct dma_req) #define FPGA_IOC_GET_STAT _IOR(FPGA_IOC_MAGIC, 2, struct dma_stat) #define FPGA_IOC_RESET _IO(FPGA_IOC_MAGIC, 3) struct dma_req { __u64 user_buf; /* 用户态缓冲区地址,内核里要用 __user 标注 */ __u32 length; __u32 timeout_ms; };struct dma_req里所有字段都用定长类型(__u64/__u32),不要用long,否则 32/64 位混跑时结构体布局会变。user_buf传进来后必须走access_ok校验,再考虑用pin_user_pages钉住页面做零拷贝——如果只是简单场景,老老实实copy_from_user到内核缓冲区再 DMA 更稳妥。
4.2 mmap 零拷贝:把 DMA 缓冲区直接映射给用户态
大块数据反复拷贝是吞吐杀手,所以高性能场景下驱动会预留一段一致性内存,用dma_mmap_coherent把它直接映射到用户进程地址空间,应用侧mmap一次,之后读写就是纯内存操作,DMA 完成后只需一个ioctl拿长度。
static int fpga_mmap(struct file *filp, struct vm_area_struct *vma) { struct fpga_dev *dev = filp->private_data; unsigned long size = vma->vm_end - vma->vm_start; if (size > dev->dma_buf_size) return -EINVAL; /* 用 dma_mmap_coherent 而不是 remap_pfn_range, 它会正确处理非一致性架构上的 cache 属性 */ return dma_mmap_coherent(&dev->pdev->dev, vma, dev->dma_buf, dev->dma_buf_dma, size); }这里必须用dma_mmap_coherent而非手搓remap_pfn_range。在 ARM 等非一致性架构上,一致性内存的物理地址与 cache 属性是绑定的,手动 remap 会丢掉这些属性,用户态读到的是 cache 里的陈旧数据——这个坑在 x86 上完全复现不出来,换到嵌入式板子上百分百中招。
4.3 并发与生命周期:probe/remove 里的顺序问题
驱动的remove回调顺序几乎和probe严格相反,但有两个地方必须特别注意:一是释放 DMA 缓冲区前要确保设备已经停止发起 DMA,否则可能在释放瞬间被写坏内存;二是free_irq必须在关掉设备中断使能之后调用,否则中断处理函数还在跑的时候释放上下文,就是一个现成的 UAF。
static void fpga_remove(struct pci_dev *pdev) { struct fpga_dev *dev = pci_get_drvdata(pdev); /* 1. 先让设备停下来:关门铃、清使能位、等当前传输完成 */ iowrite32(0, dev->ctrl_base + REG_DMA_CTRL); fpga_wait_idle(dev, 100); /* 2. 再摘中断,此时不会有新的中断进来 */ free_irq(pdev->irq, dev); /* 3. 最后释放 DMA 内存和 BAR 映射 */ dma_free_coherent(&pdev->dev, dev->dma_buf_size, dev->dma_buf, dev->dma_buf_dma); pci_iounmap(pdev, dev->ctrl_base); pci_release_regions(pdev); pci_disable_device(pdev); }fpga_wait_idle那种轮询等待要有超时上限,不能写成死循环。真实场景里 FPGA 固件挂了是常事,死循环会让整个rmmod卡死,只能重启。
5. 避坑与排查:那些只有上板才会暴露的问题
下面几条是这类驱动最典型的翻车记录,每条都能独立耗掉一整天。
现象:寄存器读写全部正确,一开 DMA 就返回超时,设备侧看不到任何总线请求。原因:漏了pci_set_master(),命令寄存器的 Bus Master 位没置位,设备发起的 memory write 请求被上游直接丢弃,且不报错。 解决:probe里确认调用;运行中用lspci -xxx读命令寄存器,bit2 必须是 1。
现象:传输小数据正常,超过几 MB 后数据尾部出现乱码或全零。原因:流式映射的长度和描述符里填的长度不一致,或者分了多个 sg 段但只映射了第一段。 解决:用dma_map_sg处理分散聚合,逐段填描述符;调试时打印每个sg->length与映射返回值比对。
现象:x86 平台跑得好好的,换到嵌入式板子上,用户态 mmap 读到的数据永远滞后一帧。原因:用了remap_pfn_range手动映射一致性内存,丢了 cache 属性。 解决:改用dma_mmap_coherent;同时检查 BAR 是否被错误标记为 prefetchable。
现象:中断计数远大于实际完成的传输数,系统si占用飙高。原因:中断状态清除不完整,电平触发中断反复重入;或者共享中断线时没有正确判断IRQ_NONE。 解决:一次写入读到的全部状态位;共享中断下无匹配状态时返回IRQ_NONE,让内核统计 spurious 计数。
现象:rmmod偶发卡死或 Oops。原因:remove里先free_irq后停设备,中断线程还在访问已释放的 DMA 缓冲区。 解决:严格按「停设备 → 摘中断 → 释放内存」的顺序,并在停设备后加带超时的 idle 等待。
6. 设计取舍:为什么这套驱动不追求「最通用」
写完这一整套之后,回头看会发现每个设计点都是一次取舍,而不是唯一正确答案。描述符环用一致性内存,换来了实现简单和无锁,代价是环的大小不能太大,否则白白占掉 uncached 内存;数据通路用流式映射,换来了吞吐,代价是每次传输都要一次 map/unmap,小包高频场景下这部分开销会超过拷贝本身。所以在真实项目里,我一般会把「小包走 copy + 一致性缓冲、大包走 sg 流式映射」做成两条路径,由 ioctl 参数让应用自己选,而不是指望一套路径通吃。
再比如 mmap 零拷贝,看起来是纯赚,但它把缓冲区生命周期的管理责任部分转移给了用户态:应用如果 mmap 之后不munmap就退出,或者在 DMA 还在写的时候读这块内存,驱动没法拦。稳妥的做法是引入「缓冲区所有权」状态机,ioctl里明确 acquire/release,并在release回调里检查有没有未归还的缓冲区。这类防御性设计在 demo 阶段显得多余,量产后能省掉无数扯皮。
还有中断下半部的选择:线程化中断实现简单,但高包速率下线程调度延迟会成为瓶颈,此时更适合 NAPI 式的轮询收尾,或者干脆把完成通知也做成 doorbell + 定时轮询的混合模式。选哪种,取决于你的延迟预算和 CPU 余量,没有普适答案。
验证这套东西是否真的 work,我的习惯是三步:先用一个只做寄存器读写的裸驱动确认枚举和 BAR 映射没问题;再关掉 DMA 用 PIO 跑通数据通路,确认地址翻译和中断路由正确;最后才打开 DMA 做压力测试,同时用perf观察 CPU 占用是否真的降下来——如果开了 DMA 但 CPU 占用没变,那多半是数据还在被copy_to_user搬,DMA 只是搬了个寂寞。
从那以后我每次接手这类 PCIe 驱动,都强制先按「枚举 → BAR → PIO → 中断 → DMA → 零拷贝」这个顺序逐层验证,任何一层没过就不往上叠,因为这类问题的排查成本随层数指数上升,早一层发现就少熬一个通宵。这份资源把这六层从配置空间到描述符环的完整实现都保留了下来,包括那些容易写错的内存屏障和映射方向,对着源码走一遍,比在板子上盲试快得多。希望帮到你。
本文还有配套的精品资源,点击获取