前言
异步 FIFO 中,写数据和读数据分别工作在两个没有固定相位关系的时钟域中。
例如:
写时钟:
wclk读时钟:
rclk写指针:在
wclk域更新读指针:在
rclk域更新
为了判断 FIFO 是否为空或已满,写时钟域需要知道读指针的位置,读时钟域也需要知道写指针的位置。
问题就在这里:
读写指针是多位信号,不能直接用普通二进制形式跨时钟域。
异步 FIFO 使用格雷码,不是因为格雷码更节省资源,也不是因为格雷码不会产生亚稳态,而是因为:
格雷码相邻两个计数值之间只变化一个 bit,可以降低多位指针跨时钟域时被采样成错误组合值的风险。
一、异步 FIFO 到底有哪些信号需要跨时钟域
一个典型异步 FIFO 可以简化为下面的结构:
写时钟域 wclk 读时钟域 rclk +---------------------+ +---------------------+ wdata ->| 写地址、写指针 | | 读地址、读指针 |-> rdata | | | | | 生成 full | | 生成 empty | +----------+----------+ +----------+----------+ | | | 双口 RAM | +--------> [ FIFO Memory ] <------+ | | | | 同步读指针 同步写指针 rgray -> wclk wgray -> rclkFIFO 中的数据通常存放在双口 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 二进制计数和对应的格雷码如下:
| 十进制 | 二进制 | 格雷码 |
|---|---|---|
| 0 | 000 | 000 |
| 1 | 001 | 001 |
| 2 | 010 | 011 |
| 3 | 011 | 010 |
| 4 | 100 | 110 |
| 5 | 101 | 111 |
| 6 | 110 | 101 |
| 7 | 111 | 100 |
观察格雷码的连续变化:
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,也应该理解格雷码的作用,否则遇到full、empty延迟或 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 使用格雷码,核心不是为了编码方便,而是为了安全传递读写指针。
需要记住以下几点:
二进制指针进位时可能同时变化多个 bit。
多位二进制信号不能简单地逐位加两级同步器。
格雷码相邻状态只变化一个 bit。
格雷码仍可能产生亚稳态,必须配合同步器。
二进制指针用于加一和 RAM 寻址。
格雷码指针用于跨时钟域。
empty 在读时钟域产生,full 在写时钟域产生。
同步后的远端指针存在延迟,空满状态通常采用保守判断。
经典格雷码异步 FIFO 通常要求深度为 2 的整数次幂。
格雷码只适合每次加一、减一或保持不变的计数类信号。
一句话概括:
异步 FIFO 使用格雷码,是为了让跨时钟域的指针每次最多只变化一个 bit,再通过同步器降低亚稳态风险,避免二进制指针多 bit 同时变化导致错误的空满判断。