书接上回:从 IP 开始,学习数字逻辑:FIFO 篇(上)

作者:李凡
来源:https://zhuanlan.zhihu.com/p/47884776

为 FIFO 编写 testbench

在使用各种手段测试我们的 FIFO ip 之前,我们首先得写一个 testbench。

testbench 是什么,Vivado 会告诉你就是一个普通的 v 文件。在这个 v 文件中,实例化需要被测试的模块,然后写一些激励语句:

FIFO,好好干,年底升职加薪。。

。。激励是不可能这么激励的。激励语句指的是为待测试模块的输入端口信号指定电平状态,观察输出端口的信号是否满足设计功能。比如

rst_n = 0;//好了,大家休息下,我们复位了
#100;     //100ns 后
rst_n = 1;//好了 大家肯定休息好了 我们该干活了。

testbench 唯一特殊的一点可能是他不需要真正的输入输出端口。只需要在模块中,将待测试模块的输入端口连接到声明的 reg 变量,将输出端口连接到 wire 型变量。因为在 testbench 中需要改变待测试模块的输入信号,但只观察而不需要更改输出信号。

那么如何生成 testbench 呢,和之前添加顶层文件的时候有一点小特殊:在 Add source 后选择添加 sim 文件而不是 design 文件。

这里给 testbench 文件的命名提个小建议,可以将 tb 文件的名字加上前缀 tb\_ 这样比较容易将 tb 文件与源文件区分。

那么如何编写 testbench ,其实很简单。悄悄说下 ISE 时代更简单,只要鼠标点点就行,现在还是要想点办法的。

首先,自己写,其实也很简单,实例化 FIFO 模块顶层,然后将输入端口声明为 reg 变量,输出端口声明为 wire 变量即可。

第二种办法:使用 Vivado Tcl 商店中的 Tcl 脚本工具。(这个我没用过)

第三种办法:暂时还不能用,但这里又要插一个广告了:我想写一个 VSCode 插件(想写,还没开始写插件,只写了 Python 生成 tb 的代码),可以很方便地生成 tb 文件,以下就是用该插件的代码生成的:https://zhuanlan.zhihu.com/p/45791661

