☰
Zynq AXI DMA实战:S2MM/MM2S接口时序与调试经验
2026/9/28 2:19:21 网站建设 项目流程

去年调一块Zynq-7020的数据采集板,我在AXI DMA这个IP核上卡了整整两个星期。PL侧ADC采到的波形数据明明已经送到了S_AXIS_S2MM接口,ILA抓波形时tvalid和tready也在正常握手,可DDR里读回来的数据要么全是0,要么隔一段才出现一块有效数据,折腾到差点怀疑硬件焊错了。最后还是老老实实把S_AXIS_S2MM和M_AXIS_MM2S的完整数据路径梳理了一遍,才发现问题出在我对接口方向的底层理解上——S2MM和MM2S看起来就是两个流接口,实际上一个管进一个管出,背后还牵扯着一整条描述符链、缓存一致性还有PS端寄存器的配合。

这篇东西不是照着UG021念手册,而是把我在Zynq上使用AXI DMA IP核做高速数据搬运的实战经验完整摊开,重点围绕S_AXIS_S2MM和M_AXIS_MM2S这两个接口的信号时序、上下游对接方式、PS端配置流程和调试踩坑。适合已经会建Vivado工程、但对AXI DMA还不算熟的开发者,看完应该能少走我之前那一大圈弯路。

1. 从数据流向看清AXI DMA的两张脸

1.1 为什么PS-PL之间的高速数据搬运绕不开DMA

Zynq的PS和PL之间要交换数据,常规思路无非是CPU直接读写PL侧的寄存器或BRAM,这条路走AXI_GP接口,优点是实现简单,缺点是带宽很低,我记得GP口实际也就几百MB/s的峰值,而且CPU会一直被占用,搬运大数据时毫无体验可言。

第二种做法是让PL逻辑直接通过AXI_HP口去读写DDR,这个速度快得多,但代价是PL侧要自己写一套完整的AXI Master逻辑,包括读请求、写请求、地址管理、B响应处理,工作量不比做一个小CPU核少。

AXI DMA这个IP核解决的就是这个尴尬问题。它把"从DDR读数据"和"向DDR写数据"这两件事做成了标准的AXI4 Master接口,PL侧只需要关心两个AXI-Stream接口——一个往里面送数据,一个从里面拿数据。你做ADC采集时,采样逻辑把数据拼好,往S_AXIS_S2MM一送,DMA自己负责把这些数据以Burst方式高效写进DDR;要做波形回放时,DMA从DDR把数据读出来,通过M_AXIS_MM2S吐给你的DAC或显示逻辑。CPU只需要配置一下寄存器,剩下的大流量搬运全交给DMA硬件完成。

当年我第一次看这个IP的时候,最懵的就是接口名太像了。这里先给一个我后来总结的口诀:看名字先看后半段,S2MM就是Stream to Memory-Mapped,方向是从流变成内存;MM2S就是Memory-Mapped to Stream,方向是从内存变成流。

1.2 S2MM与MM2S:一个入一个出,方向别再搞反

很多初学者栽在"主从"这个字眼上。其实从AXI DMA这个IP的角度去看,每个方向都有两条通路:

  • MM2S方向:DMA通过M_AXI_MM2S这个AXI4 Master接口主动去读DDR内存,读回来的数据从M_AXIS_MM2S这个流主接口吐给下游PL逻辑。也就是说,M_AXI_MM2S是面向内存的读通道,M_AXIS_MM2S是面向下游逻辑的输出流通道。

  • S2MM方向:上游PL逻辑把数据从S_AXIS_S2MM这个流从接口送进来,DMA通过M_AXI_S2MM这个AXI4 Master接口主动把数据写入DDR内存。S_AXIS_S2MM是输入流,M_AXI_S2MM是面向内存的写通道。

  • SG引擎还有一个M_AXI_SG主接口,用来读取和回写描述符链,这个在开启Scatter Gather功能时会用到。

也就是说,从DMA角度看,S_AXIS_S2MM和M_AXIS_MM2S都不算"主接口",数据方向完全相反。我用一张简单的对照表帮自己理清,也分享给你:

接口名方向(从IP核看)传输动作数据端点
S_AXIS_S2MM从接口(输入流)接收PL上游数据,DMA写入DDR外部PL逻辑 -> S_AXIS_S2MM -> M_AXI_S2MM -> DDR
M_AXIS_MM2S主接口(输出流)DMA从DDR读出数据,吐出给PL下游DDR -> M_AXI_MM2S -> M_AXIS_MM2S -> 外部PL逻辑
M_AXI_SG主接口读取/回写SG描述符DDR描述符区 -> DMA内部SG引擎

