☰
同步FIFO设计详解:Verilog实现深度8数据缓冲与空满判断
2026/10/17 2:47:28 网站建设 项目流程

先说个建议:初学 Verilog 的人想搞懂 FIFO,别一上来就碰异步 FIFO 和跨时钟域,先把同步 FIFO 打通。同步 FIFO 听起来简单,但存储阵列、读写指针、空满判断这几样数字逻辑的核心要素它全占了。你把它彻底搞明白,后面学异步 FIFO 就是在它基础上多加几个跨时钟域处理模块而已。我自己带项目时也常用 FIFO 做数据缓冲和速率匹配,属于真正的高频组件。

这篇就实现一个深度为 8 的同步 FIFO,数据宽度取 8 位。深度 8 意味着最多存 8 个数据条目,宽度 8 就是每个条目 8 bit。选深度 8 是因为边界逻辑最简,方便把每个信号都观察到。参数化成 16、32、64 也就是改一个数字的事。

1. FIFO 基础概念与功能拆分

1.1 FIFO 到底是什么

FIFO 的全称是 First In First Out,先进先出。拿食堂排队打饭来理解最直接:先站到队伍里的人先打到饭先走,后面来的只能排在后面。数字电路里的 FIFO 就是一个能做这种“排队”功能的存储结构。

之所以需要它,本质上是因为两个模块之间的数据节奏对不上。我举个最常见的场景:CPU 侧突发写一批数据,外设这边慢慢消化,中间没有一个缓存的话,CPU 就得一直等着外设,整体吞吐全被拖死。在中间放一个 FIFO,CPU 把数据一股脑倒进去就干别的事去了,外设按自己的节奏慢慢读,两边互不拖累。另一个例子是串口和高速总线之间的速率差异,差了上百倍,没有 FIFO 基本没法干活。

从这个角度看,FIFO 就是一个数据蓄水池,解决的是速率失配和突发吸收的问题。

1.2 同步 FIFO 与异步 FIFO 的适用边界

同步 FIFO 的读写共用一个时钟,典型场景是同一个时钟域内的流水线缓冲、总线缓冲。异步 FIFO 的读写时钟不同,典型场景是两个独立时钟域模块之间的数据交汇。

为什么先学同步?因为同步 FIFO 里最自然的那套“计数器判断空满”的思路,在异步场景下完全不能用。但先把同步的时序模型和存储结构吃透,再去理解异步那套格雷码、两级同步的做法,会顺很多。下面这个对比表可以先建立整体概念。

对比项同步 FIFO异步 FIFO
时钟单时钟双时钟
空满判断用计数器比较读写指针通过格雷码跨时钟域比较
计数器能否使用能用,且很方便不能,计数器无法被两个时钟域同时可靠采样
典型场景同域缓冲跨时钟域数据传递
学习难度入门进阶

这篇文章先把同步 FIFO 做扎实,异步部分后续单写。

1.3 同步 8 位深度 FIFO 的功能定义

动手写代码前,先把功能需求明确下来:

  • 复位:异步复位,低有效,复位后 FIFO 为空
  • 写操作:wr_en 为高且 FIFO 未满时,在时钟上升沿把 din 写入存储体
  • 读操作:rd_en 为高且 FIFO 未空时,在时钟上升沿把数据更新到 dout
  • 满标志:FIFO 中数据个数等于深度 8 时,full 拉高
  • 空标志:FIFO 中数据个数为 0 时,empty 拉高
  • 同时读写:FIFO 内数据个数保持不变

这里面有一个容易忽略的点:写满后继续写必须丢弃,读空后继续读必须保持 dout 不变。这两个边界行为直接决定后续代码怎么写,后面我会重点讲。

2. 同步 FIFO 核心设计思路

2.1 三要素:存储体、读写指针、计数器

同步 FIFO 的 RTL 逻辑可以拆成三块:

  1. 存储体:一个宽度 DATA_WIDTH、深度 DEPTH 的数组,硬件上对应 RAM 或寄存器堆。
  2. 读写指针:写指针 w_ptr 指向下一次写入的位置,读指针 r_ptr 指向下一次读出的位置。
  3. 计数器:记录当前 FIFO 里有多少个有效数据。

