news 2026/8/23 18:14:10

异步 FIFO 为什么使用格雷码

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
异步 FIFO 为什么使用格雷码

前言

异步 FIFO 中,写数据和读数据分别工作在两个没有固定相位关系的时钟域中。

例如:

  • 写时钟:wclk

  • 读时钟:rclk

  • 写指针:在wclk域更新

  • 读指针:在rclk域更新

为了判断 FIFO 是否为空或已满,写时钟域需要知道读指针的位置,读时钟域也需要知道写指针的位置。

问题就在这里:

读写指针是多位信号,不能直接用普通二进制形式跨时钟域。

异步 FIFO 使用格雷码,不是因为格雷码更节省资源,也不是因为格雷码不会产生亚稳态,而是因为:

格雷码相邻两个计数值之间只变化一个 bit,可以降低多位指针跨时钟域时被采样成错误组合值的风险。


一、异步 FIFO 到底有哪些信号需要跨时钟域

一个典型异步 FIFO 可以简化为下面的结构:

写时钟域 wclk 读时钟域 rclk +---------------------+ +---------------------+ wdata ->| 写地址、写指针 | | 读地址、读指针 |-> rdata | | | | | 生成 full | | 生成 empty | +----------+----------+ +----------+----------+ | | | 双口 RAM | +--------> [ FIFO Memory ] <------+ | | | | 同步读指针 同步写指针 rgray -> wclk wgray -> rclk

FIFO 中的数据通常存放在双口 RAM 中:

  • 写端口由wclk控制;

  • 读端口由rclk控制;

  • 写指针决定写地址;

  • 读指针决定读地址。

数据本身并不是简单地经过两级触发器同步到另一个时钟域。

真正需要跨时钟域的是:

写指针:写时钟域 -> 读时钟域 读指针:读时钟域 -> 写时钟域

同步后的指针主要用于生成:

empty:读时钟域判断 FIFO 是否为空 full :写时钟域判断 FIFO 是否已满

二、为什么不能直接同步二进制指针

假设 FIFO 的写指针是一个 3 bit 二进制计数器。

它的部分变化如下:

十进制 二进制 2 010 3 011 4 100 5 101

观察从 3 增加到 4:

011 -> 100

三个 bit 全部发生变化。

在 RTL 仿真中,这三个 bit 看起来像是同时变化的,但在真实 FPGA 或 ASIC 电路中,不同 bit 的路径延迟不可能完全相同。

因此真实变化过程可能类似:

原值:011 bit2 先变化:111 bit1 再变化:101 bit0 最后变化:100 目标值:100

也可能出现其他中间组合,例如:

011 -> 001 -> 000 -> 100

这些中间值不是计数器真正想发送的值,只是多个 bit 传播延迟不一致造成的瞬间状态。

如果目标时钟刚好在这个过程中采样,就可能采到错误值。

例如写指针实际只是:

3 -> 4

读时钟域却可能短暂采到:

0 1 5 7

具体采到什么,取决于各 bit 的传播延迟和目标时钟采样位置。

这会直接影响 FIFO 的空满判断。


三、每个 bit 都加两级同步器不行吗

很多新人会想到:

二进制指针有多个 bit,那我给每个 bit 都加两级触发器,不就可以了吗?

例如:

binary_pointer[0] -> 两级同步器 binary_pointer[1] -> 两级同步器 binary_pointer[2] -> 两级同步器

这样做仍然有问题。

两级同步器主要解决的是:

降低单个异步信号的亚稳态传播概率。

它不能保证多个 bit 在目标时钟域的同一个周期完成同步。

例如二进制指针从:

011 -> 100

三个 bit 都可能在目标时钟边沿附近变化。

同步后可能出现:

bit2 已经同步为 1 bit1 仍然是旧值 1 bit0 已经同步为 0

目标时钟域看到的就是:

110

虽然每一个 bit 都经过了同步器,但组合起来的多位数据仍然可能不是旧值011,也不是新值100

所以需要明确:

两级同步器可以降低亚稳态传播风险,但不能保证普通多位总线的一致性。


