☰
FIFO设计核心解析:从同步到异步的跨时钟域与Verilog实现
2026/10/4 1:23:08 网站建设 项目流程

做FPGA和数字IC设计的人,几乎没有绕开过FIFO的。不管是图像数据流的跨时钟域处理、以太网包缓存、还是CPU与外设之间的数据交互,你总能碰到这个长得不太起眼、却无处不在的缓冲结构。我刚接触硬件设计那会儿,总觉得FIFO不过是一个“先进先出的队列”,把它当成一个现成的IP核调一下、接上读写端口就完事了。后来真正开始自己动手搭RTL、调试异步FIFO的空满信号、甚至为了一个假满问题折腾了两天之后,才意识到这个模块的“水”比想象中深得多。

这篇文章我就想把自己的理解完整梳理一遍:FIFO到底是怎么工作的,为什么它能解决跨时钟域的数据传输问题,同步FIFO和异步FIFO在设计思路上有什么本质区别,以及当你需要自己写一个FIFO的Verilog代码时,哪些核心细节决定了它能不能在工程中稳定跑起来。适合刚入门FPGA、数字IC验证、以及想把手写FIFO用在实际项目里的朋友,我会尽量用大白话把原理和代码拆开讲透。

1. FIFO到底解决什么问题

1.1 从数据结构到硬件模块

我们在软件课里学到的FIFO(First In First Out),描述的是一个数据缓冲队列:最先写入的数据最先被读出,就像排队买东西一样,先来的人先结账。这个逻辑在硬件世界里同样成立,只是它不再是一个抽象的数据结构,而是由一组存储单元、读写指针、状态逻辑共同构成的真实电路。

硬件FIFO的核心组成可以拆成几块:存储体(通常用双口RAM或者寄存器阵列实现)、写指针、读指针,以及空/满状态的判断逻辑。数据从写端口进来,写到写指针指向的地址,写完写指针加一;数据从读端口出去,从读指针指向的地址读出,读完读指针加一。当写指针追上读指针,说明存储区被填满;当读指针追上写指针,说明存储区被清空。

但真正让FIFO在硬件设计里“封神”的并不是它的队列特性,而是它能天然地横跨两个不同的时钟域。比如一个ADC采集模块工作在50MHz,一个总线接口工作在150MHz,两边没法直接相连,中间塞一个FIFO,两边各写各的、各读各的,互不干扰。这就是同步FIFO和异步FIFO在实际项目中最重要的应用场景:数据缓冲和时钟域隔离。

1.2 为什么不用寄存器堆直接接

有人可能会问:不就是两个模块之间传数据吗,我在中间放一堆寄存器,用握手信号挨个传不行吗?当然行,但代价很高。如果数据速率是400Mbps,每次传输都要握手应答,那握手信号本身的建立保持时间约束、跨时钟域打拍延迟,会严重拉低吞吐量。而且当数据的产生和消费速率不完全匹配时,中间任何一个环节稍微堵塞,立刻会丢数据或者卡住上游。

FIFO的好处在于,它把“存储”和“流控”绑在了一起。只要FIFO不满,写端就可以持续往里面灌数据;只要FIFO不空,读端就可以持续往外取数据。它的满信号天然就是一个反压信号,告诉上游“你先停一停,我消化不过来”;它的空信号天然就是一个就绪信号,告诉下游“我已经有货了,你可以来取”。这种“自我调理”的能力,让FIFO几乎成了跨时钟域传输的默认答案。

2. 同步FIFO的核心原理与Verilog实现

2.1 同步FIFO的读写指针与空满判断

所谓同步FIFO,就是读写两端共用一个时钟,只是数据速率或节奏可能不同。比如某个模块在时钟上升沿连续写入8个数据,然后暂停;另一个模块在同一时钟下慢慢读出。因为共同时钟,指针变化天然同步,空满判断比较简单。

用计数器的方式来理解最直观:写指针指向下一个要写入的地址,读指针指向下一个要读出的地址。当写操作发生且FIFO未满,写指针加一;当读操作发生且FIFO未空,读指针加一;当读和写同时发生时,保持一个数据条目被抵消。要判断空还是满,最直接的方法是维护一个计数器:写入时加一,读出时减一,读写同时发生时不变,计数器为0表示空,计数器等于深度表示满。

