早些年我在一个AXI DMA接口上调试Xilinx同步FIFO IP核,波形上看empty已经拉低,rd_en也给了,dout上却不是预期数据,整整调了两天。最后排查下来根本不是逻辑写错,而是FIFO生成时选了默认的Standard读模式,数据在rd_en之后还要再等一拍才出现在dout上。这个经历让我后来每次例化FIFO都会先确认读模式,也想清楚了一件事:Standard和FWFT之间不只是“延迟一拍”的差别,而是读数据约定的本质差异。
这篇博客从两种模式的行为语义讲起,配合完整的Vivado仿真代码,把读时序、valid信号、握手方式、适用场景和工程里的坑一次说清楚。无论是刚开始用FIFO IP核的初学者,还是已经在项目里被读时序坑过的工程师,都能拿来做选型参考。
1. 两种读模式的差异,从一次“读错数据”的bug说起
1.1 事件回顾:empty已经拉低,为什么dout还是老数据
当时模块的逻辑大概是这样的:FIFO的empty信号拉低后,状态机判断“非空”,于是拉高rd_en,然后在下一个时钟上升沿直接采样dout。理论上看很顺,empty有效代表有数据,读使能给了,数据应该跟着出来。但仿真里看到的却是:rd_en拉高的那一拍,dout还是上一个旧值,再等一拍才变成新数据,整个数据流整体慢了一拍,后续所有逻辑全对不上。
这就是Standard模式最典型的行为:rd_en是一个读请求,不是数据有效指示。发出请求后,FIFO会在下一个时钟沿把数据送到dout。如果你以为rd_en有效的那拍就应该采样,那采到的必然是旧值。
与此相对,FWFT模式(First Word Fall Through,首字直通)是另一套约定:只要FIFO非空,第一个数据就会提前出现在dout上,rd_en变成了“当前数据我收下了”的消费信号。这两种模式对应着完全不同的接口握手设计,选择错误往往不会直接报错,而是让整个系统在时序上慢慢“跑偏”,排查起来相当隐蔽。
1.2 Standard与FWFT的本质区别:数据什么时候被放在dout上
Standard模式可以理解成“点菜模型”:你告诉后厨要什么菜(拉高rd_en),后厨开始做(内部读指针移动),菜做好端出来(dout更新),整个过程需要一拍。读取方必须有一个“等数据返回”的状态,不能假设dout在请求同一拍就有效。
FWFT模式则是“自助餐模型”:菜已经摆在台面上(FIFO非空时dout一直有效),你只需要拿走(rd_en有效)。数据不是“读出来的”,而是“天然待在那里等你取”,读取方少了一个请求到响应的等待周期。
这个区别看似很小,但会直接影响状态机怎么设计:
- Standard模式下,状态机通常是
IDLE -> 拉rd_en -> WAIT -> 采样dout -> 处理; - FWFT模式下,状态机可以直接跳过等待,看到valid有效就采样,同时拉rd_en确认消费。
1.3 一张表看清四种关键差异
| 对比维度 | Standard FIFO | FWFT FIFO |
|---|---|---|
| 数据出现时机 | rd_en有效后的下一个时钟沿,dout才更新 | FIFO非空时,首个数据自动出现在dout |
| rd_en含义 | 读请求,触发一次读操作 | 接受/消费当前数据 |
| valid信号行为 | 读请求后数据有效的那一拍拉高 | FIFO非空且dout有效时拉高 |
| empty拉低后的行为 | 仍需等待读请求和数据延迟 | 拉低后首个数据同时可见 |
| 典型接口适配 | 寄存器轮询、自定义请求-响应 | AXI4-Stream、流水线消费 |
| 组合逻辑开销 | 较低 | 相对略高,需要额外首字保持逻辑 |
2. Standard读模式:rd_en发出后还要再等一拍的“请求-应答”机制
2.1 Standard模式的时序拆解
Standard模式的完整读取过程可以拆成三步:
- FIFO内写入数据后,写指针变化,empty在下一个时钟沿拉低;
- 读取方检测到非空,拉高rd_en;
- 下一个时钟沿,内部读指针指向目标地址,dout更新,valid(如果配置了该信号)随之拉高。
连续读取时,每个时钟周期可以读出一个数据,前提是rd_en保持有效且FIFO一直没有读空。如果你用Block RAM实现FIFO,这个“一拍延迟”其实来自BRAM的读出延迟;用分布式RAM时IP核也会用寄存器把时序打整齐,保证输出行为一致。
时序上最需要注意的是empty和dout的相位关系。empty拉低只是告诉你“FIFO里有货”,不代表“货已经在dout上”。很多新手直接把empty当作数据有效信号来用,这在Standard模式下是错的,empty之后通常还要经过一次rd_en触发才能采样数据。
2.2 适合Standard模式的场景
Standard模式最适合请求-响应式的读取场景。比如一个状态机要通过FIFO读固定长度的数据包:状态机先查empty,非空则拉rd_en,下一拍读取dout并开始处理。整个过程每个节拍都是一个明确的“请求-响应”配对,逻辑上非常容易推导。
还有一个场景也常选Standard模式:当FIFO连接的总线接口本身就需要等待状态时。比如挂在自定义寄存器总线上的模块,总线发起读请求后本来就要等一拍或两拍才能拿到数据,FIFO用Standard模式反而能和总线的等待状态自然对齐。
另外,当系统工作频率比较高、时序比较紧张时,Standard模式通常更友好。因为dout在rd_en之后才通过寄存器更新,数据路径上不要求“dout提前稳定”,对后端时序收敛的压力比FWFT小。
2.3 Standard模式常见的两个误区
误区一是把rd_en当成采样信号。有些人写代码时看到empty拉低就直接拉rd_en,然后在同一个状态里对dout进行数据处理,这样必然会采到上一拍的旧数据。正确做法是拉rd_en之后至少再等一拍,在数据有效的那一拍做采样。
误区二是不管empty,直接把rd_en当成自由时钟信号持续拉高。这会让FIFO在空状态下反复执行无效读操作。对于Xilinx IP核,空状态下rd_en会被内部逻辑忽略,但连续的空读会使你的监控逻辑看到错误的empty/valid变化,间接增加排查难度。
每一次读操作前都应该检查empty,或者使用valid信号作为“数据是否可采”的判据,而不是盲拉rd_en。
3. FWFT读模式:数据先行出现,rd_en变成“消费”信号
3.1 FWFT的直通机制
FWFT模式下,当你向FIFO写入第一个数据后,即使还没拉rd_en,dout也会直接变成这个数据。此时如果配置了valid信号,valid会拉高,表示“dout上有数据且可以直接取走”。当rd_en有效时,当前数据被消费,下一个时钟沿dout更新为FIFO中的下一个数据。
这里面有个关键概念转换:Standard模式下rd_en是“请把数据给我”,FWFT模式下rd_en是“这个数据我收下了”。同样是拉高rd_en,语义完全不同。FWFT读取方更像是流水线上的下游工位,数据流到工位时直接拿走,不需要先发起请求再等响应。
valid信号在FWFT模式下变得非常关键。它标记的是“dout当前是否有效”,而不是“刚刚完成一次读操作”。只要FIFO非空,valid就应该是高,dout也稳定在有效数据上。读取方可以无条件等待valid拉高,然后采样dout,同时拉rd_en,等下一拍继续。
3.2 适合FWFT模式的场景
FWFT模式在AXI4-Stream接口场景下几乎是首选。AXI4-Stream的握手信号是TVALID/TREADY,语义正是“数据已经准备好,等对方接收”。FWFT模式下:
- TVALID可以直接映射到FIFO的valid;
- TREADY可以映射到rd_en;
- TDATA自然对应dout。
这组映射几乎不需要额外状态转换,非常顺畅。
另一个常见场景是“需要先看数据再决定是否接收”的模块。比如一个数据过滤单元,要先判断包头地址是否匹配,匹配才接收,不匹配就丢弃。如果用Standard模式,你得先把数据读出来,判断之后才能决定是否消费,但此时数据已经被读走了,操作上非常别扭。FWFT则可以先观察dout上的数据,再决定这一拍要不要拉rd_en,天然支持这种“先看再吃”的逻辑。
3.3 FWFT模式不能盲目使用
FWFT模式并不是所有场景的万能解。第一,dout上提前出现的组合数据会给数据路径增加额外的组合逻辑,在高速设计中可能成为时序瓶颈。第二,FIFO空与非空切换瞬间,valid和empty信号在时钟沿附近的变化可能产生毛刺,必须通过寄存器采样来规避,不能用在纯组合逻辑判断上。
还有一个隐藏问题:FWFT模式下,采样方很容易以为“dout上有数据”就等于“我可以随时处理”,但如果不处理好和rd_en的配对,很容易出现“数据被看到但没被消费”的假象,导致上游以为数据已经被取走,实际上FIFO里还存着这个数据。设计握手逻辑时,一定要保证valid和rd_en同时有效的那一拍,才算真正消费了一个数据。
4. Vivado里配置FIFO IP核的参数清单
4.1 创建同步FIFO IP核的完整配置过程
在Vivado中创建FIFO核的路径是:IP Catalog,搜索“FIFO Generator”,双击打开配置界面。核心配置项如下:
- 接口类型:选择“Native”接口,最简单直接;
- FIFO类型:由于这里讨论同步FIFO,选择“Synchronous FIFO”;
- Read Mode:在“Standard FIFO”和“First Word Fall Through”之间二选一;
- 数据宽度/深度:根据实际需求填写读写位宽与FIFO深度;
- 复位:选择同步复位或异步复位,同步FIFO两种都支持;
- 标志信号:按需勾选full、empty、valid、almost_full、almost_empty、data_count等输出端口;
- Memory Type:可选Block RAM、Distributed RAM或Auto;深度较小时用分布式RAM,深度较深时用Block RAM。
配置完成后,在IP Sources里能看到生成的wrapper文件,例化端口包括clk、srst/rst、din、wr_en、rd_en、dout、full、empty、valid等。
4.2 关键选项对两种模式的影响
| 配置选项 | Standard FIFO | FWFT FIFO |
|---|---|---|
| Read Data Valid | 可作为数据采样指示 | 应作为AXI-Stream的TVALID使用 |
| Output Registers | 可增加输出寄存器改善时序,会增加延迟 | 一般不建议额外寄存器,会影响首字直通特性 |
| Reset Type | 两种模式均可 | 两种模式均可 |
| Memory Type | Block/Distributed均可 | 更依赖Block RAM/额外寄存器做首字缓存 |
| Empty输出 | 与读延迟无关,只表示是否为空 | 与valid配合判断首个数据是否有效 |
Output Registers这个选项一定要留意。Standard模式下勾选后,dout的有效时刻会再往后挪一拍,如果读状态机没有跟着改,很可能又是错一拍的问题。FWFT模式下勾选额外的输出寄存器会打破“首字直通”的时序约定,导致第一个数据出现时间变得不直观。
4.3 不用IP核的方式:用xpm_fifo_sync轻量例化
如果你不想在工程里维护一个独立的FIFO IP核,可以用Vivado内置的XPM原语xpm_fifo_sync,效果和FIFO Generator生成的核等价,但例化方式更轻量,适合脚本化或代码复用。FWFT和Standard模式通过READ_MODE参数区分:
xpm_fifo_sync #( .FIFO_MEMORY_TYPE ("auto"), .READ_MODE ("fwft"), // 可选 "std" 或 "fwft" .FIFO_WRITE_DEPTH (16), .WRITE_DATA_WIDTH (8), .READ_DATA_WIDTH (8) ) u_fifo ( .clk (clk), .rst (rst), .din (din), .wr_en (wr_en), .rd_en (rd_en), .dout (dout), .full (full), .empty (empty), .valid (valid) );需要注意,xpm_fifo_sync的READ_MODE指定为fwft时,内部会专门生成首字直通逻辑;指定为std时就是标准FIFO行为。具体端口和参数以所用Vivado版本自带的UG953为准,不同版本对XPM参数的支持细节略有差异。
5. 两种模式的仿真验证:代码与波形解读
5.1 测试平台整体设计思路
验证思路很简单:同一个时钟、同一组写入数据,分别例化一个Standard模式FIFO和一个FWFT模式FIFO,观察两者的dout、valid和empty行为差异。为了便于独立运行,下面用一个行为级FIFO模型模拟两种模式,综合项目里直接用FIFO Generator生成的IP核替代即可。
测试步骤分四段:先复位,再向两个FIFO写入同样的4个数据,接着同时拉高两个FIFO的rd_en连续读4拍,最后打印每一拍两个FIFO的dout和valid。这样能直观看到FWFT先有数据、Standard后出数据,以及两者valid的不同含义。
5.2 完整仿真代码
行为级模型:
`timescale 1ns/1ps module sync_fifo_model #( parameter DATA_WIDTH = 8, parameter DEPTH = 16, parameter READ_MODE = "STANDARD" )( input wire clk, input wire rst, input wire [DATA_WIDTH-1:0] din, input wire wr_en, input wire rd_en, output reg [DATA_WIDTH-1:0] dout, output wire full, output wire empty, output reg valid ); reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; integer wr_ptr = 0; integer rd_ptr = 0; integer count = 0; wire wr_ok = wr_en && !full; wire rd_ok = rd_en && !empty; assign full = (count >= DEPTH); assign empty = (count == 0); always @(posedge clk) begin if (rst) begin wr_ptr <= 0; rd_ptr <= 0; count <= 0; dout <= {DATA_WIDTH{1'b0}}; valid <= 1'b0; end else begin if (wr_ok) begin mem[wr_ptr] <= din; wr_ptr <= (wr_ptr == DEPTH-1) ? 0 : wr_ptr + 1; end if (rd_ok) begin rd_ptr <= (rd_ptr == DEPTH-1) ? 0 : rd_ptr + 1; end if (wr_ok && rd_ok) count <= count; else if (wr_ok) count <= count + 1; else if (rd_ok) count <= count - 1; if (READ_MODE == "STANDARD") begin if (rd_ok) begin dout <= mem[rd_ptr]; valid <= 1'b1; end else if (count == 0) begin dout <= {DATA_WIDTH{1'b0}}; valid <= 1'b0; end else begin valid <= 1'b0; end end else begin // FWFT 模式:dout 组合输出当前读指针数据 // 真实Xilinx IP会在此基础上增加寄存器打拍,不影响语义理解 dout <= mem[rd_ptr]; valid <= (count > 0); end end end endmodule测试平台:
`timescale 1ns/1ps module tb_fifo_std_fwft; reg clk = 0; reg rst = 1; reg [7:0] din = 0; reg wr_en = 0; reg rd_en_std = 0; reg rd_en_fwft = 0; wire [7:0] dout_std, dout_fwft; wire full_std, empty_std, valid_std; wire full_fwft, empty_fwft, valid_fwft; always #5 clk = ~clk; sync_fifo_model #( .READ_MODE ("STANDARD") ) u_std ( .clk (clk), .rst (rst), .din (din), .wr_en (wr_en), .rd_en (rd_en_std), .dout (dout_std), .full (full_std), .empty (empty_std), .valid (valid_std) ); sync_fifo_model #( .READ_MODE ("FWFT") ) u_fwft ( .clk (clk), .rst (rst), .din (din), .wr_en (wr_en), .rd_en (rd_en_fwft), .dout (dout_fwft), .full (full_fwft), .empty (empty_fwft), .valid (valid_fwft) ); initial begin #25 rst = 0; // 连续写入 4 个数据 repeat (4) begin @(posedge clk); wr_en = 1; din = din + 8'h11; // 依次写入 0x11, 0x22, 0x33, 0x44 end @(posedge clk); wr_en = 0; // 写完后观察 FWFT 是否已把第一个数据放到 dout #1; $display("after write: fwft dout=%02x valid=%b empty=%b", dout_fwft, valid_fwft, empty_fwft); // 两个 FIFO 同时连续读 4 拍 rd_en_std = 1; rd_en_fwft = 1; repeat (4) begin @(posedge clk); #1; $display("@%0t std : dout=%02x valid=%b empty=%b | fwft: dout=%02x valid=%b empty=%b", $time, dout_std, valid_std, empty_std, dout_fwft, valid_fwft, empty_fwft); end rd_en_std = 0; rd_en_fwft = 0; #50; $finish; end initial begin $dumpfile("fifo_std_fwft.vcd"); $dumpvars(0, tb_fifo_std_fwft); end endmodule5.3 从打印与波形能看到什么
如果直接用上面代码跑仿真,打印结果会很清楚:
- 在写阶段结束后,FWFT的dout已经能读到0x11,valid为高,empty为低;Standard的dout则还是0,valid为低。
- 开始同时读之后,同一拍的打印结果中,Standard的dout比FWFT落后一个数据:FWFT显示0x22时,Standard刚显示0x11,FWFT显示0x33时,Standard显示0x22。
这正好对应两种模式的核心差异:FWFT模式下数据始终“待命”在dout上,rd_en只是消费;Standard模式下必须等rd_en之后的一拍,数据才从请求变成实物。
仿真模型里FWFT的dout用了组合逻辑模拟直通,真实Xilinx IP会通过内部寄存器把输出打一拍,但接口上的语义是一致的。如果你在Vivado里用FIFO Generator生成IP后跑仿真,重点观察valid和rd_en的配合位置,以及empty到dout有效之间的周期数,会发现同样的规律。
6. 工程落地经验:复位、握手、反压与资源边界
6.1 复位释放后别急着写读
Xilinx的FIFO IP核在复位释放后,内部读写指针和状态信号需要一定时间进入稳定状态。常见做法是系统上电复位结束后,给FIFO留出至少2-4个时钟周期的“静默期”,再进行写操作或读操作。如果复位释放后立即写第一个数据,有些配置下可能丢失首字。
这个现象在FWFT模式里尤其明显,因为复位释放后valid、empty、dout需要协同建立“直通状态”,过早写入容易让内部首字缓存逻辑处于不确定状态。在实际工程中,我习惯用一个计数器在复位释放后数几个周期,再使能外部逻辑访问FIFO。
6.2 读侧握手:valid与rd_en如何配合
两种模式下的握手写法差别很大。
Standard模式下,推荐的状态机流程是:查empty,非空则拉rd_en;下一个周期检查valid,valid为高时采样dout。valid在这个模式里是“读操作完成”的指示。
FWFT模式下,更推荐的写法是:直接等valid拉高,valid为高时把dout上的数据消费掉,同时拉一个周期的rd_en。valid在这里是“数据可消费”的指示。
可以把FWFT直接映射成AXI4-Stream握手:
- tvalid = valid;
- tready = rd_en;
- tdata = dout。
握手机制就是经典的TVALID/TREADY:两个信号同时为高表示数据传输完成一拍。这个模式下,不需要额外状态机去猜测数据什么时候出现,数据流驱动逻辑更清晰。
6.3 almost_full与almost_empty:反压与阈值判断
full和empty两个信号通常作为边界指示,但在实际流水线设计中,更常用almost_full和almost_empty做反压阈值。比如下游处理速度跟不上时,上游看到almost_full拉高就要提前停止写入,而不是等full拉高才停。因为full是组合输出或寄存器输出,存在一定的传播延迟,等full真正置位再停,可能已经多写了一个数据。
almost_empty同理,适合用来提前预判FIFO即将读空,让下游状态机减少无效等待。FWFT模式下,almost_empty配合valid使用,可以在最后一个数据被消费之前就给出预警,帮助前置逻辑准备状态切换。
6.4 资源与性能边界
FWFT模式并不是免费的。为了让第一个数据提前出现在dout上,FIFO内部需要额外的首字保持逻辑,通常会多消耗几十个FF/LUT,BRAM资源两者基本一致。如果FIFO位宽很宽、深度很深,FWFT模式增加的数据路径逻辑会更明显。
在高速设计里,如果读侧逻辑本来就紧张,优先考虑Standard模式,因为它对数据路径时序更友好。FWFT模式适合读侧有天然流水线结构、能接受一定组合路径开销的场景。
我在自己做的网络包处理设计里,曾经统一用FWFT,后来其中一路高速数据通道时序收敛困难,把FIFO改成Standard模式、读状态机多等一拍之后,时序马上就解了。可见模式选择没有绝对的对错,完全看接口和系统约束。选型前先画清楚读写两侧的握手时序图,再决定用哪种模式,能省很多不必要的调试时间。