最近整理上一轮流片用的内部模块时,又被同事问起“同步FIFO到底怎么判空满”,才发现很多在FPGA里写了几年代码的人,对这个小模块的理解还是“看着 DDR3 控制器或者某个 AXI FIFO IP 的时序图能跑通,但让自己从零写一个就露怯”。所以这篇先把同步 FIFO 这件最简单、但所有异步设计都会踩到的地基讲透:用 Verilog 实现一个参数化的同步 8 深度 FIFO,带完整代码、空满判定方案对比和仿真测试思路。内容适合刚学 Verilog 的校招选手,也适合要用裸 FIFO 替代 IP 核的工程师。
FIFO 全称 First In First Out,本质是一个先进先出的数据队列。同步 FIFO 意味着读写共用同一个时钟域,考虑的问题比异步 FIFO 简单很多,但空满判断这个核心逻辑,恰恰是后续做异步 FIFO 时要处理的最重要问题之一。把同步版本吃透,跨时钟域版本才有基础。
同步 8 深度 FIFO 本身功能并不复杂:写入端在 wr_en 拉高且未满时把数据压入队列,读出端在 rd_en 拉高且未空时把最老的数据取走。真正的难点在于,读写操作可以同时进行,你怎么知道当前队列里有多少数据、到底是空还是满,以及如何在极端边界条件下不丢数据、不读空数据。这篇文章我会从指针设计、空满判定、代码实现、仿真验证四个维度逐一拆开,最后再聊几个实际工程里必须注意的坑。
1. 同步FIFO在数字电路中的定位:读写指针与空满判断的核心矛盾
1.1 为什么一个小队列会成为一个独立设计话题
很多人第一次看到 FIFO 的 RTL 代码,觉得不过就是一个数组加两个计数器,但真正上手写 testbench 测边界时就会发现,问题远没有这么简单。比如你连续写了 8 个数据,第 9 个写请求到来时应该被阻塞;你连续读了 8 个数据,第 9 个读请求到来时读端口不能把无效数据送出去。这两件事同时发生时,空满标志的时序必须既准确又及时,否则下游模块可能采到错误的数据,或者乒乒乓乓地产生一连串协议违例。
我在某模数转换器的数据采集链路里调试过一个典型的丢失问题:上游 ADC 每 16 个周期产生 1 个样本,下游 DSP 模块每两个样本做一次窗函数运算,两边的节奏完全不一致,如果不加 FIFO 做缓冲,要么上游等待浪费采样周期,要么下游因数据未就绪而算错。加一个深度 8 的同步 FIFO 后,上游只要 FIFO 未满就可以随时写入,下游只要 FIFO 非空就可以随时读出,两边各自按自己的节拍工作,依赖性被完全解耦。这正是 FIFO 的价值所在——它不是一个存储元件,而是一个速率匹配和节奏解耦元件。
1.2 读写指针的本质:两个相对位置不断变化的游标
FIFO 的存储体是一个固定深度的数组,读写双方各持一个游标,写指针永远指向下一个将要写入的地址,读指针永远指向下一个将要读出的地址。当读写指针相等时,FIFO 可能处于两种状态:要么一个数据都没写过(空),要么刚刚写满一整轮又读空归零(也空),还有一种诡异状态是写指针绕了一圈后恰好追上了读指针,此时如果处理不当就会把满当成空。
所以同步 FIFO 的核心矛盾就变成了:如何在指针相同时区分“空”和“满”,以及在满了之后指针继续绕圈时如何不能把新数据覆盖到旧数据上。深度 8 带来的一个好消息是地址只有 8 个,指针位宽很小,逻辑非常简单,调试时也能手动穷举所有状态,所以我才建议初学者从这个规模入手。
1.3 深度8的实际含义:数据位宽与深度的关系
标题里的“8位深度”容易被误解成数据位宽 8 bit,实际上它指的是队列深度为 8,即最多可以存放 8 个数据。数据位宽是另一个参数,我们用DATA_WIDTH单独定义。深度 8 意味着如果数据位宽也是 8 bit,整个存储体只有 64 bit,综合时会直接放进 LUTRAM 而非 Block RAM,这是后续工程选型时要格外注意的。
2. 8深度×参数化:接口定义与存储体选型的工程考量
2.1 模块接口设计原则
写 RTL 的第一步永远是定义接口,接口信息一旦定型,后面改起来非常痛苦。同步 FIFO 的标准接口包括:时钟 clk、异步复位 rst_n、写使能 wr_en、写数据 din、读使能 rd_en、读数据 dout、空标志 empty、满标志 full。有些场景还需要 almost_empty / almost_full 做预警,或者 wr_count / rd_count 做占用统计,但我们第一步只保留最核心的信号,避免一次引入太多概念。
module sync_fifo #( parameter DATA_WIDTH = 8, parameter DEPTH = 8 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] din, input wire rd_en, output reg [DATA_WIDTH-1:0] dout, output wire empty, output wire full ); // 指针位宽计算 localparam ADDR_WIDTH = $clog2(DEPTH); // 高位扩展,指针位宽比寻址宽度多 1 位 localparam PTR_WIDTH = ADDR_WIDTH + 1;这里有一个非常容易被忽略的点:DEPTH被定义为 8,$clog2(8)结果是 3,那么地址位宽是 3 位,可以表示 0~7。如果我把指针位宽也定义成 3 位,那么写满 8 个数据后指针回绕到 0,此时读指针也在 0,两者相等,FIFO 既可能是空也可能是满,无法区分。所以空满判定必须要引入一个额外的维度,这就是我为什么会用PTR_WIDTH = ADDR_WIDTH + 1。
高位扩展法的核心思想是让指针多出一位“圈数”标志位。当写指针绕完一圈回到地址 0 时,最高位翻转一次,这样同样指向地址 3,第一次到达地址 3 和绕了一圈后再次到达地址 3,指针值是完全不同的。最高位不同就能区分出“写指针在下一圈”这个事实,而不只是看到地址位相同就判定为空。
2.2 存储体:寄存器堆还是Block RAM
深度 8 的存储体可以直接用寄存器数组实现:
reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];这个写法综合后的结果通常是 LUTRAM 或分布式 RAM,不占用 Block RAM。反过来,如果我用 Xilinx 的 BRAM 原语去实现深度 8 的 FIFO,Block RAM 最小粒度也是 18Kb 或 36Kb,深度 8 的 FIFO 连零头都用不到,严重浪费存储资源。这也是为什么很多场景下,小深度的 FIFO 用寄存器堆实现,深度超过 64 或 128 时才考虑真正的 Block RAM。
判断标准不是“FIFO 一定要用 RAM”,而是深度较小的情况下,LUTRAM 的时序更好、布局更灵活、不消耗宝贵的 BRAM 资源。我在某图像处理 Demo 里做过一个 64 深度、24 bit 位宽的跨行缓存,用 LUTRAM 实现只占了不到 200 个 LUT,但换成 BRAM 原语后直接把整片 BRAM 占掉,导致后续其他模块没有 BRAM 可用。所以这里的一个经验是:深度小于 64 的 FIFO,我通常会直接让综合工具推断 LUTRAM,不强行例化 BRAM 原语。
2.3 参数化设计的陷阱:深度必须是2的幂
使用$clog2作为地址位宽计算函数有一个隐含前提:深度必须是 2 的整数次幂,否则$clog2的结果不是整数,部分仿真工具会报错或出现 0 位宽的情况。实际工程中 FIFO 深度基本上都是 2、4、8、16、32、64、128、256、512、1024、2048 这样依次翻上去,很少出现深度 5、深度 10 这类怪数值。
如果需要支持任意深度,则要在 wraparound 判断时加入特殊处理,即“小于 DEPTH 时加 1,等于 DEPTH-1 时回绕到 0”。这会增加一点逻辑,但可以保证任意深度都能用。我在做某跨平台系统时,一个语音 buffer 需要深度 240,因为算法帧长是 30,而帧数是 8,240 不是 2 的幂。当时我并没有直接扩展成 256 浪费空间,而是在参数化设计里加入了通用回绕逻辑。不过第一版设计我强烈建议保持深度为 2 的幂,逻辑最简洁,边界情况最少。
3. 空满判定:计数器法、高位扩展法与组合逻辑对比逐项拆解
3.1 计数器法:最直观但位宽和比较器压力最大
计数器法的思路是维护一个cnt,记录当前 FIFO 中的数据个数。写入时 cnt 加 1,读出时 cnt 减 1,读写同时发生时 cnt 不变。empty 为cnt == 0,full 为cnt == DEPTH。
reg [$clog2(DEPTH):0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin 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 end end assign empty = (cnt == 'd0); assign full = (cnt == DEPTH[($clog2(DEPTH)):0]);这个方案在小深度下非常好用,可读性也高,几乎不会写错。但它有几个隐患。首先是 cnt 的位宽是$clog2(DEPTH)+1,深度 8 时需要 4 位,比较器也是 4 位,看不出什么问题;如果把深度提升到 1024,cnt 宽度变成 11 位,每个时钟周期都要做 11 位比较和 11 位加减,组合逻辑路径变长,时序压力随之上升。
更深层的问题是 cnt 同时依赖读写两个方向的更新,任何一方的毛刺都会直接污染计数结果,而且布局布线时,cnt 的扇出比较大,不利于后端收时序。同步 FIFO 里虽然不像异步 FIFO 那样需要格雷码同步,但这种“每个周期都要更新全局状态”的设计风格,在高速时钟下可能成为瓶颈。
3.2 高位扩展法:FPGA 工程中更常用的空满判定
高位扩展法的指针位宽比地址位宽多 1 位,用这一位的不同来编码“圈数”。当写指针和读指针完全相等时,说明 FIFO 为空;当写指针的最高位与读指针最高位相反、低几位相同时,说明写指针恰好比读指针多走了一整圈,即 FIFO 满。
以下代码是完整的高位扩展空满判定:
reg [PTR_WIDTH-1:0] wr_ptr, rd_ptr; wire [ADDR_WIDTH-1:0] wr_addr = wr_ptr[ADDR_WIDTH-1:0]; wire [ADDR_WIDTH-1:0] rd_addr = rd_ptr[ADDR_WIDTH-1:0]; assign empty = (wr_ptr == rd_ptr); assign full = (wr_ptr[PTR_WIDTH-1] != rd_ptr[PTR_WIDTH-1]) && (wr_ptr[ADDR_WIDTH-1:0] == rd_ptr[ADDR_WIDTH-1:0]);判断空的逻辑很简单:两指针完全相等。判断满的条件则有两个,最高位不同意味着写指针已经比读指针多绕了一圈;低地址位相同意味着写指针和读指针正指向同一个 slot,而由于最高位不同,这个相同的地址是下一圈的位置,所以队列满。
这个方案的精妙之处在于,不需要额外的计数器,空满判断只与两个指针相关,不依赖读写并行与否。也就是说,即使 wr_en 和 rd_en 同时拉高,写指针和读指针各自独立递增,空满标志也是由指针的实际位置直接推导的,不存在“先加后减”的中间态问题。
3.3 组合逻辑输出标志 vs 寄存器输出标志
空满标志可以做成组合逻辑直接输出,也可以打一拍寄存。组合逻辑输出的优点是实时性好,写使能在时钟沿被采样的同时,full 信号可以立即反映下一拍还能不能写;缺点是当指针变化沿与标志产生沿混在同一个组合回路里时,可能出现窄脉冲毛刺。在同步 FIFO 里,这两个信号通常要送到外部模块的时序逻辑采样,所以组合输出即便出现亚稳态窗口,只要在下一个时钟沿采样时已经稳定就问题不大。但更稳妥的做法是将其寄存一拍,牺牲一个周期的即时性换取时序上的干净。
我在某数据采集项目里吃过大亏:直接把组合 full 信号拉出来作为中断标志送给 CPU 侧逻辑,结果在满与不满切换的瞬间产生了 1ns 量级的毛刺,CPU 那边的寄存器正好在毛刺时采样,误判成 FIFO 满,导致一帧数据被丢弃。从那之后,凡是送给外部模块的空满标志,我都打了一拍寄存器输出,内部数据通路如果是同步采样则直接用组合逻辑标志,因为采样沿会对齐,毛刺会被彻底滤掉。
reg empty_r, full_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin empty_r <= 1'b1; full_r <= 1'b0; end else begin empty_r <= empty; full_r <= full; end end要注意 empty_r 和 full_r 都滞后组合标志一个周期。使用寄存器版标志时,写满后还能再写一个数据,这个“额外写入”是否允许,取决于协议定义。我在下面的完整代码里默认使用组合标志作为读写使能的钳制条件,这样时序最贴近教科书模型。
4. 完整代码逐段拆解:写指针、读指针与数据输出时刻的博弈
4.1 完整 RTL 代码
把上面的设计整合成一个完整、可综合的模块:
module sync_fifo #( parameter DATA_WIDTH = 8, parameter DEPTH = 8 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] din, input wire rd_en, output reg [DATA_WIDTH-1:0] dout, output wire empty, output wire full ); 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; wire [ADDR_WIDTH-1:0] wr_addr = wr_ptr[ADDR_WIDTH-1:0]; wire [ADDR_WIDTH-1:0] rd_addr = rd_ptr[ADDR_WIDTH-1:0]; // 分为两段:第一段为指针更新 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin wr_ptr <= {PTR_WIDTH{1'b0}}; end else if (wr_en && !full) begin wr_ptr <= wr_ptr + 1'b1; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rd_ptr <= {PTR_WIDTH{1'b0}}; end else if (rd_en && !empty) begin rd_ptr <= rd_ptr + 1'b1; end end // 写入与读出 always @(posedge clk) begin if (wr_en && !full) mem[wr_addr] <= din; end always @(posedge clk or negedge rst_n) begin if (!rst_n) dout <= {DATA_WIDTH{1'b0}}; else if (rd_en && !empty) dout <= mem[rd_addr]; end // 空满判断 assign empty = (wr_ptr == rd_ptr); assign full = (wr_ptr[PTR_WIDTH-1] != rd_ptr[PTR_WIDTH-1]) && (wr_ptr[ADDR_WIDTH-1:0] == rd_ptr[ADDR_WIDTH-1:0]); endmodule这段代码每次读写操作都比真实的数据存取晚一拍。对于写端口,数据din在wr_en拉高的时钟沿写入mem[wr_addr];对于读端口,dout在rd_en拉高的下一个时钟沿才更新。这样设计带来一个特点:读使能拉高时,当前dout还是上一次的数据,下一个时钟沿才会变成新读出的数据。
4.2 为什么选择寄存器输出而不是组合输出
读数据有两种常见形式:
// 方式A:组合输出 // assign dout = mem[rd_addr]; // 方式B:寄存器输出 // always @(posedge clk) dout <= mem[rd_addr];组合输出的最大问题是,rd_ptr 变化后 dout 立即变化,不再与时钟沿对齐。下游如果是一个时序逻辑直接采dout,必须在 rd_en 拉高后的下一个时钟沿之前数据保持稳定,但组合逻辑的延时和布线抖动可能导致建立时间不够。寄存器输出则把数据变化严格对齐到时钟沿,时序分析更容易通过,但代价是读延迟增加一拍。
实际工程里我会看下游模块的时序要求。如果下游模块只要在 rd_en 拉高后的下一拍采样数据,寄存器输出最省心;如果下游模块要求 rd_en 和数据出现在同一拍(比如某些 AXI 接口),那组合输出更合适,但需要手工加约束并做后仿真确认。
4.3 写满后继续写会发生什么
wr_ptr更新条件是wr_en && !full,所以 FIFO 满时,即使wr_en保持拉高,指针不移动,数据也不会写入mem。这是标准 FIFO 的行为。如果做的是“覆盖写”FIFO,也就是满时新数据覆盖最旧数据,需要额外修改读指针或采用环形缓冲加计数器的方案,不建议在这个模块上插一脚。
读空后继续读的情况类似:rd_en保持拉高但empty为 1,指针不动,dout保持上一次的值。这个“读空保持”特性在使用时要特别注意,因为下游可能误把残余数据当作有效数据。最稳妥的做法是读出端再增加一个 valid 信号,但为了保持协议简单,第一版 FIFO 不做这个扩展,留给后续版本。
5. testbench三场景实战:从空到满、满到空与读写同时进行的边界测试
5.1 场景一:连续写入直到满,再连续读出直到空
最基础的测试是从空状态连续写 8 个数据,检查每个周期 full 信号的变化;再从满状态连续读 8 个数据,检查 empty 信号的变化。
`timescale 1ns/1ps module tb_sync_fifo; parameter DATA_WIDTH = 8; parameter DEPTH = 8; reg clk, rst_n; reg wr_en, rd_en; reg [DATA_WIDTH-1:0] din; wire [DATA_WIDTH-1:0] dout; wire empty, full; sync_fifo #(.DATA_WIDTH(DATA_WIDTH), .DEPTH(DEPTH)) dut ( .clk(clk), .rst_n(rst_n), .wr_en(wr_en), .din(din), .rd_en(rd_en), .dout(dout), .empty(empty), .full(full) ); initial clk = 0; always #5 clk = ~clk; integer i; initial begin rst_n = 0; wr_en = 0; rd_en = 0; din = 0; @(posedge clk); rst_n = 1; // 连续写 8 个数据,验证 full 在第 8 个周期后拉高 for (i = 0; i < DEPTH; i = i + 1) begin @(posedge clk); wr_en = 1; din = i + 1; end @(posedge clk); wr_en = 0; #1; if (full !== 1'b1) $display("FAIL: full should be 1"); // 连续读 8 个数据,验证 empty 在第 8 个周期后拉高 for (i = 0; i < DEPTH; i = i + 1) begin @(posedge clk); rd_en = 1; end @(posedge clk); rd_en = 0; #1; if (empty !== 1'b1) $display("FAIL: empty should be 1"); #50; $finish; end endmodule注意这里我用了#1延时后检测信号,是为了避开时钟沿附近 combinational 输出的亚稳态窗口。如果你直接用@(posedge clk)后的 immediately 值,可能在同一个时刻采样到旧值,产生假 FAIL。
5.2 场景二:读写同时进行,验证空满不误判
读写同时进行的测试最容易暴露判断逻辑的错误。假设 FIFO 从空开始,每个周期同时写入和读出,那么队列永远只有 0 个或 1 个数据,full 不应该拉高,empty 在写入数据后的一拍内应该拉低。这种场景用来验证空满标志是否被“本来可以写却误判为满”“本来可以读却误判为空”的边界条件带偏。
我的测试策略是在前 4 个周期只写不读,让 FIFO 变为 4 个数据;然后连续 10 个周期同时写读,此时 FIFO 内部数据应该保持 4 个不变,full 始终为 0,empty 始终为 0。如果某一个周期 full 意外拉高,说明高位扩展判断逻辑在 wr_ptr 与 rd_ptr 恰好相差一圈时出了错。
5.3 场景三:随机读写的终极压力测试
前面两道测试都是固定模式,压制不了隐蔽问题。我习惯再加一轮随机读写测试:每个周期用简单伪随机数决定 wr_en 和 rd_en 的取值(也可以用 Verilog 的$random),同时用独立软件模型模拟一个理想 FIFO,逐周期对比理想 FIFO 的 empty/full/dout 与 DUT 是否一致。
reg [DATA_WIDTH-1:0] ref_mem [0:DEPTH-1]; reg [ADDR_WIDTH-1:0] ref_wr, ref_rd; reg ref_empty, ref_full; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin ref_wr <= 0; ref_rd <= 0; end else begin if (wr_en && !ref_full) begin ref_mem[ref_wr] <= din; ref_wr <= ref_wr + 1'b1; end if (rd_en && !ref_empty) begin ref_rd <= ref_rd + 1'b1; end if ((wr_en && !ref_full) && !(rd_en && !ref_empty)) ref_cnt <= ref_cnt + 1'b1; else if (!(wr_en && !ref_full) && (rd_en && !ref_empty)) ref_cnt <= ref_cnt - 1'b1; else ref_cnt <= ref_cnt; end end assign ref_empty = (ref_cnt == 0); assign ref_full = (ref_cnt == DEPTH);用 ref_cnt 模型去对拍高位扩展法的 DUT,本质是引入了“第三种实现”做交叉验证。实际运行 10 万个随机周期的对拍,哪个周期空满标志或 dout 不匹配,就可以定位到具体输入序列,再用单独 testbench 复现。这个方法帮我抓到过一个几乎不可见的 bug:复位释放后前几个周期内 dout 有 X 态。
6. 综合实现与常见坑点:满标志打拍、数据有效窗口与复位释放时序
6.1 满标志打拍后带来的“再写一个”问题
如果你把 full 寄存器输出,写满后 full 拉高的时刻实际上晚了一个周期。在这一个周期里,外部逻辑可能已经再次拉高 wr_en,而 RTL 里的写条件用的是!full,此时 full 还是旧值 0,于是又写入了一个数据。这个行为在某些协议中是致命的,因为 FIFO 深度明明是 8,却最多能装 9 个数据。
解决办法之一是独立生成一个内部组合 full 作为写钳制,寄存器 full 只送给外部做状态上报。在上面的完整代码里,我对外只利用组合 full 进行写保护,因此不存在这个问题。但如果你参考其他代码风格看到full_r直接进wr_en条件,需要格外小心它的“一个周期的宽容窗口”。
6.2 读数据有效窗口:下游采样时的“老数据还魂”
寄存器输出的dout在 rd_en 拉高后的一拍才更新,这一拍之内如果下游模块误采样,会拿到上一轮的数据。很多初学者在调试时发现“为什么我读出来的第一个数据是复位值 0,第二个才是真正数据”,就是因为没有把读延迟考虑进时序。
如果下游要求 rd_en 与 dout 严格同步,可选的方案是改回组合输出,但要加上适当的时序约束。还有第三种方案是输出端带回读有效信号:
assign rd_valid = rd_en && !empty;下游在rd_valid为高时采样,就不会被残余值误导。这是我比较推荐的标准做法。
6.3 复位释放与时钟沿的竞争
异步复位虽然用起来简单,但复位释放不能随意交叉在时钟沿上。推荐采用异步复位同步释放的结构,否则释放时如果恰逢时钟上升沿,指针可能从复位态跳到一个不确定值,导致空满标志出错。
reg rst_n_r1; reg rst_n_r2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rst_n_r1 <= 1'b0; rst_n_r2 <= 1'b0; end else begin rst_n_r1 <= 1'b1; rst_n_r2 <= rst_n_r1; end end wire rst_n_sync = rst_n_r2;把内部模块的复位信号从rst_n换成rst_n_sync,可以避免复位释放与时钟沿竞争。对 FIFO 这种小模块来说,复位释放毛刺会导致指针初始状态异常,空满判断全面崩溃,所以这个细节务必处理好。
6.4 综合时的工作量评估
深度 8、位宽 8 的同步 FIFO,综合后 LUT 用量通常不到 50,FF 用量主要来自指针寄存器、dout 寄存器和存储体。资源占用极小,但它在时序上的意义远大于资源意义。做布局布线时序分析时,主要观察 wr_ptr 和 rd_ptr 的路径延迟,因为它们会驱动 mem 的地址端口和空满比较器。
从代码可读性的角度,我更推荐用localparam把地址位宽和指针位宽分开定义,不要写死“3 位”之类的魔数。某同事在某音视频处理项目中把 FIFO 深度从 8 改成 512 时,因为代码里到处是写死的2'b拼接和3'd7比较,改了整整三天才全对。参数化设计从第一版开始就养成习惯,后面的大深度扩展才会轻松。
7. 从同步8深度到下一步:这个模块还能怎么演进
写到这里,同步 FIFO 的基础已经完整闭环:存储体、读写指针、空满判定、testbench 验证、综合注意事项,该踩的坑也都提了。整个模块在标准流程下大约 30 行 RTL 代码,花一个晚上就能写出来并跑通仿真,但它背后涉及的指针绕圈、边界判断、打拍策略,是所有 FIFO 设计的通用骨架。
系列后续如果要继续深入,方向有三个比较值得做。第一是异步 FIFO,把读写指针各自用格雷码转换再跨时钟域打拍,空满判断变成“读指针采写指针”“写指针采读指针”,这是你开始真正理解跨时钟域设计的起点;第二是 FWFT(First Word Fall Through)模式,把读延迟压缩到 0,在高速 AXI 接口中很常见;第三是附加功能,比如 almost_full 预警水印、可编程满阈值、数据打包拆分格式转换,这些都是现代 SoC 设计中 FIFO 常见的复杂形态。
如果你正在写自己的第一个 FIFO,建议先把同步 8 深度的代码敲三遍:第一遍照着敲,第二遍不看代码默写,第三遍用计数器法重新实现再对拍。等你能解释清楚为什么高位扩展法不需要计数器也能判断空满时,异步 FIFO 的设计门就算真正推开了。