4. 异步FIFO架构:基本结构、双端口RAM、读写指针与空满标志

各位同学,今天我们来聊聊异步FIFO的核心架构。说实话,这个模块是我在项目中打交道最多的一个——跨时钟域处理,十有八九都得靠它。我刚开始做设计那会儿,总觉得FIFO不就是个先进先出的存储器嘛,有啥难的?结果第一次调异步FIFO,空满标志就出了bug,数据读出来全是乱的。嗯,从那以后,我对它的每一个细节都不敢马虎了。

4.1 异步FIFO的基本结构

异步FIFO,说白了就是两个时钟域之间的数据缓冲器。它要解决的核心问题只有一个:写时钟域的数据,怎么安全地交给读时钟域。

一个典型的异步FIFO,内部包含这几大块:

  • 双端口RAM——存数据的地方,读写端口独立
  • 写指针——指向下一个要写入的地址
  • 读指针——指向下一个要读出的地址
  • 空满标志生成逻辑——这是最关键的,也是最容易出错的
  • 同步器——把指针从写时钟域传到读时钟域,反过来也一样

你想想看,写时钟域和读时钟域频率可能不一样,相位也没关系。写指针在写时钟域里跑,读指针在读时钟域里跑。这两个指针要比较,才能知道FIFO是空还是满。但比较之前,必须先把指针同步到同一个时钟域里。这就是异步FIFO设计的核心难点。

核心要点:异步FIFO的本质,就是用双端口RAM做存储,用格雷码指针做跨时钟域传递,用指针比较产生空满标志。

我个人习惯把异步FIFO分成三个层次来理解:存储层(RAM)、控制层(指针)、接口层(同步器)。这样分层,调试的时候思路特别清晰。

4.2 双端口RAM

双端口RAM,顾名思义,有两个端口。一个端口专门用来写,另一个专门用来读。这两个端口完全独立,各有各的时钟、地址、数据线。

我在项目中用过两种实现方式:

  • 寄存器阵列——用寄存器搭的RAM,面积大,但读写出快。适合深度小的FIFO(比如深度4、8)。
  • SRAM硬核——芯片厂提供的SRAM宏单元,面积小,功耗低。适合深度大的FIFO(比如深度256、1024)。

双端口RAM的接口长这样:

module dpram #(
    parameter DATA_WIDTH = 8,
    parameter ADDR_WIDTH = 4
)(
    // 写端口
    input  wire                wr_clk,
    input  wire                wr_en,
    input  wire [ADDR_WIDTH-1:0] wr_addr,
    input  wire [DATA_WIDTH-1:0] wr_data,
    // 读端口
    input  wire                rd_clk,
    input  wire                rd_en,
    input  wire [ADDR_WIDTH-1:0] rd_addr,
    output reg  [DATA_WIDTH-1:0] rd_data
);

    reg [DATA_WIDTH-1:0] mem [0:(1<<ADDR_WIDTH)-1];

    // 写操作
    always @(posedge wr_clk) begin
        if (wr_en)
            mem[wr_addr] <= wr_data;
    end

    // 读操作
    always @(posedge rd_clk) begin
        if (rd_en)
            rd_data <= mem[rd_addr];
    end

endmodule

注意看,写操作和读操作用的是不同的时钟。这就是异步FIFO能跨时钟域工作的基础。写时钟域只管往里写,读时钟域只管往外读,互不干扰。

小提示:双端口RAM的读操作,我建议用rd_en使能控制。这样读指针变化时,RAM不会乱输出数据。我曾经见过有人直接把地址连到RAM上,结果读指针在同步过程中跳变,RAM输出了错误数据。

4.3 读写指针

读写指针,本质上就是地址计数器。写指针每写一次数据就加1,读指针每读一次数据也加1。当指针走到末尾时,它会折回到0,继续循环。

这里有个关键点:指针的位宽要比地址位宽多1位。为什么?

举个例子,FIFO深度是16,地址位宽是4位(0~15)。如果指针也用4位,那写指针从15加1变成0,读指针也从15加1变成0。这时候你没法区分FIFO是空还是满——因为指针相等,既可能是空(写指针追上了读指针),也可能是满(读指针追上了写指针)。