只要记住"S2MM是入内存、MM2S是出内存",后面分析时序和配寄存器时思路会清晰很多。

2. S_AXIS_S2MM接口的时序与上游对接

2.1 信号清单与握手规则

S_AXIS_S2MM接口最常见的信号有这些:

  • s_axis_s2mm_aclk:流接口时钟,所有信号都基于它采样
  • s_axis_s2mm_tdata:数据总线,位宽由IP配置里的Stream Data Width决定,可以是32位、64位、128位甚至更宽
  • s_axis_s2mm_tkeep:字节使能,标记tdata中哪些字节是有效数据
  • s_axis_s2mm_tvalid:有效标志,由上游发起
  • s_axis_s2mm_tready:DMA准备好接收,由DMA返回
  • s_axis_s2mm_tlast:包的最后一拍标志
  • s_axis_s2mm_tuser:用户自定义信号,可用于帧起始、调试打标等

AXI-Stream的核心握手规则很简单:tvalid和tready同时为高时,tdata上的一拍数据才被采样传输。也就是说,只要上游把tvalid拉高,数据就必须稳在tdata上,直到一拍tready=1的上升沿真正交易完成,tvalid才能撤掉。

有一个很容易被忽视的约束:tvalid信号不能依赖tready,即上游不能在看到tready之后才拉tvalid,而是只要数据准备好就拉tvalid,然后耐心等tready。这一点和很多串行接口的思路都不一样,我刚从串口思维转过来时吃过亏。

2.2 上游是ADC/图像传感器怎么接

做高速数据采集时,最常见的上游就是ADC采集逻辑。假设你有一颗100MSPS、16bit的ADC,输出经过两个通道拼接成了32bit数据,采样时钟作为同步时钟。最简单的方式就是把采样有效标志直接拉成tvalid,把32bit拼接数据放到tdata上,时钟用采样时钟域。如果ADC是连续采样,tvalid可以长期为高,这样DMA就会源源不断地吸收数据,中间不会出现气泡。

需要注意一个实际问题:如果ADC逻辑和DMA不在同一个时钟域,通常会在PL里加一个异步FIFO做跨时钟域缓冲。FIFO的读侧就是S_AXIS_S2MM接口逻辑,读侧几乎空时tvalid拉低,读侧非空且DMA的tready为高时,一拍出一笔数据。这样写出来的代码量很小,约30行Verilog就能搞定,但能省掉后面大量的时序问题。

如果你接的是图像传感器或视频源,一般建议按照AXI4-Stream Video协议来接。该协议把tuser用作帧同步信号SOF,把tlast用作行结束EOL,这对DMA本身不是强制要求,但如果你后面接了Xilinx的视频IP(VDMA、Video Timing Controller等),这些IP之间就靠这套约定通信。

2.3 TLAST到底要不要给

这是S2MM方向最容易被问懵的问题:我的数据源根本没有"包"的概念,TLAST能不能不接?

答案是可以,但要分情况。在SG模式下,DMA遇到两种情况会结束当前缓冲区传输:一是收到了TLAST,二是写入的数据量已经达到当前描述符里设置的Buffer Length。如果你的数据源是连续流且每个描述符的长度是固定的,那完全可以不接TLAST,DMA会在填满一个描述符缓冲后自动跳到下一个描述符继续工作。

但如果你做的是包式数据传输,比如以太网帧或者突发传感器数据,包的长度和描述符长度不一致,就一定要在包的最后一拍把TLAST拉高,否则DMA会一直傻等后续数据,直到缓冲填满才收工。这样一来,包的末尾混进了一大堆无效数据,而且下一包数据的起始位置也全乱了。

如果你要在PL里手动给数据流加TLAST,可以用一个字节计数器。记录当前包还剩多少字节,当剩余量为0且当前拍tready有效时,把tlast拉高一拍即可。这里有一个关键点:TLAST必须是和数据本身同一拍有效,不能早也不能晚。

3. M_AXIS_MM2S接口的时序与下游对接

3.1 信号清单与一次读取的完整流程

M_AXIS_MM2S的信号和S2MM方向完全类似,只是方向相反:

  • m_axis_mm2s_tdata:DMA从DDR读出的数据
  • m_axis_mm2s_tkeep:字节使能
  • m_axis_mm2s_tvalid:数据有效,由DMA发起
  • m_axis_mm2s_tready:下游逻辑准备好接收
  • m_axis_mm2s_tlast:缓冲区/包传输完成的最后一拍
  • m_axis_mm2s_tuser:用户自定义信号,在SG模式下可由描述符中的TXSOF位置位控制