用生活里的类比,存储体就是车站的一排储物柜,w_ptr 是管理员心里记着的“下一个空柜号”,r_ptr 是“下一个要取的柜号”,count 是“现在还有多少个柜子被占用”。每存一件东西,占用数加一、空柜号加一;每取一件,占用数减一、柜号加一;同时存取,占用数不变,两个柜号各自加一。

这三个东西配合起来,FIFO 的基本行为就完整了。

2.2 空满判断的两种主流方案

方案 A:计数器法。用一个计数器 count 表示当前数据个数,count == 0 判空,count == DEPTH 判满。这个方法逻辑直观,状态一眼能看懂,本次实现就采用它。

方案 B:扩展指针法。指针比寻址多一位,比如深度 8 用 4 位指针。判空用 w_ptr == r_ptr,判满用最高位不同、其余位相同。这是异步 FIFO 的基础做法,因为这种判断方式不需要计数器的绝对数值,只比较指针关系,天然适合跨时钟域比较。

两种方案对比如下。

对比项计数器法扩展指针法
逻辑复杂度低稍高
空满状态可读性直接看 count看指针关系
能否用于异步 FIFO不能能
综合面积小略大

为什么同步 FIFO 首选计数器?因为读写同频,计数器不存在跨时钟域采样问题,逻辑最简单、最容易排查。扩展指针法是异步 FIFO 的底子,这一篇先不展开。

2.3 为什么计数器位宽必须是地址位宽加一

这是一个非常容易踩的坑,我初学时就栽过。深度 8 的 FIFO,存储地址范围是 0 到 7,地址指针用 3 位就够。但计数器要表示的数值范围是 0 到 8,一共 9 个值,3 位最大只能表示 7,根本到不了 8。

如果你把 count 定义成 3 位,仿真里写入第 8 个数据时 count 会从 7 回卷到 0,满标志永远拉不起来,看起来像时序错乱,实际上就是位宽不够。

正确做法是:

localparam ADDR_WIDTH = $clog2(DEPTH); reg [ADDR_WIDTH:0] count;

$clog2(8) 等于 3,所以 count 是 4 位。如果换 DEEPTH 为 9,$clog2(9) 等于 4,count 就需要 5 位。现代综合工具和仿真器基本都支持 $clog2,个别老工具不支持的话,可以用常规的移位计数函数替代。

3. Verilog 代码实现与解析

3.1 参数化的模块端口定义

端口设计如下。

信号名方向位宽说明
clkinput1时钟
rst_ninput1异步复位,低有效
wr_eninput1写使能
rd_eninput1读使能
dininputDATA_WIDTH写入数据
doutoutputDATA_WIDTH读出数据,寄存器输出
fulloutput1满标志
emptyoutput1空标志

DATA_WIDTH 和 DEPTH 都做成 parameter,这样同一个模块可以复用到不同场景。比如深度改成 256、宽度改成 32,只需要改例化参数。

3.2 完整的 RTL 代码

直接上完整代码,可综合。

module fifo_sync #( parameter DATA_WIDTH = 8, parameter DEPTH = 8 )( input wire clk, input wire rst_n, input wire wr_en, input wire rd_en, input wire [DATA_WIDTH-1:0] din, output reg [DATA_WIDTH-1:0] dout, output wire full, output wire empty ); localparam ADDR_WIDTH = $clog2(DEPTH); // 存储体 reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; // 读写指针 reg [ADDR_WIDTH-1:0] w_ptr; reg [ADDR_WIDTH-1:0] r_ptr; // 计数器,位宽必须是地址位宽加一 reg [ADDR_WIDTH:0] count; // 有效读写信号 wire wr_valid = wr_en & ~full; wire rd_valid = rd_en & ~empty; // 空满标志 assign empty = (count == 0); assign full = (count == DEPTH); // 写指针 always @(posedge clk or negedge rst_n) begin if (!rst_n) w_ptr <= {ADDR_WIDTH{1'b0}}; else if (wr_valid) w_ptr <= w_ptr + 1'b1; end // 写数据 always @(posedge clk) begin if (wr_valid) mem[w_ptr] <= din; end // 读指针 always @(posedge clk or negedge rst_n) begin if (!rst_n) r_ptr <= {ADDR_WIDTH{1'b0}}; else if (rd_valid) r_ptr <= r_ptr + 1'b1; end // 读数据 always @(posedge clk or negedge rst_n) begin if (!rst_n) dout <= {DATA_WIDTH{1'b0}}; else if (rd_valid) dout <= mem[r_ptr]; end // 计数器 always @(posedge clk or negedge rst_n) begin if (!rst_n) count <= {ADDR_WIDTH+1{1'b0}}; else begin case ({wr_valid, rd_valid}) 2'b10: count <= count + 1'b1; 2'b01: count <= count - 1'b1; default: count <= count; endcase end end endmodule

