PCIE DMA例程深度解析:从数据通路到性能优化
2026/9/23 6:27:44 网站建设 项目流程

简介:一套围绕PCIe DMA技术从FPGA端到主机端完整落地的示例工程,面向驱动开发、FPGA逻辑设计或高速数据传输应用的工程师,帮助理解BMD(Bus Master DMA)工作模式、描述符管理与中断处理流程。压缩包共41个文件,以Verilog源码(.v)、C/C++代码(.c/.h)为主,同时包含Xilinx约束文件(.ucf/.xst)、Windows驱动安装信息(.inf)与驱动文件(.sys),并附带cab驱动包、exe上位机程序及makefile/批处理脚本,可分别对应硬件逻辑、驱动安装和软件调用三个层面。资源包约1.76MB,其中还包含setup.exe与安装配置文件,便于在仿真或实机上快速搭建验证环境。目前已有485人学习下载,适合需要借鉴BMD框架实现高速DMA传输、调试PCIE中断与带宽瓶颈的开发者,也可作为FPGA与Windows驱动联合调试的起步参考。

1. PCIE DMA 例子是什么:解压前先搞清楚它在解决什么问题

解压一个 PCIE DMA 例子.7z,很多人第一反应是找 README,第二反应是直接 make。但一个 PCIE DMA 例程在这两个动作之间,藏着的是整套数据通路:驱动怎么找到设备、描述符怎么排队、门铃怎么触发、中断怎么收尾。PCIE 链路本身是物理层的功夫,DMA 才是把链路带宽变成实际吞吐的关键。这类例程最典型的场景是高速数据采集、网卡和存储卡的原型验证,适合手里有一块 FPGA 板卡或一颗 PCIE 控制器、想把数据快速搬进系统内存的人。例程的价值不是开箱即跑,而是让你少走从零写数据通路的弯路。

拿到这种压缩包,先别急着编译。搞清楚它包含哪几部分:Linux 驱动源码、用户态测试程序,如果配套 FPGA 逻辑,可能还有 RTL 或者 bit 流。例程里的驱动通常已经实现了 probe、DMA 描述符管理、中断处理和字符设备接口,你要做的是理解它,然后改到自己的板卡上。

2. PCIE DMA 怎么搬运数据:数据通路、描述符和中断,先立住三个概念再动手

2.1 从 CPU 搬运到 DMA 搬运:为什么 PCIE 场景下 DMA 几乎是唯一选择

PCIE 链路的 LTSSM 走到 L0 状态,配置空间能被 RC 枚举到,TLP 才能在主机和外设之间流动。这时候你面对一个现实问题:数据怎么从板卡进内存?

用 CPU 做搬运工是可行的,但代价很高。每笔 PCIE 读写事务都有地址、长度、标签等开销,CPU 一次读一个寄存器可能只有几十字节的有效数据。吞吐一上来,CPU 占用率直接拉满,Cache 还被污染得厉害。DMA 引擎的存在就是把这件事从 CPU 手里接过去——外设侧的 DMA 控制器自己发起读请求或写请求,直接把数据搬到内存里的指定缓冲区,搬完再发一个中断通知你。

DMA 不是 PCIE 的专利,MCU 上的串口 DMA、SPI DMA 也是同样的思路:外设数据到了,硬件自动搬运,CPU 只在开始和结束时参与。但 PCIE DMA 有个特殊之处——它的地址空间是系统物理地址,不是外设自己的地址。描述符里填的是物理地址,翻译由 IOMMU 或直通逻辑完成。你在 STM32 上写 DMA 的经验到这里只能当参考,不能直接套,因为描述符、门铃、中断完成机制完全是另一套复杂度。

2.2 描述符环和门铃机制:例程里最核心的数据结构

PCIE DMA 例程的核心不是中断,也不是寄存器,而是描述符。描述符是一个结构体,描述了“这段数据要从哪里搬到哪里、搬多长、搬完怎么标记”。软件把多个描述符放进一块内存,形成环形队列,硬件按顺序消费它们。常见的描述符结构长这样:

struct dma_desc { u64 src_addr; /* 源地址:H2C 时是外设侧地址,C2H 时是系统内存地址 */ u64 dst_addr; /* 目的地址:对应另一侧 */ u32 len; /* 本段搬运的字节数 */ u32 ctrl; /* 控制位:是否生成中断、是否最后一段 */ u32 status; /* 完成状态:硬件搬完回写 */ u32 next; /* 指向下一个描述符的地址,环状时可能用不到 */ };

字段的意义很清楚:src_addr 和 dst_addr 决定了搬运的起止,len 决定了这段要搬多少,ctrl 里的中断位决定搬完要不要打扰 CPU,status 是硬件写回来的完成标记。例程里通常有两个环:H2C(Host to Card)和 C2H(Card to Host),每个方向各一个生产者消费者队列。

软件准备好描述符后,写一次门铃寄存器,硬件才去取描述符。门铃的作用是告诉 DMA 引擎“队列里有新活”。读描述符、搬运、回写 status、发中断,这一串动作全由硬件完成。整个过程里 CPU 只做了两件事:填描述符和写门铃。这就是 PCIE DMA 效率高的根本原因。

注意:门铃通常是一块 4KB BAR 空间里的一个偏移。改例程时最容易翻车的点就是门铃偏移——驱动里写的是寄存器偏移,不是物理地址。

2.3 读方向和写方向:H2C 与 C2H 的差异

H2C 是主机发往外设的数据,比如你要往板卡上的 DDR 写一段配置或波形;C2H 是外设发往主机的数据,比如采集卡采到的数据送进内存。这两个方向在例程里往往各有一套描述符环和完成队列。

C2H 方向通常比 H2C 方向难调。原因在于地址端:主机侧缓冲区要提前分配好,物理地址固定,而且需要按对齐要求给出。很多例程会用一个dma_alloc_coherentdma_map_single分配的缓冲区,让硬件可以直接写入。如果你从用户态传一个普通 malloc 的地址下来,驱动必须先把页表钉住、做地址映射,否则 DMA 搬运到一半,页面被换出,数据就错了。

H2C 方向的问题往往出在长度上:有些例程的长度字段是 16 位的,单次最多 64KB。你一次提交 1MB 的数据,硬件可能只搬走低 16 位能表示的长度,剩下的数据停在队列里,表现为“传输卡死”。这种事不是代码 bug,是描述符字段位宽的限制,很多例程要自己拆分大块传输。

3. PCIE DMA 例程第一次跑不通:5 个必查的避坑点

3.1 链路没起来:lspci 里根本没有这张卡

现象:插上板卡后lspci看不到任何新设备,驱动 probe 根本不会被调用。

原因:PCIE 枚举过程是 RC 在复位后发配置请求来完成的。如果板卡的 LTSSM 卡在 Detect 或 Polling 阶段,RC 侧根本不知道这个设备存在,后续一切免谈。

解决:先查物理层。供电和 100MHz 差分参考时钟是第一步,金手指没插到位也很常见。然后确认主板上这个 PCIE 插槽本身是好的。lspci -vvv可以看到根端口下的总线状态,对照热词里“pcie ltssm阶段的configuration阶段”的说法——如果根端口显示链路 Down,问题几乎都在物理层,不用急着怀疑代码。

3.2 驱动加载成功但 DMA 传输超时:门铃写进去了没反应

现象:驱动 probe 通过了,/dev设备节点也出现了,但每次启动 DMA 都卡死在等待完成的地方,看dmesg没有任何错误。

原因:门铃寄存器调用错了。PCIE 设备的寄存器空间通过 BAR 映射,驱动拿到的是pci_resource_start()返回的物理地址,ioremap 之后才能用 readl/writel 访问。门铃的偏移在例程里写死了,但你的板卡 BAR 布局和例程不同,写到了错误的地址上。

解决:先用lspci -vvv查看设备的 BAR 资源,确认寄存器空间落在哪个 BAR 和大小。然后用devmem直接读门铃对应偏移,确认硬件是否有反应。驱动里加一个模块参数暴露门铃地址,调试期从命令行传入,省得反复编译。

3.3 小包能传大包卡死:长度字段位宽和地址对齐

现象:传 1KB 数据正常,传 1MB 数据时,要么只传了一部分,要么直接超时。

