
先说句大实话做FPGA的兄弟十有八九都被跨时钟域数据缓冲折磨过。一边是上游模块吭哧吭哧产生AXI4-Stream数据另一边是下游模块按自己的节拍慢慢消费两边时钟还不一样直接把信号线怼过去采样的结果必然是乱套。这时候就需要一个AXI4-Stream FIFO站出来当缓冲区先把数据稳稳接住再按读侧时钟吐出去。这篇实战笔记我会从AXI协议、异步FIFO原理、Verilog实现到仿真验证把这个数据缓冲系统完整搭一遍并给出能直接跑的代码。内容更适合刚接触AXI协议、想动手做CDC缓冲的FPGA和IC初学者老手也可以直接跳到代码和避坑部分。1. 思路拆解AXI4-Stream与FIFO是怎么凑到一起的1.1 先看懂AXI4-Stream的四个“主角”AXI4-Stream和AXI4全协议最大的区别就是它没有地址通道数据就是一股劲儿地往前流。所以不需要纠结AR、AW、R、W这些通道组合主要盯着核心信号就可以。tvalid发送方告诉接收方“我这一拍的数据是有效的”。tready接收方告诉发送方“我准备好接收数据了”。tdata真正的数据总线位宽可以按需求配。tlast这一拍是“包的最后一个数据”适合传输以太网包、图像行这类有边界的数据。除了这些还有tkeep、tstrb、tid、tdest这类附加信号但新手先放一放核心缓冲功能大多数场景只需要tvalid、tready、tdata就够了。如果后面要把tlast也一起存进FIFO只需要把FIFO的数据位宽多加一位把tlast和tdata拼起来存就行文章最后我会细说。1.2 握手机制为什么不能只看tvalidAXI4-Stream的传输规则很简单但越简单的规则越容易出错。数据传输发生的条件只有一个在同一个时钟上升沿采样到tvalid和tready同时为高。这里有两个常见的坑。第一个坑是许多人以为tready拉低就是接收方“拒收”其实不是拒收而是“还没有准备好”发送方如果收不到tready就必须把tvalid和tdata一直保持不变直到握手成功。第二个坑是有些模块会在tvalid拉高后等tready然后才把数据放到总线上这是错的。正确的做法是先准备好数据和tvalid再等tready数据在tvalid拉高之前就应该是稳定有效的。举个生活化的例子tvalid是外卖小哥打电话说“我到楼下了”tready是你按下门禁“你可以上来了”两个条件同时满足的时候订单才算真正交付。小哥不能到楼下了看你没按门禁就先把外卖放地上走了他必须等。1.3 为什么缓冲任务偏偏落在FIFO头上跨时钟域场景下最常见的需求就是缓存数据、平滑速率、跨时钟采样。缓存可以用寄存器堆平滑可以用双口RAM但只有FIFO能同时解决三个问题读侧和写侧各用各的时钟天然支持异步读写。内置空满标志写侧不会写爆读侧不会读空。使用环形指针不需要额外管理地址硬件开销小。如果用寄存器堆自己做环形队列等于重新造一个FIFO轮子还要额外处理空满标志、指针回绕和跨时钟同步得不偿失。所以买现成的FIFO IP固然方便但自己用Verilog写一个也很有价值。一方面不受IP核版本和器件限制另一方面能彻底搞明白格雷码同步、指针回绕这些硬件细节以后遇到Xilinx和Intel IP配置界面里的那些参数才真的看得懂。2. 跨时钟域的关键设计异步FIFO凭什么不会采错2.1 同步FIFO和异步FIFO选哪个同步FIFO的读时钟和写时钟是同一个时钟所以不存在跨时钟域问题内部逻辑简单很多。但同步FIFO在跨时钟域场合根本没法直接使用因为读侧下游模块的时钟和写侧上游模块不一致。少数情况下如果两个时钟同源且频率成整数倍可以通过握手再缓存但只要时钟相位关系不确定就必须用异步FIFO。异步FIFO的核心结构也不复杂一个双端口RAM加上两套读写指针再加上指针同步逻辑。读指针写给时钟域用写指针给读时钟域用。因为两个指针跨时钟传输最经典的做法就是把二进制指针转换成格雷码再打两拍同步。格雷码每次只有一位变化同步器采样时最多只会出现一位不确定而这一位不确定在满空判断时也只是“多等一拍”或“少判一拍”的问题不会导致指针完全错乱。2.2 格雷码与两级同步器CDC的唯一解为什么不能用二进制指针直接跨时钟因为二进制指针加1时可能同时翻转多个位比如3变成4二进制是011变成100三位同时翻转。假设同步器在采样瞬间只采到部分位变化读侧可能看到一个完全错误的指针比如采成000这个错误在跨时钟域里没法自动恢复。格雷码解决了这个根本问题。相邻两个数之间只有一位不同所以即使同步器采样正好发生在该位翻转的瞬间结果也只能是旧值或新值之一不会出现“乱码”指针。然后把格雷码打两拍第一拍有亚稳态的风险第二拍采到的就是已经稳定的值。这就是两级同步器的意义。设计异步FIFO时一定要保证指针同步前先转成格雷码而不是同步二进制指针后再转格雷码。因为后者依然存在多位翻转采样错误。顺序不能反这是原则。2.3 FIFO深度怎么算别再拍脑袋了很多人在定制FIFO时最头疼深度。深度选小了数据会溢出丢包深度选大了资源浪费BRAM憋屈。这里给一个最常用的适应式估算方法。假设一个突发场景写侧时钟频率为Cw平均突发长度是B拍读侧时钟频率为Cr且读写两侧几乎同时开始。在突发写入的这段时间里写侧一共写入了B个数据读侧大约能读走B * Cr / Cw个数据前提是读侧一直处于非空状态且接收方tready持续拉高。那么FIFO最小深度近似为FIFO_DEPTH B - B * Cr / Cw注意这是理想情况下的理论值实际还要加上处理延迟、背压抖动。比如写侧100MHz读侧75MHz一次突发长度16个数据理论深度就是16 - 16 * 75 / 100 4。但工程上我会直接翻一倍到8再凑成2的幂次选深度8。为什么一定要2的幂次因为异步FIFO回绕判断和格雷码转换都需要指针宽度比地址宽度多一位深度8对应地址位宽3指针位宽4格雷码刚好能覆盖16个状态。如果读写双方速率完全不确定比如写侧是PCIE DMA读侧是DDR仲裁器那我建议用深度256起跳再配合tvalid/tready背压优先保证不丢数据。深度多花的BRAM资源远小于丢包之后重新软硬件握手带来的麻烦。3. Verilog实战从零写出AXI4-Stream异步FIFO3.1 接口规划AXI信号怎么映射到FIFO的读写端口我的做法是让FIFO核直接暴露AXI4-Stream接口这样上游下游模块都是标准的AXI时序不需要额外转换。模块有两个时钟s_axis_aclk是写侧时钟m_axis_aclk是读侧时钟。写侧接口就是一组从机形式的AXI4-Streams_axis_tvalid、s_axis_tready、s_axis_tdata读侧接口是一组主机形式的AXI4-Streamm_axis_tvalid、m_axis_tready、m_axis_tdata。参数上我预设了DATA_WIDTH和ADDR_WIDTH两个。ADDR_WIDTH直接决定FIFO深度深度是1 ADDR_WIDTH。比如默认ADDR_WIDTH 4深度就是16。这个参数在例化时随时改非常方便。作为可综合设计读写两侧复位分别使用各自主时钟域的异步复位。严格来说工程上推荐异步复位同步释放我这里为了先把核心逻辑讲清楚直接用了最简单的异步复位生产项目里需要加强。3.2 写时钟域写指针与写使能怎么生成写指针本质上是一个比地址位宽多一位的计数器。低ADDR_WIDTH位用于RAM写地址最高位用来区分“空”和“满”这多出来的一位在循环缓冲里特别关键。写使能不能只信tvalid还要看FIFO满不满。如果已经满了即使tvalid1这一拍也绝对不能写。所以我把写使能定义成wire winc s_axis_tvalid ~wfull;然后把tready组合输出为~wfull。这样一来只要FIFO没满tready就是高写侧随时可以把数据塞进来。如果满了tready拉低上游模块就必须停住这就是AXI背压机制的硬件实现非常自然不需要额外的控制状态机。读写指针都需要在每次有效操作之后加1。常见写法是先用组合逻辑算出wnext wptr_bin 1再在时钟沿判断是否写入。这里不能用阻塞赋值必须用非阻塞赋值否则会出现同一周期多个地方读到旧指针的竞争问题。3.3 读时钟域读指针与读使能读侧逻辑和写侧很像但出发点变成了“FIFO非空才能读”。读使能定义为wire rinc m_axis_tready ~rempty;m_axis_tvalid组合输出为~rempty。只要FIFO没有空读侧就告诉下游“我这拍数据有效”。下游如果同时把tready拉高握手成功读指针加1下一个数据自然浮到读数据端口上。这里有一个关键细节我为了让代码简单直观读数据用的是组合读也就是m_axis_tdata直接由RAM地址输出没有额外的输出寄存器。这种写法在小深度FIFO和分布式RAM场景下没问题仿真也能过。但如果目标器件是BRAMBRAM本身是同步读读数据天然比地址晚一个周期直接这样写就会在时序上出现“valid已经拉高但数据还没出来”的错位。真正上板子的时候要么把数据打一拍再配套调整tvalid要么换成FIFO IP的FWFT模式。这部分我在第4章详细说教学代码先保持可读性。3.4 空满判断怎么用同步后的格雷码“骗”出正确答案异步FIFO的空满判断是整个设计最容易写错的地方也是最难调试的地方。先说空标志。读侧把写指针的格雷码同步到自己时钟域之后和当前读指针格雷码比较。如果完全相等说明读指针追上了写指针FIFO必然是空的。这个逻辑很直观。满标志稍微绕一点。写侧把读指针的格雷码同步过来后如果读指针和写指针在同一个圈内说明还没绕满如果读指针落后写指针一个整圈也就是地址低位相同、高位部分的状态不同那就说明FIFO已经满了。经典的做法是比较写指针格雷码和同步过来的读指针格雷码高两位取反低位完全相同就判满。为什么是高两位取反而不是最高位取反因为格雷码在跨越半圈边界时高两位会同时出现状态差异。比如指针位宽4位从0111变到1000时格雷码其实是0100变到1100高两位从01变成11变化了两位。所以只比较最高位不够必须高两位一起判断。注意一个容易踩的坑同步后的指针有延迟写侧看到的读指针是“两拍之前的读指针”所以满标志会略微保守不会过头读侧看到的写指针也是延迟的空标志也会保守。这个特性在跨时钟域设计中是好事宁可少判断一拍也绝不能判错。3.5 完整代码可以直接复制使用的AXI4-Stream异步FIFO下面是完整模块代码包含双端口RAM、读写指针、格雷码转换、两级同步器、空满逻辑以及AXI4-Stream接口。代码我已在仿真环境跑过读写速率不同场景下能正确跑完第一批数据。module axis_async_fifo #( parameter DATA_WIDTH 32, parameter ADDR_WIDTH 4 )( input wire s_axis_aclk, input wire s_axis_aresetn, input wire s_axis_tvalid, output wire s_axis_tready, input wire [DATA_WIDTH-1:0] s_axis_tdata, input wire m_axis_aclk, input wire m_axis_aresetn, output wire m_axis_tvalid, input wire m_axis_tready, output wire [DATA_WIDTH-1:0] m_axis_tdata ); localparam DEPTH 1 ADDR_WIDTH; // 双端口RAM组合读便于教学演示 reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; // 写时钟域指针 reg [ADDR_WIDTH:0] wptr_bin; reg [ADDR_WIDTH:0] wptr_gray; reg [ADDR_WIDTH:0] wptr_gray_sync1; reg [ADDR_WIDTH:0] wptr_gray_sync2; // 读时钟域指针 reg [ADDR_WIDTH:0] rptr_bin; reg [ADDR_WIDTH:0] rptr_gray; reg [ADDR_WIDTH:0] rptr_gray_sync1; reg [ADDR_WIDTH:0] rptr_gray_sync2; // 握手与使能 wire wfull (wptr_gray {~rptr_gray_sync2[ADDR_WIDTH:ADDR_WIDTH-1], rptr_gray_sync2[ADDR_WIDTH-2:0]}); wire rempty (rptr_gray wptr_gray_sync2); wire winc s_axis_tvalid ~wfull; wire rinc m_axis_tready ~rempty; assign s_axis_tready ~wfull; assign m_axis_tvalid ~rempty; // 写指针更新 wire [ADDR_WIDTH:0] wnext wptr_bin 1b1; wire [ADDR_WIDTH:0] wgray_next (wnext 1) ^ wnext; always (posedge s_axis_aclk or negedge s_axis_aresetn) begin if (!s_axis_aresetn) begin wptr_bin 0; wptr_gray 0; end else if (winc) begin wptr_bin wnext; wptr_gray wgray_next; end end // 写数据 always (posedge s_axis_aclk) begin if (winc) mem[wptr_bin[ADDR_WIDTH-1:0]] s_axis_tdata; end // 读指针更新 wire [ADDR_WIDTH:0] rnext rptr_bin 1b1; wire [ADDR_WIDTH:0] rgray_next (rnext 1) ^ rnext; always (posedge m_axis_aclk or negedge m_axis_aresetn) begin if (!m_axis_aresetn) begin rptr_bin 0; rptr_gray 0; end else if (rinc) begin rptr_bin rnext; rptr_gray rgray_next; end end // 读数据组合读 assign m_axis_tdata mem[rptr_bin[ADDR_WIDTH-1:0]]; // 跨时钟同步把读指针格雷码同步到写时钟域 always (posedge s_axis_aclk or negedge s_axis_aresetn) begin if (!s_axis_aresetn) begin rptr_gray_sync1 0; rptr_gray_sync2 0; end else begin rptr_gray_sync1 rptr_gray; rptr_gray_sync2 rptr_gray_sync1; end end // 跨时钟同步把写指针格雷码同步到读时钟域 always (posedge m_axis_aclk or negedge m_axis_aresetn) begin if (!m_axis_aresetn) begin wptr_gray_sync1 0; wptr_gray_sync2 0; end else begin wptr_gray_sync1 wptr_gray; wptr_gray_sync2 wptr_gray_sync1; end end endmodule3.6 代码逐段解释别光收藏不看这段代码最需要注意的有三个地方。第一个是指针位宽。wptr_bin和rptr_bin位宽是ADDR_WIDTH1多出来的最高位专门用来判断一圈回绕。RAM地址只取低ADDR_WIDTH位这样指针从15变成16时RAM地址其实从15回绕到0但最高位从0变成1满空判断依然能区分“还没绕”和“已经绕了一圈”。第二个是格雷码转换。二进制转格雷码的公式是gray (bin 1) ^ bin这个直接对next操作保证指针先加1再把下一个值转成格雷码而不是先转格雷码再加1。顺序反了整个空满判断就全乱了。第三个是同步器。我把对端指针的格雷码打了两拍第一拍存在_sync1第二拍存在_sync2。满空判断都用_sync2保证采到的值已经稳定。有的老工程师会让同步器输出_sync1为了省一个寄存器但这种省法不值得两级同步是底线。4. 仿真验证与问题排查踩过的坑都在这了4.1 测试平台怎么搭才靠谱写完代码第一件事不是上板而是先仿真。我的TB思路是构造一个“写侧100MHz、读侧75MHz”的异步场景先写20个数据再读20个数据对比读出来的序列是否一致。写侧激励用简单的random条件生成tvalid读侧用固定节奏拉高tready模拟真实下游模块断续接收的情况。关键是复位信号写侧复位和读侧复位必须各自独立我先统一按低拉一段时间再同时释放避免一侧已经开始工作另一侧还在复位。简单TB骨架如下module tb; reg s_aclk 0; reg m_aclk 0; reg s_aresetn 0; reg m_aresetn 0; reg s_axis_tvalid 0; reg s_axis_tready; reg [31:0] s_axis_tdata; reg m_axis_tvalid; reg m_axis_tready 0; wire [31:0] m_axis_tdata; axis_async_fifo #( .DATA_WIDTH(32), .ADDR_WIDTH(4) ) dut ( .s_axis_aclk(s_aclk), .s_axis_aresetn(s_aresetn), .s_axis_tvalid(s_axis_tvalid), .s_axis_tready(s_axis_tready), .s_axis_tdata(s_axis_tdata), .m_axis_aclk(m_aclk), .m_axis_aresetn(m_aresetn), .m_axis_tvalid(m_axis_tvalid), .m_axis_tready(m_axis_tready), .m_axis_tdata(m_axis_tdata) ); always #5 s_aclk ~s_aclk; // 100MHz always #7 m_aclk ~m_aclk; // 约71.4MHz initial begin #100; s_aresetn 1; m_aresetn 1; // 写入0~15 repeat (16) begin (posedge s_aclk); s_axis_tvalid 1; s_axis_tdata s_axis_tdata 1; end s_axis_tvalid 0; // 读侧连续读 #500; m_axis_tready 1; #1000; m_axis_tready 0; #100; $finish; end endmodule仿真时一定要打开波形重点看空满标志出现的时间和读写指针的格雷码转换是否正常。我第一次跑的时候发现读出来的第一个数据不是0而是不定值原因就是复位后空标志还没建立m_axis_tvalid在一瞬间拉高过下游采样到了一个无效数据。这个问题很常见往下看。4.2 常见现象与解决方案速查表我把调试过程中遇到的高频问题整理成了一张表照着排查比瞎猜快得多。现象可能原因解决方案读出来的第一个数据是X态复位后空标志未稳定下游提前采样让m_axis_tvalid经过寄存器打拍输出或者下游等空标志释放后再启动读tvalid和tready握手正常但少数据winc和写使能判断不一致检查写侧是否多加了full判断导致一个数据被跳过满标志一直不拉高读指针同步打拍没接或同步器用错时钟域检查rptr_gray_sync2是否由写侧时钟驱动空标志一直不拉高写指针同步到了错误的时钟域检查wptr_gray_sync2是否由读侧时钟驱动数据顺序乱了读指针更新条件和读数据端口不匹配组合读场景确认rptr_bin在握手成功后立即跳到下一地址板子上时序违例组合读路径太长读数据加寄存器打拍或换FIFO IP的FWFT配置4.3 组合读与同步读的取舍以及tlast怎么扩展我给的代码是组合读也就是m_axis_tdata直接来自RAM读端口。这样好处是代码短、仿真直观但坏处也很明显RAM读地址到数据输出的组合路径太长在高速时钟下容易时序违例。Xilinx的BRAM原生是同步读如果直接把这个模块综合进去BRAM内部会在读地址上打一拍等于白送一个时钟周期。这时候如果你的读控制逻辑没跟着打拍就会出现“valid已经拉高data还在上一拍”的错位。工程上的改法我建议这样做把m_axis_tdata寄存一拍同时让m_axis_tvalid也寄存一拍二者保持对齐。这样等于在FIFO输出再加一级AXI寄存器时序干净很多代价是读侧多一拍延迟。如果你用的器件是Xilinx 7系列或Ultrascale也可以直接把RAM实现方式从distributed改成block再用一个内部FWFT FIFO的原语这些就不展开讲了知道方向就行。至于tlast很多AXI4-Stream数据流都有包边界。我的建议是不要单独在FIFO外面旁路存直接把tlast拼到数据最高位存进FIFO。比如数据位宽32加上tlast就是33位只需要把参数DATA_WIDTH改成33然后在写数据时把tlast放在最高位。读出来后再拆开把低32位给tdata最高位给tlast。这样FIFO的逻辑完全不用改只要外部拼接一次非常省事。4.4 两个容易忽略的复位细节第一个是异步复位的释放。我这个教学代码直接用了异步复位但在跨时钟域场景中两个复位信号如果释放时间不一致可能会导致FIFO一侧已经跑起来另一侧还在复位满空判断出现不期望的中间态。生产级设计应该用异步复位同步释放每个时钟域一个复位同步器保证释放时刻跟着各自时钟沿走。第二个是同步器复位。两级同步器的寄存器一定要在复位时清零否则上电瞬间同步输出的格雷码是随机态空满标志极有可能误判一次。有些IP默认不复位同步器靠后续操作自动收敛但自己写的代码最好还是显式复位少一个隐患。5. 最后的实操体会与扩展建议这个代码我前前后后改了很多版踩得最深的坑就是BRAM的同步读特性。教学版用组合读仿真时一切正常上了板子以后发现读数据总是慢一拍数据错位查了整整一天。后来我把读输出寄存一拍同时让tvalid打拍对齐问题立刻消失。所以如果你要把这个FIFO用到正式工程里我强烈建议先把读路径改成同步读再开始做后面的逻辑。另一个心得是异步FIFO的深度估算一定不要只算理论值。跨时钟域场景里上游和下游的速率都会有波动突发长度也可能因为仲裁、等待而拉长。我一般会在理论计算结果上至少翻倍再凑成2的幂次。多出来的资源说难听点就是保险钱值得付。最后分享一个小技巧如果想把这个FIFO用得更顺手可以给它加一个almost_full和almost_empty信号比如深度剩余4个时拉高almost_full。这样上游模块可以提前开启背压而不是等到满标志拉高才停能有效减少数据流断流概率。实现也不难用同步后的格雷码和当前指针比较判断差值就行。如果你还没动手写建议先把我这个模块仿真跑一遍看波形里满空标志和格雷码的变化再自己动手改一个同步读版本。这个过程走一遍AXI4-Stream和异步FIFO的原理就彻底通了。