代码不长,但每个部分都有讲究,下面逐段拆开讲。

3.3 逐段解析关键逻辑

先看内部信号的抽取:

wire wr_valid = wr_en & ~full; wire rd_valid = rd_en & ~empty;

这一步是代码清晰的关键。后面所有逻辑只要判断“到底能不能写”“到底能不能读”,都用这两个信号。直接使用 wr_en 的话,每一处都要再叠加满标志判断,容易漏,也容易写错。

再看空满标志:

assign empty = (count == 0); assign full = (count == DEPTH);

这里没有用组合逻辑去比较指针,而是直接比较计数器值。注意 count 是 4 位,DEPTH 是 8,比较时工具会自动做位宽对齐,不会出错。

读数据这部分值得单独讲一下:

else if (rd_valid) dout <= mem[r_ptr];

dout 是寄存器输出。标准 FIFO 的读时序是“rd_en 有效后的下一个时钟上升沿,dout 输出数据”,寄存器输出能把时序卡得很干净,不会出现组合逻辑毛刺。如果在设计里看到 dout 用 wire 连续赋值(类似于 FWFT 模式,数据提前可见),那属于另一种接口风格,时序约束要额外小心,这里采用的标准模式是绝大多数 FIFO IP 的默认模式。

计数器这段是最见功力的地方:

case ({wr_valid, rd_valid}) 2'b10: count <= count + 1'b1; 2'b01: count <= count - 1'b1; default: count <= count; endcase

写入且未满时 count 加一,读且未空时 count 减一,同时读写时 count 保持不变。default 分支把 2'b11 和 2'b00 都覆盖到了。这个写法比两个 if 拼起来的优先级逻辑要清晰得多,也不会出现同时读写时的竞争。

另外注意,count 为 0 时 rd_valid 必然为 0,count 为 DEPTH 时 wr_valid 必然为 0。所以 count == 0 且 rd_valid 为 1 的情况根本不会出现,边界逻辑不会冲突。

3.4 综合后的硬件形态与 RAM 推断

这样一份代码综合出来是什么电路?一小块 RAM、两个计数器、一个加一减一逻辑、一个比较器,还有几个空的组合逻辑门。存储体的深度只有 8,在 FPGA 上一般会被映射成 LUTRAM 或分布式 RAM,如果深度做到 1K 以上,工具会自动推断成 block RAM。

RAM 推断是否成功,可以看综合报告里的相关提示。要注意的是,读逻辑如果写成了下面这样:

always @(posedge clk) dout <= mem[r_ptr];

也就是说,每个时钟周期无条件读取存储体并更新 dout,这样虽然也能仿真出存储效果,但在 FIFO 场景下会让读空后的 dout 自己乱变。原因后面第五部分会详细展开。为了保证行为正确又方便 RAM 推断,写读逻辑时必须把 rd_valid 条件加上,本质上就是让读地址只在有效读操作时才推进,dout 逻辑用条件更新的写法即可。

4. 仿真验证与 Testbench 设计

4.1 测试场景怎么定

写测试之前先问一个问题:FIFO 的核心状态有哪几种?答案是空、部分、满三种。测试用例就是把这三种状态的读写行为全部覆盖,尤其要覆盖边界行为。

我习惯用下面这张表来管理测试场景。

测试场景操作期望结果
复位拉起 rst_nempty 为 1,count 为 0
顺序写入连续写 4 个数据count 递增,写指针递增
混合读写写一个读一个count 保持不变
写满连续写 8 个数据full 为 1,写无效
满后继续写再给 wr_en数据被丢弃,count 仍为 8
顺序读空连续读 8 次empty 为 1,数据顺序正确
空后继续读再给 rd_endout 保持最后读出的值

