跳转至

实验2:时序逻辑电路

提示

在查阅本文档进行实验时,时序逻辑电路的基础知识可查阅普通班lab5 时序逻辑元件,时序逻辑电路实现的典型例子可查阅普通班lab5 时序逻辑电路案例分析。

在本次实验中,我们有以下主要任务:

  • 分别设计读、写优先的双端口寄存器堆
  • 设计定时器和分频器,实现数码管动态扫描显示

1 寄存器堆

在计算系统概论课程上,大家已经在LC3处理器的CPU中看到了寄存器堆的存在。寄存器堆是处理器可以快速访问的存储单元,它的特点是:

  • 读写速度快:读为异步读,当周期出结果;写为同步写,写入后下个周期即可访问写入数据。
  • 读写端口多:一般有两个读端口和一个写端口,可以同时进行两次读操作和一次写操作。
  • 容量小:一个寄存器堆中一般有32-64个寄存器,过多的寄存器会导致电路的面积和延迟增加。
  • 读写并行可定制性强:虽然写数据需要一个周期,但写数据也可以通过前递的方式在当周期被读请求读出。

1.1 读优先的双端口寄存器堆

读优先、写优先是针对读写同时发生且地址相同的情况下读数据的来源而言的。

读优先是一种处理器读写方式,若当前周期的读写地址相同,且写使能为有效,则当周期读出的数据仍然为寄存器堆中读地址存储的数据,而不是写入的数据。这种方式可以保证在写入数据的同时,依旧读出寄存器堆中保存的数据。其verilog代码如下:

module  reg_file # (
    parameter ADDR_WIDTH  = 5,              //地址宽度
    parameter DATA_WIDTH  = 32              //数据宽度
)(
    input                       clk,        //时钟
    input   [ADDR_WIDTH -1:0]   ra0, ra1,   //读地址
    output  [DATA_WIDTH -1:0]   rd0, rd1,   //读数据
    input   [ADDR_WIDTH -1:0]   wa,         //写地址
    input   [DATA_WIDTH -1:0]   wd,         //写数据
    input                       we          //写使能
);
    reg [DATA_WIDTH -1:0]  rf [0:(1<<ADDR_WIDTH)-1];    //寄存器堆

    //读操作:读优先,异步读
    assign rd0 = rf[ra0];   
    assign rd1 = rf[ra1];

    //写操作:同步写
    always@ (posedge clk) begin
        if (we)  rf[wa] <= wd;  
    end

endmodule

对于读优先的理解,我们可以在这里举一个例子:当ra0 = 4, wa = 4, we = 1时,我们可以看到,此时的读出数据应当为rf[4]的值,而不是wd的值。

1.2 写优先的双端口寄存器堆

写优先是一种处理器读写方式,若当前周期的读写地址相同,且写使能为有效,则当周期读出的数据为写入的数据,而不是寄存器堆中读地址存储的数据。这种方式可以保证在写入数据的同时,依旧读出写入的数据。其verilog代码如下:

module  reg_file # (
    parameter ADDR_WIDTH  = 5,              //地址宽度
    parameter DATA_WIDTH  = 32              //数据宽度
)(
    input                       clk,        //时钟
    input   [ADDR_WIDTH -1:0]   ra0, ra1,   //读地址
    output  [DATA_WIDTH -1:0]   rd0, rd1,   //读数据
    input   [ADDR_WIDTH -1:0]   wa,         //写地址
    input   [DATA_WIDTH -1:0]   wd,         //写数据
    input                       we          //写使能
);
    reg [DATA_WIDTH -1:0]  rf [0:(1<<ADDR_WIDTH)-1];    //寄存器堆

    //读操作:写优先,异步读
    assign rd0 = (wa == ra0 && we) ? wd : rf[ra0];  
    assign rd1 = (wa == ra1 && we) ? wd : rf[ra1];

    //写操作:同步写
    always@ (posedge clk) begin
        if (we)  rf[wa] <= wd;
    end
endmodule

特殊的寄存器要求

在某些指令集架构(当然,这肯定不包括LC3)中,规定:r0的值必须为0,且不可被修改。因此,我们在设计寄存器堆时,需要将rf[0]的值固定为0,且不可被修改。这样的寄存器堆被称为特殊寄存器堆。这样的寄存器堆如果使用写优先,那么对于0号寄存器的处理应当如下:

