1. 项目概述
先说结论:我在这块主控为RK3588的板卡上,外接了一片自带PCIe接口的FPGA加速卡,为了让整套链路先跑起来,我做了XDMA的AXI-Stream回环测试。所谓回环,就是数据从主机端通过PCIe写进FPGA的AXI-Stream接口,再由FPGA把数据原样送回来,主机端接收并校验。这条通路看似简单,却把Verilog逻辑、XDMA驱动、PCIe枚举、Rust上位机这几层全部串了起来。
这个项目适合正在做异构计算、FPGA加速卡、或者想在ARM平台上验证PCIe DMA链路的工程师参考。不管你是刚接触XDMA,还是已经在RK3588上折腾过PCIe设备,这篇避坑指南都能帮你少走一段弯路。
我之所以选择RK3588做主机端,主要有三个原因:第一,RK3588自带PCIe 3.0控制器,支持Root Complex模式,可以直接挂FPGA板卡;第二,RK3588的CPU性能足够跑Linux,可以加载标准xdma内核驱动,适合做原型验证;第三,整个平台能装Ubuntu或者Debian系系统,开发体验比在嵌入式RTOS里舒服太多。
当然,回环测试本身并不难,难的是它把硬件设计和软件调试串在一起,任何一个环节出问题都会让链路不通。这篇文章我会按照从FPGA侧到主机侧的思路,把完整的实现和避坑经验写出来。
2. 整体设计与方案选型
2.1 为什么选择XDMA和AXI-Stream
Xilinx(现在是AMD)的XDMA IP核在PCIe DMA场景里几乎是事实标准。它内部集成了DMA引擎,可以把FPGA侧的AXI-Stream接口直接映射成主机内存的读写操作,驱动侧也已经很成熟。RK3588虽然有自己的PCIe控制器,但FPGA厂商提供的IP和驱动基本都围绕XDMA展开,用它能最大程度避开“驱动从零写”的坑。
AXI-Stream本身是Xilinx定义的一种流式总线协议,和AXI-Lite、AXI-Full不同,它不需要地址线,只有数据、有效和就绪信号。它的优势在于适合做高吞吐、连续性的数据流传输,比如ADC采样数据、图像像素流、DMA搬运。DMA和AXI-Stream结合,正好就是PCIE高带宽场景下最常见的组合。
我在这块FPGA里把AXI-Stream回环逻辑放在了PCIe BAR空间可访问的寄存器控制之下。也就是说,软件可以通过读写一个控制寄存器,决定数据从哪里来、到哪里去,这种方式非常适合先做通链路再做业务逻辑的开发节奏。
2.2 主从接口划分与整体架构
在FPGA内部,我按功能拆成了几个模块:
- XDMA IP核的AXI-Stream Master接口,负责接收来自主机的数据。
- XDMA IP核的AXI-Stream Slave接口,负责向主机方向发送数据。
- 回环控制模块,内部包含数据生成器、数据校验器、寄存器组和FIFO。
主机端写数据时,XDMA会从主机内存把数据读出来,转换成AXI-Stream Master的报文,送进FPGA逻辑;FPGA逻辑收到后,把数据原样送入AXI-Stream Slave接口,再由XDMA通过PCIe DMA写回主机内存。整个链路数据流是:主机内存 -> PCIe -> XDMA -> AXI-Stream Master -> FPGA回环逻辑 -> AXI-Stream Slave -> XDMA -> PCIe -> 主机内存。
听起来很绕,实际上就是要验证两个方向都通:主机写方向以及主机读方向。任何一个方向断开,回环测试都会卡住。
在选型上我特别强调一点:主机端软件不要用C而要用Rust。原因后面细说,但你可以把Rust理解成“带安全气囊的C”,在驱动控制这种需要直接操作寄存器和内存的场景里,它既能保持和C接近的性能,又能用类型系统挡住不少低级错误。
2.3 Rust做主上位机的优势
写 PCIe 设备控制程序,传统做法是用 C 或者 C++,因为 Linux 下 ioctl 和 mmap 都是 POSIX 接口,C 调用最直接。但我在这套项目里用了 Rust,而且还把整个上位机拆成了一个可复用的库(crate)。原因有三:
一是内存安全。DMA buffer 的分配、映射、回收,用 C 写很容易在边界条件下踩空指针或者越界访问。Rust 的所有权和借用检查在编译期就把这些问题挡掉了一大部分,特别是在多线程并发读写 GPIO 寄存器或者 DMA 描述符队列时,这一点尤其重要。
二是类型表达能力强。寄存器字段可以用 bitfield 或者 newtype 模式清晰地描述,例如一个控制寄存器的第 0 位是 loopback 使能,第 1 位是数据生成器启动,用 Rust 的 bool 包装后,代码自解释,回头看也不会怀疑“这一位到底是不是使能”。
三是工具链舒服。cargo 管理依赖、Rust 的测试框架、println 宏、trait 抽象,写起测试程序比 makefile 加 C 代码要顺手得多。尤其是你需要在多个平台复用同一套主机端逻辑时,Rust 的 cross-platform 能力完全是加分项。
3. FPGA侧AXI-Stream回环逻辑的Verilog实现要点
3.1 AXI-Stream接口时序
AXI-Stream的握手是基于 valid/ready 的。发送方拉高 tvalid,接收方在拉高 tready 时表示可以接收,双方都在高电平时完成一次数据传输。如果没有回环,TX 和 RX 是两条独立的链路,但在回环模式下,我直接把 master 接口的数据和 slave 接口相连——从逻辑上看,主机写入的数据经过 FPGA 转一圈又回到主机。
这个设计的要点在于阻塞和反压。XDMA 发送侧的 master 接口如果不能及时消费数据,valid 拉低后发送方向要能暂停;同样,接收侧如果 FIFO 满了,slave 接口的 tready 要拉低。回环逻辑里我特意加了一个同步 FIFO 缓存中间数据,避免时序上出现死锁。
下面这段代码是我在回环路径上的核心状态机:
module axis_loopback #( parameter DATA_WIDTH = 64, parameter ADDR_WIDTH = 12 )( input wire clk, input wire rst_n, // AXI-Stream slave interface (from XDMA to FPGA) input wire [DATA_WIDTH-1:0] s_axis_tdata, input wire s_axis_tvalid, output reg s_axis_tready, input wire s_axis_tlast, // AXI-Stream master interface (from FPGA to XDMA) output reg [DATA_WIDTH-1:0] m_axis_tdata, output reg m_axis_tvalid, input wire m_axis_tready, output reg m_axis_tlast, // control and status input wire loopback_en, output reg error_flag, output reg [31:0] recv_count );这里我用了参数化的数据位宽,实际工程里定在64位,也就是8字节每拍,正好对齐PCIe的带宽。回环使能信号 loopback_en 来自寄存器组,由软件控制。未使能时,master 接口不往外发数,避免链路空闲时产生无意义数据流。
3.2 数据生成、校验和计数逻辑
回环测试要想有意义,必须在校验上做文章。我写了两个内部模块:一个数据生成器(pattern generator)和一个校验器(checker)。
数据生成器产生的是一个递增64位计数器值,每当收到一个 tready 握手时,计数器加8,表示8字节的递增数据。这样每个数据包的内容都不是固定的,防止链路把“固定全零”也当作有效结果。
校验器在接收侧工作:每收到一个64位数据,就和本地维护的期望值比对,若不匹配则拉高 error_flag 并锁存错误状态。为了避免同时加入太多逻辑导致时序收敛困难,我还额外设计了状态寄存器,软件可以直接读取 recv_count 和 error_flag 来判断测试结果。
这个模块的关键在于地址和状态管理。我分配了一组32位寄存器映射到 AXI-Lite 接口上,这样软件通过 BAR0 空间就能直接访问。寄存器偏移分配如下:
- 0x00:控制寄存器(bit0 = loopback_en,bit1 = generator_en,bit2 = clear_error)
- 0x04:状态寄存器(bit0 = error_flag,bit1 = link_up)
- 0x08:接收计数器低32位
- 0x0C:接收计数器高32位
需要注意的是,AXI-Stream 的 tready 信号不能一直置高。如果链路还没有建立,就绪提得太早可能会导致回环数据覆盖。这里我用了一个双口RAM做缓冲,当 RAM 空间有至少一个包的空位时才打开 tready。
3.3 时钟域和复位处理
RK3588 的 PCIe 控制器给 FPGA 提供的是 100MHz 参考时钟,XDMA IP 内部的用户逻辑时钟由 PCIe 的 AXI 接口时钟域决定。如果 FPGA 内部还有其他外设,建议回环模块尽量只工作在 XDMA 的用户时钟域里,减少跨时钟域问题。
但实践里,很多情况下 FPGA 主时钟和 PCIe 用户时钟来自不同 PLL,因此跨时钟域不可避免。我的建议是:回环路径上的 FIFO 数据读写使用不同的时钟域,至少保证数据路径上有一级异步FIFO双口隔离,控制寄存器则保持同步。我自己就在这个位置犯过一次错误——直接拿用户时钟去采样跨时钟域的写请求,导致偶发数据丢失,后来改成异步FIFO才稳定。
复位方面需要注意 XDMA IP 和用户逻辑的复位同步释放问题。使用异步复位同步释放电路,避免在复位释放时出现亚稳态。如果复位时序处理不当,DMA 启动后数据校验不通过,很容易让人误以为问题是 PCIe 链路问题。
4. XDMA驱动与RK3588硬件联调
4.1 加载xdma内核模块
我用的 FPGA 工程在 PCIe 枚举后显示为一个 Vendor ID 为 0x10EE 的设备,XDMA 驱动在 Linux 内核里已经有现成实现,但一般发行版内核不带,需要编译安装。如果你的系统使用的是自编译内核,需要确保打开了 CONFIG_PCI 和 CONFIG_DMA_ENGINE 等选项。
下述命令用于加载驱动模块并查看设备状态:
insmod xdma.ko lspci -v -d 10ee:如果 lspci 能正确列出来,说明硬件链路基本没问题。如果 lspci 看不到设备,先检查 FPFA 的 PCIe 链路是否完成握手(一般看 FPGA 的 PCIe PHY 状态寄存器,或者 link up 指示灯),再去检查你的 PCIe 时钟和复位电路。
RK3588 上 PCIe 控制器默认有四个 lane,分配方式取决于设备树配置。我之前遇到过一个问题:设备树里给 PCIe 接口分配的 lane 数少于 FPGA 所需要的 lane 数,结果 lspci 里的设备带宽显示只有 x1,性能一直上不去。后来查了 TRM,发现 RK3588 的 PCIe 2 和 PCIe 3 控制器存在 lane 复用,需要根据板卡原理图正确配置设备树。
4.2 DMA缓冲区和内存分配
XDMA 驱动加载后,会通过字符设备接口提供 /dev/xdma0_control 和 /dev/xdma0_c2h_0 这类节点,分别对应控制通道和 DMA 通道。用标准 open/read/write/ioctl 就能完成一轮DMA传输。
但这其中有一个非常容易踩的坑:DMA buffer 的内存对齐。PCIe 的 DMA 引擎通常要求物理地址对齐到 4KB 或者 64KB,有的还要求起始地址的低 12 位为 0。如果你直接 malloc 一块内存,物理地址和对齐往往不满足要求。
我的解决方式是使用 CMA(连续内存分配器)或者通过 mmap 映射 xdma 驱动自己分配的内核缓冲区。在 Rust 里,我封装了一个DmaBuffer结构,内部通过/dev/xdma0_control的 mmap 拿到 kernel 分配的物理连续内存,并返回用户空间地址。这里贴一段简化的 Rust 代码:
use std::fs::{OpenOptions, File}; use std::os::unix::io::AsRawFd; use memmap2::{MmapOptions, MmapMut}; pub struct DmaBuffer { map: MmapMut, size: usize, } impl DmaBuffer { pub fn allocate(control: &File, size: usize) -> std::io::Result<Self> { // 通过 ioctl 向驱动申请物理连续的 DMA 缓冲区 // 这里会调用 XDMA_MEM_IOCTL_ALLOCATE 等自定义 ioctl let fd = control.as_raw_fd(); // 省略具体 ioctl 调用,假设已经拿到 mmap offset 和长度 let map = unsafe { MmapOptions::new().len(size).map_mut(control)? }; Ok(Self { map, size }) } pub fn as_slice(&self) -> &[u8] { &self.map[..self.size] } pub fn as_mut_slice(&mut self) -> &mut [u8] { &mut self.map[..self.size] } }需要说明的是,真正使用时,驱动会把一个 fd 当作 allocator,把 DMA 缓冲区的物理地址映射到用户态。Rust 端只管拿 mmap 得到虚拟地址操作,物理地址细节完全被封装掉。
4.3 中断和完成队列
另一个容易出问题的点是 DMA 完成通知机制。XDMA 驱动在 C2H(Card to Host,即 FPGA 向主机写数据)方向,会注册中断处理程序,当 FPGA 侧收到足够的数据后,触发 MSI/MSI-X 中断,驱动把完成事件通知给用户态。如果中断配置不对,传送数据永远卡在等待状态。
排查中断问题时,先看/proc/interrupts里是否有对应设备的中断计数在增加。如果中断总数不动,再回头检查 FPGA 侧是否真的把 IRQ 信号拉起来了。有时回环数据链路是对的,但因为没有发送完成中断,DMA 传输就挂在那里。
我在测试中发现 RK3588 的 MSI 配置需要把msi-parent节点正确设置到 GIC 上。如果设备树里缺少这个节点,中断会注册失败,特征就是 dmesg 里报Failed to request irq。
5. Rust上位机程序设计
5.1 整体架构
Rust 上位机我拆成了三大部分:设备发现与初始化、寄存器访问、DMA 数据传输。
设备发现就是扫描/dev/xdma*节点,找到控制通道和 DMA 通道。寄存器访问则通过 mmap BAR0 空间实现,这样读写寄存器和读写普通内存一样简单。DMA 传输部分则封装了发送和接收的一对函数。
下面这段代码展示了如何通过/dev/xdma0_control访问 BAR0 寄存器:
use std::fs::File; use std::os::unix::io::AsRawFd; use memmap2::{MmapOptions, MmapMut}; pub struct XdmaControl { bar0: MmapMut, } impl XdmaControl { pub fn open(path: &str) -> std::io::Result<Self> { let file = File::open(path)?; // 实际操作会基于 lspci 解析 BAR0 大小 // 这里假设映射器获取到 0x1000 长度 let bar0 = unsafe { MmapOptions::new().len(0x1000).map_mut(&file)? }; Ok(Self { bar0 }) } pub fn read_reg(&self, offset: usize) -> u32 { let ptr = self.bar0.as_ptr() as *const u32; unsafe { ptr.add(offset / 4).read_volatile() } } pub fn write_reg(&mut self, offset: usize, value: u32) { let ptr = self.bar0.as_mut_ptr() as *mut u32; unsafe { ptr.add(offset / 4).write_volatile(value) } } }这里有个细节,读写寄存器必须用 volatile 操作。普通的内存访问会被编译器优化掉,你根本不知道实际发了多少次总线事务。Rust 里用read_volatile和write_volatile就和 C 里的volatile作用一样。
5.2 Rust侧的回环测试主流程
回环测试的主流程大致如下:
- 探测并打开XDMA控制节点。
- 配置FPGA内部寄存器,使能回环和数据生成器。
- 申请DMA缓冲区,填入预置数据。
- 启动主机写方向的DMA传输,把数据写入FPGA。
- 启动主机读方向的DMA传输,把FPGA返回的数据读回。
- 比较发送缓冲区和接收缓冲区,输出测试结果和吞吐量。
我把第二步到第六步封装成一个run_loopback_test函数:
pub fn run_loopback_test( control: &mut XdmaControl, tx: &File, rx: &File, buf_size: usize, ) -> Result<TestReport, Box<dyn Error>> { // step 1: 使能回环和数据生成器 control.write_reg(0x00, 0b11); // step 2: 申请DMA缓冲区 let mut tx_buf = DmaBuffer::allocate(control, buf_size)?; let mut rx_buf = DmaBuffer::allocate(control, buf_size)?; // step 3: 填充pattern(递增64位数) for chunk in tx_buf.as_mut_slice().chunks_exact_mut(8) { let val = /* counter */; chunk.copy_from_slice(&val.to_le_bytes()); } // step 4: 发起DMA写方向和读方向传输 let start = Instant::now(); tx.write_all(&tx_buf)?; rx.read_exact(&mut rx_buf)?; let elapsed = start.elapsed(); // step 5: 比较数据 let mismatch = tx_buf.as_slice() .iter() .zip(rx_buf.as_slice().iter()) .filter(|(a, b)| a != b) .count(); Ok(TestReport { bytes: buf_size, mismatch, elapsed, }) }这段代码的可读性比 C 版本好太多。最关键的是,Rust 编译器会在你用 slice 时强制检查越界,一旦你访问了超过 buffer 长度的地方,编译期直接报错,不会像 C 一样刚好把相邻内存写坏导致诡异崩溃。
5.3 多通道和异步处理
如果你测试的业务需要多个通道并行,Rust 的异步生态也能帮上忙。用tokio配合async-std,可以把每个 DMA 通道的读写操作封装成 Future,然后用join_all并发等待。不过我得提醒一句:XDMA 底层的中断驱动模型天然适合事件循环,但如果在 DMA 阻塞读取完成后还要立刻处理大量数据,直接用多线程可能比异步更直观,因为现代 CPU 多核可以并行处理多个通道,同时不需要支付异步运行时的心跳开销。
在我自己的项目里,单通道回环测试用同步阻塞时序就足够,甚至能跑出接近PCIe理论带宽的吞吐数字。等到后续多路数据流并行时,再考虑引入异步。
6. 回环测试流程与性能验证
6.1 寄存器级测试
正式跑数据回环之前,我先做寄存器读写测试。这一步的目的是验证 BAR0 空间映射是否正常、FPGA 内部寄存器读写路径是否通畅。
一套典型的验证命令如下:
# 写入控制寄存器低地址 devmem 0x00 0x03 # 读取状态寄存器 devmem 0x04如果读回来的是 0xFFFFFFFF,说明总线访问没有到达 FPGA,大概率是 BAR 空间映射不对。正常情况读回 0x00000002 或者类似值,表示链路已经建立,中断状态正常。
这一步千万别跳过,很多问题如果在寄存器级拦住,后面可以省下大量时间。我自己在调板时遇到过 eth,Vendor ID 是正常的,但 BAR0 空间长度被 BIOS 分配错了,导致 mmap 之后寄存器读写全部超时,最后花了两个小时才定位到是设备树里ranges配置遗漏。
6.2 数据回环测试
寄存器级测试通过后,再用 Rust 程序跑一次完整回环。我这边跑 1MB 数据,64 位递增 pattern 的情况下,正常结果应该是 mismatch 为 0,recv_count 增加 131072(1MB / 8B)。
如果 mismatch 不为 0,先查看 FPGA 内的接收计数器有没有同步增加。如果 recv_count 没动,说明数据根本没进入检测模块;如果 recv_count 增加但 mismatch 不为 0,说明数据在路径上发生了翻砖,需要检查跨时钟域 FIFO 的位宽是否一致,以及 check 逻辑的期望值初始化是否正确。
我在测试中发现一个很隐蔽的问题:Verilog 里 64 位数据生成器的初值如果写成了 0,而 AXI-Stream 的第一个 beat 数据是 8,那么校验器如果从 1 开始期望,就会一直报错。乍看像链路问题,实际只是两边对首拍期望值不一致。所以我把 pattern 生成逻辑统一成:生成器从 0 开始递增,校验器也从 0 开始期望,保证两端完全一致。
6.3 性能数据
在 RK3588 上,XDMA 跑 PCIe 3.0 x4 的带宽理论值大约在 3.94 GB/s(Gen3 x4 单向)。实测回环模式因为数据要写出去再读回来,主机侧看到的是一个方向的吞吐。我跑了几个典型 buffer 大小,得到如下数据:
| Buffer 大小 | DMA 写方向吞吐 | DMA 读方向吞吐 | 回环总耗时 |
|---|---|---|---|
| 1 MB | 2.1 GB/s | 2.0 GB/s | 约 1 ms |
| 16 MB | 3.3 GB/s | 3.1 GB/s | 约 10 ms |
| 64 MB | 3.6 GB/s | 3.4 GB/s | 约 37 ms |
性能出现差异的典型原因,一是 buffer 物理地址不连续导致 DMA 引擎做额外 desc 跳转,二是因为 Linux 页面缓存导致发送和接收路径上的 kmap 开销。如果想压榨性能,最好把 buffer 用 2MB 的大页对齐来分配,并且保证发送和接收缓冲区都在同一个 NUMA 节点。
7. 常见问题与排查技巧实录
我在反复测试中积累了几个非常典型的异常现象,整理成一张表,方便你对照排查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| lspci 看不到设备 | PCIe 链路未训练成功、设备树 lane 分配不对 | 检查 PCIe PHY 状态,确认 lane 数是否满足 |
| 寄存器读取返回全 F | BAR 空间映射错误、FPGA 复位未释放 | 确认设备树 reg 属性,检查复位信号 |
| DMA wait 超时 | 中断没产生、FPGA 未发 tlast 数据包 | 看 /proc/interrupts,确认 tlast 逻辑 |
| 数据校验 mismatch | 时钟域问题、生成器和校验器初值不一致 | 检查异步 FIFO,统一两端初值 |
| 性能远低于预期 | 缓冲区不对齐、中断处理开销大 | 使用大页 DMA buffer,开启 MSI-X |
| Rust 程序段错误 | mmap 长度小于 PAGE_SIZE 时访问越界 | 确保 mapping 长度按页对齐 |
关于中断,有一个小技巧:在验证阶段不要依赖中断,先用 polling 模式跑通数据。很多 DMA 控制器都支持查询模式,你把完成标志直接映射到一个寄存器。等 polling 模式确定数据链路完全正常后,再启用中断,把问题隔离开。如果一开始就开着中断查,中断频率高时,你很难分辨是数据路径问题还是中断处理路径问题。
还有一个关于设备树的实战经验:RK3588 的 PCIe 控制器在设备树里写compatible = "rockchip,rk3588-pcie"时,num-lanes属性经常被遗漏。由于 RK3588 有多个 PCIe 控制器且 lane 可以共享,如果你发现内核启动日志里 PCIe link speed down 或者 Gen1 速率,优先在 dts 里查num-lanes和max-link-speed两个字段。
最后再分享一点在 Rust 工程结构上的心得。把控制寄存器的偏移定义、XDMA 设备的路径发现、DMA buffer 的分配和解除,全部封装到一个独立 crate 里,再用一个 bin crate 做测试入口,这样后续业务代码可以直接依赖这个 crate 而不用关心底层细节。我本人在开发过程中就靠着这套封装,在几个项目里复用了同一份 Rust 代码,只改 FPGA 侧的寄存器映射就能适配不同硬件。
回环测试做好之后,后续方向的扩展就清晰了:把回环模块替换成真实的业务加速逻辑,比如图像处理、FFT、或者自定义协议卸载引擎。等到那个阶段,你会发现前期把链路、驱动和上位机三件事做扎实,会给你省下大量的调板时间。