01signal.com

FPGA FIFO:不同的特性和变体

范围

本页是关于 FIFO 的五页系列教程中的第二页。在上一页介绍了 FIFO 的基础知识之后,现在来讨论常见的变体和附加功能。实际使用中,FIFO 经常是由下面介绍的几个选项组合配置而成的。

单时钟 FIFO

尽管“基线 FIFO”有两个输入端口,用来接入互不相关(unrelated)的时钟,但很多时候,两侧信号其实都与同一个时钟同步。把这个同一个时钟同时接到 @wr_clk 和 @rd_clk 上,是完全没问题的。不过,既然两个时钟输入端接的是同一个时钟,就不存在跨时钟域的问题,因此 FIFO 内部会包含一些多余的逻辑。关于时钟域的更多内容可以参见这里。

因此,每家 FPGA 厂商都会提供两类 FIFO:双时钟 FIFO 和单时钟 FIFO。它们也常被冠以其他名称:独立时钟(Independent Clock)FIFO 与共同时钟(Common Clock)FIFO,或者异步(Asynchronous)FIFO 与同步(Synchronous)FIFO。上一页介绍的“基线 FIFO”就是一种双时钟 FIFO。

单时钟 FIFO 没有任何同步逻辑,因为内部所有逻辑都与同一个时钟同步。因此,其复位输入也必须与其他端口一样,与同一个时钟同步。

除了不浪费 FPGA 逻辑之外,使用单时钟 FIFO 的另一个好处是清晰明了。这等于响亮而明确地宣告:这里不打算牵涉两个时钟。

简而言之:如果 FIFO 并不是用来连接两个时钟域的,那就选择单时钟 FIFO。

FWFT FIFO

正如上一页所强调的,从“基线 FIFO”读取数据的流程是:把 @rd_en 拉高,然后在下一个时钟周期从 FIFO 的 @dout 端口取得数据值。这多少有点违反直觉:既然数据已经在 FIFO 里了,为什么还要先请求一下?FIFO 为什么不能直接把数据放到 @dout 上,然后告诉我可以放心使用呢?

于是就有了一个常见的变体,它做的正是这件事,叫做首字直通(First Word Fall Through)FIFO,即 FWFT(有时也叫 read-ahead、show-ahead 或 look-ahead)。与之相对的,非 FWFT 的 FIFO 常被称为“标准 FIFO”(Standard FIFO)——谁能告诉我这个标准出自哪里?

其想法很简单:当 FWFT FIFO 不再是空的时候(因为数据被写入其中),它就会把第一个数据字放到 @dout 上。应用逻辑接下来只需保持 @rd_en 为高,就能依次读出数据字。因此,差别其实只在于第一个数据字的呈现方式。

不过,理解 FWFT FIFO 的一个更容易的方法,是认识到它其中两个端口的意义已经变了:FWFT FIFO 上的 @rd_en 实际上表示“我已经消费掉了 @dout 上的数据,可以送来下一个了”;而 @empty(空)实际上表示“@dout 上的数据无效”。

没有变化的一点是:如果 @empty(空)为高,@rd_en 就不应该为高。你总不能说自己消费了无效数据吧。所以这条规则仍然不变,只是理由不同了。

下面这段波形展示了从 FWFT FIFO 读取数据可能是什么样子:

Example waveform for reading words from a FWFT FIFO

注意,@dout 上出现第一个有效值是在 @rd_en 为低的时候,而且 @empty(空)与有效值的出现同时变为低。正如刚才所说,在 FWFT FIFO 上,@empty(空)表示“@dout 无效”,这段波形正体现了这一点。

还要注意,@rd_en 的第一个脉冲并没有从 FIFO 中读出新的数据字,而是让 @empty(空)重新变高了。与此同时,@dout 的值也变成了未知。实际上,当 @empty(空)变高时,@dout 通常并不会发生变化,但你不能依赖这一点。

