概述
本页是 FIFOs系列的最后一篇,展示了如何将现有的 FIFO 修改为另一个。首先,如何将“标准 FIFO(standard FIFO)”变成 FWFT FIFO,然后反过来。接下来是一些更高级的方法来改进 FIFO的时序,即让它在更高的频率下工作。
从实际角度来看,除非您在实现时序约束(timing constraints)时遇到问题,否则阅读此页面毫无意义,而这个问题与 FIFO有关。此页面的内容很难,对于 FIFOs的常规使用来说并非必需。不过,为了训练您在设计逻辑(尤其是处理数据的逻辑)时要用到的技能,它可能值得您付出努力。
没有直接关系,还有另一页展示了如何在外部存储器的帮助下创建一个非常深的 FIFO (通常是 DDR memory,但任何用 AXI 接口包装的东西都可以)。这个技巧的好处是,即使这个巨大的 FIFO 可以和它使用的外部存储器一样深,所有这些对应用逻辑都是透明的: 它具有与 baseline FIFO相同的接口。
应该提一下,我多年前在这个页面上写了 Verilog 代码,所以编码风格与今天略有不同。
标准 FIFO 至 FWFT FIFO
只是为了从前面的页面快速回顾一下 FWFT FIFOs : 对于“标准 FIFO”, @empty 端口为低意味着在 @rd_en 在上升沿(rising clock edge)上为高之后,有效数据将显示在 FIFO的输出(output)上。 FWFT FIFO 会在数据可用时立即在输出上显示数据,因此 @empty 信号为低意味着输出上的数据有效。
@rd_en 的含义也不同: 对于“标准 FIFO”,它的意思是“给我带来数据”。在 FWFT FIFO 上,它类似于“我刚刚使用了数据,如果你有下一个数据,请给我带来”。
这就是将“标准 FIFO”变成 FWFT FIFO的模块。毫不奇怪,它只是操纵 @rd_en 和 @empty。其余的信号只是通过。
module basic_fwft_fifo(rst,
rd_clk, rd_en, dout, empty,
wr_clk, wr_en, din, full);
parameter width = 8;
input rst;
input rd_clk;
input rd_en;
input wr_clk;
input wr_en;
input [(width-1):0] din;
output empty;
output full;
output [(width-1):0] dout;
reg dout_valid;
wire fifo_rd_en, fifo_empty;
// orig_fifo is just a normal (non-FWFT) synchronous or asynchronous FIFO
fifo orig_fifo
(
.rst(rst),
.rd_clk(rd_clk),
.rd_en(fifo_rd_en),
.dout(dout),
.empty(fifo_empty),
.wr_clk(wr_clk),
.wr_en(wr_en),
.din(din),
.full(full)
);
assign fifo_rd_en = !fifo_empty && (!dout_valid || rd_en);
assign empty = !dout_valid;
always @(posedge rd_clk or posedge rst)
if (rst)
dout_valid <= 0;
else
begin
if (fifo_rd_en)
dout_valid <= 1;
else if (rd_en)
dout_valid <= 0;
end
endmodule
我通常会在这里解释代码,但这只会重复上一页对 FWFT FIFO 的解释。
FWFT FIFO 至标准 FIFO
这真的很简单。由于来自 FWFT FIFO 的低 @empty 意味着输出端口(output port)上存在数据,因此创建一个寄存器(register)在 @rd_en 高时采样此数据。
所以就是这样:
module standard_fifo(rst,
rd_clk, rd_en, dout, empty,
wr_clk, wr_en, din, full);
parameter width = 8;
input rst;
input rd_clk;
input rd_en;
input wr_clk;
input wr_en;
input [(width-1):0] din;
output empty;
output full;
output [(width-1):0] dout;
reg [(width-1):0] dout;
wire [(width-1):0] dout_w;
always @(posedge rd_clk)
if (rd_en && !empty)
dout <= dout_w;
fwft_fifo wrapper
(
.wr_clk(wr_clk),
.rd_clk(rd_clk),
.rst(rst),
.din(din),
.wr_en(wr_en),
.rd_en(rd_en && !empty),
.dout(dout_w),
.full(full),
.empty(empty)
);
endmodule
请注意,仅对 @dout 进行了操作。 @empty 按原样通过: 如果为高,则 @dout_w 无效,因此 @dout 无法从中采样值。
改进时序的技巧
欢迎来到 FIFOs上这四页的总决赛。这绝对是最难阅读的部分。
所以时不时地,当试图弄清楚为什么 FPGA 设计没有达到时序约束(即达到所需的时钟频率)时,事实证明关键路径(critical path)开始和/或结束于 FIFO。让我们先回顾一下容易解决的案例,并以硬螺母完成。
当 @empty 和/或 @full 在关键路径中时
@empty 信号和 @full 信号可能出现在关键路径中,特别是如果 @wr_en 和 @rd_en 是其中的组合函数(combinatorial functions)时。这主要是因为这些信号通常不仅用于请求 FIFO的写入操作或读取操作,而且还充当应用逻辑的使能信号(enable signals),用于消费或生成数据: 如果数据没有流动,逻辑也会冻结。
因此,往往有很多逻辑等式(logic equations)依赖 @wr_en 和 @rd_en,而逻辑函数往往相当复杂。结果是高扇出(fanout)。所有这一切都归结为有问题的传播延迟(propagation delay)。
在任何正确编写的 FIFO中,@empty 和 @full 都是触发器(flip-flops)的输出,因此没有太多改进之处。但是因为 FPGA的软件经常将 FIFO 作为 synthesized 网表提供,所以为了减少它们的扇出而复制这些寄存器是不可能的(或者至少是困难的)。此外,在 FPGA的逻辑阵列(logic fabric)上,这些寄存器和使用其输出值的应用逻辑之间可能存在很大的物理距离。在大型 FPGAs上,这可以对路径(paths)的时延(delay)做出至关重要的贡献。
此页面上讨论 @almost_empty 和 @almost_full时已给出了此问题的修复。通过使用这些端口, @wr_en 和 @rd_en 的输出可以是寄存器。这解决了组合函数的问题,并且还允许控制这些信号的扇出。最重要的是,这还有助于工具将这些寄存器放置在更靠近消耗其值的逻辑的位置,因此有助于减少传播延迟。
当 @wr_en 和/或 @din 在关键路径中时
这种情况绝对是最容易解决的。只需添加一层寄存器。就像是
always @(posedge wr_clk)
begin
wr_en_reg <= wr_en;
din_reg <= din_reg;
end
然后将 @wr_en_reg 和 @din_reg 连接到 FIFO 。为防止 FIFO 与溢出(overflow)发生冲突,应使用 @almost_full 而不是 @full。或者更一般地说,填充 FIFO 的阈值应该减一。
当 @rd_en 和/或 @dout 在关键路径中时
现在我们变得严肃起来。这不仅是一个相对难以解决的问题,而且也是最有可能发生的问题。有几个原因:
- @rd_en 信号用于 FIFO中的组合逻辑(combinatorial logic),同时还用于计算下一个读地址(read address)。因此, FIFO 本身会贡献一定量的时延。
- 生成 @rd_en 的应用逻辑通常是可以复杂的组合函数。这个逻辑函数可能由状态寄存器(state registers)和来自逻辑阵列不同部分的几 flags 组成。
@dout:
- FIFO的数据输出(data output)通常直接连接到 block RAM。与 FPGA的触发器相比,这些 RAMs 的 clock-to-output 时序明显更差。如果 FIFO 是用几 RAMs实现的,它们的数据输出被插入到一个多路选择器(multiplexer)中,所以 @dout 就是这个组合逻辑(combinatorial logic)的结果。这增加了更多的时延。
- 应用逻辑可以使用组合函数中 @dout 的值。
- FIFO的 RAMs 和应用逻辑在 FPGA 上的物理距离可以在大 FPGAs上加上时延。
所以目标是消除 @rd_en 和 FIFO的逻辑之间的组合路径(combinatorial path),对 @dout做同样的事情。
仅分离 @dout的组合路径
我并不是真的打算将此作为解决方案提出,但讨论可能有助于将其理解为为掌握下一步做准备。如果这只是让您感到困惑,请跳过本节。
所以假设我们只想分离 @dout的组合路径。请注意,将 FWFT FIFO 转换为“标准 FIFO”(如上所示)的 wrapper 模块正是这样做的: 它添加了一个寄存器,通过这样做,它结束了 @dout的组合路径。但这需要 FWFT FIFO 作为起点。
但是有 wrapper 模块将“标准 FIFO”转换为 FWFT FIFO。那么也许来回转换 FIFO ?或者编写一个等效的模块?无论哪种方式,这种形式的解决方案都会使 @rd_en的情况恶化。
然而,这个解决方案值得仔细研究: 转换为 FWFT FIFO 仅包括跟踪 wrapped FIFO的 @dout 何时有效,并在 @dout 无效时(和/或外部 @rd_en 为高电平时)保持 @fifo_rd_en 为高电平。
转换回“标准 FIFO”是通过在 @rd_en 为高时将 wrapped FIFO的 @dout 的值复制到寄存器来完成的。
所以总而言之,第一种机制尽可能保持 wrapped FIFO的 @dout 有效,第二种机制在外部 @rd_en 请求时将 @dout 复制到另一个寄存器。
但这并不能解决 @rd_en的组合路径的问题: 为了允许连续读取,必须在外部 @rd_en 为高电平的每个时钟上从原始 FIFO 读取一个字。否则 FWFT的 @dout 将变为无效,因为它已被消耗但未更新。因此,这个内部 FIFO的 @rd_en 必须是外部 @rd_en的组合函数。如果我们想改变这一点,需要在 @dout的路径中添加另一个寄存器,如下图所示。
将组合路径与 reg_fifo分离
不用多说,这就是 reg_fifo 模块,它为 @rd_en 和 @dout分离了组合路径:
module reg_fifo(rst,
rd_clk, rd_en, dout, empty,
wr_clk, wr_en, din, full);
parameter width = 8;
input rst;
input rd_clk;
input rd_en;
input wr_clk;
input wr_en;
input [(width-1):0] din;
output empty;
output full;
output [(width-1):0] dout;
reg fifo_valid, middle_valid;
reg [(width-1):0] dout, middle_dout;
wire [(width-1):0] fifo_dout;
wire fifo_empty, fifo_rd_en;
wire will_update_middle, will_update_dout;
// orig_fifo is "standard" (non-FWFT) FIFO
fifo orig_fifo
(
.rst(rst),
.rd_clk(rd_clk),
.rd_en(fifo_rd_en),
.dout(fifo_dout),
.empty(fifo_empty),
.wr_clk(wr_clk),
.wr_en(wr_en),
.din(din),
.full(full)
);
assign will_update_middle = fifo_valid && (middle_valid == will_update_dout);
assign will_update_dout = rd_en && !empty;
assign fifo_rd_en = !fifo_empty && !(middle_valid && fifo_valid);
assign empty = !(fifo_valid || middle_valid);
always @(posedge rd_clk)
if (rst)
begin
fifo_valid <= 0;
middle_valid <= 0;
dout <= 0;
middle_dout <= 0;
end
else
begin
if (will_update_middle)
middle_dout <= fifo_dout;
if (will_update_dout)
dout <= middle_valid ? middle_dout : fifo_dout;
if (fifo_rd_en)
fifo_valid <= 1;
else if (will_update_middle || will_update_dout)
fifo_valid <= 0;
if (will_update_middle)
middle_valid <= 1;
else if (will_update_dout)
middle_valid <= 0;
end
endmodule
首先要注意的是, @dout 是在此模块中定义的寄存器,而 @rd_en 会导致此寄存器的更新。重要的是不要将这两个与连接到内部 FIFO的类似信号(即 @fifo_dout 和 @fifo_rd_en)混淆。
现在来看看这个模块是如何工作的。
了解流水线(pipeline)
就像 FWFT FIFO的转换器一样,有一个普通的 FIFO、 orig_fifo的例化(instantiation)。当 @fifo_dout 不包含有效值时, reg_fifo 模块中的逻辑尝试通过从 orig_fifo 读取一个字来保持 @fifo_dout的值有效。但除此之外,还有第二个寄存器,称为 @middle_dout。逻辑尝试通过尽可能取 @fifo_dout的值来保持此寄存器有效。
因此,可以将 @fifo_dout、 @middle_dout 和 @dout 视为将 orig_fifo 中的数据向前移动的流水线。
有两个寄存器会跟踪这些流水线 stages (pipeline stages)何时有效: @fifo_dout 有效时 @fifo_valid 为高电平, @middle_dout 有效时 @middle_valid 为高电平。
这个流水线(pipeline)的目的是能够绕过它的中间阶段: 当 @rd_en 高(而 @empty 低)时, @dout 会从 @middle_dout 或 @fifo_dout中获取新值,但它总是更喜欢 @middle_dout。也就是说,如果 @middle_dout 有效,则 @dout 使用 @middle_dout,否则使用 @fifo_dout 。这是如何分离 @rd_en的组合路径的关键将在后面解释。
所以首先,让我们看一下实现的细节。从 FIFO的数据输出到 fifo_reg的输出寄存器(output register)有两条独立的路径。在此图中,它们分别显示在左侧和右侧:
如果两个流水线 stages (pipeline stages)(@fifo_dout 和 @middle_dout)均无效,则 @empty 为高电平表示无处可取数据:
assign empty = !(fifo_valid || middle_valid);
保持这些流水线 stages 有效的尝试反映在
assign fifo_rd_en = !fifo_empty && !(middle_valid && fifo_valid);
这表示如果两个流水线 stages 中的任何一个无效,请尽可能从 orig_fifo读取。如果 @fifo_dout 已经有效,则在更新 @fifo_dout 的同时将其值复制到 @middle_dout 中(更多信息见下文)。
现在让我们看一下 @will_update_* 对的定义:
assign will_update_middle = fifo_valid && (middle_valid == will_update_dout);
assign will_update_dout = rd_en && !empty;
首先注意 @will_update_dout 等于 @rd_en 加上一个安全防护,当 @empty 为高电平时,防止从 reg_fifo 读取它。
接下来我们有 @will_update_middle,它控制着 @middle_out的更新,如下:
always @(posedge rd_clk)
if (will_update_middle)
middle_dout <= fifo_dout;
看上面 @will_update_middle的定义,更新 @middle_dout有两个条件: 一是 @fifo_dout 的值是有效的,这很明显,然后是 (middle_valid == will_update_dout)的表达。让我们将这个表达式分解为四个可能的选项,因为它解释了整个机器是如何工作的。请记住,所有这些仅在 @fifo_dout 有效时才起作用:
- @middle_valid == 0 和 @will_update_dout == 0。 @middle_dout 无效, @fifo_dout的值不会被复制到 @dout中。所以从 @fifo_dout更新 @middle_dout 。
- @middle_valid == 0 和 @will_update_dout == 1。 @middle_dout 是无效的,但是 @dout 会更新,所以显然它会从 @fifo_dout更新。因为 @fifo_dout的值被消耗了,所以不能复制到 @middle_out中,所以什么也不做。
- @middle_valid == 1 和 @will_update_dout == 0。 @middle_dout 是有效的,没有任何东西被复制到 @dout中。两个流水线 stages 都是有效的,并且无论如何都将保持有效。所以什么也不做。
- @middle_valid == 1 和 @will_update_dout == 1。 @middle_dout 有效,将被复制到 @dout中。因此,从 @fifo_dout 更新 @middle_dout 以保持有效。
请注意,当 @middle_valid 和 @fifo_valid 同时为高时, @fifo_rd_en 为低。因此,当最后两种情况发生时,不会从 orig_fifo 中获取任何数据。
特别是当两个流水线 stages 都有效且 @rd_en 为高电平时, @fifo_dout的值被复制到 @middle_dout中。并且由于 @fifo_rd_en 为低电平, @fifo_valid 会在后面的时钟周期(clock cycle)上变为低电平。这很好,因为 @middle_valid 将保持高电平,因此如果需要,它可以提供有关以下时钟周期的数据。在时钟周期之后 @fifo_valid 将再次变高(如果 orig_fifo中有数据)。
那么为什么没有定义 @fifo_rd_en 来保持 @fifo_dout 在这种特定情况下有效呢?因为那时 @fifo_rd_en 必须是 @rd_en的组合函数,这正是 dual-stage 流水线旨在避免的。
有了这个,是时候看看 @dout 是如何定义的了。除复位(reset)外,定义为:
always @(posedge rd_clk)
if (will_update_dout)
dout <= middle_valid ? middle_dout : fifo_dout;
用它的定义代替 @will_update_dout ,它变成:
always @(posedge rd_clk)
if (rd_en && !empty)
dout <= middle_valid ? middle_dout : fifo_dout;
这类似于从 FWFT FIFO 到“标准 FIFO”的转换,只有两个来源可供选择: 如果 @middle_dout 包含一个有效值,它就会被采用。否则, @fifo_dout。如果两者都无效,则 @empty 为高电平,因此无论如何都不会发生任何事情。
为什么这有帮助?至于输出时序(output timing), @dout 显然是寄存器。关于 @rd_en,注意 @fifo_rd_en 只依赖于 @middle_valid 和 @fifo_valid,都是寄存器。加上 @fifo_empty,也就是 orig_fifo 本身的输出(这条组合路径(combinatorial path)是必然的)。因此, @fifo_rd_en 不依赖于外部 @rd_en,因此从 @rd_en 到 orig_fifo没有组合路径。
跟踪流水线 stages的寄存器的有效性
只是为了完成图片: 两 *_valid 标志告诉我们相关的寄存器是否包含有效数据。关于 @fifo_valid:
if (fifo_rd_en)
fifo_valid <= 1;
else if (will_update_middle || will_update_dout)
fifo_valid <= 0;
这就像上面从“标准 FIFO”到 FWFT FIFO 的转换中对 @dout_valid 的定义: 当上升沿上的 @fifo_rd_en 为高时, @fifo_valid 会因此而变高。这是有道理的,因为如果我们从 orig_fifo读取数据,那么这 FIFO的输出就被认为是有效的。但是如果 @fifo_rd_en 较低,并且数据被复制到 @middle_dout 或 @dout之一,我们不再认为 @fifo_dout 有效: FIFO的输出刚刚用过, FIFO 没有换新数据。
@middle_valid 遵循相同的逻辑:
if (will_update_middle)
middle_valid <= 1;
else if (will_update_dout)
middle_valid <= 0;
当 @will_update_middle 为高电平时,数据被复制到 @middle_dout,因此 @middle_valid 也变为高电平。否则,如果 @middle_dout 中的数据复制到 @dout, @middle_valid 变为低。 @will_update_dout 是这一点的充分条件,因为从上面回想一下, @dout 在可能的情况下更喜欢从 @middle_dout复制。
它真的有效吗?
这个模块非常复杂,需要一个几乎正式的证明来证明它可以工作。所以回答这个问题的一种方法是询问两个流水线 stages、 @fifo_dout 和 @middle_dout中有多少是有效的。这个值在 reg_fifo 模块中没有定义,但它可以定义为
wire [1:0] valid_count;
assign valid_count = fifo_valid + middle_valid;
这个虚构的 @valid_count 显然可以取 0、 1 或 2的值。它向上或向下计数如下:
- @valid_count 在 @fifo_rd_en 为高且 (rd_en && !empty) 为false的每个时钟周期(clock cycle)上加一。
- @valid_count 在 @fifo_rd_en 为低且 (rd_en && !empty) 为true的每个时钟周期上减一。
- 否则, @valid_count 不会改变。
看看逻辑等式,并说服自己这三个陈述是正确的。
那么让我们看看当 orig_fifo 中有数据并且应用逻辑想要连续读取时会发生什么:
reg_fifo的逻辑试图通过读取 orig_fifo将 @valid_count 推向 2。另一方面,当 @valid_count 不为零时, @empty 为低电平,因此一旦 @valid_count 为 1, @rd_en 就被允许变高。因此,当 @valid_count 为 1 时, @fifo_rd_en 将为高电平,因为 @valid_count 不是 2。
但是 @valid_count 不会达到 2 的值,因为 @rd_en 通过保持高来阻止它。因此, @fifo_rd_en 和 @rd_en 都保持高电平, @valid_count 保持为 1 的数据流。除了开头,数据从 @fifo_dout 复制到 @dout。
当 orig_fifo 变空时,这个盈亏平衡被打破: 在这种情况下, @valid_count 变为零,因为 @fifo_rd_en 不再允许为高电平。另一个决胜局是当 FIFO 不为空时, @rd_en 变低是因为应用逻辑不想阅读更多内容: 在这种情况下, @valid_count 上升到 2,并保持在那里。
但后来,当 @rd_en 再次变高时, @valid_count 下降到 1,只有在那个时候 @fifo_rd_en 才变高(除非 orig_fifo 为空)。
再一次, @valid_count 只是模块中未实现的理论信号。希望这个解释有助于理解为什么两个额外的流水线 stages 保证数据的连续流动。
使用说明
此模块可用作其包装的“标准 FIFO”的替代品。从功能的角度来看,没有任何变化。然而, orig_fifo 会看到 @rd_en、 @dout 和 @empty的行为略有变化,但只要 orig_fifo 的行为与 FIFO 一样正确(这是一个安全的假设),这并不重要。与写入数据相关的端口是通过原封不动的,因此这些没有任何变化。
由于模块增加了几个流水线 stages, orig_fifo的 fill 计数器可能呈现比 reg_fifo 中存储的总字数(即 orig_fifo 中存储的字数以及流水线 stages 一起计算的字数)更低的值。因此,如果在 orig_fifo上启用 @almost_empty 或类似的端口,它们可能会呈现出悲观的画面。
reg_fifo 的一个小缺点是它的 @empty 输出不是寄存器,而是两个寄存器的组合函数。这不是时序的最佳选择,但在大多数用例中影响很小。这可以通过以与 @next_words_in_ram相同的精神定义组合寄存器(combinatorial registers)来解决,如 @next_fifo_valid 和 @next_middle_valid ,如本页所示。这里没有实现,主要是因为 reg_fifo 已经足够复杂了。
带有改进的时序的 FWFT FIFO
为了结束这个话题,下面是模块,它与 reg_fifo做同样的事情,但给应用逻辑一 FWFT FIFO 。请注意,它基于 standard FIFO,而不是 FWFT FIFO。所以不要混淆这个...
从 reg_fifo 到模块的过渡在很大程度上与我已经讨论过的有关 FWFT FIFOs的情况相同。
module fwft_reg_fifo(rst,
rd_clk, rd_en, dout, empty,
wr_clk, wr_en, din, full);
parameter width = 8;
input rst;
input rd_clk;
input rd_en;
input wr_clk;
input wr_en;
input [(width-1):0] din;
output empty;
output full;
output [(width-1):0] dout;
reg middle_valid, dout_valid;
reg [(width-1):0] dout, middle_dout;
wire [(width-1):0] fifo_dout;
wire fifo_empty, fifo_rd_en;
wire will_update_middle, will_update_dout;
// orig_fifo is "standard" (non-FWFT) FIFO
fifo orig_fifo
(
.rst(rst),
.rd_clk(rd_clk),
.rd_en(fifo_rd_en),
.dout(fifo_dout),
.empty(fifo_empty),
.wr_clk(wr_clk),
.wr_en(wr_en),
.din(din),
.full(full)
);
assign will_update_middle = !fifo_empty && (middle_valid == will_update_dout);
assign will_update_dout = (middle_valid || !fifo_empty) && (rd_en || !dout_valid);
assign fifo_rd_en = !fifo_empty && !(middle_valid && dout_valid);
assign empty = !dout_valid;
always @(posedge rd_clk)
if (rst)
begin
middle_valid <= 0;
dout_valid <= 0;
dout <= 0;
middle_dout <= 0;
end
else
begin
if (will_update_middle)
middle_dout <= fifo_dout;
if (will_update_dout)
dout <= middle_valid ? middle_dout : fifo_dout;
if (will_update_middle)
middle_valid <= 1;
else if (will_update_dout)
middle_valid <= 0;
if (will_update_dout)
dout_valid <= 1;
else if (rd_en)
dout_valid <= 0;
end
endmodule
关于 FIFOs的系列到此结束。