四、格雷码解决了什么问题

格雷码最重要的特点是:

相邻两个编码之间只有一个 bit 发生变化。

3 bit 二进制计数和对应的格雷码如下:

十进制二进制格雷码
0000000
1001001
2010011
3011010
4100110
5101111
6110101
7111100

观察格雷码的连续变化:

000 001 011 010 110 111 101 100

任意两个相邻值之间只变化一个 bit。

例如从十进制 3 增加到 4:

二进制:011 -> 100 三个 bit 变化 格雷码:010 -> 110 只有一个 bit 变化

当格雷码指针跨时钟域时,只有一个 bit 处于变化过程中。

目标时钟域可能采到:

旧值:010

或者:

新值:110

某一个变化 bit 也可能进入亚稳态,但经过两级同步器后,最终通常只会表现为:

这次采到旧指针

或者:

下一次才采到新指针

不会像二进制计数器那样,因为多个 bit 同时变化而轻易拼出一个相差很远的错误指针。

这就是异步 FIFO 使用格雷码的核心原因。


五、格雷码并不能消除亚稳态

这里需要特别纠正一个常见误区。

错误说法:

使用格雷码以后就不会产生亚稳态。

正确说法:

格雷码不能消除亚稳态。

格雷码指针仍然是异步信号。

如果某个格雷码 bit 恰好在目标时钟采样边沿附近变化,该 bit 仍然可能进入亚稳态。

真正的处理方法是:

源时钟域:生成并寄存格雷码指针 ↓ 目标时钟域:两级或多级同步器 ↓ 目标时钟域:使用同步后的格雷码判断 full 或 empty

格雷码和同步器解决的是两个不同问题:

方法主要作用
格雷码减少一次计数变化时同时翻转的 bit 数量
两级同步器降低亚稳态继续传播到后级逻辑的概率

二者通常需要配合使用。


六、为什么还要保留二进制指针

既然格雷码适合跨时钟域,是否可以完全不用二进制指针?

一般不这样做。

异步 FIFO 中通常同时维护两套指针:

二进制指针:用于地址计算和加一运算 格雷码指针:用于跨时钟域同步

原因很简单。

二进制指针加一非常方便:

wbin_next = wbin + 1'b1;

FIFO RAM 的地址也可以直接取二进制指针的低位:

waddr = wbin[ADDR_WIDTH-1:0]; raddr = rbin[ADDR_WIDTH-1:0];

而格雷码不适合直接进行普通加法。

所以实际结构通常是:

当前二进制指针 | + 1 | 下一个二进制指针 | | binary -> gray v 下一个格雷码指针

七、二进制转格雷码

二进制转格雷码的公式非常简单:

gray = binary ^ (binary >> 1)

对应 Verilog 写法:

assign gray = binary ^ (binary >> 1);

例如二进制值为:

binary = 3'b101

右移一位:

binary >> 1 = 3'b010

异或得到:

101 010 --- 111

因此:

binary 101 -> gray 111

八、写指针的典型实现

下面是写指针生成的核心代码。

为了同时判断 FIFO 是否绕回,指针通常比 RAM 地址多一位。

假设:

FIFO 深度 = 16 地址位宽 = 4 bit 指针位宽 = 5 bit

低 4 bit 用作 RAM 地址,额外的最高位用于区分指针是否已经绕回。

localparam PTR_WIDTH = ADDR_WIDTH + 1; reg [PTR_WIDTH-1:0] wbin; reg [PTR_WIDTH-1:0] wgray; wire winc; wire [PTR_WIDTH-1:0] wbin_next; wire [PTR_WIDTH-1:0] wgray_next; assign winc = w_en && !wfull; assign wbin_next = wbin + winc; assign wgray_next = (wbin_next >> 1) ^ wbin_next; always @(posedge wclk or negedge wrst_n) begin if (!wrst_n) begin wbin <= {PTR_WIDTH{1'b0}}; wgray <= {PTR_WIDTH{1'b0}}; end else begin wbin <= wbin_next; wgray <= wgray_next; end end assign waddr = wbin[ADDR_WIDTH-1:0];

