1. 问题到底出在哪:ZYNQ数据链路上的跨时钟域
1.1 一个典型的ZYNQ数据链路
接触ZYNQ的人都知道,这芯片最迷人的地方就是PS和PL能协同干活。但踏实干过几个项目后你就会发现,ZYNQ项目里最磨人的往往不是功能逻辑本身,而是数据在不同时钟域之间传递时导致的偶发错乱。
我之前做过一个高速采集项目,PL侧挂了ADC,采样时钟是125MHz;PS侧通过DMA把数据搬进DDR做后续处理,而DMA所挂的AXI总线时钟是100MHz。ADC采集的数据需要经过一个简单的下采样、加包头、组帧逻辑,再送到DMA的S2MM通道。一开始我想得挺简单:ADC侧的逻辑是125MHz,DMA侧是100MHz,中间加个FIFO不就完事了吗?可真等系统跑起来,数据帧偶尔会错位一个字节,有时候一帧数据里出现上帧的尾巴,定位了整整两天才意识到问题出在跨时钟域同步这个看似“解决了”的环节上。
这个场景其实非常典型。ZYNQ项目里跨时钟域几乎无处不在:
- PL侧不同外设的接口时钟不同,ADC、DAC、以太网PHY、视频像素时钟,各有各的频率;
- PS与PL之间的AXI接口时钟与PL逻辑时钟不同;
- 多个PL逻辑模块之间,可能存在数据流从一个Clock Domain传入另一个Clock Domain的情况。
数据只要跨一次时钟域,就必然要面对两个问题:一是亚稳态,二是多bit数据在不同时钟沿下的位对齐。前者可能导致触发器采到不确定的电平,后者则可能让一条32bit总线传过去之后,高16bit是本次更新的数据、低16bit还是上一次的数据,直接拼出一朵“奇葩”数据。
1.2 为什么“直接连过去”行不通
很多新手会问:FPGA里跨时钟域,寄存器打两拍不就行了?打两拍确实是单bit信号的经典处理方式,也就是两级寄存器同步,用来降低亚稳态传播到后级逻辑的概率。但对多bit并行总线而言,直接打两拍是行不通的。
举个例子:一个32bit计数器在125MHz下从0x0000FFFF加1变成0x00010000,8个bit同时翻转。如果接收端时钟是100MHz,它可能在某个时钟沿抓到一部分新bit、一部分旧bit,比如0x0001FFFF、0x00000000这类完全错误的中间值。不管你怎么打拍,打出来的都是错值,只是把错误稳定地传播了出去而已。
所以对于多bit数据跨时钟域,业界常用的思路无非三种:异步FIFO、握手加数据保持、以及借助Xilinx原语或IP完成安全传递。其中异步FIFO是最通用、最稳妥的,因为它内部用格雷码对读写指针做了跨时钟同步,保证任意时刻指针变化只有一个bit翻转,从而避免了多bit同时跳变导致的采样错误。ZYNQ里的AXI-stream FIFO正是这样一个自带跨时钟域处理能力的标准接口IP。
我见过不少项目,数据流本身是标准的AXI-Stream,但做跨时钟域时非要手搓一个双口RAM加一坨握手逻辑,最后调时序调到怀疑人生。后面我会详细解释为什么不建议这样做。
2. 方案怎么选:为什么是AXI-stream FIFO而不是手写异步FIFO
2.1 三种常见跨时钟域方案的对比
在ZYNQ PL逻辑里做多bit数据跨时钟域,我经常被问到的问题就是:XPM_FIFO、手写异步FIFO、FIFO Generator里面的AXI-Stream FIFO,到底选哪个?
我自己的判断标准比较直接:看接口协议和集成成本。
| 方案 | 接口类型 | 跨时钟域支持 | 集成成本 | 适用场景 |
|---|---|---|---|---|
| 手写异步FIFO | 自定义读写信号 | 需要自己处理格雷码、空满标志、复位域 | 高,容易出错 | 对资源极致敏感、接口自定义的项目 |
| XPM_FIFO(Xilinx参数化宏) | 简单读写接口,类似BRAM时序 | 内置,CDC已处理 | 中,比手写安全很多 | 内部模块之间简单数据缓存 |
| FIFO Generator(AXI-Stream模式) | 标准AXI-Stream握手 | 内置,可配置Independent Clocks | 低,即插即用 | 需要对接AXI-Stream链路、DMA、VDMA的场景 |
粗看之下,XPM_FIFO似乎也够用。但如果你后续要把数据接给DMA、VDMA,或者和Xilinx的其它AXI-Stream IP互联,XPM_FIFO那套读使能、写使能接口反而要额外转换逻辑。与其自己封装一层标准握手,不如直接用FIFO Generator的AXI-Stream模式,接口天然就是s_axis_*和m_axis_*,往DMA的S2MM通道一怼就能跑。
再说手写异步FIFO。对于学习FPGA的人来说,手写一个异步FIFO绝对是很不错的训练项目,格雷码指针、空满判断、两级同步器,每一步都值得亲手走一遍。但在工程项目里,我强烈不建议动不动就手搓。原因很实在:
- 格雷码指针的二进制转换逻辑看着简单,但空满标志的生成时机、读写指针比较的跨域延迟,稍有不慎就引入死锁或溢出;
- 复位释放与两个时钟域的关系难处理,异步复位、同步释放的方案,不同复位释放时间会导致空满标志短暂错误;
- 你手写代码的验证时间和排错时间,大概率超过项目排期能给你的余量。
2.2 AXI-stream FIFO真正好用的几个点
FIFO Generator的AXI-Stream模式,本质上仍然是异步FIFO,只不过Xilinx把所有容易出错的部分都替你处理好了,同时对外开放的接口又恰好是AXI-Stream标准握手。它的几个优势在实际项目里非常值钱:
第一,读写时钟完全独立。在配置界面里选择Independent Clocks,写端口用s_axis_aclk,读端口用m_axis_aclk,两个时钟没有任何相位或频率关系要求,FIFO内部自己完成指针域的转换。这个特性正好是我们解决PS-PL跨时钟域最需要的。
第二,标准握手信号天然支持反压。tvalid/tready这套逻辑本身就是设计给数据流做流量控制的。上游写入再快,只要FIFO快满,s_axis_tready就会拉低,上游逻辑看到tready=0就必须暂停发送。下游读取慢时,m_axis_tvalid持续拉高、数据待在读端口不动,绝不会丢。这套机制比自定义的fifo_wr_en/fifo_full明确得多,因为tvalid/tready的握手规则在AXI-Stream协议里是严格定义的,不会出现“数据写进去了但full标志还没拉高导致又写一拍”的临界问题。
第三,水位信息可以直接用于流控。FIFO Generator可以输出axis_wr_data_count和axis_rd_data_count,分别反映两个时钟域中的数据水位。这个特性非常实用,我可以用它做软流控,比如当写侧水位超过阈值时,不再向FIFO写数据,而是先暂停采集逻辑或向CPU上报缓冲高水位事件。
3. 动工前的功课:AXI-Stream握手时序与FIFO Generator关键参数
3.1 握手必须先讲透:tvalid/tready的几种情况
AXI-Stream的握手规则只有一条,但这一条就能劝退不少人:只有当tvalid和tready同时拉高时,一个数据传输才算完成。
具体来说,发送方拉高tvalid表示数据有效,接收方拉高tready表示可以接收。二者都高时,数据被“锁定”传递;只要有一个为低,当前拍就不算传输。这里有几个边界情况,我在项目里看到很多人踩过:
tvalid拉高后,数据必须一直保持到tready拉高。发送方不能因为自己觉得“我数据早准备好了”就中途改变tdata,否则接收方采到的可能是一拍混合数据。tready可以在任意时钟沿拉低。对于接收方来说,这相当于反压信号,所以发送方的状态机必须支持“tvalid已经拉高但tready还没来”的等待状态,数据要一直拿着、保持到握手完成。- AXI-Stream协议规定
tvalid不能依赖tready来产生。也就是说,发送方不能等到看见tready=1之后才去拉tvalid,这会产生组合逻辑环,在时序上极其难看。在实际工程里,我经常看到有人用assign tvaild = tready这种写法,仿真没问题,上板直接时序违例或者行为诡异。
FIFO的tready行为由IP内部逻辑产生,你基本不用操心它会违反协议。真正需要上心的是你的上游发送逻辑和下游读取逻辑,这两个逻辑如果不严格遵守握手规则,FIFO再稳也会被外面的错误时序带偏。
3.2 FIFO Generator配置项逐个过一遍
在Vivado里添加FIFO Generator IP,打开配置界面后会看到非常多的选项。很多第一次用的人会被这些选项唬住,其实要紧的就那么几个:
| 配置项 | 我的推荐值 | 说明 |
|---|---|---|
| Component Name | axi_stream_fifo_0 | 叫什么都行,但建议带项目语义 |
| FIFO Interface | AXI-Stream | 核心选项 |
| FIFO Implementation | Independent Clocks | 跨时钟域场景必须选这个 |
| Read Data Width | 按需,如32 | 读写数据位宽通常保持一致 |
| Write Data Depth | 512、1024等 | 深度需结合带宽估算,后面细说 |
| Read Data Count | 开 | 用于水位监控 |
| Output Register | 开(或者选Registered) | 改善读端口时序,代价是加一拍延迟 |
| Full Threshold | 可留默认或按需设置 | 用于提前拉低tready的高水位阈值 |
特别提一下Output Register选项。开启了输出寄存器后,读端口相当于在FIFO内部RAM数据输出后再加了一级寄存器,把读数据路径从RAM输出时序解放出来,改善了对下游的时序收敛。代价是m_axis_tvalid会相对数据实际读出的时刻延迟一个周期左右,同时空标志的响应也会变慢。在跨时钟域场景中,这个延迟相对于系统的吞吐来说通常可以忽略,所以我在大多数项目里都会开启。
还有一个容易忽略的点:AXI-Stream FIFO的tkeep信号。如果你配置成32bit数据宽度,tkeep是4bit,正常的整字传输时全部为高。但要注意,FIFO Generator并不会帮你做“跨位宽”的字节使能映射,如果上游只希望DMA接收有效字节,比如某个包只有3字节,你必须在发送时自己管理好最后一个word的tkeep,然后原样送到FIFO。FIFO不解析、不修改tkeep,它只是把这个信号当成随路控制信号从读端口原样呈递出来。这一点在组帧封装时非常关键。
3.3 FIFO内部分层机制:格雷码怎么帮你守住亚稳态
既然是用异步FIFO思路做跨时钟域,就绕不开内部的空满判断问题。FIFO Generator内部维护读写两组指针,各自在自己的时钟域里累加。空满比较必须跨时钟域拿对方的指针过来比较,而指针是多bit的,直接跨域打两拍又会遇到多bit同时翻转的问题。
Xilinx的做法是把二进制指针转成格雷码,格雷码的特点是计数器加1时只有1个bit发生变化。这样把格雷码指针经过两级同步器传到对侧时钟域时,即使采到的是变化前或变化后的瞬间值,也不会采出“中间乱码”,因为任意时刻只有一个bit在变化。对侧拿到同步后的格雷码指针再转回二进制,与本地指针做比较,从而判断空满状态。
理解这个机制对排错很有帮助。比如你会看到FIFO读侧明明还有数据,m_axis_tvalid却短暂拉低了一拍,这很可能不是FIFO坏了,而是写指针同步到读时钟域有延迟,读逻辑在某一瞬间认为“可能空了”。这种延迟天然存在,属于异步FIFO的正常行为。后面我会讲这也是反压设计时要注意的点。
4. 完整实现:一个采集链路跨时钟域设计的Verilog实例
4.1 顶层设计框图和时钟域划分
假设现在的项目背景是:ADC以125MHz时钟输出连续采样数据,组帧逻辑把连续采样打包成64字一帧,每帧末尾附带tlast。DMA在100MHz的AXI总线时钟下通过S2MM通道读走FIFO里的数据到DDR。两个时钟域在这里交汇,中间正好用AXI-stream FIFO做隔离。
顶层模块可以这样划分:
- 写侧时钟域(125MHz):
adc_clk驱动ADC接口和组帧逻辑,组帧逻辑产生s_axis_tdata、s_axis_tvalid、s_axis_tlast,送入FIFO的写端口。 - FIFO本体:内部双口RAM/分布式RAM,读写指针各自独立,通过格雷码同步跨时钟域判断空满。
- 读侧时钟域(100MHz):
dma_clk驱动FIFO读端口,m_axis_tdata、m_axis_tvalid直接输出到DMA S2MM通道,DMA返回m_axis_tready。
这里的要点是:组帧逻辑和读取逻辑分别只跟自己时钟域内的信号打交道,不要出现任何跨时钟域的直接寄存器访问。所有跨时钟域的“脏活”都交给FIFO完成。
4.2 关键代码:AXI-stream FIFO例化和组帧逻辑
下面给出一段我在项目中使用的简化版代码,覆盖FIFO例化、写侧组帧、读侧接口。这段代码基于FIFO Generator生成的IP核,实例名为axi_stream_fifo_0。
module cdc_fifo_example #( parameter DATA_WIDTH = 32, parameter PKT_WORDS = 64 )( input wire adc_clk, // 写侧时钟 125MHz input wire adc_rstn, // 写侧复位,低有效 input wire [DATA_WIDTH-1:0] adc_data, // ADC采样数据 input wire adc_valid, // ADC数据有效 input wire dma_clk, // 读侧时钟 100MHz input wire dma_rstn, // 读侧复位,低有效 output wire [DATA_WIDTH-1:0] dma_data, // 输出到DMA S2MM output wire dma_valid, input wire dma_ready, output wire [11:0] fifo_wr_count, // 写侧水位,用于观察 output wire [11:0] fifo_rd_count // 读侧水位,用于观察 ); reg [DATA_WIDTH-1:0] wr_data_buf; reg wr_valid_buf; reg wr_last_buf; reg [5:0] wr_word_cnt; wire s_axis_tready; wire wr_allow; // 高水位控制:FIFO剩余容量不足1帧时,暂停写入 assign wr_allow = (fifo_wr_count < (4096 - PKT_WORDS)); always @(posedge adc_clk or negedge adc_rstn) begin if (!adc_rstn) begin wr_data_buf <= {DATA_WIDTH{1'b0}}; wr_valid_buf <= 1'b0; wr_last_buf <= 1'b0; wr_word_cnt <= 6'd0; end else begin // 默认清掉已经完成握手的valid if (wr_valid_buf && s_axis_tready) begin wr_valid_buf <= 1'b0; wr_word_cnt <= (wr_word_cnt == PKT_WORDS - 1) ? 6'd0 : wr_word_cnt + 1'b1; end // 产生新一拍的组合条件:ADC有数据,且FIFO的水位允许 if (adc_valid && wr_allow && !wr_valid_buf) begin wr_data_buf <= adc_data; wr_last_buf <= (wr_word_cnt == PKT_WORDS - 1); wr_valid_buf <= 1'b1; end end end axi_stream_fifo_0 u_fifo ( .s_axis_aclk (adc_clk), .s_axis_aresetn (adc_rstn), .s_axis_tvalid (wr_valid_buf), .s_axis_tready (s_axis_tready), .s_axis_tdata (wr_data_buf), .s_axis_tlast (wr_last_buf), .s_axis_tkeep (4'b1111), .m_axis_aclk (dma_clk), .m_axis_aresetn (dma_rstn), .m_axis_tvalid (dma_valid), .m_axis_tready (dma_ready), .m_axis_tdata (dma_data), .m_axis_tlast (dma_last), .m_axis_tkeep (dma_tkeep), .axis_wr_data_count (fifo_wr_count), .axis_rd_data_count (fifo_rd_count) ); endmodule这段代码里值得注意的有几点。
wr_valid_buf的清除逻辑:我在每个时钟周期开始先判断上一拍的wr_valid_buf是否已经和tready握手成功,如果成功就清除valid。这样当前拍如果有新的ADC数据,才能继续发起下一次写入。这个处理方式保证了每个ADC数据只进FIFO一次,不会因为tready长时间拉低而反复写入同一份数据。
wr_last_buf的生成时机:tlast必须在当前帧最后一个word发出时伴随该word一起拉高。这里用wr_word_cnt判断当前word在帧内的位置,当wr_word_cnt == PKT_WORDS - 1时,说明当前要写入的是第64个word,因此wr_last_buf拉高,DMA看到tlast后就会结束当前描述符对应的传输。
4.3 读侧与DMA对接:为什么说tready反压是福利
读侧逻辑相比写侧简单很多,因为AXI-stream FIFO已经帮我们把FIFO返回值转换成了标准的握手机制。DMA S2MM通道本身就是AXI-Stream从机,它会根据内部缓冲状态拉高或拉低dma_ready。
我在这块踩过一个不大不小的坑:第一次做ZYNQ采集系统时,我在FIFO和DMA之间又加了一层寄存器打拍来同步tdata,结果因为打拍导致tvalid沿数据多延迟了一拍,DMA总是少收到数据。后来才想明白,AXI-stream FIFO比起普通异步FIFO好的地方就在于它把握手协议内化了,你根本不需要担心数据路径上的打拍问题,直接把m_axis_tdata接到m_axis_tdata就行。
如果你非要在这中间做点处理,比如数据加密、格式转换、Byte交换,我建议用AXI-Stream Data Width Converter、AXI-Stream Subset Converter这类官方IP,而不要自己手写comb逻辑插在中间。自己手写的逻辑一旦在握手时序上有疏忽,表现出的故障会比跨时钟域本身还难查。
5. 板级实测避坑:tready假低、复位不同步、tlast丢失的根因
5.1 现象一:FIFO明明没满,s_axis_tready却长时间拉低
有段时间我的采集系统出现了莫名其妙的吞吐下降,测下来125MHz的采样时钟,DMA搬运速度却连一半都跑不满。ILA抓FIFO写侧信号发现,s_axis_tready经常长时间拉低,但axis_wr_data_count显示FIFO里根本没有多少数据。
这其实是我自己代码造成的:写侧水位控制条件fifo_wr_count < (4096 - PKT_WORDS)里,我把4096写成了1024这个配置值,而FIFO实际深度是4096。本意是防止FIFO满时写入导致数据丢失,结果因为阈值设得太低,FIFO里还剩大几百个word时就开始反压。别笑,这种“配置深度和代码参数不一致”的错误,比逻辑错误更隐蔽,因为仿真里未必会跑到这么满。
这也引出一个排查经验:遇到tready异常拉低,第一步是确认数据计数信号的位宽和FIFO深度配置是否一一对应。axis_wr_data_count的位宽由Vivado根据FIFO深度自动生成,深度4096时位宽是12bit,最大计数4095。如果你的比较逻辑里手工写了一个大于实际深度的常数,或者把12bit信号和16bit常量比较,很容易出现逻辑永远不成立的情况。
5.2 现象二:数据偶发错位,一帧数据会串到下一帧
这个问题的表现比5.1隐蔽得多。系统大多数时候工作正常,但偶尔会有一帧的开头混入上一帧结尾的几个字节。刚开始我怀疑是DMA描述符配置问题,查了半天无果。后来用ILA同时抓FIFO写侧和读侧的tdata、tvalid、tlast,对比后发现:写侧产生的tlast和下一帧第一个word之间,存在一个时钟周期里wr_valid_buf并没有完全复位的情况。
根因在于我最初写的组帧逻辑里,tlast拉高后立刻清零wr_word_cnt,清零后的下一个ADC valid到来时,由于状态机的分支覆盖不全,对wr_valid_buf的处理多了一拍。FIFO接受了本应作为下一帧首个word的数据,读侧自然就把上一帧tail和下一帧head连在一起了。
解决方法是把wr_last_buf的生成与wr_word_cnt更新放在同一个分支里,并且强制在tlast握手完成后,让wr_valid_buf至少保持一个周期的低电平,确保下一帧和上一帧在时间上彻底划清界限。
5.3 现象三:DMA通道卡死,等不到tlast
还有一种板级故障让我印象很深:DMA搬运偶尔会整个通道卡死,描述符停在Partially Transferred状态,不再前进。用ILA抓读侧信号,看到m_axis_tvalid一直为高,m_axis_tready也一直为高,但tlast就是不来,DMA只好一直等。
这个问题的根源还是tlast没有和帧内最后一个数据对齐。在我的实现里,wr_last_buf依赖wr_word_cnt的值,但如果wr_word_cnt在tready拉低期间被意外清零,或者上游数据源的adc_valid在帧边界处恰好断了一拍,就会导致当前帧最后一个word发出时wr_last_buf仍然是0,而下一帧第一个word反而带了tlast。DMA等不到tlast自然不结束传输。
从这里我总结出的工程习惯是:像tlast这种帧边界信号,必须在写入数据改变时一起锁存,不能用“先更新计数器、下一拍再产生tlast”的方式。最好是数据、last、valid三者在同一个时序逻辑里同时输出,保证进入FIFO的每一拍都是完整自洽的。
5.4 排查链路实录:从ILA抓信号到定位复位的坑
除了协议上的时序问题,跨时钟域FIFO还有一个很容易被忽略的点,就是复位信号的释放。FIFO Generator要求s_axis_aresetn和m_axis_aresetn各自在自己的时钟域内同步释放,否则上电或软复位之后,FIFO内部状态可能出现一侧已经离开复位、另一侧还在复位中的情况,导致空满标志短暂乱跳。
我遇到过最典型的故障是:系统刚上电时,第一次DMA传输偶尔正常,偶尔读到的全是0。抓信号看到m_axis_tvalid一直没拉高,但axis_rd_data_count又显示里面是有数据的。最后定位到是CPU软复位流程问题:PS只复位了PL侧的读时钟域,而写时钟域没有复位,FIFO内部的两个域状态不一致,读侧认为FIFO为空。
从那之后,我在设计里约定了一条铁律:FIFO两侧的复位信号必须都来自同一个复位源,且分别在各自时钟域内做同步释放。在ZYNQ里,我一般用proc_sys_resetIP为每个时钟域生成独立的peripheral_aresetn,保证所有FIFO写侧复位由写时钟域的reset模块统一产生,读侧复位由读时钟域的reset模块统一产生。这样虽然复位信号不同名,但来源是一致的,只要两个reset模块都输出释放,两侧就能对齐。
6. 设计之外的工程心得:深度估算、水位反压与仿真验证
6.1 FIFO深度不是拍脑袋定的
很多工程师定FIFO深度就一个原则:越大越稳。这句话在资源不紧张时有一定道理,但在ZYNQ里,AXI-stream FIFO如果使用BRAM实现,深度越大占用的BRAM块越多,可能挤占图像缓存、数据缓冲的BRAM预算。FIFO深度应该按最坏情况算出来,而不是随手填1024或4096。
带宽估算的核心是:在任意足够长的时间窗口内,写侧平均速率不能超过读侧平均速率,否则FIFO必然溢出,深度只能吸收短时突发差异。
以一个具体例子算一遍:
- 写侧时钟125MHz,数据32bit,理论带宽500MB/s;
- 读侧时钟100MHz,数据32bit,理论带宽400MB/s;
- 如果写侧连续发送,读侧也持续接收,长期来看写入速率大于读出速率,深度再大也扛不住;
- 但如果写侧是突发性的,比如采集逻辑每1ms产生一次64-word的突发帧,那么突发期间写入500MB/s,但一个64-word突发只有256字节,在1ms周期内平均速率只有0.256MB/s,远低于读侧带宽。此时FIFO深度只要能装下几次突发之和即可。
我的经验公式是:
FIFO深度 ≥ (写入突发总字节数 ÷ 数据位宽字节数) × 安全系数安全系数我一般取2~4,主要考虑到读时钟域的响应延迟、DMA通道重配延迟、以及复位后第一次传输时读侧可能还没就绪等情况。如果系统里不只有一路数据流,还有多个模块共享同一DMA通道,深度还要再放大,因为DMA可能在处理别的描述符,你的FIFO要等更久。
6.2 水位监控与反压:不要把FIFO用到100%满
在5.1里我提到水位控制写错参数的事故,反过来说,水位控制本身是非常必要的。工程上我不建议让FIFO跑到接近满的状态再触发反压,因为满标志的跨时钟同步有延迟,很可能你看到还有十几个空位,但实际已经过冲了。
我的做法是引入一个High Watermark,比如深度4096的FIFO,把高水位阈值设在3840左右,也就是预留约256个word的缓冲。当写侧计数超过阈值时,上游逻辑主动暂停生产新的帧,或者通过中断通知PS侧降低注入速率。这样可以保证FIFO永远在离满标志还有足够余量的状态下运行,跨时钟域的同步延迟再多也有地方消化。
这里要注意,axis_wr_data_count本身属于写时钟域,用它做水位判断时没有任何跨域问题,可以直接用组合逻辑比较。这个信号在FIFO Generator配置里可以选不生成,省一点LUT,但代价是失去精确水位。我建议始终开启,性价比非常高。
6.3 仿真阶段就要把跨时钟域场景构造出来
最后聊一个问题:很多人写仿真时习惯只给一个时钟源,甚至直接把写侧和读侧接到同一个时钟上跑仿真,快速验证功能之后就宣布“FIFO没问题”。但实际上,AXI-stream FIFO的跨时钟域正确性只有在两个异步时钟下才能真正体现。
我现在的仿真策略是:
- 写侧用100MHz时钟,读侧用150MHz时钟,两个时钟用不同的always块独立生成,并在初始化时故意错开相位,模拟真实异步关系;
- 用计数器产生多帧数据,每帧64个word,通过校验和或CRC值来判断读侧恢复的数据是否完整;
- 在写侧随机插入反压,也就是周期性拉低
tready,验证读侧出来的数据顺序依然正确; - 在读侧随机插入反压,验证FIFO不会写溢出、DMA不会丢帧。
仿真阶段把这些极端情况测透,板级的排错时间会成倍减少。跨时钟域问题最怕的不是难,而是“偶尔复现”和“无法稳定复现”,仿真能稳定复现,定位就等于完成了一半。
6.4 一个小习惯:让计数标志也参与状态上报
在使用AXI-stream FIFO的这些项目里,我养成了一个习惯:把axis_wr_data_count和axis_rd_data_count接到GPIO或AXI-Lite寄存器上,让PS侧能实时读取FIFO的水位。
这个习惯在联调阶段救过我很多次。比如DMA吞吐异常时,通过读取这两个水位寄存器,我可以立刻判断瓶颈在写侧还是读侧:如果写侧计数长期接近满,说明写入速率大于读出速率或DMA反压剧烈;如果写侧计数长期很低但读侧也读不到数据,故障大概率在更上游的数据源。把FIFO水位当成一个天然的“流量仪表盘”,比上ILA抓波形更快定位系统级问题。
回到最开头那个125MHz ADC加100MHz DMA的项目,最后真正稳定跑起来,靠的并不是什么高深技巧,而是把这些最基础的东西都落实到位:FIFO选型用标准AXI-Stream接口、写入侧严格遵循握手机制、复位统一来源并同步释放、水位监控留足余量。跨时钟域这个老生常谈的话题,做深了之后你会发现,难点从来不在于“知道要加FIFO”,而在于把FIFO周边所有看似简单的细节都处理干净。希望这篇实战记录能帮你少踩几个我踩过的坑。