这个方案在小型FIFO里完全够用,代码清晰、逻辑简单、仿真也容易验证。但当你把FIFO深度做到1024、4096甚至更大,用二进制计数器的组合逻辑去算空满信号,路径延迟可能会变得很难看。更常见的做法是直接比较读写指针:读指针等于写指针时,可能空也可能满,需要额外加一个附加位来区分。比如把指针位宽扩展一位,最高位不同的同时低几位相同,就表示满;完全相等则表示为空。

2.2 一个可直接使用的同步FIFO代码

下面我写一个参数化、带计数器的同步FIFO,这个结构在中小规模数据缓存里很实用,代码结构也适合做二次修改:

module sync_fifo #( parameter DATA_WIDTH = 32, parameter DEPTH = 16 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] wr_data, input wire rd_en, output reg [DATA_WIDTH-1:0] rd_data, output wire full, output wire empty ); localparam ADDR_WIDTH = $clog2(DEPTH); reg [ADDR_WIDTH:0] cnt; reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; reg [ADDR_WIDTH-1:0] wr_ptr; reg [ADDR_WIDTH-1:0] rd_ptr; assign full = (cnt == DEPTH); assign empty = (cnt == 'd0); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin wr_ptr <= 'd0; rd_ptr <= 'd0; cnt <= 'd0; end else begin case ({wr_en & ~full, rd_en & ~empty}) 2'b10: cnt <= cnt + 1'b1; 2'b01: cnt <= cnt - 1'b1; default: cnt <= cnt; endcase if (wr_en && !full) begin mem[wr_ptr] <= wr_data; wr_ptr <= wr_ptr + 1'b1; end if (rd_en && !empty) begin rd_data <= mem[rd_ptr]; rd_ptr <= rd_ptr + 1'b1; end end end endmodule

这段代码有几个细节值得注意。写入条件的wr_en && !full和计数器的更新条件保持一致,避免出现“计数器觉得没满但写入指针已经溢出”的时序风险。读出数据用了寄存输出,虽然多了一拍延迟,但能够有效避免在同一个周期内读指针变化导致读数据不稳定的问题。如果项目对读延迟敏感,可以把rd_data改成组合逻辑直接接mem[rd_ptr],代价是时序收敛难度略增。

2.3 参数设计里容易被忽略的两个点

第一点是深度必须是2的整数次幂吗?上面代码里直接用wr_ptr + 1'b1做回卷,隐含假设了地址可以自然溢出。如果深度不是2的幂次,比如你需要12个深度的FIFO,那指针就要额外做回卷判断,空满计数器的计算也会变得繁琐。工程上我建议优先把深度设成2的幂次,如果实在需要非2幂次深度,就得在指针自增后加边界判断,代码复杂度和出错概率都会上升。

第二点是$clog2函数在部分老工具里可能不支持,或者综合结果和你想的不一样。如果你的开发环境比较老,建议直接用常量定义,比如localparam ADDR_WIDTH = 4;,同时写一行注释说明深度是16。用$clog2不是不行,但一定要在仿真阶段就确认参数展开是否正确,别等到综合报错才发现问题。

3. 异步FIFO的设计难点与经典解法

3.1 跨时钟域的本质风险

异步FIFO的读写端口分别工作在两个完全独立的时钟域里,比如写时钟100MHz,读时钟75MHz,两个时钟之间没有相位关系,甚至频率都不成整数倍。这时读写指针都各自在自己的时钟域里变化,如果直接把读指针的二进制值拿到写时钟域里去比较,就会遇到一个致命的跨时钟域问题:多位信号同时变化时,采样结果可能“四不像”。

举个例子,读指针从0111变成1000,二进制下最低四位同时翻转。如果恰好在这个变化的瞬间,写时钟域对它采样,采样结果可能是0000、0111、1000或者任何中间组合。这种没有意义的中间值一旦被用于空满判断,FIFO就可能发出错误的满信号,直接导致写端停写丢数据,或者发出错误的空信号,让读端读到垃圾数据。这是异步FIFO设计里最核心的坎,也是面试时必考、工程里必踩的坑。

3.2 格雷码:让跨时钟域指针安全传递

解决多位跨时钟域传递的标准手段,是把指针从二进制转换成格雷码。格雷码的特点是,相邻两个数值之间只有1位发生变化。这意味着指针从一个状态递增到下一个状态时,跨时钟域采样最多只有一个比特处于变化中。对于单比特信号,只要接收端用两级寄存器打拍同步,就能以极高的概率采到正确的稳态值,不会出现“中间组合值”的问题。