解决办法就是:指针用5位。最高位用来区分是空还是满。

  • 空标志:指针完全相等(包括最高位)
  • 满标志:指针的低4位相等,但最高位相反

我画了一张图,帮你理解指针的循环过程:

读写指针循环示意图(深度=8,指针位宽=4) 地址0 (0000) 地址4 (0100) 地址8 (1000) 地址12 (1100) 写指针 当前: 0010 读指针 当前: 1010 写方向 读方向 写指针最高位=0,读指针最高位=1 → FIFO非空

你看这张图,写指针当前是0010(地址2),读指针是1010(地址10)。低4位都是1010,但最高位不同。这说明读指针已经绕了一圈,FIFO里还有数据可以读。

4.4 空满标志

空满标志是异步FIFO的灵魂。标志错了,整个FIFO就废了。

空标志(empty):当读指针追上写指针时,FIFO为空。这时候读操作必须暂停,否则读到的是无效数据。

满标志(full):当写指针追上读指针时,FIFO为满。这时候写操作必须暂停,否则新数据会覆盖掉还没读走的数据。

但这里有个坑——指针比较必须在同一个时钟域里进行。写指针在写时钟域,读指针在读时钟域。怎么办?

答案是:把对方的指针同步过来,再比较。

  • 生成空标志时:把写指针同步到读时钟域,和读指针比较
  • 生成满标志时:把读指针同步到写时钟域,和写指针比较

同步器一般用两级触发器。但直接同步二进制指针会有问题——多个bit同时变化时,同步器可能采到中间值。所以指针要用格雷码编码,每次只变化1个bit。

格雷码的转换逻辑:

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

// 格雷码转二进制(组合逻辑)
integer i;
always @(*) begin
    binary[ADDR_WIDTH] = gray[ADDR_WIDTH];
    for (i = ADDR_WIDTH; i > 0; i = i - 1)
        binary[i-1] = gray[i-1] ^ binary[i];
end

空满标志的生成逻辑:

// 空标志(在读时钟域)
assign empty = (rd_ptr == wr_ptr_gray_sync);

// 满标志(在写时钟域)
assign full  = (wr_ptr_gray == {~rd_ptr_gray_sync[ADDR_WIDTH:ADDR_WIDTH-1],
                                 rd_ptr_gray_sync[ADDR_WIDTH-2:0]});

注意满标志的判断条件:写指针的格雷码,和同步过来的读指针格雷码,最高两位取反后比较。这是格雷码特有的判断方式,和二进制指针不一样。

我曾经踩过的坑:有一次做异步FIFO,满标志总是提前拉高。查了两天才发现,是格雷码同步器的两级触发器之间,组合逻辑延迟太大,导致同步后的指针值不稳定。后来我在同步器前后都加了寄存器,问题就解决了。记住:跨时钟域的路径上,不要有任何组合逻辑!

4.5 架构总结

好了,我们把异步FIFO的架构串起来看:

模块 所在时钟域 功能 注意事项
双端口RAM 写时钟域 + 读时钟域 存储数据 读写地址独立,时钟独立
写指针 写时钟域 生成写地址 用格雷码编码
读指针 读时钟域 生成读地址 用格雷码编码
空标志生成 读时钟域 判断FIFO是否为空 同步写指针到读时钟域
满标志生成 写时钟域 判断FIFO是否为满 同步读指针到写时钟域
同步器 跨时钟域 传递格雷码指针 两级触发器,路径上无组合逻辑

这个架构,说白了就是一套闭环的控制逻辑。写时钟域控制写入,读时钟域控制读出,中间用同步器交换指针信息。只要每个环节都做对了,异步FIFO就能稳定工作。

我的个人习惯:设计异步FIFO时,我会先画一个架构图,把每个模块的时钟域标清楚。然后写代码时,严格按照时钟域来划分always块。这样后期调试,一眼就能看出问题出在哪个时钟域。

嗯,异步FIFO的架构就讲到这里。记住我说的:双端口RAM是基础,格雷码指针是关键,空满标志是灵魂。下一节我们深入讲讲格雷码指针的细节,以及怎么避免那些常见的坑。


可可精品资料库