这里有三个关键点。

1. 只有允许写入时指针才增加

winc = w_en && !wfull;

FIFO 已满时,即使外部仍然给出w_en,写指针也不能继续增加。

2. 先计算二进制指针,再转成格雷码

wbin_next = wbin + winc; wgray_next = (wbin_next >> 1) ^ wbin_next;

3. 跨时钟域的格雷码必须先寄存

跨域同步器的输入应尽量来自寄存器输出,而不是直接使用可能产生毛刺的组合逻辑。

因此实际跨域的是寄存后的:

wgray

而不是直接把一串复杂组合逻辑接到同步器输入。


九、读指针的典型实现

读指针与写指针基本对称:

localparam PTR_WIDTH = ADDR_WIDTH + 1; reg [PTR_WIDTH-1:0] rbin; reg [PTR_WIDTH-1:0] rgray; wire rinc; wire [PTR_WIDTH-1:0] rbin_next; wire [PTR_WIDTH-1:0] rgray_next; assign rinc = r_en && !rempty; assign rbin_next = rbin + rinc; assign rgray_next = (rbin_next >> 1) ^ rbin_next; always @(posedge rclk or negedge rrst_n) begin if (!rrst_n) begin rbin <= {PTR_WIDTH{1'b0}}; rgray <= {PTR_WIDTH{1'b0}}; end else begin rbin <= rbin_next; rgray <= rgray_next; end end assign raddr = rbin[ADDR_WIDTH-1:0];

FIFO 为空时:

rinc = 1'b0;

读指针保持不变,避免继续读导致下溢。


十、格雷码指针如何跨时钟域

写指针需要同步到读时钟域,读指针也需要同步到写时钟域。

下面给出一个简单的两级同步器。

module gray_sync #( parameter WIDTH = 5 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] async_gray, output wire [WIDTH-1:0] sync_gray ); (* ASYNC_REG = "TRUE" *) reg [WIDTH-1:0] sync_ff1; (* ASYNC_REG = "TRUE" *) reg [WIDTH-1:0] sync_ff2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sync_ff1 <= {WIDTH{1'b0}}; sync_ff2 <= {WIDTH{1'b0}}; end else begin sync_ff1 <= async_gray; sync_ff2 <= sync_ff1; end end assign sync_gray = sync_ff2; endmodule

读指针同步到写时钟域:

gray_sync #( .WIDTH(PTR_WIDTH) ) u_rgray_to_wclk ( .clk (wclk), .rst_n (wrst_n), .async_gray (rgray), .sync_gray (rgray_sync) );

写指针同步到读时钟域:

gray_sync #( .WIDTH(PTR_WIDTH) ) u_wgray_to_rclk ( .clk (rclk), .rst_n (rrst_n), .async_gray (wgray), .sync_gray (wgray_sync) );

需要注意:

rgray_sync 是写时钟域信号 wgray_sync 是读时钟域信号

同步后的指针只能在对应的目标时钟域中使用。


十一、empty 是怎么判断的

FIFO 为空的本质是:

读指针追上了写指针

在读时钟域中,应该比较:

下一个读格雷码指针

和:

已经同步到读时钟域的写格雷码指针

代码如下:

wire rempty_next; assign rempty_next = (rgray_next == wgray_sync); always @(posedge rclk or negedge rrst_n) begin if (!rrst_n) rempty <= 1'b1; else rempty <= rempty_next; end

为什么比较的是rgray_next,不是当前rgray

假设 FIFO 中只剩一个数据。

当前读指针还没有移动,因此当前读写指针不相等。

当本周期执行最后一次读取后:

rgray_next == wgray_sync

说明这次读完后 FIFO 将变为空,因此应在当前读时钟边沿更新rempty


十二、full 为什么要翻转最高两位

FIFO 已满表示:

写指针比读指针正好多走了一整圈

二进制指针通常会增加一个额外最高位,用于区分:

地址相同且圈数相同:FIFO 为空 地址相同但相差一圈:FIFO 已满

但是格雷码的满判断不能简单地只翻转一个最高位。