那是不是用格雷码就万事大吉了?还差一步:同步后的格雷码指针,在判断空满时不能直接拿来和本地的二进制指针比较。通常的做法是,写时钟域里把写指针转成格雷码,再打两拍同步到读时钟域;读时钟域里把读指针转成格雷码,再打两拍同步到写时钟域。判断满时,用写时钟域里的写指针格雷码(可以再转回二进制或直接格雷码比较)和同步过来的读指针格雷码比较;判断空时,用读时钟域里的读指针格雷码和同步过来的写指针格雷码比较。

指针从二进制转格雷码的公式是gray = (bin >> 1) ^ bin,这个公式到处都是,但我还是建议亲手推一遍。它的本质是保留二进制最高位,然后每一位都和上一位做异或。解码也就是把格雷码变回二进制,需要从最高位逐位向下异或,综合出来是一串组合逻辑链。如果FIFO深度很大,这个译码链的延迟也会成为一个时序关注点,不过对于一般深度在32到1024范围内的FIFO,通常都能跑得很稳。

3.3 异步FIFO空满判断的精确语义

异步FIFO的空满判断,本质上是一个“近似判断”。因为读指针同步到写时钟域需要两拍,写指针同步到读时钟域也需要两拍,所以任何一端看到的对端指针,都可能是两个时钟周期之前的状态。这个延迟会带来两个现象:假满和假空。

假满的意思是,写端看到满信号拉高了,但实际上因为读端正在读数据、FIFO已经有空位了,这时候写端选择停写,就会损失一些写带宽。假空同理,读端看到空信号,但因为写端正在写数据,FIFO里其实马上就要有数据了,这时候读端等待,就会出现一段读空档。异步FIFO设计里,我们宁可出现假满假空,也绝不能让满信号在FIFO真正满了之后才拉高,或者空信号在FIFO真正空了之后才拉低——前者导致覆盖写入、数据被冲掉,后果极其严重。

设计时我们用格雷码比较来判断空满。以“满”为例,在写时钟域,写指针格雷码的每一位和同步过来的读指针格雷码要满足特定关系。最简单的方法是:当二进制指针的最高两位不同、其余位都相同时,表示FIFO已满。转换成格雷码之后的判断是,最高位和次高位相反,其余位相同。这个关系我能记住是因为有一次调试时发现,自己的判断条件写反了,导致FIFO从不通告满信号,结果上游数据哗哗往里灌,存储区被覆盖得面目全非。那次查了一晚上,最终就是把~和==对调了一个位置。

3.4 异步FIFO的核心代码框架

这里展示一个经典异步FIFO的关键逻辑结构。省略了存储体部分,重点看指针同步和空满判断的框架:

module async_fifo #( parameter DSIZE = 8, parameter ASIZE = 4 )( input wire wclk, wrst_n, input wire winc, input wire [DSIZE-1:0] wdata, output wire wfull, input wire rclk, rrst_n, input wire rinc, output wire [DSIZE-1:0] rdata, output wire rempty ); wire [ASIZE-1:0] waddr, raddr; wire [ASIZE:0] wptr, rptr; wire [ASIZE:0] wq2_rptr, rq2_wptr; // 读指针同步到写时钟域,用于满判断 sync_r2w sync_r2w_inst ( .wclk(wclk), .wrst_n(wrst_n), .rptr(rptr), .wq2_rptr(wq2_rptr) ); // 写指针同步到读时钟域,用于空判断 sync_w2r sync_w2r_inst ( .rclk(rclk), .rrst_n(rrst_n), .wptr(wptr), .rq2_wptr(rq2_wptr) ); // 满判断:写指针与同步读指针的格雷码差一个“来回” assign wfull = (wptr == {~wq2_rptr[ASIZE:ASIZE-1], wq2_rptr[ASIZE-2:0]}); // 空判断:读指针与同步写指针完全相等 assign rempty = (rptr == rq2_wptr); // 存储体、读写指针自增逻辑略 endmodule

严格说这个框架里的wptr、rptr应该以格雷码形式存储和传递,实际代码里会在指针模块内部维护一份二进制指针用于寻址,再额外输出格雷码指针用于跨域同步。上面的结构重点展示了满判断条件:当写指针格雷码的高两位等于同步读指针格雷码高两位的反时,认为FIFO满。这是异步FIFO设计中一个非常经典的判断方式,也提醒了我:空和满的判断条件是不对称的,千万不要图省事复制粘贴。

4. 设计中的常见坑与排查方法

4.1 实测最容易翻车的三个场景

