简介:面向FPGA与PCIe驱动开发者的PCIE DMA示例工程包,压缩包内包含FPGA端BMD(总线主控DMA)实现、Windows内核驱动源码、Win32应用程序以及安装程序,完整覆盖从硬件RTL逻辑到上位机读写验证的开发链路。资源共包含41个文件,主要由17个Verilog源文件(.v)和C/C++头文件与源码(.h/.c)构成,同时提供.inf驱动配置、.sys驱动二进制、.exe安装器及.bat/.cmd辅助脚本,使开发者能够参考DMA控制器、描述符管理、中断处理和缓冲收发等核心逻辑;压缩包内还有基于BMD框架的寄存器配置与约束文件,便于移植到不同FPGA工程。压缩包仅1.76MB,却典型呈现了DMA引擎的关键模块,对理解PCIe总线上DMA读写的时序与握手过程很有帮助。已有484人下载学习,适合正在调试PCIe链路、想深入理解DMA在FPGA与Windows驱动间如何协同工作的工程师,也可作为课程设计、毕业设计或快速搭建验证平台的起点。
1. 从BMD例子拆PCIe DMA:一个7z压缩包里藏着完整的高速传输链路
拿到“PCIE DMA例子.7z”这个包,第一反应是它比想象中完整:目录里不是一两个零散文件,而是FPGA实现(fpga implement)、Windows驱动(win32_driver)和上位机性能工具(win32_application)三件套都齐了。一个“例子”能做到这个粒度,通常是Xilinx经典的BMD(Bus Master DMA,总线主控DMA)参考设计,也就是FPGA侧带DMA引擎、Windows侧带驱动和带宽测试程序的那套标准样板。这篇拆解顺着这条链路走:先说DMA描述符和BAR映射怎么运转,再讲FPGA侧BMD引擎怎么接、驱动与安装怎么落,最后收在做性能验证和常见故障上。做FPGA高速数据采集、自研PCIe板卡或者刚接手DMA驱动调试的人,可以沿着这份包把整个搬运流程串起来。
2. PCIe DMA架构剖析:描述符、BAR0寄存器与读写TLP
2.1 BMD与PIO:为什么数据面必须交给总线主控
PCIe端点访问系统内存有两种方式:PIO(Programmed I/O)和DMA。PIO是软件通过MMIO读写发起TLP,每次访问都要CPU参与,地址由CPU发起“内存读/写请求”,对大数据吞吐毫无意义。BMD则是让FPGA里的DMA引擎作为PCIe总线事务的主动发起者,CPU只需要把DMA描述符准备好,写一个“开始”寄存器,引擎就会自动按描述符里的地址和长度发起Memory Read/Memory Write TLP,传输完成后再用中断或状态轮询通知软件。这套机制在Xilinx参考设计里已经固化,压缩包里的pcie_demo.sys驱动就是围绕它写的。
PIO通道在中低速逻辑分析、配置寄存器回读时还有价值;只要涉及连续搬移几十MB以上数据,BMD是唯一现实的选择。原因很直接:一次Memory Write TLP最大净荷(MPS)通常只有128到512字节,如果每个TLP都由软件发起,CPU必然成为瓶颈。BMD引擎可以连续把一整块描述符指向的数据拆成背靠背TLP,硬件流水线饱和后,带宽才能接近PCIe链路理论值。
2.2 描述符链:一次DMA搬运的“快递单”格式
所有DMA传输的参数都集中在一张描述符表里。结合此压缩包内驱动源码的常见做法,描述符按16字节对齐组织,四个DWORD字段的含义如下。
typedef struct _BMD_DESCRIPTOR { uint32_t dst_addr_low; // 目标地址低32位 uint32_t dst_addr_high; // 目标地址高32位 uint32_t control; // bit[15:0]传输长度(字节);bit[16]中断使能;bit[31]链结束 uint32_t next_ptr; // 下一条描述符物理地址,0表示结束 } BMD_DESCRIPTOR;这个结构对C2H(卡到主机,FPGA往内存写)和H2C(主机到卡,从内存读数据回FPGA)都适用,区别只在字段语义:C2H时dst_addr是主机物理内存地址,数据源是FPGA侧BRAM或DDR;H2C时dst_addr换成FPGA内部目标地址,源地址是主机内存。描述符里的next_ptr把多条传输串成环或链表,这就是硬件批量搬运的核心,也是驱动实现scatter-gather与DMA连续请求的基础。
描述符一定要放在物理连续且不可换页的内存里,Windows驱动中通常用MmAllocateContiguousMemory或WdfCommonBufferCreate分配,否则地址表本身无法被DMA引擎访问。下面是驱动侧一次性提交三段不连续缓冲区,组成链表交给引擎的典型流程。
// 驱动提交三段物理不连续的缓冲区,串成描述符链 BMD_DESCRIPTOR desc[3]; PHYSICAL_ADDRESS desc_phys = WdfCommonBufferGetAlignedLogicalAddress(dmaBuffer); for (int i = 0; i < 3; i++) { desc[i].dst_addr_low = (uint32_t)(user_buf_phys[i] & 0xFFFFFFFF); desc[i].dst_addr_high = (uint32_t)(user_buf_phys[i] >> 32); desc[i].control = (len[i] & 0xFFFF) | 0x10000; // 每段开中断 desc[i].next_ptr = (i < 2) ? desc_phys.LowPart + (i+1)*16 : 0; } // 把首地址写入BAR0,硬件开始读取描述符链 WRITE_REGISTER_ULONG((ULONG*)bar0 + 0x04, (ULONG)desc_phys.LowPart); WRITE_REGISTER_ULONG((ULONG*)bar0 + 0x08, (ULONG)desc_phys.HighPart); WRITE_REGISTER_ULONG((ULONG*)bar0 + 0x00, 0x01); // 启动C2H引擎这段代码的逻辑是:先分配公共缓冲区放描述符本身,再把三个物理不连续的用户缓冲区地址填进三个描述符,用next_ptr串成链。写bar0+0x04和bar0+0x08表示把首描述符地址交给引擎,最后写控制寄存器才是真正的触发。有一个点要特别提醒:描述符控制字段的长度位宽只有16位,硬件侧最大单次长度就是64KB,超过必须拆段,这也是很多人第一次传1MB数据失败的主要原因。
2.3 BAR空间:寄存器就是DMA引擎的控制台
PCIe设备通过BAR向外暴露寄存器窗口,BMD参考设计一般只用BAR0,里面按固定偏移定义了DMA引擎的控制、状态和描述符指针寄存器。典型偏移如下。
| 偏移 | 名称 | 位含义 |
|---|---|---|
| 0x00 | 控制寄存器 | bit0:C2H开始;bit1:H2C开始;bit2:引擎复位 |
| 0x04 | 描述符指针低32位 | C2H首描述符地址 |
| 0x08 | 描述符指针高32位 | C2H地址高32位,32位系统恒为0 |
| 0x0C | 状态寄存器 | bit0:C2H忙;bit1:H2C忙;bit2:完成中断事件 |
| 0x10 | 完成计数 | 每次DMA完成自增,软件可用来校验 |
这个布局并非所有厂商完全一致,但控制、指针、状态三件套的逻辑是通用的。这套寄存器对驱动可见的前提,是PCIe枚举过程已经为设备分配了BAR地址,配置空间Type 0 Header里的BAR0字段在枚举后会被写入系统分配的地址。要理解BMD的数据流向,还应该区分inbound和outbound:BAR窗口映射的是inbound地址,即主机侧看到的FPGA资源;而DMA引擎发起的是outbound访问,即FPGA作为发起者去读写主机内存。两者方向相反,但共用同一套TLP格式。判断BAR0能否被软件访问,是驱动调试的第一步,下文第4章会讲驱动如何映射这段地址。
3. 从Vivado工程到BMD IP:FPGA侧DMA引擎的配置矩阵
3.1 PCIe核与User Logic:寄存器到底落在谁家
BMD工程在Vivado里打开后,会看到PCIe IP核(例如7系列下的axi_pcie,或UltraScale+下的pcie4_uscale_plus)和外层用户DMA控制逻辑两个部分。PCIe IP负责物理层、数据链路层和事务层,把TLP解包后转成AXI接口。BAR0地址空间不会自动变成寄存器,而是通过IP的AXI-Lite从接口暴露给用户逻辑,DMA控制器的寄存器要由用户逻辑自己在axi_awaddr/axi_wdata这些信号上解码。这是第一次打开工程时最困惑的点:寄存器不是放在硬核里,而是放在FPGA的可编程逻辑里。
常见做法是写一段简单的地址译码逻辑完成BAR0到控制寄存器的映射,实现上可以模仿Xilinx官方example design里的bmd_control模块。它对AXI-Lite写事务按偏移分发,下面是最小实现。
// 用户逻辑中的AXI-Lite地址译码,映射BAR0偏移0x00~0x18范围 always @(posedge axi_aclk) begin if (axi_awvalid && axi_awready) begin case (axi_awaddr[7:0]) 8'h00: ctrl_reg <= axi_wdata[2:0]; // DMA控制 8'h04: desc_ptr_low <= axi_wdata; // 描述符低位 8'h08: desc_ptr_high <= axi_wdata; // 描述符高位 endcase end end这段逻辑本身很简单:主机驱动写BAR0+0x04时,AXI-Lite写通道携带地址和数据,译码后存进内部寄存器。写地址通道的握手条件axi_awvalid && axi_awready保证一次写事务完整提交。最容易漏掉的是返回写响应,AXI-Lite协议要求每笔写事务必须回bresp,不然驱动侧的WRITE_REGISTER_ULONG会卡死或超时。接口里还有读通道araddr/rdata需要做类似处理,供上位机读取状态和完成计数。
3.2 描述符解析引擎:构建H2C/C2H状态机
BMD引擎读取描述符后要产生真正的数据搬运。C2H方向的典型状态机是:空闲 → 读描述符地址 → 等待描述符数据 → 发Memory Write TLP → 检查next_ptr→ 继续或回到空闲。Xilinx模板里用一个FSM实现,用户逻辑需要根据AXI接口把TLP映射成读写事务。以C2H为例,一个可复现的最小状态机骨架如下。
typedef enum {IDLE, RD_DESC, RD_DATA, WR_TLP, DONE} bmd_state_t; bmd_state_t state, next; always_ff @(posedge clk) begin if (rst) state <= IDLE; else state <= next; end always_comb begin next = state; case (state) IDLE: if (ctrl_reg[0]) next = RD_DESC; RD_DESC: if (desc_ram_valid) next = RD_DATA; // 描述符取到 RD_DATA: if (data_cnt == desc_len) next = WR_TLP; WR_TLP: if (tlp_done) next = DONE; DONE: next = (desc_next == 0) ? IDLE : RD_DESC; endcase end状态机的含义很直白:启动位拉起来后,先取描述符,按长度从FPGA内部缓冲读数据,拼成TLP发出,完成后再决定是否取下一条描述符。这里的desc_next对应结构体里的next_ptr。实际工程中要额外处理地址跨4KB边界、TLP最大读请求长度拆分和完成超时,否则高负载下容易挂死。状态机还有一个容易忽略的细节:RD_DESC阶段读的是主机内存里的描述符,本身也是一次DMA读操作,必须有独立的超时判断,否则描述符地址写错时整个引擎会卡在等待上。
3.3 中断与阈值参数:让主机知道数据到了
传输完成通知有两条路:轮询状态寄存器和MSI中断。BMD工程里status_reg的完成位通常常开,驱动在等待事件时用DPC中断与KeWaitForSingleObject结合。中断映射在PCIe核侧做,用户逻辑只需在完成时对中断控制器拉一个脉冲。关键点在于阈值控制:如果每段描述符都发中断,块大小很小、描述符很多时中断频率会非常高,CPU直接被打满。参考驱动和上位机PCIe_Perf的做法是对完成计数做阈值判断,累计到一定次数再上报。
| 参数 | 典型值 | 影响 |
|---|---|---|
| 单段描述符长度 | 4KB~64KB | 太小则描述符开销占比过高 |
| 中断聚合阈值 | 16~32次完成 | 太小CPU占用上升,太大延迟上升 |
| MPS/MRRS | 256B或512B | 决定单TLP有效载荷效率 |
中断聚合的代价是应用侧延迟增大,适合吞吐优先的场景;如果驱动要做实时响应,应该把阈值调小,或者为控制通道单独保留一条低延迟中断路径。这一节调参时建议在驱动里把“完成计数”和“中断次数”分别打点,两个数字一对比,很快能判断瓶颈在数据路径还是事件通知路径。
4. Windows驱动链路:从oemsetupXP.inf到PCIe_Perf.CAB的安装与首跑
4.1 驱动包结构:inf、sys与source三个角色的分工
win32_driver目录里三个关键文件分工明确:oemsetupXP.inf是驱动安装描述文件,告诉系统设备ID、驱动版本和文件清单;pcie_demo.sys是编译好的内核驱动程序;source目录里放的是驱动源码,配合WDK的build命令可以重新编译。这个包虽然叫XP,是因为参考驱动的WDF版本比较老,但只要配置得当,Win7到Win11 64位系统也能加载。
打开oemsetupXP.inf会看到[Manufacturer]段里标注的硬件ID,与设备配置空间的Vendor ID、Device ID绑定。以Xilinx参考驱动为例,Vendor ID是0x10EE,Device ID随IP核配置变化,比如0x7011。这个ID必须和FPGA里PCIe IP的IDS值一致,否则设备在Windows设备管理器里会一直显示“未知设备”或带感叹号的PCI设备。修改FPGA工程的Device ID后,inf文件里对应字段必须同步改。
4.2 安装与签名:从禁用强制签名到驱动加载验证
Windows 10/11 64位系统装这个老驱动的第一道门槛是驱动签名。操作路径是:设置 → 更新与安全 → 恢复 → 高级启动 → 立即重新启动 → 疑难解答 → 高级选项 → 启动设置 → 重启后选择“禁用驱动程序强制签名”。重启后右键oemsetupXP.inf选择“安装”,或在设备管理器里手动定位到该目录更新驱动。
:: 以管理员身份运行,查看设备是否枚举成功 pnputil /add-driver oemsetupXP.inf /install :: 查询驱动是否成功加载 driverquery /v | findstr pcie_demo第一条命令把驱动加进驱动库并安装,第二条命令验证pcie_demo.sys有没有真正被加载。很多时候安装后设备管理器里没有新设备,原因不是驱动,而是FPGA侧的电源、时钟或复位时序导致PCIe链路没有起来。设备管理器里如果出现设备但资源列表没有任何内存窗口,基本可以判定是BAR空间枚举失败,要回头查FPGA工程,而不是继续调驱动。
4.3 上位机:setup.exe与PCIe_Perf.CAB背后的性能测试逻辑
win32_application里的setup.exe、SETUP.LST和PCIe_Perf.CAB是InstallShield安装包的三件套:SETUP.LST记录组件路径,PCIe_Perf.CAB是压缩数据文件,setup.exe负责解压和注册表写入。安装后生成的PCIe_Perf.exe通过设备IOCTL向驱动发DMA请求,做的事可以概括成三步:打开设备句柄、分配页面锁定的缓冲区、逐个发起不同长度的DMA传输并计算耗时。
上位机与驱动之间的命令约定由一组IOCTL码维护,常见划分如下。
| IOCTL码 | 功能 |
|---|---|
| IOCTL_DMA_PERF_START | 发起一次DMA传输并计时 |
| IOCTL_DMA_GET_STATUS | 读取完成计数与状态寄存器 |
| IOCTL_DMA_RESET_ENGINE | 复位DMA引擎 |
// 上位机调用驱动的核心片段 hDev = CreateFile("\\\\.\\PciePerf", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); DeviceIoControl(hDev, IOCTL_DMA_PERF_START, &req, sizeof(req), &result, sizeof(result), &bytesRet, NULL); printf("DMA size=%u KB, time=%llu us, BW=%.2f MB/s\n", req.sizeKB, result.timeUs, req.sizeKB * 1024.0 / result.timeUs);DeviceIoControl的IOCTL码在驱动和上位机两端必须一致,通常由公共头文件统一约定。PCIe_Perf的测试逻辑是跑一组固定档位,如4KB、16KB、64KB、256KB、1MB,并分别测H2C和C2H两个方向。这也是判定板卡DMA链路是否正常的金标准:如果跑出的结果是0或者几十MB/s,问题几乎可以确定在描述符、BAR映射或中断路径。
5. 性能验证与调参:DMA带宽上不去的七类原因
5.1 先用链路协商值和TLP统计定位瓶颈
把板卡插上机器并成功加载驱动后,第一步不是直接跑PCIe_Perf,而是确认物理链路状态。驱动调试信息或配置空间读取工具里,重点看两个字段:Link Speed(Gen1为2.5GT/s,Gen2为5GT/s,Gen3为8GT/s)和Link Width(x1/x2/x4/x8)。举例,预期Gen2 x4约2GB/s,实际协商只有Gen2 x1,先查参考时钟和复位时序,而不是去调软件。链路参数确认后,把MSI中断关掉改成轮询状态寄存器再跑一次测试,如果带宽明显提升,说明中断频率拖累了吞吐;如果没变化,瓶颈大概率在描述符读取或数据路径。
5.2 高频失败原因与处理对照
| 现象 | 根因 | 对策 |
|---|---|---|
| 传输完成但数据全零 | C2H的FPGA源地址没接对地址空间 | 检查BRAM读地址与描述符长度 |
| 超过64KB传输卡死 | 描述符长度字段16位溢出 | 单段长度拆到64KB以内 |
| 驱动装完设备仍带感叹号 | INF的DeviceID与IP核配置不一致 | 同步修改INF或FPGA工程ID |
| DeviceIoControl超时 | AXI-Lite写通道没有回bresp | 补全写响应通道 |
| 带宽只有理论值的一半 | MPS/MRRS配置偏小 | 把MPS和MRRS调成256或512 |
| CPU占用率100% | 小包频繁触发中断 | 按完成计数阈值合并中断上报 |
| Win10蓝屏IRQL_NOT_LESS_OR_EQUAL | 用户态缓冲没锁页或地址不连续 | 改用MDL或CommonBuffer |
补充一个多见于scatter-gather传输的场景:描述符链里的next_ptr没有同步更新,驱动第二次提交时DMA引擎接着读旧链,表现就是上位机第一次测试正常、后面数据错乱。排查时在驱动里回读完成计数,与上位机发送的段数比对,如果计数少于段数,说明描述符链在中途断裂。从这七类里逐条排除后,一块正常的BMD参考设计在Gen2 x4链路下可以跑到接近1.5到1.6GB/s的稳定带宽,继续往上就要换XDMA IP配合多队列和更大的MPS了。
本文还有配套的精品资源,点击获取