资讯动态

异步FIFO设计解密:格雷码指针与空满信号详析

发布时间:2026/10/16 1:55:06 来源:尧图企业网站定制
写异步FIFO新手最容易产生一个错觉代码看起来很简单嘛不就是一块RAM、两个计数器、两组打拍寄存器吗。可真到了项目里你会发现全信号偶尔提前拉高、数据偶发错位、仿真的好好的上板就挂。我自己的第一个跨时钟域模块就是因为想当然用二进制指针打三拍最后在回归环境里复现出一堆“不可能出现”的指针值排查了两天才意识到问题出在同步编码上。这篇文章就对着异步FIFO的Verilog代码逐段拆把每一行为什么存在、空满逻辑为什么长这样、同步器到底在防什么讲清楚。适合正在学跨时钟域设计的FPGA和IC方向同学也适合需要review别人FIFO代码的工程师。读完之后你至少能回答三个问题为什么指针要比地址多一位、为什么指针要用格雷码、为什么满信号要把同步过来的指针最高两位取反。1. 跨时钟域的本质问题为什么直接同步数据总线会翻车1.1 一次数据丢包事故的复盘早几年我接手过一个模块A侧每周期产生一个32bit数据B侧以不同的时钟频率消费。当时团队里的第一版实现很朴素把32bit数据每个bit用两级同步器打两拍再在B侧直接使用。仿真看波形没什么大问题但在长时间的随机测试里偶尔会读出错误的值而且错误出现的位置没有任何规律错误率低到拿示波器都不一定抓得住。后来把问题定位到同步器本身32个bit里只有部分bit在采样窗口内翻转另外部分没有翻转结果就是“新数据”和“旧数据”被硬生生拼在一起。比如上一个数据是0x7F下一个数据是0x80但B侧收到的可能是0x00、0xFF这种从来不存在的数据。这些非法数据再往后一传表现就是偶发的、不可重复的数据错误。这个案例说明一个朴素的道理两级同步器解决的是“单个bit能否稳定”的问题它不保证“一组bit合在一起是否有意义”。跨时钟域传多比特数据永远不要去同步数据本身。1.2 多比特总线同步的真正风险非法中间值为什么一组bit会在同步时“裂开”因为每个bit走的布线路径不同、到达同步寄存器的时刻不同各自相对时钟沿的建立保持余量也不同。二进制指针从3变成4011到100时三个bit同时翻转但同步寄存器看到的是“有些bit已经到新值、有些bit还在旧值”的混合状态。于是可能出现011、100、000、111这些组合里的任意一种其中000在正常的指针序列里根本没有对应位置。用这个非法指针去做空满判断后果分两种如果它让逻辑误判“满”最多是损失一点吞吐如果它让逻辑误判“空”读端就会在没有数据时发起读操作直接读到还没写入的存储单元数据完整性就被破坏。异步FIFO代码里最隐蔽的这种事故源头多半在这。很多同学会问那我用两级同步器专门同步一个“数据有效”信号等它稳定了再去采数据总线行不行答案是不行。有效信号从A域传到B域本身就有延迟等你看到有效信号时数据总线可能已经换成了下一拍的数据。你永远不知道“哪一版数据”对应“哪一版有效信号”。这正是异步FIFO存在的意义——它把数据放进存储阵列由指针告诉你该读哪个位置而不是靠猜。1.3 异步FIFO的设计思路把大问题拆成三件事异步FIFO之所以是跨时钟域数据传输的默认答案是因为它改变了问题的结构数据本身不跨域数据待在双口RAM里两端各自用自己的时钟访问真正跨域的只有读写指针而指针又经过格雷码编码和两级同步器把多bit跨域问题降成单bit亚稳态问题。所以看任何一份异步FIFO代码脑子里都要装着三个独立问题数据存在哪里——双口RAM怎么知道还能写、还能读——指针比较生成空满信号指针怎么安全地从一端传到另一端——格雷码两级同步器。代码读不懂没关系先把这三个问题在代码里找出来剩下的都是实现细节。只要这三个问题各自的实现是独立清晰的这份FIFO代码就成功了一半。2. 核心代码骨架双口RAM、N1位指针与格雷码换算2.1 双口RAM在RTL里的写法与推断先看存储体。最常见的写法就是用一个两维数组配一个写口时序逻辑和一个读口组合逻辑reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; // 写口 always (posedge wr_clk) begin if (wr_en_int) mem[wr_ptr_bin[ADDR_WIDTH-1:0]] wr_data; end // 读口组合读 assign rd_data mem[rd_ptr_bin[ADDR_WIDTH-1:0]];写口只在写时钟域被访问读口只在读时钟域被访问读写地址互不相关这就是双口RAM的行为模型。FPGA综合工具一般会自动把它推断成块RAM或LUTRAMASIC流程里则会由存储器编译器生成对应的宏单元。这里有一个关键细节读地址和写地址只取指针的低ADDR_WIDTH位。指针最高位不参与寻址它只服务于空满判断。如果你在代码里看到有人拿完整的N1位指针去做地址基本可以断定这份代码有问题——地址越界和空满误判是一对孪生兄弟。2.2 指针为什么必须多一位空与满都要靠它区分FIFO空了和满了读指针和写指针的值很有可能是相同的——都指向同一个存储地址。如果不加额外信息空满逻辑根本不知道当前是“里面没数据”还是“里面已经堆满”。这就是指针要比地址多一位的根本原因。以深度8为例地址位是3bit指针要做成4bit。写指针从0走到15读指针也从0走到15。用最高位表示“走过几轮”当写指针是1000、读指针是0000时低3位地址都是000但最高位不同说明写端已经比读端多走了一整轮FIFO满当写指针和读指针完全一样时说明读端追上了写端FIFO空。场景写指针读指针低3位地址最高位结论复位00000000相同相同空写了8个10000000相同不同满写8读210000010不同不同非空非满读追上写10001000相同相同空再写8个00001000相同不同满注意最后一行写指针从1000继续写8个变成00004bit回绕读指针还停在1000。此时低3位地址又相同了但最高位不同所以仍然是满——这个状态说明写端比读端又多走了一圈。指针的最高位不是地址它记录的是“圈数”。有了这个圈数空和满才能被严格区分。2.3 格雷码换算一行assign背后的数学二进制指针直接在两个时钟域之间传不行所以几乎所有异步FIFO都先把指针转成格雷码再同步。二进制转格雷码的公式极简单assign wr_ptr_gray (wr_ptr_bin 1) ^ wr_ptr_bin; assign rd_ptr_gray (rd_ptr_bin 1) ^ rd_ptr_bin;它的数学含义是第i位格雷码 第i位二进制 ^ 第i1位二进制。我们通常说格雷码“每次只变一位”这句话在指针按固定步长递增时成立。下面这张表是4bit的二进制和格雷码对照十进制二进制格雷码000000000100010001200100011300110010401000110501010111601100101701110100810001100910011101101010111111101111101211001010131101101114111010011511111000看2和3二进制从0010到0011只变1位3到4二进制从0011到0100同时变了3个bit但格雷码从0010到0110只变了1个bit。越是在回绕边界比如7到8、15到0这种优势越明显。满空比较、复位初值比较都会直接在格雷码域完成这就是后面逐行代码里空满信号长那样的原因。3. 空满信号的生成多出来的那位指针到底在表达什么3.1 满信号的“魔法比较”为什么是反掉最高两位看代码时最劝退的就是满信号这一行长得像魔法assign wr_full (wr_ptr_gray {~rd_ptr_gray_sync2[ADDR_WIDTH], ~rd_ptr_gray_sync2[ADDR_WIDTH-1], rd_ptr_gray_sync2[ADDR_WIDTH-2:0]});先说结论这是在格雷码域判断“写指针比读指针恰好领先一圈”。第2章讲过领先一圈等价于二进制指针相差2^N也就是深度个entry。格雷码有一个不太起眼但很关键的性质二进制数加2^N在格雷码域表现为最高两位取反其余位不变。举深度8的例子gray(0)0000gray(8)1100最高两位从00变11gray(2)0011gray(10)1111最高两位也正好取反。所以判断满只要把同步过来的读指针格雷码最高两位取反再和本地写指针格雷码逐位比较就够了。为什么满信号要在写时钟域产生因为写端需要这个信号决定是否停止写入。如果满信号放在读时钟域产生再跨回写域那就又多了一次跨域还会多两拍延迟FIFO很容易溢出。所以标准做法永远是满信号在写域产生判断依据是从读域同步过来的读指针空信号在读域产生依据是从写域同步过来的写指针。3.2 空信号对称逻辑与格雷码域比较空信号对应的代码简单很多assign rd_empty (rd_ptr_gray wr_ptr_gray_sync2);它的含义是读指针和写指针完全相等包括最高位。空和满本质上是对称的空是读端追平了写端满是写端领先了读端一圈。所以满需要反转最高两位去构造“领先一圈”的参考值空不需要——读指针格雷码直接和同步过来的写指针格雷码相等就够了。另一个容易忽略的点空满比较都在格雷码域做不要图省事先把同步过来的格雷码转回二进制再比较。格雷码转二进制是一串组合逻辑长度越长越容易成为时序瓶颈更关键的是同步器输出的格雷码本来就是合法值直接在格雷码域比较语义最干净。这也是经典实现里“同步器输出直接连比较器”的原因。3.3 保守空满看到的是过去反而更安全同步器必然给空满判断带来一个现象写端看到的读指针是两拍之前的值读端看到的写指针也是两拍之前的值。也就是说空满信号永远基于“过去的对方状态”不可能基于当前状态。这个“滞后”恰好带来两个安全方向的效果写端看到的读指针偏旧、偏小所以写端算出来的水位总是偏高full会更早拉高——最坏情况只是浪费几个槽位绝不会在真正满的时候漏报读端看到的写指针偏旧、偏小所以读端算出来的水位总是偏低empty会更早拉高——最坏情况只是多等几拍绝不会在真正空的时候漏读。这个性质在工程上叫“保守空满”是异步FIFO不溢出不读穿的安全底牌。代价是有效深度缩水。同步器加比较逻辑通常吃掉2到3拍如果FIFO深度是512这点损耗无所谓如果深度只有4或8实际可用容量可能只剩一半或更少。所以浅FIFO的选型要格外小心别拿“理论深度”当“可用深度”。4. 两级同步器与亚稳态格雷码为什么是安全底牌4.1 亚稳态到底发生了什么触发器不是无限快的。数据在时钟沿附近变化时如果正好落在建立时间和保持时间之间的窗口里输出既不是稳定的0也不是稳定的1而是处在一个中间电平上然后慢慢解析。解析结果是0还是1没法预先知道纯粹看工艺和运气。这个中间状态就叫亚稳态。打两拍的原理其实很简单第一级寄存器允许亚稳态出现第二级寄存器等到下一个时钟沿才采样第一级的输出相当于给了第一级整整一个周期去解析。解析不出来的概率随等待时间指数下降所以两个寄存器中间只要留出完整周期MTBF就可以压得很低。反过来如果两级同步器之间插了一段组合逻辑第一级的解析时间被压缩亚稳态概率就直接上升。所以“同步器之间不加组合逻辑”是绝对红线。4.2 二进制指针直接同步为什么会“飞掉”回头看1.2节的例子二进制指针从011到1003个bit同一时刻翻转但每个bit到达同步寄存器的时刻不同、采样余量不同。两级同步器只能分别稳定这3个bit却无法保证它们稳定后组成一个合法的指针值。结果就是写端可能看到一个从未出现过的读指针比如010或111。拿010去比满可能误判为有空位继续写等到同步器把真正的指针送过来时FIFO已经溢出拿111去比空可能误判为空读端读走还没写进去的数据。哪个方向都是灾难。所以二进制指针直接跨域问题不在“同步器本身”而在“多个bit组合的语义”无法被同步器保证。这一步想通了你就能理解为什么网上所有异步FIFO代码都在强调格雷码。4.3 格雷码只变一位的性质如何保住不变量格雷码的入场就是为了解决上面这个问题。相邻格雷码之间只有1个bit变化所以无论采样点落在哪里最多只有1个bit处于翻转状态可能出现亚稳态的也只有这1个bit。解析结束后这一位要么是旧值要么是新值于是同步器拿到的格雷码只可能是源指针的旧值或新值绝不会是几个bit拼出来的非法组合。更进一步因为指针只会单调递增同步后的格雷码永远对应一个不超前于当前真实指针的位置。空满比较即使基于这个“旧位置”方向也是安全的。格雷码并没有消除亚稳态它只是把一个多bit问题降成一个单bit问题而单bit亚稳态是可以靠打两拍用概率压住的。这就是“格雷码两级同步器”这个组合能成为业界标准方案的底层原因。5. 逐行注释版RTL一份可以直接落地的异步FIFO5.1 参数、端口和整体布局一个标准的异步FIFO模块参数就那么几个数据位宽DATA_WIDTH、地址位宽ADDR_WIDTH深度固定为2的ADDR_WIDTH次方。端口分两侧写侧带写时钟、写复位、写使能、写数据和满标志读侧带读时钟、读复位、读使能、读数据和空标志。module async_fifo #( parameter DATA_WIDTH 32, parameter ADDR_WIDTH 8 ) ( input wire wr_clk, input wire wr_rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] wr_data, output wire wr_full, input wire rd_clk, input wire rd_rst_n, input wire rd_en, output wire [DATA_WIDTH-1:0] rd_data, output wire rd_empty ); localparam DEPTH 1 ADDR_WIDTH; // ---------- 存储体 ---------- reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; // ---------- N1位二进制指针 ---------- reg [ADDR_WIDTH:0] wr_ptr_bin; reg [ADDR_WIDTH:0] rd_ptr_bin; // ---------- 格雷码 ---------- wire [ADDR_WIDTH:0] wr_ptr_gray; wire [ADDR_WIDTH:0] rd_ptr_gray; // ---------- 跨时钟同步寄存器 ---------- reg [ADDR_WIDTH:0] rd_ptr_gray_sync1; reg [ADDR_WIDTH:0] rd_ptr_gray_sync2; reg [ADDR_WIDTH:0] wr_ptr_gray_sync1; reg [ADDR_WIDTH:0] wr_ptr_gray_sync2; // 内部读写使能受空满信号门控 wire wr_en_int wr_en ~wr_full; wire rd_en_int rd_en ~rd_empty; // ---------- 写指针与RAM写入 ---------- always (posedge wr_clk or negedge wr_rst_n) begin if (!wr_rst_n) begin wr_ptr_bin h0; end else if (wr_en_int) begin mem[wr_ptr_bin[ADDR_WIDTH-1:0]] wr_data; wr_ptr_bin wr_ptr_bin 1b1; end end // ---------- 读指针 ---------- always (posedge rd_clk or negedge rd_rst_n) begin if (!rd_rst_n) begin rd_ptr_bin h0; end else if (rd_en_int) begin rd_ptr_bin rd_ptr_bin 1b1; end end // ---------- 组合读 ---------- assign rd_data mem[rd_ptr_bin[ADDR_WIDTH-1:0]]; // ---------- 二进制转格雷码 ---------- assign wr_ptr_gray (wr_ptr_bin 1) ^ wr_ptr_bin; assign 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 h0; rd_ptr_gray_sync2 h0; 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 h0; wr_ptr_gray_sync2 h0; end else begin wr_ptr_gray_sync1 wr_ptr_gray; wr_ptr_gray_sync2 wr_ptr_gray_sync1; end end // ---------- 满信号写时钟域产生 ---------- assign wr_full (wr_ptr_gray {~rd_ptr_gray_sync2[ADDR_WIDTH], ~rd_ptr_gray_sync2[ADDR_WIDTH-1], rd_ptr_gray_sync2[ADDR_WIDTH-2:0]}); // ---------- 空信号读时钟域产生 ---------- assign rd_empty (rd_ptr_gray wr_ptr_gray_sync2); endmodule5.2 代码要点与工程微调第一RAM写口我单独放在指针递增的always块里。FPGA综合工具对这种写法很熟悉一般能直接推断块RAM。如果你的工具不太配合可以把写口单独拆成一个always块只负责mem写入指针递增另写一个块逻辑上完全等价推断上更稳。第二这个代码是“组合读”风格rd_en拉高的同一个周期rd_data就已经是有效数据。下游拿到rd_en就能直接用数据不需要再等一拍。代价是mem地址到数据输出的路径比较长读时钟一高就容易紧张。如果你的读侧时序吃紧可以改成“先读地址打一拍、数据晚一拍出来”的寄存器读风格但那时读侧的握手方式也要跟着改不是简单加个打拍的事。第三wr_full和rd_empty是组合逻辑输出从同步器寄存器到比较器输出有一长条路径。高频率设计里这段常常是时序关键路径。如果想优化可以把满空信号各打一拍输出代价是背压和释放都慢一拍下游接口需要配合。工程上常见做法是保持组合输出然后用约束告诉工具“这两条路径要按照异步关系处理”具体见第6章。第四再强调一次非2的幂深度问题。格雷码只在2的幂处自然回绕所以上面这套结构只支持深度为2的幂。如果非要深度12、24这种值通用做法是三选一一是物理深度扩成2的幂用外部“几乎满”阈值来控制实际用量二是做受限格雷码构造一段长度刚好为指定值的格雷码序列维护成本高一般不建议自研三是直接换用带有效计数器的异步FIFO结构但那种结构在线里指标和面积上都有额外开销。大多数商业IP和开源代码都默认只用2的幂深度这个约束最好在设计需求阶段就确认下来。6. 仿真验证与工程落地怎么证明FIFO没问题怎么保证时序不翻车6.1 测试平台异步时钟、四阶段测试与随机背压写testbench第一步就是生成两个频率不同的异步时钟。100MHz和75MHz的组合我经常用initial begin wr_clk 1b0; forever #5 wr_clk ~wr_clk; // 100MHz end initial begin rd_clk 1b0; forever #6.67 rd_clk ~rd_clk; // 约75MHz end测试流程我一般固定走四个阶段每一阶段都能暴露一类问题阶段一先写后读写端连续写入固定序列数据直到wr_full拉高之后读端连续读出检查读出的序列和写入完全一致直到rd_empty拉高。这个阶段验证基本读写路径、满信号和空信号。阶段二边写边读两端同时以不同速率工作写快读慢观察是否溢出再切到写慢读快观察是否读穿。这个阶段最考验保守空满的边界行为。阶段三随机背压读写使能都用随机数门控模拟真实业务的不规则节奏。推荐用$urandom生成使能概率并跑足够长的周期让指针绕很多圈格雷码回绕边界才可能被覆盖到。阶段四换频组合把两个时钟频率对调或者让其中一个时钟不工作、另一个连续读写。异步FIFO的故障常藏在“某一侧长时间不干活”的低频场景里。6.2 数据完整性校验参考模型与序列号FIFO测试最容易犯的错误是只看波形、不看数据内容。波形上wr_full拉高、rd_empty拉高都正常不代表中间没有数据错位。最稳的做法是维护一个参考计数模型在testbench里模拟一个“理想FIFO”的读写计数integer wr_cnt 0; integer rd_cnt 0; always (posedge wr_clk) begin if (wr_en_int) wr_cnt wr_cnt 1; end always (posedge rd_clk) begin if (rd_en_int) begin // 写入时把序号塞进数据高16bit读侧比对 if (rd_data[31:16] ! expected_rd_seq) $error(data mismatch at rd_cnt%0d, rd_cnt); expected_rd_seq expected_rd_seq 1; rd_cnt rd_cnt 1; end end更简单实用的小技巧写侧把“写指针的当前值”编码进数据字读侧只需要校验“读到的数据是不是一个连续递增的序列”。因为指针是顺序递增的任何一位数据错位都会破坏这个连续性一抓一个准。另外还要断言一个不变量任何一个读动作发生时已提交的读次数绝不能超过已提交的写次数。这个断言是“不读穿”的最终裁判比任何空信号检查都硬。6.3 复位同步、时序约束与浅深度陷阱异步FIFO两个时钟域各自需要独立的复位同步释放电路。直接把外部复位接给wr_rst_n和rd_rst_n看起来很省事但复位释放时刻如果和时钟沿打架同步器和指针可能从不确定的初始状态开始跑空满信号第一拍就错了。标准做法是每个时钟域各做一次“异步复位、同步释放”reg [1:0] wr_rst_n_sync; reg [1:0] rd_rst_n_sync; always (posedge wr_clk or negedge arst_n) begin if (!arst_n) wr_rst_n_sync 2b00; else wr_rst_n_sync {wr_rst_n_sync[0], 1b1}; end assign wr_rst_n wr_rst_n_sync[1]; always (posedge rd_clk or negedge arst_n) begin if (!arst_n) rd_rst_n_sync 2b00; else rd_rst_n_sync {rd_rst_n_sync[0], 1b1}; end assign rd_rst_n rd_rst_n_sync[1];时序约束方面约束的核心是告诉工具写时钟和读时钟是异步关系同步器路径跨过时钟域不能按普通同步路径去约束。通用写法类似下面这样具体命令名各家工具略有差异set_clock_groups -asynchronous \ -group {wr_clk} \ -group {rd_clk} set_false_path -from [get_clocks wr_clk] -to [get_clocks rd_clk] set_false_path -from [get_clocks rd_clk] -to [get_clocks wr_clk]对同步器内部的“打拍寄存器对”有些团队还会额外要求两拍之间的延迟不能超过某个值用来限定第一级寄存器解析亚稳态的时间上限。这样做的目的是给MTBF一个明确的量化依据而不是让工具随便布线。最后再说浅深度陷阱。深度为4甚至为2的异步FIFO同步延迟加比较延迟就能吃掉小一半容量电路会长期处于“满→等→松→满”的节奏里吞吐上不去。这时候别硬调FIFO参数先确认业务上是不是真的需要异步FIFO。如果只是工程上顺手完全可以换成同步FIFO加跨域握手或者用直通旁路逻辑处理小批量数据效果反而更好。我自己在项目里给异步FIFO定的规矩就两条深度必须是2的幂任何非幂次需求都要在架构阶段提出来同步器上排查问题时永远先怀疑指针编码和时间约束最后才怀疑RAM本身。这两条帮我省掉了大量在“看似正常却偶发出错”的异步FIFO上浪费的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