简介:这是一份面向FPGA与驱动开发者的PCIe DMA实战示例包,围绕PCI Express总线主控DMA(BMD)机制展开,适合正在学习高速数据传输、编写PCIe设备驱动的中高级开发者参考。包内共41个文件,以Verilog源码(.v)与头文件(.h)为主体,配合C语言应用、INF安装信息、SYS驱动、批处理与Makefile等脚本,以及可执行程序与固件配置,覆盖从FPGA逻辑、驱动安装到上位机测试的完整链路。压缩包约1.76MB,体积轻巧但结构紧凑。资源中既有DMA传输配置、描述符与中断处理的实现代码,也包含驱动注册、性能测试与调试排错的配套材料,可帮助读者理解总线主控角色、DMA请求发起与完成流程,并动手验证传输速率与错误处理。目前已有485人学习下载,适合作为PCIe DMA入门的对照范例与二次开发起点。
1. PCIE DMA 例子到底在演示什么:从一次数据搬移说起
如果你手里只有一块开发板、一根调试线,想验证 PCIE 链路到底能不能跑通,最直接的办法不是去读协议手册,而是找一个能跑起来的 DMA 例子。标题里的「PCIE DMA 例子」本质就是一份最小可运行工程:它把主机内存和板卡内存之间的数据搬移过程完整走了一遍,让你看到描述符怎么建、寄存器怎么配、中断怎么收。很多人第一次接触 PCIE 时会被 TLP、BAR、配置空间这些词绕晕,但真正上手后会发现,DMA 才是决定吞吐和延迟的那一环。这份例子适合两类人:一类是刚拿到 FPGA 板卡、需要快速验证链路的驱动开发者;另一类是想把采集数据实时传到主机、却卡在带宽瓶颈上的嵌入式工程师。它不教你写完整驱动,但能让你在半天内看到数据从 A 点到 B 点的完整轨迹。
2. 拆开一个 PCIE DMA 例子:描述符、BAR 与中断怎么串起来
2.1 先分清 RC 和 EP 两侧各自要做什么
PCIE 是主从架构,主机侧叫 RC,板卡侧叫 EP。DMA 例子通常把 EP 当作发起方,主动把数据写进主机内存,或者从主机内存读数据。RC 侧需要做三件事:枚举设备、映射 BAR 空间、分配一块物理连续的内存并拿到它的总线地址。EP 侧则要配置 DMA 引擎的源地址、目的地址、长度,然后启动传输。很多例子跑不通,不是逻辑错,而是 RC 侧给的总线地址不对——操作系统给的虚拟地址不能直接塞进 DMA 寄存器,必须经过 IOMMU 或物理地址转换。我一般会先在 RC 侧打印出分配缓冲区的物理地址,再和 EP 侧读到的值对一遍,这一步能省掉后面大量猜谜时间。
2.2 描述符环:把一次大传输拆成多个小包
DMA 引擎通常不支持一次搬移几兆字节,而是靠描述符环来串联。每个描述符包含源地址、目的地址、长度和控制位。EP 侧写好描述符后,更新尾指针寄存器,引擎就会自动取描述符并执行。下面是一个典型的描述符结构定义,用 C 语言表示:
// 单个 DMA 描述符,通常要求 16 字节或 32 字节对齐 struct dma_desc { uint64_t src_addr; // 源地址,EP 侧本地内存或 FIFO 地址 uint64_t dst_addr; // 目的地址,主机侧总线地址 uint32_t length; // 本次搬移字节数,必须是 4 的倍数 uint32_t flags; // bit0: 中断使能,bit1: 描述符有效 } __attribute__((packed));逻辑说明:src_addr 和 dst_addr 都是总线地址,不是 CPU 虚拟地址。length 一般限制在 4KB 到 64KB 之间,太大容易触发引擎内部 FIFO 溢出。flags 里的中断使能位决定这次传输完成后是否产生 MSI 中断。参数上,描述符数量建议取 2 的幂,比如 64 或 128,这样尾指针回绕时可以用位与操作代替取模,减少逻辑资源。
2.3 BAR 空间映射:别把配置空间和内存空间搞混
EP 通常至少有两个 BAR:一个用于寄存器访问,一个用于 DMA 数据窗口。寄存器 BAR 一般映射到 RC 侧的内存空间,驱动通过 readl/writel 访问。数据 BAR 则用来暴露 EP 侧的一块内存,让 RC 能直接读写。常见错误是把寄存器 BAR 的大小设成 4KB,却把 DMA 描述符环也放在里面,结果描述符被寄存器访问冲掉。我一般会把描述符环单独放在数据 BAR 里,寄存器 BAR 只放控制寄存器和状态寄存器。下面是一个 BAR 映射的检查步骤:
# 在 RC 侧查看设备 BAR 分配情况 lspci -v -s 01:00.0 # 输出中关注 Region 0 和 Region 2 的地址和大小 # 然后用 devmem 读取寄存器 BAR 的偏移 0x00,确认设备 ID 正确 devmem 0x<bar0_addr> 32逻辑说明:lspci 能看到 BAR 的物理地址和大小,devmem 用来手动读写。如果读出来的设备 ID 和手册一致,说明 BAR 映射成功。参数上,注意 BAR 地址是物理地址,devmem 需要 root 权限,且不能访问未映射区域,否则会触发总线错误。
2.4 中断收尾:MSI 比 legacy 中断更省心
DMA 完成后,EP 会发中断通知 RC。老例子常用 legacy INTx,但 PCIE 时代更推荐 MSI 或 MSI-X。MSI 本质上是一个内存写事务,EP 往 RC 指定的地址写一个数据,RC 的中断控制器就收到中断。配置 MSI 需要读配置空间的 Capability 链表,找到 MSI Capability,然后写 Message Address 和 Message Data。下面是一段伪代码:
// 使能 MSI 并设置中断向量 uint32_t msi_cap = pci_find_capability(dev, PCI_CAP_ID_MSI); pci_write_config_word(dev, msi_cap + 2, 0x01); // 使能 MSI pci_write_config_dword(dev, msi_cap + 4, msg_addr); // 主机侧中断地址 pci_write_config_word(dev, msi_cap + 12, 0x00); // 数据低 16 位逻辑说明:msg_addr 通常由操作系统在分配中断向量时给出,不能硬编码。数据部分一般填中断向量号。参数上,MSI 支持多个向量,但例子通常只用 1 个。如果中断收不到,先检查 MSI Enable 位是否置 1,再确认 EP 侧是否真的往 msg_addr 写了数据。
3. 从零跑通一个最小 DMA 回环:代码、命令与参数
3.1 环境准备:内核模块和用户态工具各司其职
跑通例子需要两样东西:一个内核模块负责枚举设备、映射 BAR、注册中断;一个用户态程序负责分配缓冲区、触发 DMA、等待完成。内核模块可以用现成的 pci-epf-test 框架,也可以自己写一个简单的字符设备。用户态程序通过 ioctl 或 mmap 和内核交互。下面是一个用户态分配缓冲区的示例:
// 分配 1MB 对齐缓冲区,用于 DMA 传输 void *buf = aligned_alloc(4096, 1024 * 1024); if (!buf) { perror("aligned_alloc failed"); return -1; } // 锁定内存,防止被换出 mlock(buf, 1024 * 1024); // 获取物理地址(实际驱动中通过 ioctl 从内核获取) uint64_t phys = virt_to_phys(buf); printf("buffer virt=%p phys=0x%lx\n", buf, phys);逻辑说明:aligned_alloc 保证缓冲区按页对齐,mlock 防止页面被换出导致 DMA 地址失效。virt_to_phys 在用户态不可直接调用,这里只是示意,实际需要通过内核模块的 ioctl 返回物理地址。参数上,缓冲区大小建议从 4KB 开始,逐步增加到 1MB,观察吞吐变化。
3.2 寄存器配置顺序:先复位,再使能,最后启动
EP 侧的 DMA 引擎通常有一组控制寄存器。正确的顺序是:先写复位位,等待复位完成;然后配置描述符环基地址和长度;接着使能中断;最后写启动位。顺序错了可能导致引擎挂死。下面是一个配置函数:
// 假设寄存器基地址为 reg_base void dma_start(uint32_t desc_base, uint32_t desc_num) { writel(0x1, reg_base + DMA_CTRL_RESET); // 复位 while (readl(reg_base + DMA_CTRL_RESET) & 0x1); // 等待复位完成 writel(desc_base, reg_base + DMA_DESC_BASE); // 描述符环基地址 writel(desc_num, reg_base + DMA_DESC_NUM); // 描述符数量 writel(0x1, reg_base + DMA_INT_ENABLE); // 使能中断 writel(0x1, reg_base + DMA_CTRL_START); // 启动 DMA }逻辑说明:复位位写 1 后需要轮询直到硬件清零,否则后续配置无效。描述符环基地址必须是总线地址,不是 CPU 虚拟地址。参数上,desc_num 建议设为 64,太大占用内存,太小容易断流。
3.3 触发一次传输并验证数据一致性
配置完成后,往描述符里填源地址、目的地址和长度,然后更新尾指针。RC 侧等待中断,中断到来后比较源缓冲区和目的缓冲区的内容。下面是一个简单的校验循环:
// 填充源缓冲区 for (int i = 0; i < 1024; i++) { src[i] = i & 0xFF; } // 触发 DMA dma_trigger(src_phys, dst_phys, 1024); // 等待中断,超时 1 秒 if (wait_for_interrupt(1000) < 0) { printf("DMA timeout\n"); return -1; } // 比较数据 for (int i = 0; i < 1024; i++) { if (src[i] != dst[i]) { printf("mismatch at %d: src=%d dst=%d\n", i, src[i], dst[i]); return -1; } } printf("DMA transfer OK\n");逻辑说明:wait_for_interrupt 通常通过 read 系统调用或 poll 实现。数据比较时注意缓存一致性问题,如果 RC 侧开了 cache,需要先 invalidate 目的缓冲区再读。参数上,超时时间根据传输长度调整,1MB 传输一般不超过 100ms。
3.4 用 perf 和示波器看真实带宽
跑通之后,下一步是看性能。软件层面可以用 perf 统计中断次数和 CPU 占用,硬件层面可以用示波器抓 TLP 波形。下面是一个 perf 命令示例:
# 统计中断次数和上下文切换 perf stat -e interrupts,context-switches -a sleep 5 # 查看 PCIE 链路速率和宽度 lspci -vv -s 01:00.0 | grep -i "lnksta"逻辑说明:interrupts 事件反映 DMA 完成中断的频率,context-switches 反映用户态和内核态的切换开销。lnksta 显示当前链路速率和宽度,比如 5GT/s x4 表示 Gen2 x4。参数上,如果链路速率低于预期,检查 RC 和 EP 的链路训练是否完成,以及参考时钟是否稳定。
4. 避坑指南:PCIE DMA 例子跑不通的五个常见原因
4.1 现象:DMA 启动后没有任何中断,寄存器读回全是 0
原因:EP 侧的 BAR 空间没有正确映射,或者 RC 侧根本没有枚举到设备。解决:先在 RC 侧执行 lspci 确认设备存在,如果不存在,检查 EP 的配置空间是否返回了正确的 Vendor ID 和 Device ID。如果设备存在但寄存器读回 0,检查 BAR 地址是否被操作系统重新分配过,用 lspci -v 看实际映射地址。
4.2 现象:中断来了,但数据比较时发现部分字节错乱
原因:缓存一致性没有处理。RC 侧如果开了 cache,DMA 写入的内存可能还在 cache 里,CPU 读到的是旧数据。解决:在读取目的缓冲区之前,调用 dma_sync_single_for_cpu 或手动 invalidate cache。如果是用户态程序,用 mmap 映射时加上 O_SYNC 标志,或者用 clflush 指令刷新缓存行。
4.3 现象:传输小数据正常,超过 64KB 就挂死
原因:描述符环溢出或者 DMA 引擎内部 FIFO 深度不够。解决:把大传输拆成多个描述符,每个描述符长度不超过 4KB。同时检查描述符环的尾指针更新逻辑,确保引擎取描述符的速度跟得上软件填充的速度。如果引擎支持链式描述符,用链式模式代替环式模式。
4.4 现象:MSI 中断只来一次,后续传输没有中断
原因:MSI 的 Message Data 被硬件固定,或者中断状态位没有清除。解决:在中断服务程序里读一下 DMA 状态寄存器,确认中断原因,然后写 1 清除。如果 MSI 只触发一次,检查 EP 侧是否在每次传输完成后都重新写 Message Data,有些引擎需要手动触发 MSI 写事务。
4.5 现象:链路速率显示 Gen1 x1,带宽远低于预期
原因:参考时钟不稳定,或者链路训练时协商失败。解决:检查 EP 侧的参考时钟是否满足 PCIE 规范要求的 100MHz ±300ppm。如果时钟没问题,尝试降低链路速率到 Gen1 先跑通,再逐步提高到 Gen2 或 Gen3。另外,检查 RC 侧的链路控制寄存器,确认没有强制降速。
5. 进阶技巧:用分散聚集 DMA 和预取描述符压榨带宽
跑通最小例子之后,真正决定项目成败的是带宽利用率。我一般会做两件事:一是把连续传输改成分散聚集模式,让描述符指向不连续的物理页,这样用户态缓冲区不需要大块连续内存;二是预取描述符,在引擎执行当前描述符时,软件提前填好后面几个,减少引擎等待时间。下面是一个分散聚集描述符的示例:
// 分散聚集描述符,每个描述符指向一个 4KB 页 struct sg_desc { uint64_t src_addr; uint64_t dst_addr; uint32_t length; uint32_t flags; } __attribute__((packed)); // 填充 16 个描述符,覆盖 64KB 数据 struct sg_desc descs[16]; for (int i = 0; i < 16; i++) { descs[i].src_addr = src_phys + i * 4096; descs[i].dst_addr = dst_phys + i * 4096; descs[i].length = 4096; descs[i].flags = 0x3; // 中断使能 + 有效 }逻辑说明:每个描述符对应一个物理页,这样即使源缓冲区在虚拟地址上不连续,也能正确传输。flags 设为 0x3 表示最后一个描述符产生中断,其他描述符不产生中断,减少中断次数。参数上,描述符数量根据传输总长度除以页大小计算,建议不超过 256,否则环管理开销会抵消收益。
预取描述符的做法是:在启动 DMA 之前,先把前 8 个描述符写好并更新尾指针,然后在中断服务程序里只补充已经完成的描述符,而不是等所有描述符都完成再重新填充。这样引擎始终有活干,不会因为软件响应慢而空转。我实测过,在 Gen2 x4 链路上,预取能把有效带宽从 600MB/s 提升到 900MB/s 左右,接近理论值的 80%。
验证带宽是否达标,可以用一个简单的计时循环:记录传输开始和结束的时间戳,用总字节数除以时间差。注意时间戳要取在 DMA 启动之前和中断收到之后,中间不要有打印或 sleep。如果带宽低于预期,先看链路速率和宽度,再看描述符长度是否太小,最后检查中断处理是否过于频繁。我习惯把中断合并打开,比如每 16 个描述符才产生一次中断,这样 CPU 占用能降一半。
最后说一个血泪教训:调试 DMA 时,永远先用小数据量跑通,再逐步加大。我见过太多人一上来就传 1MB,结果引擎挂死,连寄存器都读不了,只能重新烧录。另外,描述符环的基地址一定要按引擎要求的对齐方式对齐,通常是 64 字节或 128 字节,不对齐会导致引擎取到错误的描述符。希望帮到你。
本文还有配套的精品资源,点击获取