把这张表跑完,FIFO 的主要行为就算验证扎实了。

4.2 一个能直接跑的测试平台

下面给出一份精简但完整的 testbench,用常见仿真器都能跑。这里用 iverilog 的写法,换成其他工具也只需要改机制部分。

module fifo_sync_tb; reg clk; reg rst_n; reg wr_en; reg rd_en; reg [7:0] din; wire [7:0] dout; wire full; wire empty; fifo_sync #( .DATA_WIDTH(8), .DEPTH (8) ) u_fifo ( .clk (clk), .rst_n (rst_n), .wr_en (wr_en), .rd_en (rd_en), .din (din), .dout (dout), .full (full), .empty (empty) ); always #5 clk = ~clk; initial begin $dumpfile("fifo_sync_tb.vcd"); $dumpvars(0, fifo_sync_tb); clk = 0; rst_n = 0; wr_en = 0; rd_en = 0; din = 8'h00; #20 rst_n = 1; // 复位检查 if (empty !== 1'b1) $error("reset: empty should be 1"); // 顺序写 4 个数据 repeat (4) begin @(posedge clk); wr_en = 1; din = din + 8'h10; end @(posedge clk); wr_en = 0; // 读 2 个数据 repeat (2) @(posedge clk) rd_en = 1; @(posedge clk); rd_en = 0; // 再写 4 个填满 repeat (4) begin @(posedge clk); wr_en = 1; din = din + 8'h10; end @(posedge clk); wr_en = 0; if (full !== 1'b1) $error("full should be 1 after 8 writes"); // 满后继续写,应该被丢弃 @(posedge clk); wr_en = 1; din = 8'hAA; @(posedge clk); wr_en = 0; if (full !== 1'b1) $error("full should remain 1 after over write"); // 连续读 8 次读空 repeat (8) @(posedge clk) rd_en = 1; @(posedge clk); rd_en = 0; if (empty !== 1'b1) $error("empty should be 1 after 8 reads"); // 空后继续读,dout 应保持 @(posedge clk); rd_en = 1; @(posedge clk); rd_en = 0; repeat (2) @(posedge clk); $finish; end endmodule

跑仿真的几条命令:

iverilog -o fifo_sync_tb.vvp fifo_sync.v fifo_sync_tb.v vvp fifo_sync_tb.vvp gtkwave fifo_sync_tb.vcd

如果你用的是 Vivado 或 Questa,创建工程后把这几个文件加进去,行为完全一致。仿真的关键是看 VCD 波形的时序边沿,而不是只看 $display 打印。

4.3 检查波形时容易忽略的时序细节

读数据有一拍延迟,这个要特别注意。标准模式同步 FIFO 中,rd_en 拉高后的第一个时钟上升沿,dout 才更新为当前读指针指向的数据。我见过不少人第一次看波形,发现 rd_en 拉高了但 dout 没立刻变,以为代码写错了,其实是读时序本身就是这样。

时刻rd_endout 行为
T0 上升沿前0dout 保持之前的值
T0 上升沿1内部捕获 mem[r_ptr]
T0 之后1dout 变为 mem[r_ptr]
T1 上升沿1r_ptr 已加一,dout 变为 mem[r_ptr+1]

看波形的时候,把 clk、wr_en、rd_en、w_ptr、r_ptr、count、dout 全部拉出来,按这个节奏对照,很快就能建立时序感。另外注意看写满和读空边界时 full、empty 跳变是否正确,以及满后写、空后读时的使能信号是否被正确屏蔽。

如果你追求更严谨的验证,可以在 testbench 里维护一个期望数据队列,每完成一次有效读操作之后比对 dout。真实项目中 FIFO 是一个频繁复用的基础模块,值得投资一个通用测试环境。

5. 常见问题与调试实录

5.1 满标志永远不拉高,先查计数器位宽

初学深度 8 的 FIFO 时,我把 count 定义成了 3 位。仿真里写入第 8 个数据时 count 从 7 回卷到 0,full 信号永远拉不起来。当时盯着波形看了很久,以为是写指针逻辑的问题,后来才发现是计数器位宽不足,3 位二进制最多表示 0 到 7,根本装不下 8 这个值。

