简介:这份资源面向嵌入式Linux驱动开发与FPGA高速通信方向的学习者,聚焦Linux系统下FPGA通过PCIe接口与主机通信的设备驱动实现,并集成DMA机制以降低CPU干预、提升大数据量传输效率。内容围绕PCIe分层协议、单向与双向DMA原理、Linux PCI子系统框架下的设备初始化、中断处理、DMA配置与数据传输流程展开,适合具备一定内核驱动基础、希望理解PCIe+DMA实战的中高级开发者参考。压缩包共11个文件、约27KB,以C源文件与头文件为核心实现,辅以内核模块ko、Makefile构建脚本及加载、卸载、运行等shell脚本,便于直接编译验证。已有4825人学习下载,可帮助读者梳理驱动探测到资源释放的完整链路,理解DMA通道申请与内存映射的落地方式,并借助配套脚本快速完成模块加载与功能测试,是学习PCIe设备驱动开发的实用参考。
1. 一块 FPGA 插上 Linux 主机后,驱动到底在忙什么
把一块 PCIe 加速卡插进工控机,lspci能看到设备,/dev下却什么都没有,DMA 一跑就报超时——这是很多做嵌入式 Linux 的同行第一次碰 FPGA PCIe 通信时的真实开局。这份资源给的就是把这段路走通的完整设备驱动:内核侧完成 PCIe 枚举后的资源映射、BAR 空间读写、中断注册,用户态通过字符设备节点下发命令,数据面用 DMA 在主机内存和 FPGA 之间搬大块数据。它解决的不是“PCIe 协议是什么”,而是“枚举成功之后,驱动怎么写、DMA 怎么配、中断怎么收、出错看哪里”。适合已经能编译内核模块、手里有 FPGA 板卡和主机、需要把高速数据通路跑起来的嵌入式 Linux 从业者。下面按“资源能干什么 → 驱动骨架怎么搭 → DMA 与中断怎么落地 → 坑在哪 → 怎么验证”的顺序拆。
2. 驱动骨架:从 PCIe 枚举到字符设备节点
2.1 先看清这份资源的内核侧结构
这份驱动的组织方式很典型,围绕一个pci_driver结构体展开,probe里做资源获取,remove里做释放,用户态接口走字符设备。核心文件大致分三块:PCIe 设备匹配与 probe/remove 生命周期、DMA 缓冲区管理与描述符环、字符设备 file_operations 实现。理解这个分层,后面改参数才知道改哪。
设备匹配靠id_table,厂商号和设备号必须和 FPGA 综合时写进配置空间的 ID 一致。常见做法是先用lspci -nn拿到[vendor:device],再回填到驱动里。这一步错了,probe根本不会进,dmesg里连一行日志都没有,新手最容易在这里怀疑人生。
static const struct pci_device_id fpga_pcie_ids[] = { { PCI_DEVICE(0x10ee, 0x7024) }, /* 厂商号:设备号,按实际 FPGA 配置空间填 */ { 0, } }; MODULE_DEVICE_TABLE(pci, fpga_pcie_ids); static struct pci_driver fpga_pcie_driver = { .name = "fpga_pcie_dma", .id_table = fpga_pcie_ids, .probe = fpga_probe, .remove = fpga_remove, }; module_pci_driver(fpga_pcie_driver);PCI_DEVICE两个参数就是配置空间里的 vendor ID 和 device ID,十六进制。MODULE_DEVICE_TABLE让模块支持热插拔匹配,没有它,设备后插入时驱动不会自动绑定。module_pci_driver是注册和注销的宏封装,省掉手写pci_register_driver和pci_unregister_driver。
2.2 probe 里必须按顺序拿到的几样东西
probe是整个驱动的入口,顺序错了会出现资源泄漏或者映射失败。我一般按“使能设备 → 申请 BAR → 映射 BAR → 设置 DMA 掩码 → 申请中断 → 建字符设备”这个次序走,每一步失败都要回滚前面已做的动作。
static int fpga_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; struct fpga_dev *fdev; ret = pci_enable_device(pdev); /* 使能设备,激活内存空间 */ if (ret) return ret; ret = pci_request_regions(pdev, "fpga_pcie_dma"); /* 独占 BAR 区域 */ if (ret) goto err_disable; fdev->bar0 = pci_iomap(pdev, 0, 0); /* 映射 BAR0,长度 0 表示映射全部 */ if (!fdev->bar0) { ret = -ENOMEM; goto err_regions; } 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_iomap; } ret = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI | PCI_IRQ_LEGACY); if (ret < 0) goto err_iomap; ret = request_irq(pci_irq_vector(pdev, 0), fpga_irq_handler, 0, "fpga_pcie_dma", fdev); if (ret) goto err_irq_vectors; /* 建字符设备节点,供用户态 open/ioctl/mmap */ return fpga_chrdev_init(fdev); err_irq_vectors: pci_free_irq_vectors(pdev); err_iomap: pci_iounmap(pdev, fdev->bar0); err_regions: pci_release_regions(pdev); err_disable: pci_disable_device(pdev); return ret; }pci_enable_device激活设备的内存和 IO 空间,不做这步后面访问 BAR 会直接挂。pci_request_regions把 BAR 区域标记为驱动独占,防止别的驱动抢同一段地址。pci_iomap把物理 BAR 映射成内核虚拟地址,之后用ioread32/iowrite32访问寄存器,不能直接解引用指针。dma_set_mask_and_coherent先试 64 位地址掩码,失败退回 32 位,这决定了 DMA 能寻址的内存范围,板卡只支持 32 位 DMA 时必须走回退分支。pci_alloc_irq_vectors优先申请 MSI,失败退到传统 INTx,参数1, 1表示只要一个中断向量。
提示:
pci_iomap的第三个参数传 0 表示映射整个 BAR,但前提是 BAR 长度在配置空间里写对了。FPGA 侧 BAR 长度配错,映射会成功但访问越界,表现为读写随机值或直接 oops。
2.3 字符设备接口怎么和 DMA 对上
用户态要下发命令、要拿数据,最直接的方式是字符设备加ioctl。命令通道走ioctl传寄存器地址和值,数据通道走mmap把 DMA 缓冲区映射到用户态,避免大块数据在用户态和内核态之间来回拷贝。这份资源的做法是:open时分配 DMA 一致性缓冲区,mmap时把该缓冲区的物理页映射出去,ioctl负责触发 DMA 传输和查询状态。
static long fpga_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct fpga_dev *fdev = filp->private_data; struct fpga_dma_req req; switch (cmd) { case FPGA_IOC_REG_WRITE: if (copy_from_user(&req, (void __user *)arg, sizeof(req))) return -EFAULT; iowrite32(req.val, fdev->bar0 + req.offset); /* 写 FPGA 寄存器 */ return 0; case FPGA_IOC_DMA_START: if (copy_from_user(&req, (void __user *)arg, sizeof(req))) return -EFAULT; return fpga_start_dma(fdev, req.dir, req.len); default: return -ENOTTY; } }copy_from_user把用户态结构体拷进内核,不能直接解引用用户指针。iowrite32是带内存屏障语义的寄存器写,普通赋值在部分架构上会被编译器或 CPU 重排,导致写寄存器顺序错乱。FPGA_IOC_DMA_START只传方向和长度,源/目的地址由驱动内部持有的 DMA 缓冲区决定,这样用户态拿不到物理地址,安全性更好。
3. DMA 通路:一致性缓冲区、描述符环与中断收尾
3.1 一致性缓冲区和流式 DMA 怎么选
DMA 缓冲区分两类:一致性(coherent)和流式(streaming)。一致性缓冲区用dma_alloc_coherent分配,CPU 和设备看到的内容始终一致,适合控制结构、描述符环这种频繁小改动的内存。流式缓冲区用dma_map_single临时映射已有内存,适合一次性大块数据传输,映射和解除映射有开销但不用长期占内存。
这份资源里描述符环用一致性缓冲区,数据缓冲用流式映射。原因是描述符会被 CPU 反复改写、被 FPGA 反复读取,必须保证一致性;数据缓冲一次传完就解除映射,用流式更省。常见翻车点是把大数据缓冲也用一致性分配,结果在内存紧张的嵌入式设备上分配失败,或者分配出来的内存不可缓存,CPU 读写慢得离谱。
/* 描述符环:一致性分配,CPU 和设备共享 */ fdev->desc_ring = dma_alloc_coherent(&pdev->dev, RING_SIZE * sizeof(struct fpga_desc), &fdev->desc_ring_dma, GFP_KERNEL); if (!fdev->desc_ring) return -ENOMEM; /* 数据缓冲:流式映射,一次传输一次映射 */ dma_addr_t data_dma = dma_map_single(&pdev->dev, fdev->data_buf, len, DMA_TO_DEVICE); if (dma_mapping_error(&pdev->dev, data_dma)) { /* 映射失败,常见于 IOMMU 未配置或地址越界 */ return -EIO; } /* ... 启动 DMA ... */ dma_unmap_single(&pdev->dev, data_dma, len, DMA_TO_DEVICE);dma_alloc_coherent返回两个地址:CPU 侧虚拟地址和设备侧 DMA 地址,两者都要保存。RING_SIZE是描述符个数,通常取 2 的幂,方便用位掩码做环形索引。dma_map_single的方向参数很关键:DMA_TO_DEVICE表示数据从内存到设备,DMA_FROM_DEVICE反之,方向写反在部分架构上会导致缓存不一致,读出来的数据是旧的。dma_mapping_error必须检查,映射失败不检查直接启动 DMA,轻则数据错乱,重则总线异常。
3.2 描述符环的读写指针怎么维护
FPGA 和 CPU 通过描述符环协作,典型做法是环上每个描述符带一个“所有权”位,CPU 填好描述符后把所有权交给设备,设备处理完把所有权还回来并触发中断。CPU 侧维护一个写指针(下一个要填的位置),设备侧维护一个读指针,两者都在环上回绕。
struct fpga_desc { __le64 buf_addr; /* 数据缓冲区 DMA 地址 */ __le32 len; /* 传输长度 */ __le32 flags; /* bit0: 所有权,1 表示归设备 */ }; static int fpga_submit_desc(struct fpga_dev *fdev, dma_addr_t addr, u32 len) { u32 idx = fdev->wr_idx & (RING_SIZE - 1); struct fpga_desc *d = &fdev->desc_ring[idx]; if (le32_to_cpu(d->flags) & DESC_OWN) /* 环满,设备还没还回来 */ return -EBUSY; d->buf_addr = cpu_to_le64(addr); d->len = cpu_to_le32(len); d->flags = cpu_to_le32(DESC_OWN); /* 最后写 flags,保证前面字段已可见 */ wmb(); /* 写屏障,防止重排 */ fdev->wr_idx++; iowrite32(fdev->wr_idx, fdev->bar0 + REG_DESC_TAIL); /* 通知设备 */ return 0; }DESC_OWN位是环的同步核心,CPU 只在设备归还所有权后才复用该槽位。wmb()写屏障保证buf_addr、len先于flags对设备可见,没有它,设备可能读到所有权已置位但地址还是旧值的描述符。REG_DESC_TAIL是 FPGA 侧的尾指针寄存器,写它等于敲门告诉设备“有新描述符了”。wr_idx用无符号递增再掩码,天然处理回绕,不要用取模运算,掩码更快也更不容易错。
3.3 中断处理里只做最少的事
中断处理分上半部和下半部。上半部在中断上下文跑,不能睡眠、不能做耗时操作,只清中断状态、记录完成情况,把实际的数据处理推到下半部或者工作队列。这份资源的做法是上半部读 FPGA 的中断状态寄存器,判断是传输完成还是错误,然后唤醒等待队列。
static irqreturn_t fpga_irq_handler(int irq, void *data) { struct fpga_dev *fdev = data; u32 status = ioread32(fdev->bar0 + REG_IRQ_STATUS); if (!(status & IRQ_DONE)) return IRQ_NONE; /* 不是本设备的中断,共享中断线时必须判断 */ iowrite32(status, fdev->bar0 + REG_IRQ_STATUS); /* 写 1 清中断 */ fdev->dma_done = true; wake_up_interruptible(&fdev->waitq); /* 唤醒等待的读/写进程 */ return IRQ_HANDLED; }IRQ_NONE的返回很重要,共享中断线上多个设备挂同一根线,不判断状态直接返回IRQ_HANDLED会让内核以为中断被处理了,实际可能是别的设备触发的,导致中断风暴。清中断用写 1 的方式,具体是写 1 清还是写 0 清取决于 FPGA 寄存器设计,写反了中断会一直挂着。wake_up_interruptible唤醒在wait_event_interruptible上睡眠的用户进程,用户态read或ioctl阻塞等传输完成就靠这个。
4. 避坑排查:五个让驱动跑不起来的具体问题
4.1 现象:lspci能看到设备,/dev下没有节点
原因通常是probe没进,或者进了但在建字符设备前失败回滚了。先看dmesg | grep fpga,如果一行都没有,说明设备匹配失败,检查id_table里的厂商号和设备号是否和lspci -nn输出一致。如果有 probe 日志但没到建节点,看回滚路径上哪一步返回了错误,最常见是pci_request_regions失败,说明 BAR 被别的驱动占了,或者 BAR 长度配置为 0。
解决:用lspci -vvv -s <BDF>看设备的 BAR 信息和当前绑定的驱动,cat /proc/iomem看地址区域被谁占用。确认 ID 匹配后重新编译模块,insmod后立刻dmesg -w盯日志。
4.2 现象:DMA 传输偶尔成功、偶尔超时
原因多半是缓存一致性或者描述符顺序问题。流式 DMA 映射后 CPU 又写了缓冲区,没有重新同步,设备读到的是旧数据;或者描述符字段写入顺序不对,设备在flags置位时读到的地址还是旧的。
解决:流式映射后如果 CPU 还要改数据,用dma_sync_single_for_cpu和dma_sync_single_for_device做同步。描述符写入严格按“先填字段、最后写所有权位、中间加wmb()”的顺序。排查时可以在每次提交描述符后打印wr_idx和设备侧读指针寄存器的值,看两者是否卡住不动。
4.3 现象:中断收不到,或者收到后系统卡死
收不到中断先确认pci_alloc_irq_vectors的返回值,MSI 申请失败会退到 INTx,但有些平台 INTx 共享严重。系统卡死多半是中断处理里做了睡眠操作,比如在硬中断里调用了copy_to_user或者msleep。
解决:中断上半部只清状态、置标志、唤醒等待队列,数据处理放到工作队列或用户态主动读取。用cat /proc/interrupts看中断计数是否增长,不增长说明中断根本没到 CPU,检查 FPGA 侧中断使能寄存器和 PCIe 配置空间的 Interrupt Disable 位。
4.4 现象:大块 DMA 传输报dma_mapping_error
原因是 DMA 地址掩码设置不对,或者 IOMMU 配置导致映射地址超出设备可寻址范围。32 位设备配了 64 位掩码,映射到高地址就失败。
解决:dma_set_mask_and_coherent先试 64 位再退 32 位,不要硬编码。开了 IOMMU 的平台确认透传配置正确,必要时在启动参数里调整。映射失败必须检查返回值,不能假设一定成功。
4.5 现象:卸载模块时 oops 或者资源泄漏
remove里的释放顺序和probe的申请顺序相反,漏掉任何一步都会泄漏。常见漏项是没调pci_free_irq_vectors、没dma_free_coherent、没注销字符设备。
解决:remove严格按“注销字符设备 →free_irq→pci_free_irq_vectors→dma_free_coherent→pci_iounmap→pci_release_regions→pci_disable_device”的顺序写。用rmmod前后对比/proc/iomem和/proc/interrupts,确认资源都归还了。
5. 验证与压测:让 DMA 通路自己证明自己
驱动写完不等于通了,得有可重复的验证手段。我一般分三层验证:寄存器读写、单次 DMA、环形压测。
第一层,寄存器读写。用ioctl往 FPGA 的一个测试寄存器写值再读回,确认 BAR 映射和ioread32/iowrite32通路正常。这一步不过,后面都不用谈。
# 假设字符设备节点为 /dev/fpga0,测试工具为自带的 fpga_test sudo ./fpga_test /dev/fpga0 reg 0x00 0xdeadbeef # 写测试寄存器 sudo ./fpga_test /dev/fpga0 reg 0x00 # 读回,应输出 0xdeadbeef第二层,单次 DMA。传一小块已知模式的数据(比如递增序列),FPGA 侧做回环或者校验,用户态读回比对。这一步能暴露缓存一致性和方向参数的问题。
第三层,环形压测。连续提交几百上千个描述符,统计完成数和超时数,同时用perf或ftrace看中断频率和 CPU 占用。压测时重点观察三个指标:描述符环是否卡在某个位置、中断计数是否和完成数对得上、dmesg有没有DMA-API相关的警告。
| 验证层 | 手段 | 通过标准 | 常见失败点 |
|---|---|---|---|
| 寄存器 | ioctl 读写测试寄存器 | 读回值等于写入值 | BAR 映射长度错、字节序错 |
| 单次 DMA | 已知模式数据回环比对 | 数据完全一致 | 方向参数反、缓存未同步 |
| 环形压测 | 连续提交 + 中断统计 | 完成数等于提交数、无超时 | 环满未处理、中断丢失 |
压测里有个容易被忽略的点:dma_alloc_coherent分配的描述符环在长时间运行后可能因为 CPU 和设备读写指针不同步而“假满”——明明设备已经处理完,CPU 侧看到的所有权位还是 1。这通常是缓存一致性问题在特定架构上的表现,排查时可以在读所有权位前加rmb(),或者改用dma_sync_single_for_cpu同步后再判断。
还有一个习惯值得养成:每次改完驱动参数,先跑一遍寄存器层验证,再跑单次 DMA,最后才上压测。跳过前面直接压测,出了问题分不清是映射错、方向错还是环逻辑错,排查成本翻倍。从那以后我每次调 DMA 相关代码,都强制按这三层走一遍再提交,省下来的调试时间远比多写的几行测试代码值。希望帮到你。
本文还有配套的精品资源,点击获取