此后,FIFO 再次把一个数据字放到 @dout 上,并把 @empty(空)拉低。应用逻辑读出了三个数据字,然后把 @rd_en 拉低。总的来说,应用逻辑从 FIFO 中消费了四个或五个数据字。

注意,这段波形并不能告诉我们应用逻辑是否也使用了 D4 这个值。它可能忽略了第五个数据字,那意味着它只消费了四个数据字;也可能使用了第五个数据字的值。从波形中能确定的只有:应用逻辑在四个时钟周期之后把 @rd_en 保持为低,因此它没有让 FIFO 继续更新 @dout。

另外要注意的是,我们不知道 FIFO 存储器中是否还有更多数据。这段波形结束时 @empty(空)为低,只表示 @dout 上的数据有效。

现在,我们把上一页中的 Verilog 例子改一下。这段代码同样是计算从 FIFO 中读出的所有数据的累计和:

assign rd_en = !empty; // If @dout's value is valid, it's consumed.

always @(posedge rd_clk)
  if (!empty) // FIFO is FWFT, so !empty means @dout contains valid data
    sum <= sum + dout; // Don't try this at home: @sum is never reset.

与上一个例子不同,这里使用的是 FWFT FIFO,因此不需要用一个寄存器来保存上一个时钟周期的 @rd_en 值。相反,当 @empty(空)为低时,可以直接消费 @dout 上的数据。这个简单的规则之所以可行,是因为当 @empty(空)为低时,@rd_en 为高,所以 FIFO 输出的每个数据字在 @dout 上恰好有效一个时钟周期。

在结束 FWFT 这个话题之前,我想再谈一个稍微有点不相关的问题。“标准”FIFO 与 FWFT FIFO 之间的区别,其实反映了一个任何两个逻辑模块之间进行数据流传输时的基本问题:接收方需要主动“请求”数据吗?还是说,发送方应尽快把数据摆出来,接收方只需确认可以继续即可?当某个模块向另一个模块传递数据时,一定要问自己这个问题;特别是要问一问:双方对这件事的理解是否一致?

非对称 FIFO

通常,FIFO 是允许把 @din 和 @dout 两侧的位宽配置成不同值的。例如,当数据以 32 位字的形式到达 FPGA,而应用逻辑却按字节(即每字 8 位)来处理这些数据时,这种功能就很有用。这种情况下,可以把写侧位宽设为 32 位,读侧位宽设为 8 位。两侧的行为照常,只是需要四个读周期才能消费掉一次写周期写入的一个数据字。

当读侧位宽大于写侧位宽时,行为也符合预期:写入 FIFO 的数据要等到积累了足够的数据,足以凑成一个读侧位宽大小的数据字之后,才能在读侧被读到。

至于数据字是按什么顺序打包的,似乎所有 FIFO 都使用小端序(Little Endian)。例如,一个 FIFO 把 32 位字打包成 8 位字时,从 FIFO 读出的第一个数据字的位范围是 [7:0],然后是 [15:8]、[23:16] 和 [31:24]。

不过,如果你想使用这项功能,还是务必查阅文档。

@empty 与 @full 上的组合逻辑依赖

@empty(空)和 @full(满)这两个端口有一个共同的缺点:应用逻辑必须在同一个时钟周期内对它们作出响应。换句话说,@rd_en 必须是一个依赖于 @empty(空)的组合逻辑函数,以确保这两个信号不会在同一个时钟周期内同时为高(如前所述,这是不允许的)。同样,@wr_en 也必须是一个依赖于 @full(满)的组合逻辑函数。

使用组合逻辑函数可能会成为实现时序收敛(timing closure)的障碍。当时钟频率较高(相对于 FPGA 的规格而言)或逻辑函数比较复杂时,这就可能变成问题。问题的主要根源在于:@rd_en 和 @wr_en 常常被用在产生或消费数据的逻辑中;尤其是为一大片逻辑计算时钟使能(clock enable)的逻辑函数,可能会依赖这些信号。例如,如果有一条很长的流水线(pipeline)在处理来自 FIFO 的数据,那么当来自 FIFO 的数据流暂时中断时,流水线中的所有逻辑都必须停下来。