对于常见的反射二进制格雷码,满判断通常写成:

wire [PTR_WIDTH-1:0] rgray_full_value; wire wfull_next; assign rgray_full_value = { ~rgray_sync[PTR_WIDTH-1:PTR_WIDTH-2], rgray_sync[PTR_WIDTH-3:0] }; assign wfull_next = (wgray_next == rgray_full_value);

然后在写时钟域寄存:

always @(posedge wclk or negedge wrst_n) begin if (!wrst_n) wfull <= 1'b0; else wfull <= wfull_next; end

也就是:

同步后的读格雷码最高两位取反 其余低位保持不变 然后与下一个写格雷码比较

该写法适用于常见的:

FIFO 深度 = 2 的整数次幂 指针位宽 = 地址位宽 + 1 ADDR_WIDTH >= 2

不要把这段逻辑简单理解为“格雷码最高位表示圈数”。

格雷码各 bit 与二进制位之间存在编码关系,因此满判断需要按格雷码序列的规律比较。


十三、波形上应该怎么看

先看二进制指针从 3 变成 4。

源时钟域二进制指针: bin[2] _________/‾‾‾‾‾‾‾ bin[1] ‾‾‾‾‾‾‾‾‾\_______ bin[0] ‾‾‾‾‾‾‾‾‾\_______ 数值: 011 -> 100

三个 bit 都在变化。

由于实际路径延迟不同,目标时钟采样时可能看到错误组合。

再看对应的格雷码:

十进制: 3 -> 4 格雷码: 010 -> 110

波形上只有一个 bit 变化:

gray[2] _________/‾‾‾‾‾‾‾ gray[1] ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ gray[0] _______________ 数值: 010 -> 110

如果目标时钟边沿正好落在变化附近,变化的那个 bit 可能进入亚稳态。

但另外两个 bit 保持不变。

经过同步器后,目标时钟域看到的通常是:

旧值 010

或者晚一两个周期看到:

新值 110

对于 FIFO 来说,指针晚同步几个周期通常是安全的。


十四、为什么指针同步延迟不会让 FIFO 溢出

两级同步器会引入延迟。

例如读操作已经释放了一个存储位置,但写时钟域可能还没有看到更新后的读指针。

这时写时钟域可能仍然认为:

FIFO 是满的

于是晚几个周期才撤销full

结果是:

暂时少写几个数据

而不是覆盖尚未读取的数据。

同样,写端已经写入新数据后,读时钟域可能还没有看到更新后的写指针。

此时empty可能多保持几个周期。

结果是:

暂时晚几个周期开始读取

而不是读取无效数据。

这种行为通常称为保守判断或悲观判断:

full 可能撤销得晚 empty 可能撤销得晚

它牺牲少量延迟,换取不溢出和不下溢。


十五、快时钟域连续变化,慢时钟域会不会出问题

假设写时钟比读时钟快很多。

在两个rclk边沿之间,写指针可能增加多次:

000 -> 001 -> 011 -> 010

读时钟域可能没有采到中间状态,而是直接看到:

000 -> 010

这两个采样值之间可能有多个 bit 不同。

这并不代表格雷码失效。

格雷码保证的是:

源时钟域每一次相邻计数变化只翻转一个 bit

并不是保证:

目标时钟域连续两次采到的数值永远只差一个 bit

慢时钟域跳过部分指针值是允许的。

异步 FIFO 不需要让对面看到每一个中间指针,只需要得到一个安全、可能稍微滞后的指针位置。


十六、格雷码的使用条件

格雷码并不是所有多位 CDC 信号的通用解决方案。

要使用这种方法,源数据必须满足:

相邻有效值每次只增加 1 或减少 1

异步 FIFO 指针正好满足这个条件:

不操作:保持不变 成功写入:写指针加 1 成功读取:读指针加 1

下面这些情况不能直接套用 FIFO 指针的格雷码同步方法:

计数器一次跳过多个值 普通数据总线任意变化 地址总线任意跳转 多个字段组成的状态总线

例如:

binary:0011 -> 1001

即使分别转换成格雷码,也不能保证两个状态之间只变化一个 bit。