原因:描述符的长度字段是 16 位或 24 位,单次传输长度超过上限;另一个常见原因是缓冲区首地址没有按 64 字节对齐,硬件要求对齐才能启动搬运。

解决:把大块数据拆成多个描述符,每个描述符的长度控制在字段上限以内。地址对齐问题用ALIGN()宏处理,或者在分配缓冲区时直接按页对齐。很多例程里都会有一个dma_map_single()的调用,检查传入的地址是不是已经被内核对齐过。

3.4 中断风暴:CPU 占用率 100%

现象:DMA 能传输,但完成一次搬运后 CPU 占用率飙升,系统响应变慢,top里可以看到中断占用接近饱和。

原因:中断完成位没有在中断处理函数里清掉,或者清的顺序不对。硬件发出中断后,软件读完 status 寄存器,必须写回完成标志,硬件才会停止拉高中断线。还有一个常见情况:MSI/MSI-X 配置不正确,导致中断一直在触发。用 MSI 时,软件侧清的是设备内部寄存器,不是中断控制器。

解决:仔细看例程里的中断处理函数,确认读 status 之后有没有写回。再看驱动的pci_alloc_irq_vectors()参数,MSI 和 MSI-X 的申请方式不同,中断号也不一样。用cat /proc/interrupts看中断号是否对应到设备。

3.5 带宽只有理论值的一半:MPS 和 MRRS 的参数在拖后腿

现象:链路显示 Gen3 x4,理论上 4GB/s 的带宽,实际测下来只有 2GB/s 左右,读方向尤其明显。

原因:PCIE 报文大小不匹配。MPS(Max Payload Size)影响写方向,MRRS(Max Read Request Size)影响读方向。DMA 读方向发起的读请求如果被 RC 限制在 512B,效率会大打折扣,因为要发很多个请求才能凑满一页。

解决:lspci -vvv 里看 DevCtl 的 MaxPayload 和 MaxReadReq 字段。驱动里在初始化时主动设置设备的 MaxReadRequest,用pcie_set_readrq()把 MRRS 提上去。很多例程默认没做这一步,性能差距在这里体现得最明显。

4. 把例程改到自己的板卡:BAR 映射、寄存器偏移和中断号的三处改动

4.1 先搞清楚例程是为哪块板写的:看厂商 ID 和设备 ID

改例程的第一步不是改代码,而是确认设备的识别信息。例程的驱动里通常有一个pci_device_id表,列出它支持的厂商 ID 和设备 ID。你的板卡如果用的 FPGA 型号不同,ID 很可能对不上。

static const struct pci_device_id my_pcie_ids[] = { { PCI_DEVICE(PCI_VENDOR_ID_XILINX, 0x9038) }, { PCI_DEVICE(PCI_VENDOR_ID_XILINX, 0x903F) }, {} }; MODULE_DEVICE_TABLE(pci, my_pcie_ids);

这个表决定了设备枚举时驱动会不会绑定它。拿到板卡后,先用lspci -n读出实际的 vendor ID 和 device ID,填到这里。FPGA 的 device ID 通常是 bit 流里设定的,同一个 IP 不同版本生成的 ID 可能不一样。改完别忘update-pciids或用 lspci -n 直接确认。

4.2 改 BAR:从 256MB 映射到 4KB 的寄存器空间

PCIE 设备可以有好几个 BAR,每个 BAR 映射一段物理空间。常见做法是 BAR0 放寄存器控制块,BAR1 放 DMA 缓冲区或大块存储空间。例程里可能默认映射了 BAR0 和 BAR2,你的板卡可能只有 BAR0 和 BAR1。

static int my_pcie_probe(struct pci_dev *pdev, const struct pci_device_id *id) { resource_size_t bar0_start, bar0_len; void __iomem *bar0_virt; bar0_start = pci_resource_start(pdev, 0); bar0_len = pci_resource_len(pdev, 0); bar0_virt = pci_ioremap_bar(pdev, 0); if (!bar0_virt) { pci_disable_device(pdev); return -ENOMEM; } pci_set_drvdata(pdev, bar0_virt); return 0; }

