异步FIFO可复用Verilog模板:格雷码指针与满空判定逐段精讲
更新时间:2026-10-29。这是一篇「拿来即用」的模板文:给你一份经过 lint 和双时钟并发仿真验证的异步 FIFO 代码,讲清每一段为什么这么写,以及改参数、加功能时该动哪里。异步 FIFO 背后的跨时钟域(CDC, Clock Domain Crossing)原理在 异步 FIFO 与 CDC 设计 里有深入展开,本篇专注「可复制、可复用、可验证」的工程形态。
本文要回答的问题
- 什么场景必须用异步 FIFO,同步 FIFO 为什么不行?
- 格雷码指针跨时钟同步为什么安全?两级同步器在防什么?
- 满和空用格雷码怎么判定,高两位取反是什么含义?
- 直接抄这份模板有哪些硬性前提(深度、复位)?
- 想加 almost_full / almost_empty 这类水位标志怎么改?
一、什么时候需要异步 FIFO
写侧和读侧在两个互不相关的时钟域时,数据不能直接握手——两边时钟没有相位关系,任何「请求-应答」都会撞上亚稳态。典型场景:慢速外设往高速处理核灌数据、DMA 引擎跨总线时钟搬数、两片 FPGA 之间过高速串行收发器的数据缓存。
异步 FIFO 的职责就是在这两个域之间当缓冲:写侧只跟写时钟打交道,读侧只跟读时钟打交道,中间靠一套特殊设计的指针传递机制交换「还能不能写 / 还有没有数」的状态。这套机制的三个核心件,看下面这张概念卡。
读这张卡抓住一条主线:两个时钟域之间唯一跨过去的信号是格雷码形式的指针,而且每一根跨域信号都先经过两级触发器同步。存储体本身不跨域——数据写入和读出用的是同一块 RAM 的不同端口,这在物理上就是双口 RAM 天然支持的。
二、完整模板代码
下面这份 async_fifo.v 是全文的主角,可以直接复制进工程。参数只有两个:WIDTH 是数据位宽,DEPTH 是深度(必须是 2 的幂)。
// 异步 FIFO 模板:格雷码指针跨时钟同步(两级同步器)
// DEPTH 必须为 2 的幂
module async_fifo #(
parameter WIDTH = 8,
parameter DEPTH = 16, // 必须是 2 的幂
parameter AW = $clog2(DEPTH) // 地址位宽
)(
input wire wr_clk,
input wire wr_rst_n,
input wire wr_en,
input wire [WIDTH-1:0] wr_data,
output wire full, // 寄存量:写域时序输出
input wire rd_clk,
input wire rd_rst_n,
input wire rd_en,
output wire [WIDTH-1:0] rd_data,
output wire empty // 寄存量:读域时序输出
);
reg [WIDTH-1:0] mem [0:DEPTH-1]; // 双口存储体
reg [AW:0] wr_bin, rd_bin; // 二进制指针(多 1 bit 区分满/空)
reg [AW:0] wr_gray, rd_gray; // 格雷码指针
reg [AW:0] wr_gray_sync1, wr_gray_sync2; // 写指针同步到读域
reg [AW:0] rd_gray_sync1, rd_gray_sync2; // 读指针同步到写域
wire wr_advance = wr_en & ~full;
wire rd_advance = rd_en & ~empty;
wire [AW:0] wr_bin_next = wr_bin + {{AW{1'b0}}, wr_advance};
wire [AW:0] rd_bin_next = rd_bin + {{AW{1'b0}}, rd_advance};
wire [AW:0] wr_gray_next = (wr_bin_next >> 1) ^ wr_bin_next;
wire [AW:0] rd_gray_next = (rd_bin_next >> 1) ^ rd_bin_next;
// 写侧:存储与指针更新
always @(posedge wr_clk or negedge wr_rst_n) begin
if (!wr_rst_n) begin
wr_bin <= {AW+1{1'b0}};
wr_gray <= {AW+1{1'b0}};
end else begin
if (wr_en && !full)
mem[wr_bin[AW-1:0]] <= wr_data;
wr_bin <= wr_bin_next;
wr_gray <= wr_gray_next;
end
end
// 读侧:指针更新(读出为组合逻辑)
always @(posedge rd_clk or negedge rd_rst_n) begin
if (!rd_rst_n) begin
rd_bin <= {AW+1{1'b0}};
rd_gray <= {AW+1{1'b0}};
end else begin
rd_bin <= rd_bin_next;
rd_gray <= rd_gray_next;
end
end
assign rd_data = mem[rd_bin[AW-1:0]];
// 写指针格雷码 -> 读域(两级同步器)
always @(posedge rd_clk or negedge rd_rst_n) begin
if (!rd_rst_n) begin
wr_gray_sync1 <= {AW+1{1'b0}};
wr_gray_sync2 <= {AW+1{1'b0}};
end else begin
wr_gray_sync1 <= wr_gray;
wr_gray_sync2 <= wr_gray_sync1;
end
end
// 读指针格雷码 -> 写域(两级同步器)
always @(posedge wr_clk or negedge wr_rst_n) begin
if (!wr_rst_n) begin
rd_gray_sync1 <= {AW+1{1'b0}};
rd_gray_sync2 <= {AW+1{1'b0}};
end else begin
rd_gray_sync1 <= rd_gray;
rd_gray_sync2 <= rd_gray_sync1;
end
end
// 空:读指针格雷码追上同步过来的写指针格雷码
wire empty_val = (rd_gray_next == wr_gray_sync2);
reg empty_r;
always @(posedge rd_clk or negedge rd_rst_n) begin
if (!rd_rst_n) empty_r <= 1'b1;
else empty_r <= empty_val;
end
assign empty = empty_r;
// 满:高两位相反、其余位相同
wire full_val = (wr_gray_next ==
{~rd_gray_sync2[AW:AW-1], rd_gray_sync2[AW-2:0]});
reg full_r;
always @(posedge wr_clk or negedge wr_rst_n) begin
if (!wr_rst_n) full_r <= 1'b0;
else full_r <= full_val;
end
assign full = full_r;
endmodule三、逐段讲解:三个必须想清楚的问题
格雷码为什么安全。 跨时钟域采样一个多位总线时,如果多位同时翻转,采样点可能落在翻转中途,抓到几位新几位旧的「混合值」。二进制计数器从 7 到 8 一次变 4 位,正是这种场景。格雷码相邻值只差 1 位,指针每次自增只翻转一根线,采样最多抓到一个拍前或拍后的值——两个值都是合法的指针,只是「旧了一拍」,而旧指针只会导致保守的满空判断(多等一拍),永远不会导致数据错乱。为什么不能把多位二进制指针直接打两拍同步,为什么多比特信号不能用两级触发器直接同步 一文有波形级演示。
两级同步器在防什么。 第一级触发器采样异步信号时可能进入亚稳态,输出在一个时钟周期内不稳定;第二级再采一次,亚稳态衰减到一个周期的概率已极低。代价是传递延迟固定为两个目的域时钟——这正是「旧指针保守判断」的来源,也是满标志撤除要等几个写时钟的原因。
满空判定的格雷码比较法。 指针用 AW+1 位,最高位是「圈数」。空很简单:读写指针完全相等(同圈同位置)就是空。满则是「写指针比读指针多走一整圈」:展开成格雷码的等价条件是高两位相反、其余位相同。最高位相反表示差一圈,次高位相反是格雷码回绕的特性,二者一起构成唯一的满判据。两个标志都寄存一拍再输出(Cummings 风格):组合逻辑只产生 full_val/empty_val,标志本身是时序输出,既断了组合环、又让标志在各自时钟域内是干净的时序信号。
这份模板已过 Verilator 最严格档的静态检查,并用 Icarus Verilog 跑了三阶段仿真:写满判满、读空判空、然后写时钟 100 MHz / 读时钟约 71.4 MHz 双时钟并发随机读写 120 拍、记分板逐笔对拍数据。
实录里三个关口都在:--lint-only -Wall 零警告零错误,iverilog 编译通过,仿真三个阶段的 [OK] 检查点全绿、最后 TEST PASSED: 0 errors。阶段 3 的读写使能各按 2/3 概率随机,写读时钟频率刻意取不同值,就是为了压跨时钟并发下满空标志与数据顺序的正确性。
四、使用注意事项
- 深度必须是 2 的幂。满空判定依赖指针自然回绕,DEPTH 非 2 的幂时格雷码回绕点错位,判据失效。DEPTH 至少为 4(AW≥2,满判据需要高两位)。
- 复位两域都要拉。
wr_rst_n和rd_rst_n各自复位本域指针与同步器,只拉一边会让另一域同步器里残留旧值。释放可以异步,拉低建议两个域都保持若干拍。 - 标志是保守的,这是特性不是 bug。empty 撤除要等写指针同步过来(约两个读时钟),full 撤除同理。设计水位时把这两拍延迟算进去。
- rd_data 是组合读出,读使能拉高那一拍数据就在线上;若后端接同步 RAM,需要把存储体换成 RAM IP 并给 rd_data 加一级寄存。
五、修改示范:加 almost_full
水位标志最常见的加法:在写域比较「同步过来的读指针」与「写指针」的差值。注意要在二进制域做减法,格雷码不能直接减。
parameter AF_LEVEL = DEPTH - 4; // 距满还差 4 个格时拉高
wire [AW:0] rd_bin_sync; // 同步后的读指针转回二进制
gray2bin #(.W(AW+1)) u_g2b (.gray(rd_gray_sync2), .bin(rd_bin_sync));
wire [AW:0] wr_used = wr_bin_next - rd_bin_sync;
reg almost_full_r;
always @(posedge wr_clk or negedge wr_rst_n) begin
if (!wr_rst_n) almost_full_r <= 1'b0;
else almost_full_r <= (wr_used >= AF_LEVEL);
endgray2bin 是几行异或链的小模块(bin[i] = ^(gray >> i)),照此模式加 almost_empty 只是换成读域、差值方向反过来。
实验环境
- 机器/系统: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 async_fifo.v;iverilog -g2012 -o tb_async_fifo.vvp tb_async_fifo.v async_fifo.v;vvp tb_async_fifo.vvp
代码获取
本文全部代码(async_fifo.v 模板与三阶段测试平台)已同步到公开仓库 geek-doc.cn/experiments/tpl-async-fifo-verilog,克隆后按 README 里的两条命令即可复现 lint 与仿真结果。
这一篇在知识体系里的位置
本篇是芯片设计入门簇中「跨时钟域」节点的可复用模板页:原理层的深入讨论(亚稳态量化、深度计算)见 异步 FIFO 与 CDC 设计,单比特信号的同步原理见 为什么多比特信号不能用两级触发器直接同步,FIFO 与仲裁器的基础概念见 FIFO 与仲裁器基础,单时钟域的对照实验见 同步 FIFO 的 iverilog 实验。
总结
异步 FIFO 的工程要点就三条:指针用格雷码保证跨域采样安全,每根跨域信号过两级同步器防亚稳态,满空判定在格雷码域做比较并以寄存量输出。本文给出的模板参数化、零 lint 警告、双时钟并发仿真对拍通过,复制即用;改深度位宽只动两个参数,加水位标志按第五节的 almost_full 示范扩即可。前提是守住两条红线:深度必须是 2 的幂,两个时钟域的复位都要拉到位。