这种信号通常应考虑:

  • 握手机制;

  • 请求应答同步;

  • 异步 FIFO;

  • Toggle 同步;

  • 其他专用 CDC 结构。


十七、FIFO 深度为什么通常是 2 的整数次幂

标准反射格雷码的完整循环长度是:

2^N

异步 FIFO 中常见深度为:

4、8、16、32、64、128……

这样二进制指针自然循环,转换后的格雷码也能保持首尾连接时只变化一个 bit。

例如 3 bit 格雷码:

000 001 011 010 110 111 101 100

最后一个状态回到第一个状态:

100 -> 000

同样只变化一个 bit。

如果直接截取其中一部分作为非 2 的整数次幂 FIFO,计数器回绕时可能出现多个 bit 同时变化,破坏格雷码跨域的前提。

因此手写经典格雷码异步 FIFO 时,通常要求:

FIFO_DEPTH = 2^ADDR_WIDTH

非 2 的整数次幂深度不是完全不能实现,但空满判断、指针循环和 CDC 处理会更复杂,不能直接照搬经典模板。


十八、工程中还需要注意什么

1. 格雷码必须在源时钟域寄存

推荐结构:

binary next ↓ gray next ↓ 源时钟寄存器 ↓ 跨域同步器

不要让带有组合毛刺的信号直接进入 CDC 同步器。


2. 同步器应添加工具识别属性

在 Vivado 中,常见写法为:

(* ASYNC_REG = "TRUE" *) reg [WIDTH-1:0] sync_ff1; (* ASYNC_REG = "TRUE" *) reg [WIDTH-1:0] sync_ff2;

该属性主要帮助实现工具识别同步器,并优化同步触发器的布局。

它不能代替正确的 RTL 结构。


3. 复位释放也要注意时钟域

工程中常见策略是:

异步拉低复位 同步释放复位

写时钟域和读时钟域应分别生成各自同步释放的复位信号。

不要假设一个异步复位在两个无关时钟域中能够完全同时、安全地释放。


4. 不要只看功能仿真

理想 RTL 仿真通常不会自动暴露亚稳态和真实路径偏斜问题。

除了功能仿真,还应检查:

CDC 报告 同步器识别结果 时序例外 跨域路径约束 复位跨域结构

使用 Vivado 时可以重点查看:

report_cdc

确认同步结构是否被正确识别。


5. 手写 FIFO 前先评估是否应该使用厂商 IP

如果项目目标是快速交付,而不是学习异步 FIFO 原理,可以优先使用:

Xilinx XPM_FIFO_ASYNC FIFO Generator Intel FIFO IP

成熟 IP 通常已经处理:

  • 格雷码指针;

  • 空满判断;

  • 同步器;

  • RAM 推断或实例化;

  • CDC 属性;

  • 部分约束;

  • 不同读模式;

  • 数据计数和 almost full 等功能。

但即使使用 IP,也应该理解格雷码的作用,否则遇到fullempty延迟或 CDC 报告时仍然很难定位问题。


十九、常见错误总结

错误一:直接同步二进制指针

sync_ff1 <= binary_pointer; sync_ff2 <= sync_ff1;

问题:

二进制指针可能同时变化多个 bit 目标域可能采到错误组合

错误二:认为格雷码不需要同步器

assign remote_pointer = gray_pointer;

问题:

格雷码仍然是异步信号 变化的那个 bit 仍然可能产生亚稳态

错误三:先跨域同步二进制,再转格雷码

错误结构:

binary pointer ↓ 多位同步器 ↓ binary to gray

二进制指针在进入同步器之前已经存在多 bit 同时变化的问题。

正确结构应是:

binary pointer ↓ binary to gray ↓ 源时钟寄存 ↓ 同步器

错误四:格雷码指针一次增加多个数

例如:

wbin_next = wbin + 4;

这种情况下前后两个格雷码可能变化多个 bit,不能再使用“相邻状态只变化一位”的结论。


错误五:认为同步后的指针一定是最新值

同步器一定会引入延迟。