一次MM2S传输的完整动作是:CPU把输入缓冲区的源地址写到MM2S_SA寄存器,把传输长度写到MM2S_LENGTH,然后置位DMA控制寄存器的RS位并启动。DMA内部发起一系列AXI读突发,从DDR读数据,数据缓冲到内部FIFO之后,按照流接口时钟从M_AXIS_MM2S输出。

这里的握手下游必须主动配合:当DMA把tvalid拉高时,下游如果在忙必须把tready拉低,DMA会自动暂停等待;等tready拉高后继续发送。这个机制对慢速下游很友好,不会丢数据。

3.2 下游是DAC/显示屏怎么接

DAC回放场景里,下游DAC通常有固定的采样率,数据必须按节拍灌入。一般做法是DMA输出的M_AXIS_MM2S先接一个AXI-Stream FIFO,FIFO读侧再接一个采样率控制逻辑,每次采样节拍到达时从FIFO取出一个样本送给DAC。DMA这边只要保证FIFO不读空就行,FIFO水位一旦低于阈值,可以反馈给CPU或者通过某种机制重新装载数据。

显示屏场景一般会走Xilinx的视频通路,VDMA是比AXI DMA更贴合的IP,但如果你坚持用AXI DMA,需要自己把MM2S流打包成视频时序。这时TUSER作为SOF、TLAST作为行同步的设计就很重要了。SG模式下的描述符TXSOF位可以控制DMA在某个描述符的第一个有效数据拍拉高TUSER,正好用来标记帧起始。

3.3 SOF/EOF在流接口上的体现

Direct Register模式下,DMA每完成一次LENGTH长度的传输,都会在最后一拍自动拉高TLAST。这一点非常直观,也适合用来验证链路。但到了SG模式,TLAST出现的位置不再依赖长度自动收尾,而是看描述符里是否置位了TXEOF。如果描述符Buffer Length对应的数据是一个包的结尾,那就要在CONTROL字段里置TXEOF位,这样DMA才会在该描述符的最后一拍把TLAST拉起来。

TUSER的用法类似。MM2S方向把描述符CONTROL字段的TXSOF置位,DMA在该描述符的第一拍把TUSER拉高;S2MM方向接收到带TUSER的数据时,DMA会把TUSER值连同数据状态写回描述符的STATUS/APP字段,软件可以从内存中读到该信息。这套机制在做视频帧同步和打时间戳时非常好用。

4. PS端寄存器与描述符链:把DMA跑起来的关键

4.1 直通模式和SG模式怎么选

AXI DMA有两种工作模式,这个在IP配置阶段就要决定:

  • Direct Register模式:不使能SG,只支持单次传输。CPU设置源地址/目的地址和长度,DMA传完就停。优点是寄存器少、逻辑简单,缺点是每次传输都要CPU重新配置一轮,适合一次性波形搬移或调试起步。

  • SG模式:使能Scatter Gather Engine,数据搬运由一组描述符链(BD)控制。CPU不需要反复写地址长度,只要构建一条或多条BD链,告诉DMA链首和链尾,DMA会自动沿着链表逐条执行。非常适合连续采集、乒乓缓冲、循环输出等场景。

我的建议是:正式项目只要数据流是持续不断的,优先SG模式;如果只是临时验证IP或者做板卡自检,先用Direct Register模式把链路跑通,再切换到SG。

4.2 描述符的布局与填写

SG模式下,描述符存放在DDR中,DMA通过M_AXI_SG接口自己读取。一个典型描述符链由N个BD组成,每个BD保存了下一跳BD的地址、数据缓冲区地址、控制信息、状态信息和APP字段。

我平时习惯在裸机工程里用一个结构体来管理BD:

typedef struct { uint32_t NXTDESC; // 下一描述符地址 [31:0] uint32_t NXTDESC_MSB; // 下一描述符地址 [63:32] uint32_t BUFFER_ADDRESS; // 数据缓冲地址 [31:0] uint32_t BUFFER_ADDRESS_MSB; // 数据缓冲地址 [63:32] uint32_t Reserved0; uint32_t CONTROL; // 控制字:长度、SOF/EOF位 uint32_t STATUS; // 状态字:DMA完成时回写 uint32_t APP0; // 应用字段,可记录TUSER等信息 } Bd;

填充BD链时要关注几个常见位。MM2S方向CONTROL字段里Buffer Length占低位,TXSOF在Bit31,TXEOF在Bit30;S2MM方向的CONTROL字段主要填Buffer Length,STATUS字段在DMA写完后会回写SOF/EOF以及其他状态。注意不同IP版本的具体位偏移可能略有差异,以你手上Vivado生成的IP版本和UG021为准。

描述符和对应的数据缓冲区地址都必须是对齐后的物理地址。另外,描述符一定是DMA要主动去DDR里读的内存区域,CPU侧更新描述符后必须做Cache Flush,否则DMA读到的很可能是旧值。

4.3 一次完整的启动流程

以SG模式下MM2S方向为例,启动流程如下:

  1. 在DDR中准备好N个BD,每个BD的NXTDESC指向下一个BD,最后一个BD的NXTDESC可以指向首BD形成循环,也可以为空。
  2. 每个BD的BUFFER_ADDRESS指向源数据缓冲,CONTROL字段写入Buffer Length,按需置位TXSOF/TXEOF。
  3. 对BD链内存区域和源数据缓冲执行Xil_DCacheFlushRange。
  4. 将第一个BD地址写入MM2S_CURDESC寄存器。
  5. 将最后一个BD地址写入MM2S_TAILDESC寄存器,这个写操作会触发SG引擎开始读取描述符。
  6. 将MM2S_DMACR的RS位置1,使能DMA通道。
  7. 轮询或中断等待状态寄存器里的IOC_Irq位置1,表示这一轮描述符执行完毕。

S2MM方向的流程完全对称,只是BUFFER_ADDRESS填的是DDR写入目的地址,TAILDESC的写入同样触发DMA进入等待接收状态。启动后上游只要往S_AXIS_S2MM发送数据,DMA就会按描述符把数据写入DDR。

5. 缓存一致性:Zynq上最容易翻车的环节

5.1 PL DMA和CPU Cache的关系

Zynq-7000的PS侧L1/L2 Cache在CPU和DDR之间做了一层缓存。CPU读DDR时可能命中Cache里的旧副本,CPU写DDR时数据也可能先留在Cache里,没有立刻写回DDR。神奇的是,PL侧的AXI DMA访问DDR时完全不经过这层Cache,它是直接面对DDR控制器的。

这就产生了一个经典的不一致问题:CPU先把一段波形数据写到内存里,这段数据还在Cache中没落DDR,DMA紧接着执行MM2S去读这块内存,结果读出来的全是旧数据。反过来也一样,DMA通过S2MM写入DDR的数据,CPU再去读时命中的可能是Cache里的旧副本,读出来一片乱码。

这不是玄学,几乎是Zynq上DMA开发必踩的坑。解决办法就两个函数:写完后用Xil_DCacheFlushRange把数据从Cache刷进DDR;DMA写完数据CPU要读之前,用Xil_DCacheInvalidateRange把Cache中相应地址的副本作废,让CPU重新去DDR读最新数据。

5.2 Flush/Invalidate的时机

我总结了一套规规矩矩的顺序,照着做基本不会出问题:

MM2S方向(DMA把DDR数据发给PL):

  1. CPU写入源数据缓冲区。
  2. 调用Xil_DCacheFlushRange刷源数据缓冲和BD链。
  3. 启动DMA传输。
  4. 等DMA传输完成中断后,CPU如果要复读该缓冲区,先Xil_DCacheInvalidateRange,再读。

S2MM方向(PL数据由DMA写入DDR):

  1. 调用Xil_DCacheFlushRange刷BD链。
  2. 启动DMA传输。
  3. 等DMA传输完成中断后,CPU读DDR数据前必须调用Xil_DCacheInvalidateRange。
  4. 再访问缓冲区。

很多人只Flush不Invalidate,导致回读还是老数据。切记缓存一致性是两个方向都要处理,不能只做一边。

5.3 内存对齐与length的隐藏规则

AXI DMA对地址和长度有一套隐含的对齐要求,不问清楚很容易出诡异问题。

地址对齐:如果Memory Map Data Width是64位,那源地址和目的地址必须8字节对齐;如果总线是32位,则4字节对齐。如果地址不对齐,DMA传输可能报错或者行为异常。我在用SDK的malloc分配缓冲区时从来不敢直接用返回值,而是先分配一个比需求大一些的区域,再手动对齐到8字节边界。

长度方面,DMA的Buffer Length寄存器单位是字节,但传输的数据总线一拍能传多个字节。建议传输长度尽量做成总线宽度的整数倍。以64位总线为例,长度最好是8的倍数。如果不是整数倍,最后一拍会依赖TKeep来标记有效字节,DMA会处理好,但下游逻辑和存储端必须理解TKeep语义,否则可能出现错位。

还有一点:Buffer Length的位宽由IP配置里的Width of Buffer Length Register决定。默认14位时,单次缓冲最大16383字节。如果你要单次传输超过这个数,要么把IP参数调到26位,要么拆成多个BD由SG链自动接力。

6. 实测踩坑:ILA抓时序与常见故障定位

6.1 数据没进内存,先从接口抓波形

我调试AXI DMA时有一套固定的排查链路,从接口向两端扩散:

第一步看S_AXIS_S2MM或M_AXIS_MM2S的握手。ILA里挂上tdata/tvalid/tready/tlast,如果数据源有输出但tready始终为低,说明DMA内部并没有真正进入可接收状态。常见原因有:RS位没置1、CURDESC/TAILDESC没配对、SG引擎报错、或者时钟没起来。

第二步看DMA到DDR的方向。如果S2MM方向流接口握手正常,DMA肯定发起了写DDR的AXI请求,这时候ILA或Vivado里观察M_AXI_S2MM通道的awvalid/awready/wvalid/wready,如果看到请求一直在等应答,往往是地址映射没配好,DMA访问到了无效地址区域。

第三步直接用XSDB命令读DDR数据验证。比如:

mrd 0x01000000 4

如果读出来和你启动DMA前写入的初始值不同,说明DMA确实写了数据;如果完全没变,就要回头查DMA到底有没有执行传输。

6.2 常见失败场景与对策

把几个我实际遇到过的典型故障整理成一张表,方便对照排错:

现象直接原因排查/对策
S2MM的tready一直为低DMA未启动或BD链配置错误检查RS位、CURDESC、TAILDESC;读DMASR确认Halt/Idle状态
MM2S的tvalid一直为低描述符长度填0或源地址无有效数据打印所有BD的地址和长度;确认TAILDESC写入时间
中断触发但数据不对忘了Invalidate Cache回读前加Xil_DCacheInvalidateRange
描述符报SGDecErr/SGSlvErr描述符地址未对齐、指向不可访问区核对描述符物理地址;排除地址越界
数据错位、每段开头有垃圾长度不是总线字节倍数,或TLast位置不对调整长度对齐;检查上游包计数逻辑
DMA传一次后再也不动TAILDESC或CURDESC没能正确更新SG模式下需要软件更新下一个里程碑BD并写TAILDESC唤醒

有一个我特别想提醒的点:SDK/XSDB里读内存看到的地址和DMA看到的地址必须一致。Zynq里DDR物理地址通常在0x00100000之后,而如果你在SDK里用指针访问0x01000000,要确保这个地址既在DMA的地址空间里,也落在DDR的实际映射范围内。Vivado Address Editor里DMA的S2MM/MM2S地址映射要指到DDR所在区域。

6.3 我的调试习惯:先Direct Register跑通,再上SG

直接上SG模式调试,一旦不工作,你很难判断是BD链的问题、Tail Descriptor问题还是Cache问题。所以我的建议是第一次接触AXI DMA时,先用Direct Register模式做一个最简单的自回环:CPU写一段缓冲区,MM2S把数据发出来绕一圈送进S2MM,DMA写进另一段缓冲区,CPU读回来对比。这条路走通,说明DMA数据通路、时钟复位、PS到PL的中断路径都没问题,再上SG模式去啃BD链。

实际这么操作之后,我后面做ADC采集项目时,基本是半天就调通了S2MM通道,剩下的时间全在优化DDR带宽和Cache刷新策略上。相比第一次硬啃SG描述符时的狼狈,效率差距非常明显。

最后再分享一个小习惯。调试AXI DMA时,我会在Vivado的Block Design里把MM2S、S2MM两个通道的流接口各引出一组ILA探针,同时把DMA的中断输出也接到PS端的IRQ_F2P。这样硬件上能直接看到握手和中断时序,软件里也能通过GIC状态确认中断有没有到达PS。两头对照着看,定位问题比只看一头快得多。等整个系统稳定了,再把多余的ILA摘掉,减小一点资源占用。这个思路从Zynq到Zynq UltraScale+我一直在用,省下来的时间绝对值得。

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

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

立即咨询