同步 FIFO Verilog 实现:iverilog 回归实测 148 次传输零错误
更新时间:2026-10-25。本文是「集成电路大类 · 芯片设计」入门层(beginner)的一篇实验文。理论篇 三个最常见基础模块:FIFO、仲裁器、握手 已经讲过 FIFO 的空满判据(指针多一位折返位),那一篇解决「为什么要这么设计」;本篇解决另一个问题——亲手写一份同步 FIFO,并用 iverilog 真跑回归证明它是对的。所有代码可下载、仿真日志原样保留,读者可以照着复现。
本文要回答的问题
- 一份可参数化的同步 FIFO,RTL 最小骨架长什么样?
- 空、满、almost_full、almost_empty 四个标志各有什么工程用途?
- 测试平台怎么做到「自校验」,不靠肉眼翻波形?
- 填满、排空、随机压力三阶段回归,实测结果到底过没过?
先给结论卡,全篇的数字都来自后文真实仿真日志:
这张卡片的四个数字分别对应:测试平台 scoreboard 统计的传输总数、最终错误计数、DUT(被测设计)参数、回归的阶段数。它们都可以在下文 termshot 实录的日志里逐一找到,不是示意数字。
一、同步 FIFO 的 RTL 最小骨架
同步 FIFO(读写同一个时钟)核心就三样:存储数组、写指针 wptr、读指针 rptr。本文实现选用「计数法」——used = wptr - rptr,指针比地址位宽多 1 bit,永不回绕归零,减法天然处理折返:
module sync_fifo #(
parameter DW = 8,
parameter AW = 3, // depth = 2**AW
parameter ALMOST = 2
)(
input wire clk,
input wire rst_n,
input wire wr_en,
input wire [DW-1:0] wr_data,
output wire full,
output wire almost_full,
input wire rd_en,
output reg [DW-1:0] rd_data,
output wire empty,
output wire almost_empty
);
localparam DEPTH = (1 << AW);
reg [DW-1:0] mem [0:DEPTH-1];
reg [AW:0] wptr, rptr; // 多 1 bit 折返位
wire [AW:0] used = wptr - rptr;
assign full = (used == DEPTH);
assign empty = (used == 0);
assign almost_full = (used >= DEPTH - ALMOST);
assign almost_empty = (used <= ALMOST);写逻辑只有一句守卫:if (wr_en && !full)——满了再写会被静默丢弃而不是覆盖旧数据;读逻辑同理 if (rd_en && !empty),空了读不出新数据。这两条守卫是 FIFO 的第一道安全门,回归测试会专门验证「满了硬写不会漏进去」。
为什么指针要多 1 bit?以本文 8 深为例:地址只要 3 bit 就能寻址 0-7,但指针用 4 bit。写满一圈后 wptr 从 7 走到 8(二进制 1000),这个多出来的最高位就是「折返位」——它记录指针已经绕了多少圈。used = wptr - rptr 这条减法因此永不产生歧义:同为 8 深的 FIFO,空的时候两个指针相等(差 0),满的时候写指针恰好领先一整圈(差 8),两个状态在数值上错得远远的。如果只留 3 bit,写满后指针回绕成 000,空和满就分不清了——这是新手实现 FIFO 最常踩的第一个坑,理论篇里叫「额外位法」,本文用的是它的计数法变体。
数据与控制的拓扑关系用一张图看清楚:
读这张图抓住一条主线:数据只走 mem,控制只走指针。上下游彼此不见面,全靠四个标志耦合——这就是 FIFO「解耦速率」的物理本质。almost_full 的工程用途是给上游提前一拍预警:流水线上游收到 almost_full 就开始减速,等 full 真拉起时管道里在飞的数据不会溢出。
二、自校验测试平台:不靠肉眼翻波形
新手写 testbench 最常见的错误是「打几个 $display 然后肉眼看波形对不对」——规模一大必然漏。本文测试平台用 scoreboard(记分板) 模式:testbench 内部维护一个理想队列 model[],DUT 每做一次真实写,scoreboard 同步记一笔;每做一次真实读,下一拍把 DUT 吐出的数据和 scoreboard 的期望值逐位比对,不等就计一个 error。
// 每拍 negedge 检查半拍前 posedge 弹出的数据
if (pending_read) begin
if (rd_data !== model[m_cmp]) begin
errors = errors + 1;
$display("[%0t] MISMATCH got=%0d expect=%0d",
$time, rd_data, model[m_cmp]);
end
m_cmp = m_cmp + 1;
end这样做的好处是测试可以随机化:压力阶段 wr_en/rd_en 用 $random 生成,谁也不知道具体序列,但只要 scoreboard 不报错,数据通路就是对的。还有一个细节值得说:scoreboard 的比对放在 negedge 加 #1 延迟进行,而不是 posedge 当场比对。原因是 DUT 的 rd_data 在 posedge 由非阻塞赋值更新,同一时刻去读会拿到旧值——把检查推迟半拍,等所有 NBA 更新落定再采样,这是写 Verilog 自校验平台的标准手法,能避开一整类「时序赛跑」导致的假报错。
回归分三个阶段,各盯一个风险点:
| 阶段 | 激励 | 验证点 |
|---|---|---|
| phase 1 填满 | 连写 8 次再硬写一次 | full/almost_full 拉起;溢出写被守卫丢弃 |
| phase 2 排空 | 连读 10 次(比深度多 2 次) | empty/almost_empty 拉起;多读不计数 |
| phase 3 随机压力 | 400 拍随机读写 | 指针折返多次后数据仍有序,最终收发计数相等 |
三、iverilog 实测:三阶段全绿
整个流程就两条命令:iverilog -g2012 编译、vvp 运行。真实执行实录如下:
读这份实录抓三个位置:第 8-10 行三个阶段的横幅依次出现,说明测试按计划走完没有中途卡死;第 11 行 total transfers=148 errors=0 是 scoreboard 的总结账——随机阶段共完成 148 次真实传输(写读配对),逐拍比对零错误;最后 grep 'RESULT: PASS' 命中,脚本级退出判据也过了。148 这个数字不是设计出来的,是 $random 序列跑出来的实际配对数,每次随机种子不同会略有浮动,但 errors=0 必须恒成立。
为什么要让脚本最后做一次 grep 'RESULT: PASS' 而不是只看仿真有没有跑完?因为「仿真跑完」只证明没有崩溃,不证明结果正确。把 PASS/FAIL 判据收敛成一行固定字符串、让外层脚本 grep 它,回归就能挂进任何 CI:以后每次改 RTL,重跑一遍 run.sh,退出码直接告诉你改坏了没有。这也是工业界回归脚本的标准收尾方式——仿真器输出给人看,机器只看那一行结果。
| 日志行 | 含义 | 对应验证点 |
|---|---|---|
== phase 1: fill to full == | 填满阶段开始,无 MISMATCH | 溢出写被守卫拦截 |
== phase 2: drain to empty == | 排空阶段无报错 | 多读不产生幽灵数据 |
total transfers=148 errors=0 | scoreboard 总账 | 数据有序性 + 计数一致 |
RESULT: PASS | 脚本判据命中 | 全回归通过 |
再把从编译到判定的完整过程原样摘录一段,可以看到一次回归从 iverilog 编译、VCD 波形文件生成、三个阶段横幅、scoreboard 总结账到最终脚本判定的完整链条,中间没有任何一行 MISMATCH 或 FLAG ERROR:
+ iverilog -g2012 -o sim.vvp /tmp/evidence-src/exp-sync-fifo-verilog-iverilog/sync_fifo.v /tmp/evidence-src/exp-sync-fifo-verilog-iverilog/tb_sync_fifo.v
+ vvp sim.vvp
+ tee sim.log
VCD info: dumpfile sync_fifo.vcd opened for output.
== phase 1: fill to full ==
== phase 2: drain to empty ==
== phase 3: random stress ==
total transfers=148 errors=0
RESULT: PASS
+ cp -f sync_fifo.vcd /tmp/evidence/exp-sync-fifo-verilog-iverilog/
+ grep -q 'RESULT: PASS' sim.log
+ echo 'RESULT: PASS'
RESULT: PASS这段摘录的价值在于「过程干净」:三个阶段之间如果 scoreboard 抓到任何一次数据错位,就会在对应横幅之后立刻打出 [时间] MISMATCH got=.. expect=..,而这里三道横幅后面都直接安静走过,直到总结账 errors=0。开头的 VCD info 一行还说明波形文件同步生成,想逐拍复核时可以用 GTKWave 打开 sync_fifo.vcd 对照日志时间点检查指针与标志的变化。
顺带说明为什么是 iverilog 而不是更重的仿真器:本实验只验功能契约,不验时序,iverilog 的 -g2012 已覆盖全部用到的 SystemVerilog 语法(参数化模块、非阻塞赋值、$random),编译加运行全程秒级,适合放进每次改动后的快速回归。等需要带 SDF 反标的门级仿真、断言覆盖率统计时,再换 Verilator 或商业仿真器——工具选型跟验证目标走,不为用重工具而用。
四、从这份实验能带走的三条经验
第一,守卫条件比存储逻辑更容易错。wr_en && !full 少了 !full,写满后再写就会覆盖还没读的旧数据,仿真里表现为数据流中间悄悄少了一项——所以 phase 1 专门安排了一次「明知满还硬写」,断言它不能漏进去。这里也顺手整理一份新手对照表,都是真实项目里反复出现的错误:
| 错误写法 | 后果 | 本篇对应防护 |
|---|---|---|
| 指针只用地址位宽、不留折返位 | 空满分不清,写满被当空 | 指针多 1 bit + 计数法 |
写逻辑漏 !full 守卫 | 溢出覆盖未读数据 | phase 1 溢出写测试 |
| 读数据用组合逻辑直出 mem[rptr] | 深 FIFO 时序差、且空读无防护 | 寄存器化 rd_data + !empty |
| 肉眼翻波形当验证 | 随机激励下必然漏 bug | scoreboard 逐拍自动比对 |
第二,scoreboard 是性价比最高的验证手段。三十行理想模型换来任意随机激励下的全自动比对,比维护一堆固定激励加人工波形检查可靠得多。后续写更复杂的模块(如 valid/ready 握手 接口)这套模式原样套用:上游 valid 拉高且 ready 为 1 时往模型里记一笔,下游弹出时比对,换的只是一个记分时机。
第三,同步 FIFO 是异步 FIFO 的一半。本篇读写同钟,指针直接相减即可;一旦读写分属两个时钟域,指针跨域就要走格雷码加两级同步,那一步的原理见 跨时钟域 CDC 基础,实测演示见本站另一篇两级同步器实验。把本篇的存储体、守卫、scoreboard 全部继承过去,只把「指针比较」换成「格雷码跨域比较」,就是一份异步 FIFO 的雏形——这也是学习路径上先吃透同步 FIFO 的原因。
最后提醒一个参数化细节:ALMOST 水位阈值给的是 2,意味着 almost_full 在用到 6 项时就拉起。这个值不是越小越保守越好——它要根据上游从收到预警到真正停写的「反应延迟拍数」来定,反应慢的模块需要更早的预警,否则预警拉了数据照样溢出。真实项目里这个值来自系统级预算,本篇取 2 只是为了让回归里能看到它拉起。
动手复现时建议按「先跑通、再改坏、再修好」三步走:第一步原样跑 run.sh 确认 PASS;第二步故意把 RTL 里的 !full 守卫删掉重跑,观察 phase 1 立刻报错、错误信息指名「overflow attempt leaked」——亲眼看一次回归抓到真实 bug,比读十遍原理解说更能建立对自校验平台的信任;第三步把深度参数 AW 从 3 改成 4 或 5,确认参数化设计无需改任何逻辑即可适配新深度。这三步走完,这份实验的价值才算真正变成读者自己的东西。
这一篇在知识体系里的位置
- 理论承接:三个最常见基础模块:FIFO、仲裁器、握手(空满判据与设计动机);
- 跨时钟域进阶:跨时钟域 CDC 基础(指针跨域为什么必须格雷码);
- 深度工程化:FIFO 深度计算(深度不是拍脑袋)。
一句话总结
同步 FIFO = 存储数组 + 多一位的读写指针,空满靠 used = wptr - rptr 判定;写一份带 scoreboard 的自校验 testbench,用 iverilog 跑填满、排空、随机压力三阶段,148 次传输零错误,比任何肉眼检查都能证明设计是对的。
实验环境
- 机器/系统:云主机 S6.4XLARGE32 16C32G / Ubuntu 22.04(eda-golden-v1 镜像)
- 工具版本:iverilog -g2012(eda-golden-v1 镜像自带 Icarus Verilog)
- 日期:2026-10-25
- 完整命令:
bash run.sh(内部执行iverilog -g2012 -o sim.vvp sync_fifo.v tb_sync_fifo.v,随后vvp sim.vvp,最后grep 'RESULT: PASS' sim.log)
代码获取
完整 RTL 与测试平台已同步到公开仓库:geek-doc.cn/experiments/exp-sync-fifo-verilog-iverilog,克隆后进入该目录执行 bash run.sh 即可复现本文全部结果。