第一,复位释放的跨时钟域同步问题。异步FIFO的复位信号如果是异步复位、释放时又和各自的时钟没有对齐,可能让指针处于一个非法的中间状态。工程上比较稳妥的做法是,在读写时钟域分别做异步复位同步释放,确保复位信号在本地时钟域内干净地释放。我见过最诡异的一个现象是,复位明明拉低了,但第一个写操作写完,FIFO直接报满——原因是复位释放时写指针的格雷码变了一个非法值,同步到读时钟域后又引发了对端判断混乱。

第二,读写同时发生时计数器更新顺序。在同步FIFO里,读写同时发生的优先级到底给谁?这看起来是个小问题,但处理不好会导致计数器和指针不一致。我惯用的方式是让写优先,也就是先保证写不丢,因为读端如果等一个周期再读,通常只是损失一点读吞吐;而写端一旦因为FIFO误判为满而丢数据,这个数据就是永久性丢失了。

第三,FIFO深度和指针位宽不匹配。深度16需要4位地址指针,但空满判断有时需要额外扩展一位做区分。如果直接在原来的$clog2(DEPTH)基础上加1,代码没问题;但如果有人图省事把指针直接定义成[DEPTH-1:0],仿真时看不出来,综合后大概率会出问题。写代码之前把参数列一张表:深度、地址位宽、指针位宽、格雷码位宽,一一对应写清楚,后面排查会省很多事。

4.2 空满标志的常见陷阱速查

现象可能原因排查方向
写端还没写几个数,FIFO就报满读指针没有正确同步到写时钟域;格雷码判断条件写反检查同步器输出波形;检查满判断条件高两位的反逻辑
读端一直读不到数据,empty拉高不释放写指针同步有问题;复位释放异常看写时钟域里写指针是否正常递增;检查格雷码是否出现多比特跳变
FIFO数据被覆盖,旧数据丢失满信号来得太晚,写端在未满时提前写入了非法地址确认满信号和写指针自增的时序关系;仿真打标记检查是否出现写穿
仿真正常,上板后偶发错数存储体读写碰撞;时钟偏斜;未处理读延迟差异检查双口RAM读写时序;确认是否有组合逻辑路径未约束

这个表格里的每一种现象我都碰到过。印象最深的是“仿真正常,上板偶发错数”,当时用的是IP核生成的异步FIFO,但没留意IP核里读数据有额外的输出寄存器选项,导致读路径比预期晚了一拍,和后续逻辑的采样窗口错开。从那以后,我每次例化FIFO IP都会先做一次极简环回验证:写端往FIFO里灌0到255,读端再读出来比对,跑一遍全流程再放到大工程里。

5. 选型建议与工程实践心得

5.1 手写还是调IP核

现在Xilinx、Intel的FPGA开发环境里都有非常成熟的FIFO IP核,支持同步、异步、标准模式、首字直通模式、almost full/empty标志,甚至还能生成计数与错误标志。如果你是在FPGA上做项目,绝大多数情况下直接用IP核就好,原因是IP核经过厂商充分验证,时序约束、复位同步、格雷码转换都已经处理好了,比自己在RTL里手搓要省心得多。

那什么时候需要手写FIFO?一类是ASIC设计里没有现成IP可用,所有东西都得从标准单元搭;另一类是FIFO的逻辑非常特殊,比如需要同时读写多个端口、需要带优先级的覆盖写、深度或位宽非常规。还有一个场景是你在做IC验证或面试准备,想弄明白FIFO内部到底怎么工作,这时候手写一个同步FIFO、再写一个异步FIFO,收获远比直接调IP大得多。我自己就是靠“手写三个不同版本的FIFO”彻底搞懂了空满判断和格雷码的深水区。

5.2 从AXI Stream FIFO看FIFO的延伸

如果你接触过AXI Stream接口,你会发现AXI Stream FIFO和经典FIFO不完全一样。它不只是把数据存进队列,还要处理valid/ready握手信号:当FIFO不满时,写侧ready拉高,表示愿意接收数据;当FIFO不空时,读侧valid拉高,表示有数据可供读取。它比传统FIFO多了一层“流控协议”的封装。

在设计这类FIFO时,核心思路是一样的,只不过把“空满信号”翻译成了握手语义。full对应写侧ready拉低,empty对应读侧valid拉低。如果再加almost full、almost empty这些水位线信号,还能在数据量接近上限或下限时提前给出预警,这在DMA传输、DDR读写调度里非常有用。理解了基础FIFO的原理,再去看各种衍生FIFO,基本就是加一个协议壳子的事。