其实,要说准确的话,有一种方法可以避免这种组合逻辑函数。例如,假设 @wr_en 声明为一个寄存器,而 @want_to_write 是表示应用逻辑在某一时刻需要写入数据的信号。可以这样做:

always @(posedge wr_clk)
  wr_en <= want_to_write && !wr_en && !full;

这可以保证 @wr_en 和 @full(满)永远不会在同一个时钟周期内同时为高,因为 @full(满)只能在 @wr_en 为高之后的那个时钟周期才可能变高。表达式中的 !wr_en 部分确保 @wr_en 不会连续两个时钟周期都为高。因此,如果 @full(满)变高了,那么在第一个时钟周期里,@wr_en 会因为 !wr_en 而变低;随后 @wr_en 又会因为 @full(满)本身而继续保持低。

但这种解决方案要求 @wr_en 有一半时间必须为低,结果只能利用 FIFO 数据率的一半。这通常是无法接受的。

@rd_en 也可以采用同样的解决方案,而且同样存在只能利用一半数据率的问题。

上面的讨论其实是在为下一节做铺垫:也就是“almost(几乎)”端口。

almost full、almost empty 以及类似端口

FIFO 可以增加两个可选端口:@almost_full(几乎满)端口和(或)@almost_empty(几乎空)端口。

@almost_empty 与 @rd_clk 同步,它与 @empty(空)相似,但有一点小差别:@almost_empty 不仅在 FIFO 为空时为高,在 FIFO 中恰好还有一个数据字可读时也同样为高。

类似地,@almost_full 与 @wr_clk 同步,它不仅在 FIFO 已满时为高,在 FIFO 恰好还能再写入一个数据字时也为高。

这两个输出端口的名称取决于 FPGA 厂商及其提供的软件,但通常都可以添加具有相同功能的端口。只是偶尔会有某个 FIFO 变体不支持这些端口。

这些端口有什么用呢?因为下面这种做法完全可以正常工作:

always @(posedge wr_clk)
  wr_en <= want_to_write && !almost_full;

不需要组合逻辑,也不需要跳过一半的写周期。当 @almost_full 为高时,@wr_en 不需要在同一个时钟周期内变低,只要在下一个时钟周期变低就可以了。这样一来,@almost_full 变高之后可能还会发生一次写操作。不过这没关系,因为 FIFO 中确实还留有一个数据字的位置。

请注意,如果 FIFO 在被填满的过程中 @want_to_write 持续为高,那么最后一次写操作会恰好把 FIFO 填满。否则,FIFO 最终可能是“几乎满”的状态:如果 @wr_en 因为 @want_to_write 为低而没有拉高,FIFO 也因此没有完全填满,那就不会再有第二次机会了。@almost_full 要等到另一侧从 FIFO 读出数据后才会变低,那时 FIFO 中已经有容纳两个或更多数据字的空位了。

这种事情通常无关紧要,但为了讨论起见,下面这个写法可以保证最后一个数据字也能被用到:

always @(posedge wr_clk)
  wr_en <= want_to_write && (!almost_full || (!full && !wr_en));

这个 @wr_en 表达式在大多数时候依赖于 @almost_full,只有在恰好还能写入一个数据字的情况下例外。这时,@wr_en 才像前面那个带 !wr_en 的表达式一样,依赖于 @full(满)和 @wr_en。

不过,我严重怀疑最后一个 @wr_en 表达式是否真的有实际用处。

@almost_empty 的情况与此类似,所以下面这种写法也是可以的(但别抄进你的代码里):

always @(posedge rd_clk)
  rd_en <= want_to_read && !almost_empty;

与 @almost_full 一样,这里也存在“最后一个数据字”的问题:如果 @rd_en 因为 @want_to_read 为低而没有拉高,那就会错失机会,要等到 FIFO 中重新写入更多数据之后才行。与 @almost_full 的情况不同,在某些场景下这确实可能是个问题:如果 @almost_empty 为高,但 FIFO 并非真正为空,那就意味着 FIFO 中本来有数据等着被读取,但这些数据却一直卡在 FIFO 里出不来。