写时钟域看到的读指针、读时钟域看到的写指针,本来就是滞后的。

异步 FIFO 的空满逻辑必须允许这种滞后,并保证结果偏向安全。


二十、面试时可以怎么回答

如果面试官问:

异步 FIFO 为什么使用格雷码?

可以这样回答:

异步 FIFO 的读写指针需要跨时钟域同步。如果直接同步二进制指针,指针在进位时可能同时变化多个 bit,例如011变成100,目标时钟域可能因为各 bit 延迟和同步周期不同,采到错误的组合值。格雷码相邻计数值只变化一个 bit,可以把多位同时变化的问题限制为单 bit 变化。再配合两级同步器,可以降低亚稳态传播风险,使目标域通常只会看到旧指针或新指针。格雷码不能消除亚稳态,也不能代替同步器。

如果继续追问:

格雷码跨域后 full 和 empty 为什么会有延迟?

可以回答:

因为对端指针经过同步器后会滞后。异步 FIFO 的 full 和 empty 通常采用保守判断,状态可能晚几个周期撤销,但不会因此导致溢出或下溢。


总结

异步 FIFO 使用格雷码,核心不是为了编码方便,而是为了安全传递读写指针。

需要记住以下几点:

  1. 二进制指针进位时可能同时变化多个 bit。

  2. 多位二进制信号不能简单地逐位加两级同步器。

  3. 格雷码相邻状态只变化一个 bit。

  4. 格雷码仍可能产生亚稳态,必须配合同步器。

  5. 二进制指针用于加一和 RAM 寻址。

  6. 格雷码指针用于跨时钟域。

  7. empty 在读时钟域产生,full 在写时钟域产生。

  8. 同步后的远端指针存在延迟,空满状态通常采用保守判断。

  9. 经典格雷码异步 FIFO 通常要求深度为 2 的整数次幂。

  10. 格雷码只适合每次加一、减一或保持不变的计数类信号。

一句话概括:

异步 FIFO 使用格雷码,是为了让跨时钟域的指针每次最多只变化一个 bit,再通过同步器降低亚稳态风险,避免二进制指针多 bit 同时变化导致错误的空满判断。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/23 18:14:05

四大向量存储完整区分:pgvector / Milvus / Qdrant / Chroma

四大向量存储完整区分&#xff1a;pgvector / Milvus / Qdrant / Chroma 一、基础定位本质区别 pgvector&#xff1a;不是独立向量库&#xff0c;是 PostgreSQL 的插件扩展 底层是关系数据库&#xff0c;只是新增 vector 字段类型、向量距离算子&#xff0c;业务结构化数据 向…

作者头像 李华
网站建设 2026/8/23 18:08:05

Java小厂面试核心考点与实战技巧

1. Java小厂面试现状解析最近两年Java开发岗位的就业市场呈现出明显的两极分化趋势。头部大厂招聘规模收缩&#xff0c;而中小型企业对Java开发者的需求却保持稳定增长。根据我个人参与面试和帮助朋友准备面试的经验&#xff0c;Java小厂的面试流程通常具有以下典型特征&#x…

作者头像 李华
网站建设 2026/8/23 18:06:47

基于LLM多智能体框架的自优化拓扑优化:打通CAD/CAE/CAM数据流

1. 从一个工程难题说起&#xff1a;拓扑优化的“最后一公里”在结构设计、增材制造、航空航天这些硬核工程领域&#xff0c;拓扑优化&#xff08;Topology Optimization&#xff09;早已不是什么新鲜词。简单来说&#xff0c;它就像一个超级聪明的“材料雕刻师”&#xff0c;给…

作者头像 李华
网站建设 2026/8/23 17:59:44

拼多多2027届实习生招聘:内推攻略与岗位解析

1. 拼多多2027届实习生招聘全面解析作为国内电商行业的头部企业&#xff0c;拼多多每年都会面向在校大学生开放实习生招聘通道。今年的2027届实习生招聘已经正式启动&#xff0c;主要面向服务端研发和算法两个技术方向。与常规网申不同&#xff0c;通过内推渠道投递简历可以获得…

作者头像 李华