5.3 我在实际项目里的几点习惯

做FIFO设计这么多年,我总结出几个谈不上高大上但非常实用的习惯。第一,所有FIFO的读写指针在仿真时一定要用$monitor或者断言实时监控,不能等到跑完整波形成吨的数据后才去翻波形。好的做法是,在每个写操作完成后断言!full或full之前不允许继续写,把这个断言写进testbench里,只要有微小的空满判断漏洞,仿真立刻红一大片。

第二,异步FIFO的同步器打拍寄存器,必须使用不带复位的触发器。很多人会在同步器上顺手接复位信号,但复位信号本身如果和接收时钟域不同步,反而给同步器引入了新的不确定状态。FIFO内部同步器的复位一般由接收时钟域自己的复位生成,并且打拍寄存器只做移位,不做任何复位干预。这个细节在ASIC设计规范里写得很清楚,但FPGA工程里有时会被忽略。

第三,如果在调试时看到FIFO指针出现格雷码中不存在的编码,别急着改逻辑,先检查跨时钟域采样路径上是否有组合逻辑。格雷码指针在跨域之前必须是寄存器直接输出,任何组合逻辑操作都可能破坏“每次只变一位”这个前提。我一同事曾经为了图方便,把格雷码指针做了一次加一操作再跨域,结果那个数据路径上出现毛刺,整整排查了三天才定位到。

6. 扩展视角:分清不同类型FIFO的适用场合

6.1 同步FIFO、异步FIFO与首字直通模式

同步FIFO适合两端同频但节奏不匹配的场景,比如同一个时钟域内的数据打包、解包、速率适配。异步FIFO适合两端完全异频的场景,它把跨时钟域的难题从“数据线跨域”转移成了“指针格雷码跨域”,代价是两拍同步延迟带来的假空假满。

首字直通模式(First Word Fall Through)是另一个常被提及的变体:数据写入后不需要读请求,就自动出现在读数据总线上,只是empty为低。它减少了一拍读延迟,在一些需要极低延迟的数据通路里非常实用。代价是读数据总线的组合逻辑路径边长,时序收敛难度更高。选哪种模式,核心取决于你的后续模块对读数据延迟的容忍度。

6.2 深度、位宽与宽度转换

FIFO可以同时做位宽转换,比如写侧32位、读侧8位,FIFO内部存储的还是32位数据,读侧每读一次取其中8位,这需要额外的移位和使能控制。反过来8位写32位读也常见。位宽转换会直接影响有效深度的计算,因为存储单元数量不变,但读到的最小粒度变了。设计时建议把“存储单元宽度”和“端口气位宽”分开思考,否则很容易把FIFO容量估错。

还有一种情形是,读写两侧数据速率严格相等、但交接时中间有毛刺,这时一个深度很小的同步FIFO就够用,比如深度4到8之间。深度太小会频繁拉满、拉低反压,深度过大又浪费存储资源且增加延迟。按我的经验,先估算最坏情况下写端连续burst长度,再留出两级以上裕量,是一个比较稳妥的深度选择方式。

6.3 在大系统中的定位:缓存、反压还是异步桥

FIFO在一个完整系统里扮演的角色,可以分成三种来看。第一种是纯粹的数据缓存,比如CPU要写一批数据到低速外设,先把数据暂存在FIFO里,外设慢慢取。第二种是反压接口,FIFO的满信号直接给上游模块的ready做门控,让上游的突发传输自动停下。第三种是异步桥,把两个时钟域的数据无缝地接在一起,FIFO本身相当于一个“蓄水池”,读写两端各自对池子进水、放水,互不干扰。

明白了这三种角色,你就能更好地理解为什么有的FIFO需要almost_full信号,有的需要programmable_full,有的需要错误标志。不是因为FIFO复杂,而是它要适配的系统场景本来就复杂。把FIFO放进系统的整体视野里去看,远比孤立地研究一个队列结构更有价值。

我在实际做过的Zynq平台在线升级项目里,就用过一套异步FIFO来缓存BOOT引导数据和升级镜像。写侧是PS端通过AXI总线灌数据,读侧是PL端的SPI Flash控制器按扇区慢慢写。FIFO深度选的是2048,32位宽,几乎在升级全程没有出现过反压丢包,靠的就是设计之初对两端最坏速率的准确估算和异步FIFO的空满信号合理使用。那次之后我对FIFO体感最深的一句话是:它像一个知道什么时候该喝水的缓冲池,而你真正要设计的,是池子的水位信号和进出水闸门。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询