pci_ioremap_bar 会把 BAR0 的物理地址映射到内核虚拟地址空间。注意 bar0_len 是从配置空间读出来的,不是你想映射多大就映射多大。如果你的寄存器块只有 4KB,而 BAR 声明的是 256MB,那是 bit 流里配的问题,驱动侧不用管,只管读写对应偏移。

寄存器偏移是另一个坑。例程里访问门铃的代码是writel(value, bar0_virt + DOORBELL_OFFSET),DOORBELL_OFFSET 这个宏是例程作者定义的,不一定适用于你的硬件。对照你的寄存器手册,确认门铃、状态、控制寄存器各自的偏移,改宏定义而不是改调用点。

4.3 中断从 MSI 换到 MSI-X,或反过来

同一套 DMA 逻辑,在不同 RC 平台上的中断行为可能差很多。例程默认用 MSI,你的平台可能支持 MSI-X 更稳定;或者例程用 MSI-X,但你的 FPGA 配置没把 MSI-X 的 table 加上。改动集中在pci_alloc_irq_vectors的调用参数上。

int nr_vecs = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI); if (nr_vecs < 0) { nr_vecs = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_LEGACY); } err = request_irq(pci_irq_vector(pdev, 0), my_pcie_irq_handler, IRQF_SHARED, "my_pcie", pdev);

pci_alloc_irq_vectors 的第一个参数是设备,第二第三个参数是期望的最小和最大中断向量数,第四个是中断类型标志。改成 PCI_IRQ_MSI_X 就是 MSI-X。注意 MSI 在有些老主板上只支持一个向量,MSI-X 可以支持多个,多队列 DMA 必须用 MSI-X。request_irq 之前用 pci_irq_vector 取得实际中断号,不要在 probe 里写死。

4.4 地址映射:别忽略 IOMMU 对 DMA 地址的影响

如果平台开启了 IOMMU,驱动里拿到的物理地址并不一定是设备实际使用的地址。PCIE DMA 使用的是 DMA 地址,dma_map_single返回的才是设备侧能用的地址。例程如果没有做 DMA API 的映射,拿到物理地址直接填描述符,在开启了 IOMMU 的平台上会搬运失败。

dma_addr_t dma_handle; void *cpu_addr; cpu_addr = dma_alloc_coherent(&pdev->dev, buf_size, &dma_handle, GFP_KERNEL);

dma_alloc_coherent 返回两个地址:CPU 侧虚拟地址和 DMA 侧地址。描述符里填的是 dma_handle,不是 cpu_addr。反过来,读取完成状态时要用 cpu_addr。这个双地址模型是 PCIE DMA 驱动最容易出错的地方,很多时候驱动“看起来是对的”,但搬运结果全是错的,问题就出在这里。

5. DMA 例程的性能验证:带宽、延迟和缓存一致性

5.1 用自写工具测吞吐:读方向容易虚高

例程跑通以后,第一件事不是上业务,而是测吞吐。dd 可以用来做粗测,但 dd 走的是块设备路径,并不适合直接验证字符设备的 DMA 吞吐。常见做法是写一个小工具,用 mmap 映射驱动里的 DMA 缓冲区,然后循环触发 DMA 搬运,记录时间。

int fd = open("/dev/my_pcie", O_RDWR); void *buf = mmap(NULL, BUF_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); struct timespec t0, t1; clock_gettime(CLOCK_MONOTONIC, &t0); for (int i = 0; i < LOOP_COUNT; i++) { ioctl(fd, CMD_START_DMA, &cfg); ioctl(fd, CMD_WAIT_DONE, NULL); } clock_gettime(CLOCK_MONOTONIC, &t1);

mmap 返回的地址是用户态虚拟地址,对应的物理页已经被驱动钉住。这里有一个经典的虚高陷阱:如果测试 C2H 方向时,硬件写进来的数据没有真正被 CPU 读走,Cache 不回写,DMA 缓冲区里的数据可能还停留在 Cache 中,导致下一次搬运覆盖了旧数据,测试结果看起来很快但数据是错的。刷 Cache 的操作在用户态不可控,所以严格测试要在驱动侧做dma_sync_single_for_device或使用一致性 DMA 映射。

5.2 用 lspci -vvv 确认链路速率和宽度