`timescale 1ns / 1ps
////////////////////////////////////////////////////////////////////////////////
// Company: 
// Engineer:
//
// Create Date:
// Design Name:   fifo_top
// Module Name:
// Project Name:
// Target Device:
// Tool versions:
// Description: 
//
// 
//
// Dependencies:
// 
// Revision:
// Revision 0.01 - File Created
// Additional Comments:
// 
////////////////////////////////////////////////////////////////////////////////
module tb_fifo_top;

  //inputs 
  reg clk;
  reg rst;
  reg [7:0] din;
  reg wr_en;
  reg rd_en;

  //outputs 
  wire [7:0] dout;
  wire full;
  wire almost_full;
  wire empty;
  wire almost_empty;
  wire [3:0] data_count;
  wire prog_full;
  wire prog_empty;

  // Instantiate the Unit Under Test (UUT) 
  fifo_top uut (
      .clk(clk),
      .rst(rst),
      .din(din),
      .wr_en(wr_en),
      .rd_en(rd_en),
      .dout(dout),
      .full(full),
      .almost_full(almost_full),
      .empty(empty),
      .almost_empty(almost_empty),
      .data_count(data_count),
      .prog_full(prog_full),
      .prog_empty(prog_empty)  );

  initial begin
      // Initialize Inputs 
      clk = 0;
      rst = 0;
      din = 0;
      wr_en = 0;
      rd_en = 0;

      // Wait 100 ns for global reset to finish 
      #100; 

      // Add stimulus here 

  end
  always #10 clk = ~clk ;

endmodule

Testbench 的骨架还差一些工作:为 tb 的时钟添加时钟。我们假设时钟为 50M,即每 10 ns 时钟翻转一次。注意这实际上是一个 always 块,所以要写到 initial 块外部。

always #10 clk = ~clk ;

这个实际上等同一种更完整的写法:always 块在仿真中会不断无条件地触发,触发后翻转 clk,延时 10 秒,结束 always 块,但实际上结束后又进入下一次无条件触发。

always  begin
    clk = ~ clk;
    #10;
end

开始仿真

终于,我们可以用一些骚操作来仔细观察我们的 FIFO 了。

复位特性

首先关注 FIFO 的复位特性,我们的 FIFO 复位为高电平有效。在仿真开始时候复位电平设为高,100ns 后拉低复位电平,FIFO 开始工作。从下图中可以观察到 FIFO 的一些复位特性:

在 100 ns 时刻后,empty 信号 和 almost\_empty 信号因为 FIFO 为空,所以为高电平有效。但我们可以观察到 full 以及 almost full 信号确仍然保持高电平,实际上此时,FIFO 显然没有满,所以这两个信号是不正确的。他们需要一段时间,也就是直到 260 ns 时刻,恢复到正常的低电平,这说明这两个状态信号在复位后需要一段时间才能恢复正常。

接下来我们依次向 FIFO 写入 16 个数据,再依次读取。FIFO 的深度为 16。我们通过编写 testbench,连续产生 16 次 wr\_en 写有效信号,并每次 wr\_en 写有效时,写入数据加一。延迟一段时候后,再连续产生 16 次 rd\_en 读有效信号,将之前写入的数据全数读取出来。testbench 的写法如下(修改 initial 块)

 initial begin
      // Initialize Inputs 
      clk = 0;
      rst = 1;
      din = 0;
      wr_en = 0;
      rd_en = 0;

      // Wait 100 ns for global reset to finish 
      #100; 

      // Add stimulus here 
      rst = 0;
      #200;
    
      repeat (16) begin 
        wr_en = 1;
        #20;
        wr_en = 0;
        #20;
        din = din + 1'b1;
      end 
      #100
      repeat (16) begin 
        rd_en = 1;
        #20;
        rd_en = 0;
        #20;
      end    
  end

这里使用了 repeat,这个在 testbench 中的常用语法。repeat begin 块之间的语句会被多次重复执行,重复执行次数写在括号中。

将我们要仿真的 testbench 文件设置为 simulation 中的顶层文件,在文件上右击,选择 Set as Top 即可,顶层文件的左侧就会出现那个小小的,绿绿的图案。一般在只有一个 testbench 文件时,会被默认设为顶层。

在左侧导航栏中,选择 SIMULATION 中的 Run simulation – behavioral 开始仿真,那么问题来了:会对哪个文件进行仿真?自然是我们上一步中设置的仿真顶层文件了,这里不会给你选择的机会,会直接对顶层文件进行仿真。

在开始仿真之前,可以设置选用的仿真器。

我这里推荐初学者使用 Vivado 自带的仿真器,因为不需要多余的设置,开箱即用。虽然功能相比 Modelsim 等仿真软件确实有所不足,但等熟悉仿真之后,察觉 vivado 仿真器功能有限时,再转而使用 Modelsim 应该也不迟。

状态信号

嗯,从上方这张平淡无奇的仿真结果图中,我们似乎还是能找到一些亮点。首先来看三个空状态信号。

第一个空状态信号,在第一个 wr\_en 信号结束后的第一个时钟上升沿置低。almost\_empty 信号在第二个写使能信号后的时钟上升沿置低,代表此时 FIFO 中已经有超过一个数据。而 prog\_empty 我们自定义的“几乎”空信号,在写入三个数据后置低,因为我们设置的自定义阈值是 2,FIFO 中有超过两个数据后信号不再有效。不过我们可以观察到可编程信号和原生信号相比有一个周期的延时,如果对周期敏感的应用应当注意到这个小小的周期时延。

full 满信号和 empty 信号的特性完全相同,我们来看下 full 信号的置高与置低的过程。

在复位一段时间后,full 信号恢复正常。当写入第 14 个数据后,prog\_full 信号置起。写入第15 个数据后,在写有效信号高电平之后的第一个上升沿,almost\_full 信号置起。最后是 full 信号,prog\_full 信号仍然有一个时钟的延迟。

FIFO 提供了一组接口用于显示当前 FIFO 中的数据个数。在第一个数据写入后,data\_count 就变化为 1,之后每写入一个数据增长 1 。在某些情况下,我们需要记录写入 FIFO 的数据数量,比如我们需要在 FIFO 中缓存一帧 16 byte 长的数据,我们的 FIFO 出于多帧数据缓冲的需求,深度肯定远大于一帧数据的长度,那么我们显然无法依靠空,满信号进行判断。一方面可以自己使用逻辑对写使能进行计数,或者我们可以使用 FIFO 核提供的计数功能,该功能我没有验证过,但在同步的情况下,数据计数应该是完全准确的。

值得注意的是在写入第 16 个数据后,计数输出变为 0 ,这是个小失误,因为我们的四位计数值显然在记录 16 时溢出了,因此我们一般需要 log2(深度) + 1 位宽的计数值。

读延迟与 First Word Fall Through 特性

接下来我们求证一件配置 IP 核时看到的一行小字:

不知道大家对这行小字还有没有印象,没有的话可以看下上篇的ip核配置

所谓“读延迟:1”指的究竟是怎样的延迟?我们来看读取的时序波形:

第一行是读取的数据,第二行是读使能信号,最后一行是时钟。我们从第二个读使能信号来看会比较清晰,因为数据通道的复位值是 0x0,但第一个写入的数据也是 0x0,所以第一个读使能信号看不太清晰。第二个读使能信号在黄线处的时钟上升沿置起,直到下一个时钟上升沿,数据 0x01 才会出现在数据线上,这就是读信号时的一个时钟延迟,一个时钟的长度是相对于读使能有效的第一个时钟上升沿而言。

1个时钟的固定延迟对于简单的同步系统来说问题不大,只要在读信号有效之后固定延迟一个周期再读取或者使用读数据线上的数据即可。比如使用状态机时,在上一个状态置起读有效,等到下一个状态再读取数据。

那么有没有办法消除这个延迟,这就又要说说我们上篇中配置 ip 核时见到的 First Word Fall Through 特性。

                                                             当你勾选该项功能时,延时转为显示 0

该特性的主要功能是,哪怕你还没送出读使能信号,我就把FIFO 中下一个数据准备到数据线上。比如 FIFO 中有两个数据 0x1,0x2,当你什么都还没做时,读数据线上已经是 0x1了,当你读取一个数据后,数据线上就变成了 0x2 ——下一个等待读取的数据。但注意到为了实现这个特性,FIFO 真正的深度已经扩展为 18 位。(那么计满,计空是按照 18 位还是 16 位呢?)。

在开启了 First Word Fall Through 特性后的波形图如下:

可以看到和上文描述相符的特性。在第一个数据 0x80 写入后,经过三个时钟的延迟后,dout 输出 0x80,0x80 是第一个等待读取的数据,也就是接下来一个等待读取的数据。可以在图中右侧看到,当读使能有效,0x80 在第一个时钟上升沿被读取后,接下来一个等待读取的数据就出现在 dout 信号中,即消除了一个周期的读延迟。当然这是有代价的。(小问题:请问代价有哪些?注意 empty 信号,比较未开启 Fall Through 时的情况)

当我们写溢出会怎样,是抛弃最早的数据还是无视最新的数据?

FIFO 使用中最需要注意的问题在于溢出,我们需要借助空/满信号来判定 FIFO 的状态,尽量避免 FIFO 的读写溢出。但如果我们写溢出了会怎样?

我们向深度为 16 (开启 First Word Fall Through 特性后实际深度为 18 )的FIFO ,嗯,一口气写 20 个数据和使能信号。当我们读取 20 个信号时,我们是会读到前 20 个,还是后 20个数据?

答案是前 18 个数据,读取到的最后一个数据是 0x66 ,在 0x66 之后的两个写入数据 0x00 和 0x78 并没有进入 FIFO。

所以结论是 FIFO 在写满之后,会保证之前写入的数据,而拒绝新写入的数据。另外,能够容纳的数据并不是名义上的 FIFO 深度,而是 IP 核配置界面显示的实际深度,本例中是 18 。

数据因为 FIFO 写满而丢失,很有可能造成严重的系统问题,需要认真选取合适的 FIFO 深度。(作者也在学习此道)

当我们的 FIFO 没有数据,但头铁硬要读取会怎样?

在写入 16 个数据后,我们闲来无事,决定读取个 20 次数据。

你读取到了 16 个数据,并没有什么特别的事情发生。

当我们同时读写会怎样?

当 FIFO 没有数据时,在开启 Fall Through 的情况下,同时读取和写入数据。

可以发现,这种情况下存在问题:

在前三个读使能周期,读取到的都是 FIFO 中的初始值 0x00,直到第 3 个读使能信号,才读取到 FIFO 中的第一个数据 0x80,最终 16 个读使能信号实际上只读到了 14 个有效数据。

但如果先写入 3 个数据后,再同时读写

此时就不会出现问题,所以开启 Fall Through 的情况下,前 2 个周期是无法读取数据的,但在之后的时钟中,同时读取也是不会有问题的。

没有开启Fall Through 的情况下,第一个读使能会因为一个周期的读延迟无法读到数据。也就是说会少读取一个数据。

结束语

到这里这篇有关 FIFO ,或者说有关同步 FIFO 的教程就到这里结束了。你可能觉得意犹未尽(太长不看),但没办法,同步 FIFO 作为常用的,基础的 IP 核,可玩的花样并不多。以后我们讲讲异步 FIFO ,那才有意思呢。(其实我现在还不会用,等我先学习下先)

本文中简要地介绍了如何在 Vivado 环境中配置,添加一个 FIFO ip 核,构建顶层文件与 testbench 文件。编写激励,并通过仿真了解 FIFO 的诸项特性。

如果读者能读到这里,我会告诉你本文的阅读完成率感人,你已经击败了 98% 的玩家,嗯,希望读者你有所收获。

推荐阅读

  • Zynq SDK 驱动探求(五)软件动态重配置硬件比特流
  • 从 IP 开始,学习数字逻辑:FIFO 篇(上)

关注此系列,请关注专栏FPGA的逻辑

发表评论

邮箱地址不会被公开。 必填项已用*标注