所以,下面这种写法才比较保险:

always @(posedge rd_clk)
  rd_en <= want_to_read && (!almost_empty || (!empty && !rd_en));

填充计数器

应用逻辑经常按块执行操作。例如,某段逻辑从 FIFO 中读出固定长度的数据包,然后把这些数据包通过某种物理介质发送出去。由于数据存在 FIFO 中,应用逻辑需要先知道 FIFO 中已有足够的数据来构成一个完整的数据包,然后才能开始读取。

同样,应用逻辑也经常产生固定数量的数据存入 FIFO,例如从外部存储器读回一段突发数据。除非 FIFO 中有足够的空间可以容纳整个突发数据,否则就不应该开始该操作。

为此,FIFO 通常支持填充计数器(fill counter),以及可编程空(programmable empty)和可编程满(programmable full)端口。填充计数器(有时也叫数据计数器,data counter)的形式多种多样,很大程度上取决于 FPGA 厂商,所以务必要仔细阅读 FIFO 的文档。主要有三个问题需要注意:

此外,还有可编程空和可编程满,它们是 @almost_empty 和 @almost_full 的扩展版本。其思路是:既然使用填充计数器的代码几乎肯定会写成这样:

assign dont_start_reading = (rd_data_count < 64);

那为什么不直接提供这样一个信号,并把它命名为 prog_empty 呢?同样,请仔细阅读 FIFO 的文档。

再次强调:当读取 FIFO 中的最后一个数据字很重要时,一定要问一问自己,你的逻辑是否确实会这样去做。这个问题与上面关于 @almost_empty 的讨论类似。

不用多说,如果你需要这些额外端口,在配置 FIFO 时必须主动请求添加它们。

AXI 接口

这个话题与前面内容没有直接关系,但因为它经常出现在 FIFO 的语境里,还是值得提一下以免混淆。

AXI 是 AMBA 标准中定义的一组接口规范,该标准由 ARM 推出。可以想见,带 AXI 接口的 FIFO 通常是用作 CPU 的外设。

相对于 AXI 接口,“基线 FIFO”的接口常被称为“native(原生)”接口。

AXI 接口主要有两种类型:“常规”AXI(通常是 AXI3、AXI4 或 AXI Lite),这是一种带地址和数据的总线;第二种是 AXI-S(流式 AXI),它针对数据流(可能按数据包划分)而设计。

当 FIFO 被配置成 AXI3 / AXI4 或 AXI Lite 时,它会额外增加一些逻辑,以便通过该接口作为一个带地址的外设连接到 CPU。关于这个我不再展开,因为那完全是另一个话题。

不过,由于流式接口与 FIFO 的行为有些相似,因此可以把 AXI-S 接口的握手信号转换成 “native” 接口。需要注意的是,AXI-S 往往还涉及其他需要一并处理的信号。

假设用于写入 FIFO 的 AXI-S 信号为 @axi_w_valid、@axi_w_ready 和 @axi_w_data,那么可以按下面方式连接到“标准”FIFO 的端口上:

assign axi_w_ready = !full;
assign wr_en = axi_w_valid && axi_w_ready;
assign din = axi_w_data;

同样,用于从 FIFO 读取数据的 AXI-S 信号 @axi_r_valid、@axi_r_ready 和 @axi_r_data,可以按下面方式连接到FWFT FIFO 的端口上:

assign axi_r_valid = !empty; // Non-empty means valid with FWFT FIFOs
assign rd_en = axi_r_valid && axi_r_ready;
assign axi_r_data = dout;

再次提醒,为了让上面这种接法正常工作,FIFO 必须是 FWFT 变体。

至此,本系列关于 FIFO 的第二页就结束了。下一页将展示如何用 Verilog 实现一个单时钟 FIFO。

本页由机器从英文翻译而来。如有疑问,请参阅原文。
Copyright © 2021-2026. All rights reserved. (dcc38493)