所以一句话:count 位宽必须是地址位宽加一,即 $clog2(DEPTH) + 1。这个坑在参数换深度后更容易踩,把 DEPTH 改成 16 时,如果 count 还是 4 位,满标志会在数据量到 16 时回卷异常。调试这类问题,把 count 以十进制方式显示,检查是否能正确到达 DEPTH 值即可。

5.2 读空后 dout 自己乱跳

有一次朋友给我看他的 FIFO 代码,读逻辑写的是:

always @(posedge clk) dout <= mem[r_ptr];

仿真里读空之后把 rd_en 拉低,dout 竟然自己变成了下一个数据。原因是 r_ptr 停在最后一次读操作后的地址,而 dout 的 always 块无条件每个时钟周期采样存储体。只要 r_ptr 指向的那个地址有旧数据残留,dout 就会被不断刷新。

这个现象在实际系统里危害很大,下游可能因此误采样到一个“并不存在的有效数据”。修正方式很简单,把读数据逻辑改成只有 rd_valid 时才更新:

always @(posedge clk or negedge rst_n) begin if (!rst_n) dout <= 0; else if (rd_valid) dout <= mem[r_ptr]; end

行为对了,综合工具也能正常做 RAM 推断。

5.3 同一拍读写,dout 里到底是旧数据还是新数据

这个问题十个人里面至少有三个人会搞混。先给结论:同一拍写和读,dout 输出的是写操作之前读指针所指向的旧数据,不是这一拍刚写入的数据。

物理上的理解是这样的:写操作在时钟上升沿把 din 写入 mem[w_ptr],读操作在同一个时钟上升沿把 mem[r_ptr] 的旧内容捕获到 dout,两个操作互不干扰。即便 FIFO 里只有一个数据,这一拍同时写入一个数据,读到的仍然还是原来的那个数据。计数的 default 分支保证了同时读写时 count 不变,语义就是一个进一个出。

这个细节在接总线协议时尤其重要。比如写通道和读通道同时有效时,下游必须知道当前拍读到的数据对应的是更早拍次写入的内容,不能指望拿到最新写入的数据。

5.4 存储体要不要复位?建议不要

很多人写 FIFO 时习惯把所有 reg 都复位,存储体也用一个循环清零。这样做有两个问题。

一是复位代价大。在 FPGA 上给存储体加复位意味着每个存储单元都要连复位网络,资源占用明显增加,复位时间也变长。二是综合工具可能因此无法把存储体推断成 block RAM,转而展开成一大堆触发器,面积和布线压力直接上去。

FIFO 的复位只需要清指针和计数器,存储体内容不需要管。也正因为存储体不复位,仿真中未写入的地址是 X,此时如果你读到这些地址,说明你的设计违反了“空时不能读”的规则,这个 X 恰恰能帮你定位问题。

5.5 复位风格和后续扩展的一点提醒

代码里用了异步复位,但没有做复位同步释放。FPGA 工程里建议在顶层做一个复位同步器,把复位信号打两拍再进模块,避免复位释放时刻与时钟采样沿的竞争问题。这个属于常规工程习惯,和 FIFO 本身的逻辑没有直接关系,但集成到系统里时很值得注意。

另外要明确一点:这篇实现的同步 FIFO,读写时钟必须同源。如果要把 FIFO 数据送到不同的时钟域,直接用现在这套计数比较逻辑是不行的,需要看异步 FIFO 的格雷码方案。下一篇我准备写异步 FIFO,到时候用这套同步 FIFO 做参照,你会非常有感觉。

最后分享一个小技巧:调试 FIFO 出现问题,不要急着改代码,先手动画一张读写时序草图。我实际调 FIFO 的时候,手绘的时序图比仿真波形还有用。另一个习惯是把 count、w_ptr、r_ptr 这三个信号全放进波形窗口一起观察,很多“满标志早到”“读数据错位”的奇怪问题,一眼就能看出是计数器更新条件写错还是指针回卷没对齐。这个系列的第一篇就到这,下一篇异步 FIFO 见。

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

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

立即咨询