FIFO设计详解:从同步队列到异步跨时钟域与格雷码实现 📅 发布时间:2026/9/16 4:47:22 👁 浏览次数: 1. FIFO到底解决什么问题从一个排队场景说起我当年第一次接触FIFOFirst In First Out先入先出队列的时候心里想的是这不就是一个排队逻辑吗先来的先走后来的后走。后来做数字IC和FPGA设计时间长了才明白这个排队只是表象FIFO在硬件里真正的价值是用来消化数据速率不匹配、跨时钟域传递、临时缓冲突发数据。可以说FIFO是几乎所有数据通路上绕不开的基础模块也是数字设计面试里出镜率最高的考点之一。这篇文章适合刚学Verilog、开始写FPGA逻辑的初学者也适合做接口设计、数据采集、图像处理、嵌入式系统集成的一线工程师。我会从FIFO最朴素的缓存思想讲起逐步深入到同步FIFO的完整实现、异步FIFO的跨时钟域难点与格雷码解法、工程实践中的参数选择以及我实际调试中踩过的坑。全程用可复现的Verilog代码、表格和具体的计算过程来讲读完你可以直接上手写一个自己的FIFO也知道怎么在项目里选IP核、配参数、排查故障。先给一个生活类比FIFO就像火车站售票窗口前的排队通道。旅客数据从入口依次进入通道排在最前面的先到窗口被读出后来的人排在队尾。通道能容纳的人数就是深度Depth每个旅客占的位置就是位宽Data Width。通道满了不能在进人空了窗口就得闲着。这就是FIFO的全部思想。2. 同步FIFO的设计与实现把基础打牢2.1 同步FIFO的内部架构同步FIFO的意思是读写操作使用同一个时钟所有逻辑都在同一时钟域里。结构上分为四个部分存储阵列双端口RAM用来存放实际数据。写指针write pointer指向下一个要写入的地址。读指针read pointer指向下一个要读出的地址。状态逻辑根据指针或计数器产生空empty、满full标志供外部逻辑判断能否进行读和写。这个架构看起来简单但恰恰是状态标志的产生决定了FIFO设计的好坏。因为FIFO的核心约束只有两条不能空读没有数据时读出的是垃圾不能满写存满了再写会覆盖还没读走的数据。所有设计技巧本质都是为了可靠地判断这两个状态。2.2 计数器方案 vs 指针比较方案判断空满有两种主流做法。第一种是增加一个计数器count记录当前FIFO中有效数据的个数。写入一个数据计数器加1读出一个数据计数器减1同时读写则计数器不变。计数器等于0为空等于DEPTH为满。第二种是仅比较读写指针。初始时读写指针都是0两者相等为空。写入一个数据写指针加1读出时读指针加1。问题来了当写指针和读指针再次相等时到底FIFO是空的还是满的可能写指针绕了一圈追上了读指针此时FIFO是满的也可能是读指针一直读追上了写指针此时FIFO是空的。仅靠相等关系无法区分这两种情况。解决方法是把指针扩展一位。假设FIFO深度是16地址位宽是4位实际指针用5位。读写指针完全相等时为空。当写指针比读指针多走了一圈最高位不同、其余位相同时为满。这就是经典的多一位指针判满方法。2.3 可综合的同步FIFO代码实现这里给出一个完整可综合的同步FIFO代码。参数允许自定义数据和位宽深度深度要求是2的整数次幂方便地址回卷。module sync_fifo #( parameter DATA_WIDTH 8, parameter DEPTH 16 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] din, input wire rd_en, output wire [DATA_WIDTH-1:0] dout, output wire full, output wire empty ); // 地址位宽2^ADDR_WIDTH DEPTH localparam ADDR_WIDTH clog2(DEPTH); // 实际指针位宽多一位用于满判断 localparam PTR_WIDTH ADDR_WIDTH 1; reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; reg [PTR_WIDTH-1:0] wr_ptr; reg [PTR_WIDTH-1:0] rd_ptr; reg [ADDR_WIDTH:0] count; wire wr_enable wr_en ~full; wire rd_enable rd_en ~empty; // 写数据 always (posedge clk) begin if (wr_enable) mem[wr_ptr[ADDR_WIDTH-1:0]] din; end // 读数据组合读也可改为寄存读 assign dout mem[rd_ptr[ADDR_WIDTH-1:0]]; // 写指针 always (posedge clk or negedge rst_n) begin if (!rst_n) wr_ptr 0; else if (wr_enable) wr_ptr wr_ptr 1b1; end // 读指针 always (posedge clk or negedge rst_n) begin if (!rst_n) rd_ptr 0; else if (rd_enable) rd_ptr rd_ptr 1b1; end // 计数器 always (posedge clk or negedge rst_n) begin if (!rst_n) count 0; else begin case ({wr_enable, rd_enable}) 2b10: count count 1b1; 2b01: count count - 1b1; default: count count; endcase end end assign full (count DEPTH); assign empty (count 0); // 位宽计算函数 function integer clog2; input integer value; begin value value - 1; for (clog2 0; value 0; clog2 clog2 1) value value 1; end endfunction endmodule需要注意几个细节。第一dout这里用的是组合读也叫透明读直接由读地址驱动数据输出。如果外部逻辑时序紧张建议改成寄存读即在时钟上升沿把mem[rd_addr]打一拍输出。两种方式对时序影响差别很大后面我会单独讲。第二count的位宽是ADDR_WIDTH1因为计数范围是0到DEPTH分别对应十进制0到16需要5位二进制。第三写入条件里一定要加上!full读出条件一定要加!empty这是防止满写和空读的最后一道保险。提示很多人问读使能同时给到FIFO和外部逻辑是不是就安全了。不是的。如果外部逻辑在FIFO满时仍然拉高写使能FIFO内部必须自己挡住这次写入否则数据就会覆盖到还没读出的位置产生静默丢数据非常难排查。3. 异步FIFO设计的核心难点与解法3.1 跨时钟域到底难在哪里工程上更常见的是异步FIFO写时钟和读时钟不同。比如ADC采集的数据是50MHz时钟域处理器总线是100MHz时钟域中间要传数据就得靠异步FIFO缓冲。异步FIFO的存储阵列和指针逻辑本质上和同步FIFO没有区别依然是一块双端口RAM依然有读写两个指针。难点在于空满判断既然读写时不同时钟域任何一个域中的逻辑都无法直接看到另一个域的指针变化。比如写时钟域要判断满不满必须知道读指针走到哪里了读时钟域要判断空不空必须知道写指针走到哪里了。于是就需要把另一侧的指针同步过来。跨时钟域同步最大的敌人是亚稳态。什么叫亚稳态简单说一个触发器如果输入数据在时钟沿附近发生变化那么触发器的输出可能既不是0也不是1而是一个不稳定状态需要一段时间才能恢复到确定值这个恢复时间内的输出是不可预测的甚至可能在不同位置读到不同的值。如果直接同步一个多位的二进制指针问题会非常严重。举个例子二进制指针从3011变成4100每一位都在变。如果同步到另一个时钟域时正好赶上时钟沿可能采到000、111、001等乱七八糟的任意组合。这就不是慢一拍的问题了而是直接拿到一个完全错误的指针值会导致空满判断彻底失效。3.2 格雷码为什么跨时钟域非它不可格雷码Gray Code是一种循环二进制码最大的特点是相邻两个数值之间只有1个二进制位发生变化。比如用二进制数3011和4100相邻二进制变化了3位而对应的格雷码分别是010和110只变了1位。把指针变成格雷码再跨时钟域即使采样点落在变化沿附近最坏情况下也就是采到变化前的旧值或变化后的新值之一绝不会采到其他任何值。这两个值对于判断空满来说可能造成的影响仅仅是判断结果滞后但不会错误这是可以接受的。二进制转格雷码的公式很简单gray (bin 1) ^ bin;对应的Verilog函数function [PTR_WIDTH-1:0] bin2gray; input [PTR_WIDTH-1:0] bin; begin bin2gray (bin 1) ^ bin; end endfunction3.3 异步FIFO的指针同步与空满判断异步FIFO的标准做法是写指针在自己的写时钟域内生成转成格雷码后经过两级同步器同步到读时钟域读指针同理在自己的读时钟域内生成转成格雷码后同步到写时钟域。然后在各自的时钟域内完成空满判断满判断在写时钟域进行把读指针同步过来与本地的写指针比较。空判断在读时钟域进行把写指针同步过来与本地的读指针比较。这里有一个非常关键的设计点同步过来的指针不是当前最新的而是至少两个周期之前的。因为两级同步器会带来两个目的时钟周期的延迟。这意味着写时钟域看到的读指针可能落后于实际读指针所以对满的判断是偏保守的——原本可能还没满但因为读指针显得比较小可能提前报满。但这不会造成错误。同理读时钟域看到的写指针也滞后对空的判断也偏保守但不会错误地把有数据读成空。格雷码下的空满判断逻辑是空读写指针完全相同。满最高位不同且其余位相同。为什么满判断是这样因为指针多加了一位这一位可以区分出读指针追平写指针和写指针已经比读指针多跑一整圈。当写指针的最高位与读指针不同、低几位完全相同时说明写指针比读指针恰好快了2^N个位置也就是第2^N个数据写了一半此时FIFO已满。3.4 异步FIFO参考代码与要点下面给出一个异步FIFO的核心代码骨架。为了清晰我省略了RAM实例只展示指针生成、格雷码转换、同步和空满判断的逻辑。module async_fifo #( parameter DATA_WIDTH 8, parameter DEPTH 16 )( input wire wr_clk, input wire wr_rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] din, output wire full, input wire rd_clk, input wire rd_rst_n, input wire rd_en, output wire [DATA_WIDTH-1:0] dout, output wire empty ); localparam ADDR_WIDTH clog2(DEPTH); localparam PTR_WIDTH ADDR_WIDTH 1; // 双端口RAM省略完整工程中可用always块或调用RAM IP实现 reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; // 写指针及格雷码 reg [PTR_WIDTH-1:0] wr_ptr_bin; reg [PTR_WIDTH-1:0] wr_ptr_gray; // 读指针及格雷码 reg [PTR_WIDTH-1:0] rd_ptr_bin; reg [PTR_WIDTH-1:0] rd_ptr_gray; // 同步器将读指针格雷码同步到写时钟域 reg [PTR_WIDTH-1:0] rd_ptr_gray_sync1; reg [PTR_WIDTH-1:0] rd_ptr_gray_sync2; // 同步器将写指针格雷码同步到读时钟域 reg [PTR_WIDTH-1:0] wr_ptr_gray_sync1; reg [PTR_WIDTH-1:0] wr_ptr_gray_sync2; // 写指针生成 always (posedge wr_clk or negedge wr_rst_n) begin if (!wr_rst_n) wr_ptr_bin 0; else if (wr_en !full) wr_ptr_bin wr_ptr_bin 1b1; end always (*) wr_ptr_gray (wr_ptr_bin 1) ^ wr_ptr_bin; // 读指针生成 always (posedge rd_clk or negedge rd_rst_n) begin if (!rd_rst_n) rd_ptr_bin 0; else if (rd_en !empty) rd_ptr_bin rd_ptr_bin 1b1; end always (*) rd_ptr_gray (rd_ptr_bin 1) ^ rd_ptr_bin; // 两级同步器读指针格雷码 - 写时钟域 always (posedge wr_clk or negedge wr_rst_n) begin if (!wr_rst_n) begin rd_ptr_gray_sync1 0; rd_ptr_gray_sync2 0; end else begin rd_ptr_gray_sync1 rd_ptr_gray; rd_ptr_gray_sync2 rd_ptr_gray_sync1; end end // 两级同步器写指针格雷码 - 读时钟域 always (posedge rd_clk or negedge rd_rst_n) begin if (!rd_rst_n) begin wr_ptr_gray_sync1 0; wr_ptr_gray_sync2 0; end else begin wr_ptr_gray_sync1 wr_ptr_gray; wr_ptr_gray_sync2 wr_ptr_gray_sync1; end end // 满判断写时钟域 wire full_w (wr_ptr_gray[PTR_WIDTH-1] ! rd_ptr_gray_sync2[PTR_WIDTH-1]) (wr_ptr_gray[PTR_WIDTH-2:0] rd_ptr_gray_sync2[PTR_WIDTH-2:0]); assign full full_w; // 空判断读时钟域 wire empty_r (rd_ptr_gray wr_ptr_gray_sync2); assign empty empty_r; // RAM读写 always (posedge wr_clk) begin if (wr_en !full) mem[wr_ptr_bin[ADDR_WIDTH-1:0]] din; end // 读取可以组合输出或寄存输出这里用组合输出示意 assign dout mem[rd_ptr_bin[ADDR_WIDTH-1:0]]; // 位宽计算函数 function integer clog2; input integer value; begin value value - 1; for (clog2 0; value 0; clog2 clog2 1) value value 1; end endfunction endmodule重点说三个我在实际项目中吃过亏的细节。第一两条总线回到各自时钟域时必须先转格雷码再同步。绝对不要在二进制指针上加两级同步器然后用多位的二进制比较。原因前面已经讲了多bit同时变化采样会出错。第二同步器的两级不能省。一级同步器虽然也能减少亚稳态概率但残留的概率还是很大。FPGA设计标准做法是两级对于频率比特别极端或时钟质量特别差的场景可以用三级。第三复位必须各自独立。写侧复位和读侧复位可以来自不同的复位源但要保证各自的复位能释放物理指针和同步器状态。常见的做法是两个独立的异步复位同步释放电路分别用在读写两侧。4. 工程实践中的参数选择与工具化实现4.1 位宽、深度怎么定FIFO的位宽一般直接等于数据宽度比如8位、16位、32位。但有的场景需要加sideband信号比如valid、last、user位那位宽就要一起算进去。我经手的AXI Stream FIFO数据通路通常不是简单32位而是32位数据加1位last加若干user位总共接近40位甚至更宽。深度计算才是真正需要花心思的地方。核心思想是FIFO的容量要能容纳突发数据量减去在突发期间被读走的数据量。换句话说是最大瞬时堆积量。给一个典型计算实例当写侧在一个突发周期内要写入200个数据而读侧在相同周期内最多读走140个那么FIFO深度至少要大于200减去140等于60。为了保证余量一般再乘1.2到1.5的系数取64或128。再举一个常见的水线场景。假如读写都持续工作写速率100MHz读速率80MHz瞬间突发长度是400个数据那么FIFO深度至少要大于400乘以1减80/100等于80实际上要更大因为还要考虑响应延迟和空满判断的滞后。所以我经常建议在条件允许的情况下FPGA资源充裕就选大一号的深度。深度深一点影响的是几块BRAM/URAM的资源占用但能换来空满标志更平静避免频繁触边。不过深度也不能无限大异步FIFO深度越多关于同步造成的保守判断误差越不明显但如果深度过浅可能会因为同步延迟导致明明还有空间却被判断为满从而降低吞吐。一般建议异步FIFO的深度至少大于同步器的延迟拍数加2。4.2 IP核配置与AXI Stream FIFO实际工程项目里大部分情况下你不会自己从零写异步FIFO而是直接调厂商IP。Xilinx Vivado里的FIFO Generator IP核心非常常用。新建IP时最需要注意几个选项Read ModeStandard FIFO还是First Word Fall ThroughFWFT。Standard模式下必须先给读使能数据在下个周期出现在doutFWFT模式下数据会提前出现在dout上valid信号自动变高等式说第一个字已经破门而出读者只需在valid为高时接收即可。Data Count可以选择输出写侧或读侧的数据计数方便做水线控制。Almost Full / Almost Empty按你的应用场景设置阈值比如Almost Full设为满减8个深度的位置触发用于提前停止写入留出裕量。AXI4-Stream FIFO是另一种常见形态。信号包括tvalid、tready、tdata、tlast、tkeep等。它的好处是标准化握手机制发送方拉高tvalid且接收方拉高tready才完成一拍传输。用AXI Stream FIFO几乎不需要关心空满只需要处理tvalid和tready的时序关系。如果你在做图像采集、高速接口桥接、Zynq PL与PS之间的数据通路强烈建议优先用AXI Stream FIFO而不是原始FIFO接口友好很多调试也直观。4.3 几种典型应用场景说几个我碰过的真实场景帮助你把FIFO和实际需求对上号。VGA/摄像头图像采集。早年的OV7670摄像头模块有两种带FIFO和不带FIFO。不带FIFO的模块摄像头输出的像素数据在行场有效信号期间每来一个PCLK就更新一个像素如果MCU/FPGA来不及读取数据就丢了。带FIFO的模块比如AL422B这种视频FIFO把一帧数据先写入FIFO然后由主控在帧间隙读完。这种场景下FIFO深度要至少能存放一行的像素或者干脆一帧取决于主控的读取速度。如果你用的是不带FIFO的OV7670就必须在外部加一个同步FIFO或SRAM来缓存行数据深刻体会什么叫数据缓冲。ADC采样数据跨时钟域传输。我有一个项目是ADC输出数据在采样时钟域后端处理逻辑在系统主时钟域两者频率不成整数倍关系。这种情况下FIFO就是最简单的桥梁。需要注意这种情况下FIFO的写入速率由采样时钟决定读出由处理速率决定如果平均处理速率低于采样速率FIFO迟早会满必须在应用中做丢帧或降采样处理光靠FIFO解决不了持续的速率差。Zynq中的固件数据流。基于Zynq做Bootloader在线升级时PL端常见做法是使用AXI Stream FIFO接收PS端发来的固件数据然后边收边写入Flash或DDR。这里FIFO的深度决定了PS端一次能突发下发的数据量。如果FIFO深度太小PS需要频繁等待ready信号固件下发速度就被拉低。我的经验是至少放一个较大深度的FIFO比如16KB以上做缓冲同时利用Almost Empty标志触发DMA搬移整体吞吐会高很多。4.4 做FIFO设计时通用的设计规范不管是用IP还是自己写RTL有些规范是所有FIFO设计都必须遵守的。复位后指针清零空置有效。读写指针都不能无限制自增必须回卷。写使能被满信号钳制读使能被空信号钳制。异步FIFO必须用格雷码指针跨域且至少两级同步。不要在同一时钟周期既读出又写入同一个地址以免RAM冲突虽然双端口RAM允许但在逻辑上可能造成数据不确定。不要让FIFO在非空时被强制复位否则会丢失尚未读走的数据。5. 常见问题排查与调试经验总结5.1 典型故障现象与原因我在调试中遇到过几类非常有代表性的FIFO故障每次排查完都会记录一条。整理一个速查表給大家故障现象可能原因排查思路读出的数据每隔一段时间出现错误写入侧在FIFO满时仍写入覆盖了未读走数据检查写使能是否与full信号正确互锁查看full信号是否在应该拉高时没有拉高数据好像少了读到的都是旧数据异步FIFO同步器打拍空判断延迟导致读侧在数据已写进来但empty还没拉低时试图读出检查空标志是否在第一个数据写入后经过2-3拍才拉低这属于正常延迟如果读侧逻辑无法等待可改用FWFT模式或增加数据有效信号满标志提前拉高吞吐上不去满判断使用的同步指针滞后或者深度选得太小增大FIFO深度确认同步器使用的时钟域是否正确查看Almost Full阈值设置系统复位后第一次读写就异常复位释放与时钟不同步指针初始状态不确定检查复位电路是否为异步复位同步释放确认写侧复位和读侧复位都正确复位了对应逻辑跨时钟域后偶发亚稳态导致数据完全乱掉没有用格雷码同步或者同步器只有一级打开综合报告检查跨时钟域路径强行改成格雷码指针两级同步5.2 调试手段仿真与ILA排查FIFO问题第一步永远是仿真。写一个testbench在仿真里构造极端场景连续写满、连续读空、同时读写、背靠背读写。用波形对比读出的数据和写入的数据。我个人喜欢在testbench里做FIFO数据自检写入的数据是带递增序列号的读出后检查序列号是否连续这样任何一处丢数据或者乱序都能立刻看出来。在板上调试时优先看空满标志的波形以及读写使能的波形。如果手头有ILAXilinx Integrated Logic Analyzer把写使能、读使能、full、empty、dout采进去先确认控制信号的行为是否符合预期再去怀疑RAM本身。很多时候FIFO数据出错不是因为FIFO逻辑错误而是上游写错了或者下游读早了。5.3 我个人在实际操作中的几条心得我最终想说的是FIFO是一个看起来简单、想做好却很容易翻车的模块。我自己从写个计数器RAM就开始用到认真对待空满标志、专门设计同步逻辑中间经历了好几个项目的数据丢失和时序问题。有几个习惯我觉得值得分享第一设计中尽量用IP核。有人觉得写个FIFO是基本功非要自己写。我可以理解我自己也会写但凡是项目里要用的FIFO我还是会优先调厂商IP。原因不是我不信任自己写的代码而是厂商IP经过了充分验证各种corner case处理得妥妥当当。自己写FIFO更适合学习、做IP核设计或者用在不便依赖厂商环境的场合但项目里求稳才是第一位。第二FIFO的深度不要卡得太死。很多工程师喜欢精打细算算出来深度64够用就绝不选128。但如果62个BRAM用不了一样深度的余量换来的系统稳定性是值得的。尤其是异步FIFO因为同步器的延迟FIFO达到真满和报满之间存在滞后深度如果刚好卡在边界上很容易白损失吞吐。第三在FIFO后面加上调试断言。很多时候数据丢在FIFO里面等发现问题时现场早就没有了。与其事后看波形猜不如在RTL里直接写下logic_error这样的断言比如ram地址撞了空读发生了自动拉一个报警信号板上调试时就能立刻抓到。这个习惯我延续了好几年救过我很多次。第四也是最后一个小技巧当你怀疑FIFO有问题时先不要急着改代码。先在仿真里把读写时序打印出来看看哪一拍full/empty被判高了哪一拍写使能被钳掉了。很多时候错误不在FIFO本身而在控制逻辑对FIFO状态的错误判断。能把FIFO的“行为边界”摸清楚你的设计就已经赢了一半。