4、异步FIFO设计:双时钟域下的FIFO架构、格雷码转换、同步器链

异步FIFO,说白了就是用来在两个不同时钟域之间安全传数据的。我刚开始做FPGA那会儿,总觉得这东西不就是个存储嘛,有啥难的?直到第一次遇到亚稳态问题,数据传着传着就乱了,查了三天才找到原因。嗯,从那以后我再也不敢小看异步FIFO的设计了。

4.1 双时钟域下的FIFO架构

异步FIFO的核心架构其实不复杂。它有两个时钟域:写时钟域和读时钟域。每个时钟域都有自己的指针——写指针和读指针。

我习惯把架构分成三块来看:

  • 存储体(Dual-Port RAM):真正的数据存放地,一边写一边读
  • 指针生成逻辑:写时钟域产生写地址,读时钟域产生读地址
  • 空满判断逻辑:这是最关键的,也是最容易出问题的部分

你想想看,写指针在写时钟域里跑,读指针在读时钟域里跑。要判断FIFO是空还是满,就得把两个指针放到同一个时钟域里比较。这就引出了同步的问题。

关键点:异步FIFO的设计难点不在于存储,而在于如何安全地传递指针信号,避免亚稳态。

4.2 格雷码转换——为什么非它不可?

直接传二进制指针行不行?我试过,结果很惨。二进制指针在递增时,可能多位同时变化。比如从3(011)变到4(100),三位全变了。如果同步器刚好在这个节骨眼上采样,读到的可能是个乱七八糟的值。

格雷码就不一样了。它每次只变化一位。从3(0010)变到4(0110),只有一位变化。这样即使同步器采样到中间状态,最多也就是旧值或者新值,不会出现完全错误的值。

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

// 二进制转格雷码
gray = (binary >> 1) ^ binary;

// 格雷码转二进制(用于读指针比较)
binary[MSB] = gray[MSB];
for(i = MSB-1; i >= 0; i = i-1)
    binary[i] = binary[i+1] ^ gray[i];

我在项目中遇到过一个问题:格雷码虽然解决了多位变化的问题,但它不能直接用来寻址RAM。所以实际设计中,指针在各自时钟域内还是用二进制,只在跨时钟域同步时才转成格雷码。

小技巧:格雷码的深度必须是2的幂次。如果你的FIFO深度不是2的幂,比如10,那就得用16的深度,然后做地址回绕处理。我一般直接选2的幂次深度,省心。

4.3 同步器链——两级还是三级?

同步器链,说白了就是一连串的寄存器,用来降低亚稳态的概率。最常见的做法是两级同步器。

为什么会这样?因为亚稳态虽然不可避免,但经过一级寄存器后,信号稳定下来的概率已经很高了。再加一级,基本就稳了。我个人的习惯是:

  • 普通应用:两级同步器就够了
  • 高速或高可靠性场景:用三级同步器
  • 极端环境(比如航天):可能会用到四级甚至五级

同步器链的代码长这样:

// 两级同步器,将写指针同步到读时钟域
reg [ADDR_WIDTH:0] wr_ptr_gray_sync1;
reg [ADDR_WIDTH:0] wr_ptr_gray_sync2;

always @(posedge rd_clk or negedge rst_n) begin
    if (!rst_n) begin
        wr_ptr_gray_sync1 <= 0;
        wr_ptr_gray_sync2 <= 0;
    end else begin
        wr_ptr_gray_sync1 <= wr_ptr_gray;  // 第一级
        wr_ptr_gray_sync2 <= wr_ptr_gray_sync1;  // 第二级
    end
end

注意:同步器链的每一级寄存器必须用同一个时钟域。我曾经见过有人把两级寄存器分别放在两个时钟域里,结果同步了个寂寞。另外,同步器链会增加延迟,这个延迟会影响空满判断的实时性。

4.4 空满判断——最容易被坑的地方

空满判断的逻辑其实不复杂,但细节很多。我直接说结论:

判断条件 说明
空标志 同步后的写指针 == 读指针(格雷码比较)
满标志 写指针的格雷码 == 同步后的读指针格雷码取反(最高两位取反)

为什么要取反?因为满的时候,写指针比读指针多绕了一圈。格雷码下,绕一圈的标志就是最高位和次高位都取反。举个例子:深度为8的FIFO,写指针绕了一圈回到0,读指针还在0,这时候是满的。但格雷码比较时,0和0相等,会误判为空。所以满标志的判断要特殊处理。

我曾经犯过一个错误:空满判断用的指针位宽不够。深度为16的FIFO,地址需要4位,但指针需要5位(多一位用来标记回绕)。结果FIFO深度用到一半就报满了,查了半天才发现是位宽问题。

避坑指南:指针位宽 = log2(深度) + 1。多出来的那一位用来区分空和满。这个+1很容易忘,我吃过亏。

4.5 完整设计流程

我总结一下异步FIFO的设计步骤:

  1. 确定深度和位宽:深度选2的幂次,位宽根据数据决定
  2. 设计双口RAM:写时钟域写,读时钟域读
  3. 生成二进制指针:各自时钟域内递增
  4. 转格雷码:二进制转格雷码,准备跨时钟域
  5. 同步器链同步:写指针同步到读时钟域,读指针同步到写时钟域
  6. 格雷码转二进制:同步后的格雷码转回二进制(用于比较)
  7. 空满判断:根据比较结果生成空/满标志

嗯,这套流程我用了十几年,基本没出过问题。你刚开始做的时候,建议先仿真验证一下空满标志的时序,别急着上板。异步FIFO的仿真比同步FIFO难搞,因为跨时钟域的时序仿真工具不一定能准确模拟亚稳态。我一般会在仿真里故意加一些时钟抖动,看看空满标志会不会误判。

最后说一句:如果你用的是Xilinx或Altera的FPGA,它们自带的FIFO IP核其实已经处理好了这些问题。但作为工程师,理解底层原理还是很有必要的。毕竟,出了问题你得知道怎么查,对吧?