去年调一块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方向为例,启动流程如下:
- 在DDR中准备好N个BD,每个BD的NXTDESC指向下一个BD,最后一个BD的NXTDESC可以指向首BD形成循环,也可以为空。
- 每个BD的BUFFER_ADDRESS指向源数据缓冲,CONTROL字段写入Buffer Length,按需置位TXSOF/TXEOF。
- 对BD链内存区域和源数据缓冲执行Xil_DCacheFlushRange。
- 将第一个BD地址写入MM2S_CURDESC寄存器。
- 将最后一个BD地址写入MM2S_TAILDESC寄存器,这个写操作会触发SG引擎开始读取描述符。
- 将MM2S_DMACR的RS位置1,使能DMA通道。
- 轮询或中断等待状态寄存器里的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):
- CPU写入源数据缓冲区。
- 调用Xil_DCacheFlushRange刷源数据缓冲和BD链。
- 启动DMA传输。
- 等DMA传输完成中断后,CPU如果要复读该缓冲区,先Xil_DCacheInvalidateRange,再读。
S2MM方向(PL数据由DMA写入DDR):
- 调用Xil_DCacheFlushRange刷BD链。
- 启动DMA传输。
- 等DMA传输完成中断后,CPU读DDR数据前必须调用Xil_DCacheInvalidateRange。
- 再访问缓冲区。
很多人只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+我一直在用,省下来的时间绝对值得。