在 Ubuntu 上查看 PCIE 速率,最直接的方式是lspci -vvv。输出里找到你的设备节点,看 LnkSta 一行的 Speed 和 Width。Speed 代表链路速率,Width 代表通道数,两者的组合决定理论带宽。

链路速率编码单通道带宽 (GB/s)x4 带宽 (GB/s)x8 带宽 (GB/s)
Gen1 (2.5GT/s)8b/10b0.251.02.0
Gen2 (5GT/s)8b/10b0.52.04.0
Gen3 (8GT/s)128b/130b0.9853.947.88
Gen4 (16GT/s)128b/130b0.9857.8815.75

理论值按字节算,实际能到 70% 就算正常。如果 LnkSta 显示 Speed 是 2.5GT/s 而你的 FPGA 支持 Gen3,原因是链路训练降级了,PCIE 协议会在训练阶段协商出一个双方都支持的最低速率。影响降级的因素包括 PCB 走线长度、连接器质量和参考时钟稳定性。

带宽上不去的另一个疑点是 MPS 和 MRRS。查看 DevCtl 字段里的 MaxPayload 和 MaxReadReq 值,如果 MaxPayload 是 128B,写方向性能会明显受限;如果 MaxReadReq 是 512B 或更小,读方向性能会明显受限。这个参数可以在这个设备的 -vvv 输出里直接看到,也可以在驱动里用pcie_set_readrq()在 probe 阶段显式设置。

5.3 Cache 一致性和屏障:DMA 完成以后数据真的在内存里吗

DMA 完成后,驱动在中断处理里读取描述符的 status 字段,确认搬运完成,然后唤醒等待的进程。这里有一个关键问题:CPU 读到 status 字段的时机,可能早于硬件把数据写到内存的时机。因为 PCIE 写事务是 posted 的,硬件可能已经发出写请求,但数据还驻留在 RC 侧的写缓冲里。

解决方式是用 DMA 屏障。Linux 下的dma_rmb()mb()确保读操作发生在写请求真正落盘之后。描述符的 status 字段本身是硬件写回的,CPU 读它的时候需要保证之前的读取不会被重排。在驱动代码里找到wait_event之前的读取操作,确认有一道屏障,否则调试时会出现“偶尔数据错位”的玄学问题。

6. 从例子到产品:在例程驱动里加一个 debugfs 手动触发节点

例程跑通只是起点,产品化时最常见的需求是脱离用户态工具独立验证 DMA。很多场景下没有屏幕、没有串口,只能靠/sys/debug看状态。我给例程驱动加一个 debugfs 节点,用来手动触发一轮 DMA 并把寄存器快照打出来,这个技巧在排查门铃和中断问题时特别有效。

static ssize_t dbg_trigger_write(struct file *file, const char __user *buf, size_t count, loff_t *pos) { struct pci_dev *pdev = file->private_data; void __iomem *bar0 = pci_get_drvdata(pdev); struct dma_desc desc = {0}; desc.src_addr = dma_handle; desc.len = TRIGGER_SIZE; desc.ctrl = 1; /* 完成时产生中断 */ memcpy_toio(bar0 + DESC_ADDR_OFFSET, &desc, sizeof(desc)); writel(1, bar0 + DOORBELL_OFFSET); pr_info("dbg: desc written, len=%u\n", desc.len); return count; }

这个节点做的事情很简单:构造一个描述符、写门铃、打印日志。引脚和数据内容都不重要,关键是让 DMA 引擎动起来,然后你可以在dmesg里看中断有没有触发、status 有没有被回写。有一次我就是靠这个节点,在没有上位机的情况下抓到了门铃偏移写错的问题——寄存器写下去了,但硬件没有任何动作,对照寄存器手册才发现门铃偏移多算了 0x10。

产品化时,这个 debugfs 节点还能升级成“DMA 环回测试”入口:把 H2C 搬过去的数据再通过 C2H 搬回来,比较内容是否一致,用来验证链路质量。如果你要加多队列支持,也可以从这里开始扩展——每个队列一个 debugfs 节点,逐个验证。

PCIE DMA 例子.7z 这类压缩包,本质是一张地图,地图画的是数据从板卡到内存的完整路径。照图走一遍,比什么都强。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询