做FPGA数据通路这几年,有件事我一直觉得值得专门写一篇实战总结:Xilinx FPGA里的AXI DMA配合Scatter-Gather模式到底怎么用。标题看着像是IP核手册里的词,但真正在工程里跑起来,你会发现手册讲的是“它能干什么”,而没怎么讲“你该怎么把它调顺”。这篇文章我就从一次Zynq器件上的实际项目出发,把AXI DMA选型、SG模式工作原理、BD描述符管理、缓存一致性这些关键点从头到尾捋一遍,并把我在调试中踩过的坑和总结出的排查思路一并放出来。
这篇文章适合两类人看:一是刚接触AXI DMA,准备在Vivado里搭高速数据传输链路的FPGA开发者;二是已经在用Direct Register模式,但被CPU占用率、内存非连续分配这些问题卡住,想升级到Scatter-Gather模式却不知道怎么下手的朋友。我会尽量用工程语言讲,不堆术语,该给参数的地方给参数,该写伪代码的地方写伪代码,保证你读完能直接回自己工程里动手试。
1. 为什么需要AXI DMA:从CPU搬运到硬核DMA
1.1 没有DMA时的数据搬运瓶颈
先把场景还原一下。假设你用FPGA采集ADC数据,采样率20MSPS,位宽12bit,换算下来数据率约30MB/s。如果不用DMA,常规做法是把数据通过AXI4-Lite寄存器接口逐字读走,或者让PL侧产生中断,再由PS侧在中断服务程序里搬运数据。前者的问题是:读一次寄存器要经过AXI总线握手,还要处理地址递增逻辑,一批数据收下来CPU基本就没空干别的了。后者的问题更明显——中断本身有延迟和抖动,30MB/s的数据意味着每秒钟要处理几十万次中断,操作系统光切上下文就要炸。
我见过不少项目在这个阶段硬扛,最后发现CPU占用率冲到80%以上,而且一旦系统里还有其他任务,比如网络协议栈或者GUI刷新,数据就开始丢。这个阶段最典型的特征就是:数据通道本身没问题,但整机性能被数据搬运拖死了。
1.2 AXI DMA到底替CPU干了什么
AXI DMA是一个软核IP,挂在AXI总线上,它的核心能力就是替CPU完成“把数据从一个地址搬到另一个地址”这件事。在Zynq架构里,典型路径是PL侧逻辑把数据写入DMA的发送通道,DMA再通过AXI_HP接口把数据搬到DDR指定地址;反向则是DMA从DDR读出数据送给PL侧逻辑。
CPU在这个过程里只做三件事:启动传输前配置描述符和寄存器,传输过程中等待中断,传输完成后处理数据。真正的数据通路,从DDR地址A到地址B,或者从PL寄存器到DDR,全部由DMA硬核完成,不占用CPU周期。这一点在高速、连续、大数据块的场景里收益极其明显。
1.3 Direct Register模式与Scatter-Gather模式的本质区别
AXI DMA的Direct Register模式比较简单,CPU直接往DMA的寄存器里写源地址、目的地址和传输长度,DMA就按这个线性地址去搬,搬完产生一次中断。问题在于:一次传输只能对应一段连续内存。如果数据分散在多段不连续的内存里,CPU就必须一次次地重新配置DMA,每段都要经历“写寄存器-启动-等中断-再写寄存器”的循环。
Scatter-Gather模式完全不同。它引入了一个软件维护的描述符表,每个描述符指向一段缓冲区,DMA引擎会自动按描述符链的顺序依次搬运,搬完一段自动读下一段,全部完成后再向CPU发中断。这个机制有两个直接影响:第一,内存不需要连续了,驱动层可以随意分配多个小的buffer给DMA用;第二,CPU配置一次就能让DMA搬完一整批数据,中断次数和寄存器操作次数都大幅下降。
注意:SG模式并不是在所有场景下都比Direct Register好。如果你的传输是单block、地址连续、且对延迟极其敏感,Direct Register的启动延迟反而更低,因为不需要先读描述符。SG模式的优势体现在“多段缓冲、循环收发、长时间连续搬运”这类场景,理解了这一点,选型才不会跑偏。
2. Scatter-Gather模式的硬件架构与描述符机制
2.1 AXI DMA内部到底有哪些模块
从IP核的Datasheet角度,AXI DMA主要分成Datapath、Register/Descriptor Engine、SG Engine三块。Datapath负责实际的AXI读写;Register/Descriptor Engine负责管理内部寄存器或者描述符;SG Engine是SG模式独有的,它专门负责从内存里读取BD描述符、解析控制字、然后驱动Datapath去执行实际的搬运。
从数据通道上区分,AXI DMA有MM2S和S2MM两条通道。MM2S是Memory-Mapped to Stream,从DDR读数据发给PL侧AXI-Stream外设;S2MM是Stream to Memory-Mapped,从PL侧的AXI-Stream接收数据写入DDR。在SG模式下,这两条通道分别维护自己的BD描述符链,互不干扰,可以同时工作,这就实现了全双工的高速传输。
2.2 BD描述符的字段解析:不仅仅是地址和长度
BD描述符是整个SG模式的灵魂。Xilinx的AXI DMA描述符在32位地址模式下占8个Word(32字节),地址模式下占13个Word(52字节)。为了对齐简单,通常按32字节或64字节对齐分配。
描述符的关键字段主要有这么几块:
| 字段 | 偏移(Word) | 作用 |
|---|---|---|
| Next Descriptor Pointer | 0-1 | 指向下一个描述符的地址,用于形成描述符链 |
| Buffer Address | 2-3 | 数据缓冲区的起始地址(这里指MM2S源地址或S2MM目的地址) |
| Control (Reserved/Flags) | 4 | 缓冲区长度、TXEOF/TXSOF、TXBD等控制位 |
| Status | 5 | 完成状态、实际传输字节数、错误标志 |
| APP0-APP3 | 6-9 | 用户自定义信息,AXI-Stream侧可附带sideband信号 |
| Reserved | 10-12 | 保留字段,必须置零 |
实际使用中,最容易忽视的是Word4里的长度字段在S2MM方向通常是“缓冲区的最大容量”,DMA搬了多少数据,实际字节数会写回Status字段。我早期就是没理解这个区别,把Status里的字节数当成了长度去解析,导致读到的数据全是错的。
2.3 SG引擎的运作流程,一句话讲透
SG引擎的工作流程可以概括成一句话:CPU准备好描述符链、写Tail指针,DMA引擎不断读描述符、搬家数据、更新状态,直到遇到描述符链的结尾再产生中断。
具体到一次MM2S传输,CPU做的事情是:在内存中准备好N个BD、每个BD指向一段源缓冲区,把第一个BD的地址写入DMA的Current Descriptor指针寄存器,然后把最后一个BD的地址写入Tail Descriptor指针寄存器、并启动DMA。DMA收到Tail指针后会从Current开始,逐个读取BD,每处理一个BD就搬一块数据,同时通过内部状态机更新当前描述符指针,直到处理完Tail指向的那个BD,才回写状态并产生中断。
S2MM方向流程完全对称,唯一的区别是“缓冲区长度”表示DMA可以往这个缓冲区里写多少字节。还有一个关键点:如果使用了环形描述符,那么所有BD的Next指针会连成一个环,Tail指针被更新后DMA会自动绕回开头继续搬,这是实现持续收发流水的核心机制。
3. Vivado工程里的AXI DMA配置与地址分配实战
3.1 IP核参数配置,这几个选项必须选对
在Vivado IP Catalog里搜索axi dma,双击打开配置界面。第一页的“Basic”选项卡里,有几个参数直接决定后续能不能用SG模式,我一个个说。
首先,Enable Scatter Gather Engine这个复选框必须勾上。这个选项是SG模式的总开关,不勾的话IP核退化为Direct Register模式。其次,Address Width设置为32位还是64位,取决于你的处理系统地址位宽。Zynq-7000一般选32,Zynq UltraScale+或MPSoC如果跑64位Linux可以选64,但要注意BD里的Buffer Address字段也要相应调整。
接下来是Number of Channels,如果不需要独立管理多路DMA流,选1即可。Memory Map Data Width建议和AXI_HP接口位宽一致,Zynq上常见是64位或128位,Stream Data Width取决于你的用户逻辑接口。有一条经验:Stream Data Width和Memory Map Data Width不一致时,IP会自动插入数据宽度转换逻辑,会有额外延迟,能保持一致就保持一致。
3.2 Block Design里的连接与中断配置
在Block Design里例化AXI DMA之后,需要把MM2S和S2MM的AXI_Stream接口接到你的用户逻辑上。如果你的PL逻辑是AXI-Stream从端(如ADC采集)、主端(如DAC回放),直接对接即可。如果没有现成的总线接口,需要自己写一个简单的AXI-Stream接口逻辑——建议只实现最基础的TVALID/TREADY/TLAST握手,减少不必要的麻烦。
DMA的内存映射接口要接在Zynq的HP端口上。在Zynq-7000里,S_AXI_LITE接口接在PS的GP端口上,M_AXI_MM2S和M_AXI_S2MM接在两个不同的HP端口上,这样可以并行处理读写。之前项目里试过把两条通道挂在同一个HP端口上,带宽下降非常明显,后来拆到不同端口才把吞吐提上来。
中断信号也要连。MM2S和S2MM有各自的中断输出(mm2s_introut和s2mm_introut),接到PS的PL-PS中断端口上就行。需要说明的是,在SG模式下还有一个共享的sg_introut,通常不用单独接,核心还是两个通道自己的中断。中断触发条件可配置,推荐在“完成一次完整的描述符链传输后产生中断”,避免频繁进中断。
3.3 地址分配:BD缓冲区最好单独划一块
地址分配这一步经常被忽视,但它对稳定性的影响极大。给DMA用的缓冲区,包括数据缓冲区和BD描述符缓冲区,最好是物理上连续、且避开Linux内核常用的内存区域。如果你在裸机环境下,直接分配一段连续内存地址就行,SDK的链接脚本里手动预留一段DDR空间可以做到。
在Linux环境下,推荐用DMA一致性API(dma_alloc_coherent)来分配,它自动保证物理连续且映射到内核虚拟地址。如果DMA缓冲区太大、无法一次分配,那就用SG模式的价值所在——通过多个BD把多个不连续的缓冲区串起来。但要注意:BD描述符本身必须物理连续,且对齐到32字节边界。
提示:有些工程师在裸机下用malloc给BD分配内存,结果DMA引擎读描述符时总出奇奇怪怪的错误,检查半天才发现malloc返回的地址没有做到32字节对齐。BD的起始地址必须是32字节对齐的,这是AXI DMA硬性的地址对齐要求。
4. SG模式下的描述符初始化和驱动流程实现
4.1 内存准备:描述符表和数据缓冲区的初始化
假设我们在裸机环境下,PS侧DDR地址0x10000000处放了一个环形BD表,每个BD占32字节,一共64个BD;数据缓冲区单独放在另一段地址。初始化分两个阶段:第一阶段是把所有BD的Next指针连成环,第二阶段是给每个BD填好Buffer Address和Control字段。
连成环的C语言伪代码如下:
#define BD_COUNT 64 #define BD_SIZE 32 typedef struct { uint32_t next_desc; uint32_t next_desc_hi; uint32_t buf_addr; uint32_t buf_addr_hi; uint32_t control; uint32_t status; uint32_t app0; uint32_t app1; uint32_t app2; uint32_t app3; uint32_t reserved0; uint32_t reserved1; uint32_t reserved2; } Bd_t; Bd_t *bd_table = (Bd_t *)0x10000000; for (int i = 0; i < BD_COUNT; i++) { if (i == BD_COUNT - 1) { bd_table[i].next_desc = (uint32_t)bd_table; } else { bd_table[i].next_desc = (uint32_t)&bd_table[i + 1]; } bd_table[i].buf_addr = (uint32_t)(data_buf + i * BUF_SIZE); bd_table[i].control = BUF_SIZE; // 在S2MM方向表示缓冲区容量 bd_table[i].status = 0; }这段代码有个细节:环形描述符的最后一个BD的Next要指回首地址。这样才能让DMA在到达Tail指针后能够绕回来。初始化时所有的Control字段填的是单个缓冲区的最大容量,这个值一旦设定就保持不变,传输完成后的实际字节数会由DMA写回到Status字段的低16位。
4.2 启动DMA:Current指针、Tail指针与中断的关系
BD表准备好以后,启动传输就剩下三个寄存器操作和一次中断等待。用MM2S方向举例:
第一个操作是把第一个BD的物理地址写入MM2S_Current_Descriptor寄存器。第二个操作是把最后一个BD的地址写入MM2S_Tail_Descriptor寄存器。第三个操作是设置MM2S_DMACR(DMA Control)寄存器,把RS(Run/Stop)位置1、并把IOC_IrqEn(中断使能)位置1。
DMA收到Tail指针更新的那一刻就会开始工作,按BD链把每个缓冲区的数据搬出去,搬完一个更新一次状态,直到把Tail指向的BD处理完,产生一次中断。这个流程最大的优势是:CPU只需要写一次Tail指针,DMA就自动把整条BD链跑完,不需要像Direct Register模式那样每个block都中断一次。
这里有一个常见的误解——有人以为中断必须等所有BD都处理完才产生。实际上通过配置DMACR里的IOC_IrqEn和IRQThreshold字段,可以让DMA每处理完若干个BD就产生一次中断,或者只在整条链处理完后产生中断。工程里最常见的做法是让DMA在环形缓冲机制下每处理完一批BD就中断一次,这样CPU可以及时取走数据,同时DMA继续处理下一批,达到流水线效果。
4.3 传输完成后的收尾工作
中断到来后,CPU读取MM2S_DMASR(DMA Status)寄存器判断是什么事件触发了中断。如果是IOC(Interrupt on Complete)中断,说明当前BD链已经执行完成,可以扫描状态寄存器中所有已置位的BD,提取实际传输的字节数。
处理完数据后,要重新把这些BD的控制字段填好、状态清零,然后更新Tail指针让DMA继续下一轮。如果用的是环形BD表,这一步只是从软件上把“已处理”的BD重新加入可用队列,Tail指针的值是最新一个可用BD的地址。
收尾部分最容易被遗忘的是:在修改BD内容前,必须确认DMA引擎已经不再读取这些BD。裸机下可以通过只读状态寄存器确认DMA处于Idle状态,或者等待中断后再修改;Linux下则要依赖DMAAPI的同步方法来完成。我曾见过有人在中断服务里直接改BD,结果DMA还在读,硬生生踩出数据错乱,这个坑很典型。
5. 缓存一致性:SG模式不可回避的隐蔽问题
5.1 为什么缓存一致性问题在SG模式里特别明显
理解缓存一致性问题要先明确一个基础:Zynq的PS侧ARM Cortex-A9处理器带有L1/L2 Cache,而DMA控制器是不经过Cache直接访问DDR的。这就意味着,CPU写好的BD表和缓冲区内容,可能还躺在Cache里没落到DDR,DMA去DDR里读到的就是旧数据;反过来,DMA写进DDR的数据,CPU在Cache里读到的可能是旧值。这个问题的破坏力在普通寄存器配置层面不明显,但在SG模式下,BD表和数据缓冲区天天被双方读写,几乎每次传输都会遇到。
我最早一次被这个问题坑,是在裸机环境下用SDK调试。看代码逻辑怎么都对,但DMA就是搬出错误数据,后来查了一整天,发现是BD表里的Control字段CPU已经更新了,但Cache没有flush,DMA读到的还是旧值。
5.2 裸机环境下的Cache操作
裸机环境下,问题处理直接依赖Xilinx的BSP函数。最常用的两个函数是Xil_DCacheFlushRange和Xil_DCacheInvalidateRange。前者把一个地址范围内的数据从Cache刷到DDR,用于CPU写数据给DMA读的场景;后者把一个地址范围内的Cache行置为无效,强制CPU下次访问从DDR中重新加载,用于DMA写数据给CPU读的场景。
以S2MM方向为例,完整流程是这样:在把缓冲区地址和容量填进BD之前,对BD表和缓冲区分别调用一次Xil_DCacheFlushRange;传输完成后,DMA已经把数据写进DDR,CPU要去读缓冲区里的数据,先对缓冲区调用一次Xil_DCacheInvalidateRange,再去读内容就是不经过Cache的实时数据。
注意:对缓冲区做Cache操作时,起始地址和长度都要注意对齐。Cortex-A9的Cache Line是32字节,如果地址不是32字节对齐,flush/invalidate操作会覆盖到相邻Cache行,造成意外的数据丢失或无法刷新的问题。工程上最简单的方法是把所有DMA缓冲区都按64字节对齐分配。
5.3 Linux驱动里的对应处理
在Linux下,裸机那套手动Cache操作会被内核DMA API替代。Linux的DMA一致性映射接口原理上其实和手动操作Cache是一样的,只是内核替你做了处理,同时它还处理了地址映射和物理连续性问题,用起来更稳。
SG模式在Linux下的典型用法:
struct scatterlist *sg; dma_map_sg(dev, sglist, nents, DMA_FROM_DEVICE); // 遍历sglist,把每个sg的dma_address和dma_length填入BD dma_unmap_sg(dev, sglist, nents, DMA_FROM_DEVICE);dma_map_sg会保证在DMA读取前把Cache数据刷到内存,dma_unmap_sg则在DMA写完后让CPU看到的缓存失效,从而保证双方看到的一致数据。如果你在Linux下写DMA驱动,直接使用这套API可以避免大量底层细节的坑。有一点需要明确:dma_map/unmap每次调用都有开销,如果数据是周期性、长时间收发的,更适合启动时用dma_alloc_coherent分配一个DMA缓冲区并固定映射,而不是把时间花在反复map/unmap上。
6. 常见问题与排查技巧实录
6.1 一张速查表先给出来
把我在多个项目里遇到的典型问题、现象、排查方向整理成一张表,方便你日后参考:
| 现象 | 可能原因 | 排查/解决方法 |
|---|---|---|
| DMA传输超时,状态寄存器停在Busy | 描述符地址错误或Tail指针未正确更新 | 检查Current和Tail寄存器是否写入正确物理地址 |
| 数据搬出来全是旧数据 | Cache未刷新,CPU改的BD或缓冲区没落DDR | 检查是否需要手动Flush或使用DMA一致性API |
| 搬出的数据错位/少字节 | BD长度字段填错,或Buffer Address字节对齐不满足 | 检查长度单位、是否填了最大容量而不是实际长度 |
| 中断频繁,CPU占用率还是高 | 中断阈值配置太小,IRQThreshold设成了1 | 调大阈值,降低中断频率,批量处理BD |
| 带宽上不去,DDR吞吐明显偏低 | MM2S和S2MM挂在了同一个HP端口 | 拆开挂到不同HP端口,避免读写竞争 |
| 偶发性Stall,DMA不再搬数据 | 环形BD被软件修改,DMA还在读旧状态 | 确保在DMA请求/空闲后再修改BD,保护临界区 |
6.2 排查思路:先看寄存器,再看描述符,最后抓波形
从实际经验来看,SG模式出问题之后,最忌讳直接上仿真或者ILA抓内部分信号,那会把问题放大。我的排查顺序一直很固定:先读DMA的状态寄存器和调试寄存器,看DMA有没有停在某个状态;然后读BD表内容,对比软件期望值和DMA回写的状态;最后只有前两步都没有结论,才会上ILA抓Datapath的AXI总线信号,看DMA到底在读写什么地址。
比如DMA停在Busy状态、中断一直不来,我一般会先读MM2S_CURDESC寄存器和MM2S_TAILDESC寄存器,确认这两个指针是否指向了我方预期的BD地址。如果地址对、但DMA还是卡住,再逐个看BD的Next指针有没有断链。这个方向在绝大多数情况下都能定位到问题,比闷头抓波形效率高很多。
6.3 三个典型定位案例
第一个案例是DMA传输超时。现象是MM2S方向启动后,DMASR一直显示Busy,中断没来。检查后发现BD表的Next指针在初始化时被错误地指向了一个未清零的内存区域,DMA沿着错误的Next指针跳到了非法地址,自然无法完成传输。解决方案是初始化BD表时做到两点:一是把所有BD的Next指针显式连好,二是把未使用字段全部清零。
第二个案例是数据错乱。现象是S2MM方向收到的数据整体偏移了若干个字节,长度看起来也差一点点。查了Cache操作,没有遗漏;后来发现是缓冲区地址分配时,没有做到32字节对齐,DMA对非对齐地址的写操作被拆成了多次非突发访问,违反了我对突发设计的预期。把缓冲区地址强制对齐后,问题消失。
第三个案例比较隐蔽,是带宽上不去。现象是MM2S方向单通道跑不到理论带宽的一半。最后通过对比两个HP端口的流量,发现MM2S和S2MM同时挂在了一个HP端口上,读写互相争抢,拆到两个HP端口后带宽立即翻倍。这个案例给我们的启示是:AXI DMA本身只是数据搬运引擎,系统层面的总线架构、缓存策略、地址分配,往往比IP核本身的寄存器配置更影响最终性能。
7. 收尾:几个我用得最顺的经验
讲了这么多,最后说几个我自己在日常项目里一直在用、也确实管用的经验。第一个是SG模式的BD表和数据缓冲区,在系统启动阶段就一次性分配好,运行过程中尽量不动态分配。这能避免大量与Cache、物理连续相关的隐性问题,也让系统行为更可预测。
第二个经验是和中断配合,尽量让DMA每个处理批次之间的“静默期”短一点。方法就是反复验证Tail指针更新时机,确保CPU在DMA完成前、还在处理上一批数据的同时,就已经把新一批BD准备好并更新了Tail。这才是真正的流水线工作方式,实际测下来,CPU利用率几乎可以忽略。
第三个经验是调试初期多用SDK的寄存器查看工具或ILA观察,别急着优化性能。先把最简单的单BD、单次传输流程跑通,再逐步加大BD数量,最后再开环形模式。这个顺序能帮你把软件初始化逻辑、Cache操作、硬件时序三个层面的问题分开定位,避免混在一起无从下手。
我自己是从Direct Register模式一路切换过来的,刚切到SG模式时也被描述符管理搞得焦头烂额,但理解BD链和Tail指针的机制之后,发现它反而让驱动层的代码更整洁了。希望这篇文章能帮你少走一些弯路,特别是在缓存一致性和BD对齐这两个容易踩坑的点上,提前做好设计,后面会省很多事。