module  reg_file # (
    parameter ADDR_WIDTH  = 5,              //地址宽度
    parameter DATA_WIDTH  = 32              //数据宽度
)(
    input                       clk,        //时钟
    input   [ADDR_WIDTH -1:0]   ra0, ra1,   //读地址
    output  [DATA_WIDTH -1:0]   rd0, rd1,   //读数据
    input   [ADDR_WIDTH -1:0]   wa,         //写地址
    input   [DATA_WIDTH -1:0]   wd,         //写数据
    input                       we          //写使能
);
    reg [DATA_WIDTH -1:0]  rf [0:(1<<ADDR_WIDTH)-1];    //寄存器堆

    //读操作:写优先,异步读
    assign rd0 = (ra0 == 0) ? {DATA_WIDTH{1'b0}} :
                 (wa == ra0 && we) ? wd : rf[ra0];
    assign rd1 = (ra1 == 0) ? {DATA_WIDTH{1'b0}} :
                 (wa == ra1 && we) ? wd : rf[ra1];

    //写操作:同步写
    always@ (posedge clk) begin
        if (we && wa != 0)  rf[wa] <= wd;
    end
endmodule

2 计数器及其衍生物

2.1 计数器

计数器是最为基础的电路元件之一,它可以实现对于计数的功能。对于一个计数器,一般需要包含以下几个接口:

  • din:输入数据,即强制置数的数据
  • pe:置数使能,当其有效时,计数器将会将din的值赋值给计数器的当前值
  • ce:计数使能,当其有效时,计数器将会在时钟上升沿将自身值减一
  • dout: 输出数据,即当前计数器的值
  • clk:时钟信号,计数器在时钟上升沿进行计数
  • rstn:复位信号,低电平有效,当其有效时,计数器将会被复位为0

我们在实验中也给出了这样的计数器模板,你可以参考这个模板来实现你的计数器。

module  counter #(
    parameter WIDTH = 32, 
             RST_VLU = 0
)(
    input                   clk, rstn, 
    input                   pe, ce, 
    input       [WIDTH-1:0] din,
    output reg  [WIDTH-1:0] dout
);
    always @(posedge clk) begin
        if (!rstn)      dout <= RST_VLU;
        else if (pe)    dout <= din;
        else if (ce)    dout <= dout - 1; 
    end
endmodule

复位信号

请注意,这里 rstn 为低电平有效的同步复位信号,你也可以使用 rst 作为高电平有效的复位信号。一般来说(常见设计约束下),在各种控制信号中,复位信号具有最高优先级。

2.2 定时器

定时器是一种特殊的计数器,它可以实现对于时间的计数。

在本次试验中,我们可以在定时器内部例化一个计数器,然后在外面包围一层控制逻辑,来实现定时器的相关功能。

作为一个定时器而言,我们需要实现以下几个功能:

  • 可以给定时器设置定时周期数
  • 当定时器值为0时,输出定时器到时信号,并不再继续计数

因此,一个定时器的设计应当如下:

定时器接口与计时行为

善用ce和pe接口

在这个设计中,我们不要求大家一定要对第一节中给出的计数器进行例化,你可以重新用always@(posedge clk)来实现你的定时器。但是,我们要求绝对不可以将非时钟信号的边沿作为敏感变量,否则你的设计将会被认为是错误的。为了解决这个问题,ce和pe两个接口的原理将会给你很大的帮助。

2.3 分频器

分频器也是一种特殊的计数器,它通过对时钟进行计数,输出一个较低时钟频率的新时钟,从而实现对于时钟频率的控制。作为一个分频器而言,我们需要实现以下几个功能:

  • 可以给分频器设置分频系数
  • 能够提供方波和脉冲两种输出模式

方波就是占空比为50%的方波,脉冲就是占空比为1/(k+1)的方波(k位分频比)。也就是说,方波在分频后一个周期内0和1的时间相等,而脉冲仅仅输出一个持续时间为原时钟周期的脉冲。

分频器的k一般都比较大,比如32'd20_000_000可将20MHz分频为1Hz,因此你不需要花费太多精力考虑奇偶问题和k过小引发的电平不动问题。

因此,一个分频器的设计应当如下:

分频器接口与工作过程

不厌其烦的提示

请理解助教在这里不厌其烦的提示:

绝对不可以将非时钟信号的边沿作为敏感变量,否则你的设计将会被认为是错误的。

绝对不可以将非时钟信号的边沿作为敏感变量,否则你的设计将会被认为是错误的。

绝对不可以将非时钟信号的边沿作为敏感变量,否则你的设计将会被认为是错误的。

同样地,为了解决这个问题,ce和pe两个接口的原理将会给你很大的帮助。

怎么这么烦啊,为什么不能用非时钟信号的上升沿啊!

在我们的开发板上,有且仅有时钟的信号可以被认为是绝对稳定的信号(也就是说极难出现毛刺),其他的信号因为组合延迟的原因,极易出现毛刺信号,导致整个设计的错误。这种错误在仿真里是完全无法被发现的,但上板就会出现问题。所以,绝对不可以将非时钟信号的边沿作为敏感变量,否则你的设计将会被认为是错误的。

那么,分频器的输出可以当做别的模块的时钟信号吗?

答案是几乎可以的。因为分频器的设计非常简单,组合延迟也比较低。所以,我们可以认为分频器的输出是一个相对稳定的信号。但这里一定要注意的是,对于脉冲的输出,助教建议不要使用以下语法:

```verilog
assign dout = (cnt == k)
```

当cnt位宽比较大的时候,这种描述容易出现毛刺——这是因为vivado并不会对assign的逻辑进行优化。不过,如果你把这个描述换成always_comb(即always@(*)),那么viavdo就会对这个逻辑进行优化,从而避免毛刺的出现。

2.4 按键、开关去抖动

当我们按下开发板上的按键或按钮的时候,极有可能出现一些抖动的信号。这个问题依然可以通过计数器来解决:

按键与开关去抖动

也就是说,只有当按键或者开关的状态保持一定时间后,我们才认为这个按键或者开关的状态是稳定的。这个时间可以根据实际情况进行调整,但一般来说,100ms是一个比较合适的时间。