简介:PCIE DMA例子.7z是一份面向FPGA开发者和Windows驱动工程师的PCIE DMA示例项目,完整覆盖从FPGA端硬件逻辑、驱动层到主机端应用程序的整套实现链路,可为高速数据传输场景下的驱动开发提供一套可运行的参考实现。压缩包共包含41个文件,其中17个Verilog源文件(.v)构成FPGA实现核心,用于描述DMA读/写控制逻辑;同时提供头文件(.h)、C源代码(.c)以及工程约束文件(.ucf)、仿真脚本(.sh/.bat)、makefile和sources文件,便于在Xilinx等环境中完成编译与调试。驱动侧配有.inf安装信息和.sys驱动文件,应用侧提供Windows安装程序及配置组件,可以直接在Windows系统上安装测试。整体包体仅1.76MB,结构紧凑但功能完整,已有484人学习下载。借助这个例子,读者能够学习PCIE DMA的基本工作流程,参考基于BMD(Bus Master DMA)的FPGA实现方式,了解Win32应用程序如何与驱动交互完成数据传输,并可通过示例中的描述符管理、中断处理等代码,搭建自己的验证环境,判断驱动与硬件的交互是否正常,从而有效缩短学习曲线和项目交付周期。
1. PCIE DMA 例子包:先把“搬运”这件事想对
拿到“PCIE DMA 例子.7z”时,别急着解压跑编译。这个压缩包里的代码再多,本质上只演示了一件事:把主机内存的读写权交给 PCIe 端点,让数据在内存与 FPGA/加速卡之间流动,CPU 只负责摆好描述符、按一下门铃,然后等完成通知。内核态有 DMA API,用户态有 mmap,但例子把两层都绕开了,直接操作寄存器。理解这包代码,你就能看懂 lspci 输出、BAR 地址和 TLP 预算之间的关系。我拿到这种包会先按配置空间、最小代码、参数调优、排错四部分拆,最后把它改成能压测的工具。下面直接按这个顺序讲,目标是让你看完能把自己手头的那块板卡跑出数。
2. PCIE 配置空间、BAR 与 TLP:DMA 描述符的寻址基础
在打开任何 DMA 例子的 .c 文件之前,先补一下 PCIe 端的底层逻辑。DMA 不是凭空把数据搬走,它要回答三个问题:设备在哪、内存地址是什么、怎么把它拆成总线事务。这三个问题的答案都写在配置空间和 TLP 格式里,跑例子时经常遇到的“地址错”“数据缺一段”,根子大多在这里。
2.1 枚举过程与配置空间:DMA 引擎在哪里被“发现”
主机上电时,PCIe 枚举器扫描每个 bus/dev/fn,给设备分配资源。这个过程的最终结果是一张资源表,存到配置空间里。DMA 引擎一般作为 Endpoint 暴露,配置空间里最关键的是 Base Address Registers(BAR),也就是 0x10 到 0x24 这几个寄存器。BAR 里存放的是 host 分配的物理地址窗口,代表这个设备的一小块“门面”,软件通过它访问设备内部的寄存器,包括 DMA 控制寄存器、门铃、状态。
sudo lspci -vvv -s 01:00.0 | grep -A5 Region输出中的 Region 0 就是 BAR0 的物理地址和大小。如果例子代码里写死了某个地址,先对一下这里的实际值。更深入的配置空间可以用setpci直接读:
sudo setpci -s 01:00.0 0x10.l这个命令读出配置空间偏移 0x10 的 4 字节,低 4 位是 region type 和 prefetch 标志,高位才是基地址。设备驱动里常看到pci_resource_start(dev, 0),它返回的就是这段窗口的物理地址,随后会被 ioremap 成内核虚拟地址。
关于配置空间大小,PCIe 扩展配置空间(0x100 之后)里还可能有 Vendor Specific、ACS、ATS 等等。对于纯 DMA 例子,你至少要知道设备是不是支持 ATS(Address Translation Services)。如果 device 想直接发起 DMA 到进程虚拟地址,就可能用到 ATS/ATC,否则只能做物理地址或 IOMMU 映射后的地址。热词里常说的“pcie配置空间详解”,其实落到驱动里就是这几项:Vendor ID、Device ID、Command/Status、BAR、MSI-X Capability。
2.2 inbound 与 outbound:方向不同,地址转换方式也不同
很多人在 PCIE DMA 例子里卡住,是因为没理清方向词汇。从 Endpoint 的角度看:
- Endpoint 发出的写请求(MWr)是向 RC 方向的,对于 RC 来说,这是 inbound write。一般用于 DMA 从设备搬运数据到主机内存。
- Endpoint 发出的读请求(MRd)也是 inbound read,RC 会返回 completion with data。用于主机内存数据搬到设备。
- 相反,CPU 访问端点 BAR 发出的 MRd/MWr,对于 RC 是 outbound 的,也叫 MMIO 访问。
| 方向(RC 视角) | TLP 类型 | 典型用途 | 地址来源 |
|---|---|---|---|
| Inbound Write | MWr | DMA 从板卡到主机内存 | 主机物理地址经转换 |
| Inbound Read | MRd+Cpl | DMA 从主机内存到板卡 | 同上 |
| Outbound | MRd/MWr (MMIO) | 访问端点控制寄存器 | 配置空间 BAR |
这个表里最容易被忽略的是“地址来源”。当 DMA 拿到一个主机地址,它不能直接用虚拟地址,必须是一个物理地址或经过 IOMMU 转换的 IOVA。例子代码里往往用dma_alloc_coherent分配 buffer,并拿到对应的 dma_addr_t;板卡固件拿到这个地址后填充到描述符。
2.3 描述符环与连续/离散请求:scatter-gather 和 continuous requests
描述符是 DMA 的最小工作单,通常 16 或 32 字节,包含源地址/目的地址、长度、标志。多个描述符组成环形队列,软件写 producer index,设备读 consumer index。例子里的tx_ring和rx_ring就是两个这种环。
对于大块传输,硬件一般支持两种模式。一种是 continuous requests,称为连续请求,要求内存物理连续;另一种是 scatter-gather(SG),把一个大 buffer 用多段不连续物理页拼出来。你在一些热词里看到的“离散式 DMA scatgather”就是这个。SG 的好处是不再依赖分配大块连续内存,但每个描述符只能指向 4K 页,所以大传输要消耗更多描述符。
这里要提醒:dma_alloc_coherent并不保证给你超大型连续内存。如果例子里的 buffer 大于几十 MB,往往会退化到 SG 模式。所以看 TCP/DMA 时,不要只看硬件标称的连续请求能力,还要看驱动有没有开 SG。
3. 从 .7z 到一次内存搬运:跑通最小 PCIE DMA 例子的完整路径
这章我们按最常见的 Xilinx/Intel FPGA PCIe DMA 例子包结构来拆。这个包通常包含三块:Linux 内核驱动、FPGA bitfile/逻辑代码、用户态测试程序。你不需要全部看懂,只需要抓住驱动里的probe、open、ioctl或mmap这三处。
3.1 解压与目录结构:先分清驱动、固件和测试程序
拿到 .7z(实际也可能是 .tar.gz):
mkdir pcie_dma_example && cd pcie_dma_example 7z x ../PCIE\ DMA例子.7z tree -L 2如果系统没装 7z,用sudo apt install p7zip-full。目录结构一般如下:
├── driver/ │ ├── dma_driver.c │ ├── dma_driver.h │ └── Makefile ├── fpga/ │ ├── pcie_dma.xprj │ └── src/dma_engine.v ├── host/ │ ├── dma_test │ ├── dma_test.c │ └── README └── scripts/ └── load_driver.shdriver里的是内核模块,fpga里是端点的 RTL 实现,host里的 dma_test 是跑在用户态的工具。你要做的最小验证路径是:先把驱动编译加载让 BAR 可见,再用 dma_test 发起一次写事务并读回数据。
3.2 映射 BAR 并拿到 DMA 引擎的寄存器地址
驱动里常见的 probe 片段:
static int dma_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_dma_dev *dev; int err; dev = kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; err = pci_enable_device(pdev); if (err < 0) goto free_dev; /* 启用总线主控,否则端点无法发起 DMA 请求 */ pci_set_master(pdev); err = pci_request_regions(pdev, "pcie_dma"); if (err < 0) goto disable_dev; /* BAR0 通常放控制寄存器,BAR1 放描述符环 */ dev->bar0 = pci_ioremap_bar(pdev, 0); dev->bar1 = pci_ioremap_bar(pdev, 1); return 0; }逻辑说明:pci_set_master这一行不能省。没有它,Endpoint 发起的 MWr/MRd 会被 RC 拒绝。pci_ioremap_bar把物理地址映射成内核虚拟地址,之后读写dev->bar0里的偏移就是在访问控制寄存器。你可以在 BAR0 的某个版本寄存器读几个字节验证映射是否成功。
3.3 初始化描述符环并写门铃:一次最小 DMA 写
DMA 写(从 FPGA 到内存)的流程,代码剥出来就那么几行:
dma_addr_t hw_addr; struct desc *ring = dma_alloc_coherent(dev->dev, RING_SIZE * sizeof(struct desc), &hw_addr, GFP_KERNEL); ring[0].src = cpu_to_le64(fpga_addr); /* 板卡内部源地址 */ ring[0].dst = cpu_to_le64(skb_dma_addr); /* 主机内存 DMA 地址 */ ring[0].len = cpu_to_le32(buf_len); ring[0].ctrl = DESC_CTL_IRQ_EN; /* 完成后产生中断 */ wmb(); /* 保证描述符先写入内存 */ iowrite32(1, dev->bar0 + DOORBELL_OFFSET); /* 写门铃,设备开始搬运 */参数说明:
src是板卡 BRAM/DDR 的地址,不是主机地址;dst是主机内存经过 DMA API 映射后的物理地址,虚拟地址在这里没有任何意义;ctrl位域里的 IRQ_EN 让设备在完成这次搬运后触发一次 MSI/MSI-X,例子代码默认开启;- 写完描述符后必须用
wmb()(写内存屏障)再写门铃。顺序反了,设备会读到还没有落盘的描述符。
然后在轮询方式下:
while (!(ring[0].status & DESC_STAT_DONE)) udelay(1);或者用 wait_queue 在中断里唤醒。
关于一致性:dma_alloc_coherent分配的是非缓存一致性内存,不需要手动dma_sync_*。如果例子包为了性能改用kmalloc+dma_map_single,就要注意在 DMA 写(设备到内存)后用dma_sync_single_for_cpu刷新 cache。读描述符时也一样,轮流加dma_rmb()。这一点在调试时非常容易碰到“数据看起来是脏的、缺了一段”的现象,十有八九不是逻辑错,而是 cache 同步缺失。
另外要检查 DMA mask。如果硬件需要 32 位地址,就要在 probe 里加dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64))。设得过大或过小,dma_alloc_coherent都可能返回地址,但设备收不到,表现为“门铃写了、中断不回来”。这一行必须写在pci_set_master之后。
4. 从“能跑”到“能稳住”:PCIE DMA 的关键参数与故障排查
例子包能跑一次搬运,不代表能跑满带宽。这章讲我在调每一种 PCIE DMA 板卡时都会动的参数,以及怎么从 dmesg 和lspci输出定位“reset 失败”这类问题。
4.1 PCIE DMA 例子里的五个必调参数
| 参数 | 示例值 | 影响 | 调参方向 |
|---|---|---|---|
| Max Payload Size (MPS) | 256B / 512B | 单次 TLP 最大数据量 | 两端一致,优先 256,遇到 unexpected completion 就降 |
| 描述符环深度 | 256/1024 | 吞吐与 CPU 开销 | 深度越大越扛突刺,但会吃内存 |
| 对齐粒度 | 4096 | 防止宽带效率下降 | 描述符地址和长度都对齐 cache line |
| 缓存/一致性策略 | coherent/sync | 数据正确性 | 设备写内存后必须做同步 |
| 门铃合并 | bool | 小包延迟 vs CPU 负载 | 低频高负载开 interrupt moderation,关合并在延迟敏感场景 |
MPS 是 PCIe 链路层决定一次 TLP 能装多少数据。虽然链路带宽是固定的,但 MPS 设置错了,RC 会回Unexpected Completion,DMA 直接卡死。例子代码里一般给 512 或 256,如果你在 dmesg 里看到 “Unsupported Request” 或ECRC报错,先把 MPS 改成和 BIOS 里一致的值。描述符深度方面,dma continuous requests 适合大块连续内存,SG 模式适合不连续小页。深度 1024 的环可以在 100 微秒内吸收大部分 DMA 背压。
还有一类容易忽略的参数是 read request 大小。PCIE DMA 的读吞吐往往没有写吞吐高,因为读操作要多一次 completion 往返。把 MRRS(Max Read Request Size)从 128B 提到 256B 或 512B,对顺序读的带宽提升非常明显。注意 MPS 影响写 payload,MRRS 影响读请求,这两个值可能不同,但最好都别低于 256。
4.2 常见报错:failed to reset the dma 和其他驱动初始化问题
“failed to reset the dma”这种日志在 PCIE DMA 身上出现得很勤。它不是单个原因,而是驱动在 probe 或 open 阶段等设备状态超时,可能原因包括:
- 设备还没加载 FPGA 固件就碰了复位寄存器;
- 门铃写得太早,描述符环还没有放到物理内存;
- 软件复位等待标志读回来不是预期值,说明 BAR 没映射对。
排查顺序:
dmesg | tail -20 sudo lspci -vvv -s 01:00.0 | grep -E "Memory|Status"先确认设备状态寄存器的 “Master Enable” 位。如果 BAR 地址是 0 或者无效,那 reset 失败只是个边缘症状。还可以在驱动代码里给 reset 等待加打印:
while (--timeout) { if (readl(dev->bar0 + RST_DONE_OFFSET) & RST_DONE_MSK) break; udelay(10); } if (!timeout) dev_err(&pdev->dev, "failed to reset the dma\n");这种超时循环是例子里的固定套路。关键在于检查readl返回的值是不是全 1 或全 0,如果是 0xffffffff,说明 BAR 没有正确映射,也许访问到了不存在的地址。另一个隐蔽原因是你的 PCIe 插槽在 BIOS 里被设成非 Gen 模式,设备链路没 up,寄存器根本没亮,这时请在dmesg里看link up是否出现。
另外,MSI/MSI-X 中断丢掉也会让你觉得 reset 成功后仍然搬运不完整。在lspci -vvv里看 MSI-X 是否 enable,确认 nvec_count 和驱动请求数一致。中断申请失败时,很多驱动会静默回退到轮询,这时候 dma_test 可能也能跑,但 CPU 占用率异常高。
4.3 用 dma_test 做一次带宽测算:测量结果如何解释
用户态dma_test一般提供类似-r 1 -l 4096 -n 1000的参数:读写方向、包长和次数。测出来的“带宽”受几个因素影响:描述符切换、中断回收、以及是否用轮询。
简单的带宽计算可以用下面脚本思路:
start=$(date +%s%N) ./dma_test -w -l 4096 -n 50000 end=$(date +%s%N) bytes=$((4096 * 50000)) echo "$bytes / (($end - $start)/1000000000)" | bc但这里要泼一盆冷水:50000 次 4KB 传输代表的是“小包 IOPS”,不是 PCIe 链路带宽。如果你要测真实吞吐,需要发连续的大块数据,比如每轮 2MB,并且用双缓冲让硬件不停。例子包里的测试程序往往默认小包,所以你会看到带宽只有几百 MB/s,别急着怪硬件。想测出接近 PCIe Gen3 x4 的 3.2GB/s,必须把 queue 深度和 payload 充分发挥。
顺带一提,不要把 PCIE DMA 和串口 DMA 混为一谈。串口 DMA 的环形缓冲是 CPU 触发、外设按字节填充的小型传输模型,而 PCIE DMA 描述符环里每个描述符可以指向几 MB 的地址区间,两者连便利贴和数据库都算不上同一个概念。
5. 把例子包改造成自己的带宽测试工具:轮询、多队列与中断合并
例子包的 dma_test 只适合验证流程,不适合压测。我一般会做三处改动。
第一,把完成检测从中断改成轮询,或者混合模式。中断在每秒百万点的时候会吃掉大量 CPU,轮询则可以换取极低延迟。
struct desc *desc = &ring[tail]; unsigned long now = jiffies; while (!(READ_ONCE(desc->status) & DESC_STAT_DONE)) { if (time_after(jiffies, now + 2 * HZ)) { dev_err(dev, "DMA timeout at ring %d\n", queue_id); break; } cpu_relax(); }这里的cpu_relax()会生成 pause 指令,防止读状态寄存器空转导致流水线停顿。time_after是内核比较 jiffies 的宏,不能用>直接比较,避免 32 位回绕。
第二,为每个 CPU 建独立 ring。分配时把每个 ring 大小对齐到 cache line,并给结构体加 padding,避免两个 CPU 写同一个缓存行互相拖慢。在 NUMA 机器上,dma_alloc_coherent可能返回任意 node 的内存,你需要在调用前设置dev->dev.numa_node,干脆绑定到当前 CPU。多队列的另一个好处是门铃写操作被分散到不同 BAR 偏移,硬件侧也能并行处理。
第三,注意 MPS 与 completion 对齐。如果你测试时发现带宽突然掉到 1/4,先查 RC 的 Max Read Request Size(MRRS)。MRRS 和 MPS 不一样,它影响一次读请求最多取多少数据,很多 BIOS 默认是 512 而 FPGA 逻辑按 256 组包,就会卡在读数据扇出。把setpci里的 MRRS 和 MPS 都明确设成同一值,再把 128B payload 换成 256/512,带宽提升通常立竿见影。我在 Gen3 x4 的板卡上把队列深度从 256 调到 1024、MRRS 调到 512 后,DMA 写带宽从 1.8GB/s 涨到 3.0GB/s 附近,CPU 占用反而下来了。这个数值可以作为你手头板卡调优的参照起点。
本文还有配套的精品资源,点击获取