ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AXI DMA Scatter-Gather模式详解:从原理到FPGA实战踩坑指南

AXI DMA Scatter-Gather模式详解:从原理到FPGA实战踩坑指南 AXI DMA 这个东西我第一次真正被它折腾到半夜是在一块 Zynq 板子上做高速 ADC 数据采集。那时候方案很简单ADC 出来的数据先进 FIFO再通过 AXI4-Stream 接口往 DDR 里搬。一开始我用的是中断方式一个中断搬一次搬完再来下一个结果数据稍微一快CPU 就彻底被搬运任务占满整个系统都卡住。后来换成 AXI DMA Scatter-Gather 模式传输效率直接上了一个台阶CPU 几乎不用管数据搬运的事只需要在描述符链上做文章就行。这篇文章就把我在 Xilinx FPGA 上配置 AXI DMA、用 Scatter-Gather 模式做数据传输的完整思路、操作步骤和踩坑记录整理出来给正在调同类型项目的朋友做个参考。这篇文章适合谁看如果你在用 Vivado 做高速数据采集、图像传输、PCIe 数据交互或者需要在 Zynq/MicroBlaze 系统里把 AXI4-Stream 数据高效搬进 DDR那这篇文章基本就是你的菜。内容会涉及 IP 配置、描述符链表的组织、驱动侧代码框架以及我在实际调试中遇到的几个典型问题和排查手段。1. 为什么数据传输非要上 AXI DMA1.1 几种搬运方式的真实差距很多刚接触 FPGA 数据搬运的人第一反应都是自己写状态机或者直接让 CPU 去读写数据。这两种方式在小数据量、低速场景下完全够用但一旦数据量大起来问题就全暴露了。先说 CPU 直接搬运。以 Zynq 的 ARM 核为例CPU 通过 AXI 总线读写 DDR 本身不慢但它同时还要跑操作系统、处理中断、管理协议栈“专职”搬运数据肯定不现实。假设你要搬运 1080p30 的 RGB888 图像一帧数据是 1920 × 1080 × 3大概 6.2MB每秒 30 帧就是约 186MB/s。用 CPU 一个像素一个像素地搬即使单次读写效率很高CPU 也会被拖死更别说还要做其他业务逻辑。再说自己写状态机。有些项目为了追求极致可控会自己写 AXI Master 状态机把数据从 Stream 接口搬到 DDR。这种做法性能未必差但开发周期长而且 AXI 总线的协议细节很多——对齐、突发、outstanding、读返回乱序这些问题在调试阶段特别磨人。如果你的项目核心不是“传输”本身真没必要在这上面反复造轮子。AXI DMA 的存在就是来解决这个问题的。Xilinx 官方提供的 AXI DMA IP 核把 AXI Master 读写时序、缓存管理、描述符解析这些复杂逻辑全部封装好你要做的就是配置寄存器、维护描述符链表、处理中断剩下的事硬件自己搞定。1.2 带宽计算的底层逻辑在配置 AXI DMA 之前得先搞清楚自己的带宽需求不然参数设得再漂亮实际跑起来还是卡。先算一笔账。AXI DMA 的数据吞吐量理论上取决于三个方面AXI 数据位宽、时钟频率、传输效率。假设你配置一个 64 位 AXI4-MM 接口DMA 工作在 150MHz理论峰值是 64bit × 150MHz 9.6Gbps差不多 1.2GB/s。但这只是理论值实际还要打个折扣。折扣从哪里来第一是 DDR 的刷新开销和 bank 切换第二是 AXI 总线上的仲裁和 outstanding 限制第三是 DMA 读写通道之间的相互等待。按照我实测的经验AXI DMA SG 模式下能跑到理论带宽的 60%~70% 就已经很不错了。所以当你计划用 DMA 搬运 4 路 HD 视频流时一定要先把峰值带宽打上个七折算再决定要不要做数据裁剪或者降帧率。提示带宽不足的锅不一定是 DMA 的很可能是你的 AXI Interconnect 配置不合理比如没打开数据重新排序、默认仲裁策略是 round-robin、跨时钟域转换的 buffer 太小。这些都会在性能测试时集中暴露。2. SG 模式怎么把“不连续内存”变成连续搬运能力2.1 描述符链表与环形队列普通 DMA 和 Scatter-Gather DMA 最大的区别在于对“目标地址”的组织方式。普通 DMA 模式下你要搬运数据需要提前指定一个源地址和一个目标地址搬运一次重新配置一次。这种模式适合整块连续内存的搬运但实际工程里DDR 空间往往被文件系统、协议栈、应用层瓜分得七零八落想在物理地址上找到一块又大又连续的缓冲区并不容易。Scatter-Gather 的思路就聪明多了。它把一块大的传输任务拆成若干个小的 Buffer每个 Buffer 对应一个“描述符”BDBuffer Descriptor。描述符里记录了这个 Buffer 在内存中的地址、长度以及指向下一个描述符的指针。硬件 DMA 控制器会沿着描述符链表自动取指、搬运搬完一个就跳到下一个直到链表尾。实际项目中我几乎都用“环形链表”来组织描述符。做法很简单最后一个描述符的 Next 指针指回第一个描述符形成一个环。这样 DMA 可以连续不断地处理队列循环不需要从头再来特别适合持续性的数据流场景比如 ADC 连续采样、视频流采集、网络包收发。2.2 BD 结构与控制字逐字段拆解Xilinx AXI DMA 在 SG 模式下的描述符严格来说是 8 个 32 位 word总共 32 字节。每个 BD 的核心部分如下Next Descriptor Pointer指向下一个描述符的物理地址要求按字对齐通常 4 字节对齐即可我习惯做 32 字节对齐。Buffer Address当前 Buffer 的物理地址。Buffer Length / Control低 16 位是本次搬运的长度高 16 位是控制位比如 MM2S 方向的 TXSOF、TXEOF用来标记流数据的包边界。Status / Control这个 word 里最要紧的是 CComplete位。DMA 完成一个描述符后硬件会自动把 C 位置 1CPU 轮询或者中断处理时看这个位就知道该块 Buffer 可以处理了。描述符字段里最容易忽略的是对齐要求。DMA 读描述符时是整字读如果你的描述符地址没有对齐轻则报描述符错误重则直接挂死总线。我的经验是分配描述符表时直接做到 32 字节对齐Buffer 地址至少按 4 字节对齐数据位宽大的时候尽量 64 字节对齐能省掉很多莫名奇妙的错误。注意BD 是硬件直接访问的内存不能随便让 CPU cache 接管。裸机环境下如果打开了 data cache修改完描述符之后要记得 flush cache处理 DMA 完成的数据前要 invalidate cache否则你会遇到“CPU 读不到最新数据”的诡异问题。2.3 SG 相比普通 DMA 的优势边界SG 模式优势很明显但也不是全场景碾压。我做了个对比方便理解对比维度普通 DMADirect RegisterScatter-Gather DMA内存连续性要求必须连续不连续可分散描述符维护工作量每次重配寄存器手动维护链表需仔细适合场景单块缓冲、小数据量大数据量、流式传输CPU 干预频率每次传输都要介入仅在描述符用完或中断时介入容错能力简单粗暴不易出错描述符维护不当易出错如果你的数据量很小比如只是偶尔搬几 KBSG 模式反而有点“杀鸡用牛刀”的意思因为描述符链表的建立和初始化也要花时间。但一旦你的数据被分割成几十上百个 Buffer比如网卡的多队列环形缓冲、视频采集的多帧乒乓缓存SG 模式就成了唯一合理的选择——CPU 一次性把描述符链准备好DMA 自己一路搬过去效率差距是数量级的。3. Vivado 里的搭建要点IP 配置、连线与中断3.1 AXI DMA IP 核配置建议打开 Vivado 的 IP Catalog搜索 AXI DMA双击以后会看到一大堆配置项。这里说说哪些参数直接影响 SG 模式的使用以及我建议的选择。Enable Scatter Gather Engine必须勾上这是进入 SG 模式的总开关。Enable Read Channel / Write Channel根据你的方向选择。如果你需要从 Stream 接口往 DDR 搬就勾 Write ChannelS2MM需要从 DDR 往 Stream 接口发数据就勾 Read ChannelMM2S。两个都勾可以双向搬运。Width of Buffer Length Register默认 14也就是最大记录 16KB 长度。我的项目基本都改成 23让长度字段支持到几 MB不用频繁切描述符。注意位数改了之后代码里对长度字段的掩码也要跟着改。Address Width建议直接 32 或 40跟你的系统地址空间一致。别为了省资源改小DDR 地址很容易越界。Data WidthMM2S/S2MM 的数据位宽建议和 DDR 接口位宽一致Zynq 上最常用 64 位。位宽不一致会有总线宽度的转换逻辑额外消耗 BRAM 也拖慢速度。Max Burst Size这一项对性能影响巨大。我对稳定性要求高的场景选 16对性能要求高的场景选 256。但 256 容易导致长时间占用总线其他 Master 被饿死所以多 Master 系统建议选 32 或 64。Allow Unaligned Transfers如果数据包非常规整可以不勾。开启后 DMA 能处理非对齐地址代价是逻辑资源多一点。我一般勾上因为实际工程里很难保证每块数据都按高端对齐。3.2 Block Design 连线与地址分配在 Block Design 里AXI DMA 的连接关系大概是这样的S_AXI_LITE用来配置寄存器的从接口接到 AXI Interconnect 下面CPU 从这里访问 DMA 寄存器。M_AXI_MM2SDMA 读通道的主接口连接 DDR 侧的 Interconnect 或直接连 PS 的 S_AXI_HP 接口。M_AXI_S2MMDMA 写通道的主接口同样连到 DDR 侧。S_AXIS_S2MMDMA 写通道的数据入口接你的数据源AD9671、OV5640 的 AXI4-Stream 输出、自研 FIFO 转 Stream 等。M_AXIS_MM2SDMA 读通道的数据出口接数据消费端。连线容易但地址分配的问题很隐蔽。尤其在使用 Zynq PS 的方案里DMA 的 M_AXI 接口要从哪一个端口进 DDR是 S_AXI_HP 还是 S_AXI_ACP直接影响性能和缓存一致性。我的做法是数据量大的流直接走 HP 接口因为 HP 口是专门为高带宽设计的不经过 L2 cache数据直接写进 DDR。而描述符表所在的属性要么在 PS 侧规定为 Device memory不可缓存要么在使用时手动做 cache 维护。如果图省事可以直接用 Xilinx 提供的dma_alloc_coherent函数分配它保证分配出来的内存是连续且非缓存的。3.3 中断与缓存一致性设计SG 模式搞得差不多了自然要上中断。AXI DMA 有两条中断输出mm2s_introut 和 s2mm_introut在 IP 配置里把所有中断都打开后可以合成一个中断信号进 PS 或 MicroBlaze 的中断控制器。中断触发时机也有讲究。AXI DMA 的中断是按“IRQThreshold”触发的也就是说你可以配置当累计完成 N 个描述符后产生一次中断。如果单个包很小中断会很频繁CPU 处理中断的开销反而比搬运数据还大。我一般把阈值调到 8 或者 16也就是每完成一批描述符才让 CPU 醒一次。缓存一致性是另一个容易爆雷的环节。在 Zynq 上跑 Linux 时驱动里一般用dma_alloc_coherent分配描述符和数据缓冲它天然保证 cache 一致。但如果你是裸机开发并且打开了 D-Cache就必须在修改完描述符后执行 cache flush在处理完 DMA 数据前执行 cache invalidate。这个顺序一旦弄反很容易出现“寄存器显示传输完成但 CPU 读到的数据全是 0”的诡异现象。4. 驱动侧实现描述符初始化到启动搬运4.1 描述符结构体与内存分配描述符在代码里怎么定义直接决定了后面链表操作的复杂度。我用的是这种方式typedef struct { uint32_t nxt_desc; // 下一个描述符地址物理地址 uint32_t buf_addr; // 数据缓冲区地址物理地址 uint32_t reserved; // 保留 uint32_t cntrl_sts; // 控制/状态SG下关键看C位 uint32_t reserved2[4]; // 补足32字节描述符 } sg_bd_t;注意这个结构体只列出了 4 个有效 word但实际描述符占用 8 个 word所以做数组时要保证每个元素占 32 字节并且整个描述符数组在内存中连续。分配内存那一步裸机环境要特别小心。如果你用了 lwIP、文件系统或者其他内存管理组件可能会拿到非连续的内存地址。最稳妥的做法是在链接脚本里专门划一块 DMA 内存区域或者分配完地址后检查其连续性。Linux 环境下直接提dma_alloc_coherent一个函数搞定 连续内存和非缓存属性。#define NUM_BD 32 #define BUF_SIZE 4096 sg_bd_t *bd_list; uint8_t *buf_list; // 实际是每个BD挂一块Buffer bd_list (sg_bd_t *)dma_alloc_coherent(dev, NUM_BD * sizeof(sg_bd_t), bd_dma_addr, GFP_KERNEL); buf_list dma_alloc_coherent(dev, NUM_BD * BUF_SIZE, buf_dma_addr, GFP_KERNEL);4.2 循环队列初始化与启动流程描述符链表的初始化概括起来就是三步把每个 BD 的 Next 指针串成环把 Buffer 地址和长度填好最后把控制/状态字清零。这里最关键的一环就是把最后一个 BD 的 Next 指针指向第一个 BD形成环形。for (int i 0; i NUM_BD; i) { bd_list[i].buf_addr (uint32_t)(buf_dma_addr i * BUF_SIZE); bd_list[i].nxt_desc (uint32_t)(bd_dma_addr ((i 1) % NUM_BD) * sizeof(sg_bd_t)); bd_list[i].cntrl_sts ~0x80000000; // 清完成位C位 }初始化好之后启动 DMA 的流程分两段。第一段是配置 DMA 控制寄存器DMACR把 Run/Stop 位置 1第二段是写 Current Descriptor PointerDMACURDESC和 Tail Descriptor PointerDMACURDESC 下游的 DMACURDESC 寄存器。Tail 指针是启动搬运的“发令枪”写完 Tail 后DMA 硬件开始从 Tail 指向的描述符处取描述符并执行搬运。这里有个细节Tail Descriptor 并不要求你填最后一个描述符的地址。它的含义是“告诉硬件我现在给你一批新的描述符从哪个位置开始”。在环形队列中如果你一次只更新一个描述符就把该描述符的地址写到 Tail如果你一次放了一批描述符就写这批最后一个描述符的地址。4.3 中断处理与尾描述符更新中断服务函数里要做的事情可以浓缩成三个动作读取中断状态寄存器搞清楚是哪一类中断MM2S 还是 S2MM然后清除中断标志。遍历从 LastTail 到当前 Tail 之间的所有描述符检查 C 位是否为 1C 位为 1 的表示该 Buffer 的数据已经被 DMA 搬完可以进行数据处理。处理完数据后手动把该描述符的 C 位清零然后把它“归还”给 DMA。归还的操作不是直接写寄存器而是把这个描述符地址写回 Tail Descriptor Pointer。C 位清零之后描述符重新变成空闲状态DMA 后续可以继续复用。这个过程就是典型的“软件维护环硬件跑环”。void s2mm_isr(void) { u32 sts AxiDma_ReadReg(XPAR_AXIDMA_0_BASEADDR, XPAR_AXIDMA_0_S2MM_STS_OFFSET); AxiDma_WriteReg(XPAR_AXIDMA_0_BASEADDR, XPAR_AXIDMA_0_S2MM_STS_OFFSET, sts); while (processed_bd ! tail_bd) { if (bd_list[processed_bd].cntrl_sts 0x80000000) { // 处理该Block Buffer里的数据 process_buffer(processed_bd); // 清C位重新入队 bd_list[processed_bd].cntrl_sts ~0x80000000; processed_bd (processed_bd 1) % NUM_BD; } } // 把所有归还的描述符地址写入Tail唤醒DMA AxiDma_WriteReg(XPAR_AXIDMA_0_BASEADDR, XPAR_AXIDMA_0_S2MM_TAIL_OFFSET, processed_bd_addr); }写 Tail 描述符的时候还有个坑如果你把同一个地址反复写进 Tail 寄存器硬件不会触发重新取指必须保证这次写进去的地址跟上次硬件已经处理过的地址不一样。也就是说Tail Descriptor 要“推进”不要“重复”。我刚开始调的时候每次中断都往 Tail 里写同一个环形队列头地址导致 DMA 每次都重新从队头开始搬运队列里后面的描述符永远得不到处理。这个问题排查了好久最后翻阅手册才反应过来。5. 实战踩坑与问题排查实录5.1 描述符 Fetch 错误先查对齐和缓存先说一个最常见的错误DMACSR 里面 Err_Irq 置位读错误状态寄存器会发现 Descriptor Fetch 错误。这玩意儿几乎每个用 AXIDMA SG 的人都会遇到原因主要集中在三个地方。第一个描述符地址没有对齐。AXI DMA 的取描述符是突发读地址必须按 4 字节边界对齐如果用了 64 位总线还要求 8 字节对齐。我见过有人把描述符数组定义成一个普通的char数组结果编译器把它放到了奇数地址DMA 一取指就报错。解决办法是在定义数组时加 align 属性。第二个当前描述符指针写入时指向的地址不在有效的内存区域。比如你分配的是虚拟地址结果把虚拟地址当物理地址传给 DMA那肯定访问不了。裸机上malloc出来的一般是物理地址就能用但 Linux 用户态分配的指针千万不能直接丢给 DMA。第三个cache 数据没刷出去。CPU 修改了描述符但描述符留在 cache 里DMA 去内存里读到的还是旧值。这种错误特别隐蔽因为看起来地址、长度都对就是偶尔会出错。解决办法要么用dma_alloc_coherent分配描述符要么在修改后立刻对描述符地址范围执行 cache flush。5.2 传输完成但数据不对抓总线看突发另一种很抓狂的现象是DMA 状态寄存器显示完成中断也触发了但读出来的数据就是不对——错位、乱序、或者压根是旧数据。这种问题我总结下来八成出在 AXI 总线的 out-of-order 和突发长度上。先说 out-of-order。AXI 协议允许读返回数据乱序DMA 内部通常有 reorder buffer但这个 buffer 的深度有限。如果你的描述符 Buffer 很小DMA 同时发起多个读请求返回乱序后 reorder buffer 不够用就会丢数据或者错位。解决方式是减小 Max Burst Size或者增加 reorder buffer如果 IP 支持。再说突发长度。有些 DDR 控制器或者 AXI 互联 IP比如老的 AXI Interconnect对突发长度有限制比如只支持 16。如果你配置 DMA 的 Burst 长度是 256总线事务被拆分的方式不对数据也会乱。这种情况下直接把 Max Burst Size 改成 16问题往往瞬间消失。遇到这类问题最直接的排查手段是加 ILA 抓 M_AXI 接口上的 AR/AW 通道信号看事务的地址、长度、顺序是否和预期一致。加 ILA 不需要改太多逻辑把 AXI DMA 的 M_AXI 信号接到 ILA 上就行跑仿真或者上板都能看。5.3 性能上不去从寄存器到仲裁逐个排查性能不达标的排查比功能性错误更让人头疼因为很多时候功能都对就是速度上不去。第一看实际传输效率。用逻辑分析仪测 DDR 写方向的有效数据率把 DMA 每次突发之间的空档时间算出来。如果发现空档太长说明 DMA 经常在等描述符、等 AXI 响应或者等仲裁权。第二看 AXI Interconnect 的配置。多 Master 连接 DDR 时Interconnect 默认可能是 round-robin 仲裁但这不一定是最好选择。如果你的数据源带宽需求高可以尝试把 DMA 端口设置成高优先级在 Interconnect 里配置 Master 的 QoS/优先级。第三看描述符链的 Buffer 大小。一个描述符长度 1KB和长度 4KB对吞吐的影响差别巨大。描述符太小时DMA 频繁地去取新的描述符取描述符的过程也是总线事务会占用不少带宽。所以我一般建议单块 Buffer 尽量做大让 DMA 少“停下来”几次。还有其他奇奇怪怪的原因比如数据位宽不一致导致跨时钟域转换性能下降、DDR 端 Bank 冲突、时钟频率没有提到最高等等这些都要靠逐层排查来确定。我的经验顺序是先改描述符长度 - 再改突发长度 - 再看仲裁优先级 - 最后才动 DDR 控制器配置。千万别一上来就怀疑 IP 有问题AXI DMA 这个 IP 本身很成熟问题基本都在外围。写在最后的一些体会每次有人让我评价 AXI DMA SG 模式我都会说功能本身不难难的是理解“硬件取描述符”这件事是异步发生的。你在 CPU 侧看到的是一个链表的头但在 DMA 硬件侧它已经在沿着链表疯狂往前跑了。所谓高效不是说某一笔传输跑得多快而是整个队列的“流水线”能不能做到无缝衔接。我现在的项目里已经习惯把 AXI DMA 当成一个“会自己干活的搬运工”来用CPU 只负责定期看看哪些 Buffer 干完了再把它们放回去。理解了这个模型SG 模式基本就掌握了一大半。提示如果你第一次调 AXI DMA我强烈建议先在 Direct Register 模式下把 AXI4-Stream 到 DDR 这条通路验证通之后再切到 SG 模式。Direct Register 模式相当于把 DMA 当成一个带地址的搬运工具一次只搬一块逻辑简单适合排查系统级问题。等这条路通了再上描述符链表这样出了问题至少能分清是 DMA 本身的问题还是 SG 描述符维护的问题。听起来麻烦但实际节省的调试时间远超投入。
返回列表