同步FIFO Verilog模板:计数器法与指针法两种实现对比精讲
更新时间:2026-10-29。这是一篇「拿来即用」的模板文:同步 FIFO(读写同一个时钟的先进先出缓冲)给你两份完整实现——计数器法和指针法,讲清各自怎么判空满、什么场景选哪个,以及抄走之后改参数、加水位标志该动哪里。FIFO 的概念与空满判据原理在 FIFO 与仲裁器基础 里讲过,本篇专注工程形态;读写在不同时钟域的场合请改用 异步 FIFO 与 CDC 设计 里的跨时钟结构,本篇模板不适用。
本文要回答的问题
- 什么场景该用同步 FIFO,它和异步 FIFO 的分界线在哪?
- 计数器法和指针法各怎么判空判满,代码差在哪?
- 深度不是 2 的幂时,两种做法谁还能用?
- 满的时候同拍又来写又来读,应该怎么处理?
- 想加 almost_full / almost_empty 水位标志怎么改?
先把两份实现的关键差异摆在一起,后面逐段展开:
读这张卡抓一条主线:两种实现对外行为完全一样(后文的随机压力对拍就是两个模块吃同一组激励、逐拍比对输出),差别全在内部怎么记住「队列里现在有几个数」——计数器法用一个显式的计数器,指针法靠读写指针之间多出来的一位「圈数」。
一、什么时候用同步 FIFO
同步 FIFO 的职责是在同一个时钟域内做缓冲与解耦:上游突发来数、下游匀速消费(比如打包逻辑攒够一个总线拍再发);或者两个流水段吞吐率不匹配,中间垫一层吸收抖动。典型落点:DMA 描述符队列、MAC 层包缓冲、流水线级间弹性寄存。
分界线就一条:写侧和读侧是不是同一个时钟。同钟用本篇模板;不同钟必须换异步 FIFO——指针要格雷码化、过两级同步器,空满判断整套换掉,直接抄同步版本过去会撞亚稳态。异步版本的概念铺垫见 为什么多比特信号不能用两级触发器直接同步。
二、实现一:计数器法(深度任意)
思路直白:维护一个占用计数器 cnt,写加一读减一,cnt==0 为空、cnt==DEPTH 为满。读写指针只负责寻址,到达末地址手动回绕——所以深度可以是 12、100 这种任意值。
// 同步 FIFO 模板(计数器法):DEPTH 可为任意正整数
module sync_fifo_count #(
parameter WIDTH = 8,
parameter DEPTH = 16 // 任意正整数,不要求 2 的幂
)(
input wire clk,
input wire rst_n,
input wire wr_en,
input wire [WIDTH-1:0] wr_data,
output wire full,
input wire rd_en,
output wire [WIDTH-1:0] rd_data,
output wire empty,
output wire [$clog2(DEPTH):0] count
);
localparam CW = $clog2(DEPTH); // 地址位宽
localparam [31:0] LAST32 = DEPTH - 1; // 末地址(比较时切片)
reg [WIDTH-1:0] mem [0:DEPTH-1];
reg [CW-1:0] wptr, rptr; // 地址指针,到达末地址手动回绕
reg [CW:0] cnt; // 占用计数器,0..DEPTH
// 写放行条件:未满;或满但同拍有读(读腾出格子,写补上,保持满)
wire wr_advance = wr_en & (~full | rd_en);
wire rd_advance = rd_en & ~empty;
assign full = (cnt == DEPTH[CW:0]);
assign empty = (cnt == 0);
assign count = cnt;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wptr <= {CW{1'b0}};
end else if (wr_advance) begin
mem[wptr] <= wr_data;
wptr <= (wptr == LAST32[CW-1:0]) ? {CW{1'b0}} : wptr + 1'b1;
end
end
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rptr <= {CW{1'b0}};
end else if (rd_advance) begin
rptr <= (rptr == LAST32[CW-1:0]) ? {CW{1'b0}} : rptr + 1'b1;
end
end
assign rd_data = mem[rptr];
// 计数器:同时读写时不变
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cnt <= 0;
end else begin
case ({wr_advance, rd_advance})
2'b10: cnt <= cnt + 1'b1;
2'b01: cnt <= cnt - 1'b1;
default: cnt <= cnt; // 00 无操作;11 一进一出抵消
endcase
end
end
endmodule三个要点。第一,cnt 比地址多一位:DEPTH=16 时地址 4 bit 够 0..15,但计数要表达到 16,必须 5 bit。第二,同时读写的 2'b11 分支里 cnt 不变——一进一出抵消,这是「满时并发读写保持满」的算术基础。第三,wr_advance 里的 ~full | rd_en:满的时候如果同拍有读,读会腾出一个格子,这一拍的写放行,FIFO 保持满、吞吐不掉。没有这个分支,满载时每读一个都要空转一拍才能补写,突发场景吞吐减半。
三、实现二:指针法(2 的幂深度)
指针法不要计数器:读写指针各多存一位「折返位」(第 AW+1 位),指针自然回绕不用判断。空是两个指针完全相等;满是「写指针比读指针多走一整圈」——展开成最高位相反、其余位相同。
// 同步 FIFO 模板(指针法):深度 = 2**AW,指针多 1 bit 区分满与空
module sync_fifo_ptr #(
parameter WIDTH = 8,
parameter AW = 4 // 深度 = 2**AW,必须是 2 的幂
)(
input wire clk,
input wire rst_n,
input wire wr_en,
input wire [WIDTH-1:0] wr_data,
output wire full,
input wire rd_en,
output wire [WIDTH-1:0] rd_data,
output wire empty
);
localparam DEPTH = (1 << AW);
reg [WIDTH-1:0] mem [0:DEPTH-1];
reg [AW:0] wptr, rptr; // 多 1 bit 折返位
wire wr_advance = wr_en & (~full | rd_en);
wire rd_advance = rd_en & ~empty;
assign empty = (wptr == rptr);
assign full = (wptr == {~rptr[AW], rptr[AW-1:0]});
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wptr <= {AW+1{1'b0}};
end else begin
if (wr_advance)
mem[wptr[AW-1:0]] <= wr_data;
wptr <= wptr + {{AW{1'b0}}, wr_advance};
end
end
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rptr <= {AW+1{1'b0}};
end else begin
rptr <= rptr + {{AW{1'b0}}, rd_advance};
end
end
assign rd_data = mem[rptr[AW-1:0]];
endmodule判满那一行值得拆开看:{~rptr[AW], rptr[AW-1:0]} 是「读指针最高位取反、低位不动」。写指针等于这个值,意味着两指针低位(地址部分)重合、圈数差 1——写比读整整多绕一圈,存储体每个格子都有数,这就是满。这个「多一位指针」的玩法与异步 FIFO 的满空判定同源,区别只在异步版要先把指针格雷码化再过同步器。资源上它省掉了计数器和加减器,综合后时序也更容易收敛,代价是深度被钉死在 2 的幂——指针自然回绕点就是判据的一部分,深度 12 时回绕点错位,判据直接失效。
四、验证:lint + 双 DUT 随机对拍
两份代码先过 Verilator 最严格档 --lint-only -Wall(零警告零错误),再用 Icarus Verilog 跑一个四阶段测试平台:写满判满、读空判空、填满后连续 8 拍并发读写确认满保持、最后 2000 拍随机读写——关键手法是两个模块吃完全相同的激励,记分板逐拍比对两边的读出数据和空满标志,任何实现差异都会当场现形。
实录里几个关口都在:两份实现 lint 各自零警告;阶段 1 写满 16 次后 full 恰好拉高、满时单独写被拒绝;阶段 3 的 8 拍并发读写中 full held ... count=16,正是第二节 ~full | rd_en 放行逻辑的行为;阶段 4 随机压力 2000 拍、记分板对拍 1344 次传输 TEST PASSED: 0 errors。两个实现全程输出一致,「功能等价」不是嘴上说说的。
五、使用注意事项
- 满空边界的并发读写要想清楚再抄。本模板的口径是「满时同拍有读则写放行」;如果你的下游不允许这种直通,把
wr_advance改回wr_en & ~full即可,但要知道满载吞吐会掉。 - 指针法深度必须是 2 的幂,且 AW≥1(判满要用到最高位)。计数器法无此限制,但 DEPTH=1 时 $clog2 为 0、指针退化,工程上深度至少取 2。
- rd_data 是组合读出:rd_en 拉高那一拍数据已在
rd_data上。后端如果接同步 RAM(读数据晚一拍),要把 mem 换成 RAM IP 并在读路径上加一级寄存,同时把 rd_en 提前一拍产生。 - 复位期间禁读写。两版都在
rst_n拉低时清指针与计数器;复位释放后第一拍起标志即有效(empty=1)。 - 空时读、满时无读伴随的写都会被静默拒绝,这是 FIFO 的标准行为;上游必须看 full/empty 握手,不能假设使能一定被接受。
六、修改示范:加 almost_full / almost_empty
水位标志给上游「快满了早做准备」的提前量。计数器法最简单,直接拿 cnt 比阈值:
parameter AF_LEVEL = DEPTH - 4; // 距满还差 4 格拉高
parameter AE_LEVEL = 4; // 只剩 4 格拉高
assign almost_full = (cnt >= AF_LEVEL);
assign almost_empty = (cnt <= AE_LEVEL);指针法没有现成的 cnt,用减法补一个:wire [AW:0] used = wptr - rptr;(多出来的折返位让减法天然正确,无需处理回绕),再 assign almost_full = (used >= AF_LEVEL);。两种写法都对,选哪份模板就配哪种写法。
实验环境
- 机器/系统:Mac mini M4 / macOS 15(Verilator 在 geek-eda:0.2 docker 镜像中运行)
- 工具版本:Verilator 5.020(
verilator --version输出Verilator 5.020 2024-01-01)/ Icarus Verilog 13.0(iverilog -V输出Icarus Verilog version 13.0 (stable) (v13_0)) - 日期:2026-10-29
- 完整命令:
docker run --rm -v <目录>:/work -w /work geek-eda:0.2 verilator --lint-only -Wall sync_fifo_count.v(指针法同命令换文件名);iverilog -g2012 -o tb_sync_fifo.vvp tb_sync_fifo.v sync_fifo_count.v sync_fifo_ptr.v;vvp tb_sync_fifo.vvp
代码获取
本文全部代码(两份 FIFO 模板与四阶段自校验测试平台)已同步到公开仓库 geek-doc.cn/experiments/tpl-sync-fifo-verilog,克隆后按 README 里的命令即可复现 lint 与仿真结果。
这一篇在知识体系里的位置
本篇是芯片设计入门簇中「FIFO」节点的同步模板页:空满判据的原理讲解见 FIFO 与仲裁器基础,单实现跑 iverilog 回归的对照实验见 同步 FIFO 的 iverilog 实验,读写跨时钟域时换 异步 FIFO 与 CDC 设计 的格雷码指针结构,深度规划的经验讨论见 FIFO 深度是不是越大越好。
总结
同步 FIFO 两种实现功能等价、对外接口一致:计数器法用一个显式 cnt 直出空满,深度任意、最不易写错;指针法让指针多带一位折返位,靠「最高位相反其余相同」判满,省资源但深度钉死在 2 的幂。两份模板都支持满时同拍读写保持满,已过 lint 最严档并经 2000 拍随机对拍 1344 次传输零错误验证,复制即用;加水位标志按第六节示范改几行即可。唯一红线:深度不是 2 的幂时别用指针法。