跳转至

实验3:计数器与移位器

在本次实验中,我们学习掌握计数器与移位器的功能及其应用:定时、分频、开关输入去抖动、数码管动态扫描显示、串行通信等

本次实验主要任务如下:

  • 设计定时器和分频器,实现数码管动态扫描显示(第一周)
  • 设计串行通讯模块,使得开发板可以和PC机进行通讯(第二周,尚未发布,可能更改)

1 计数器及其衍生物

1.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] d,
    output reg  [WIDTH-1:0] q
);
    always @(posedge clk) begin
        if (!rstn)      q <= RST_VLU;
        else if (pe)    q <= d;
        else if (ce)    q <= q - 1; 
    end
endmodule

1.2 定时器

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

在本次实验中,我们提到的定时器内部例化了FSM、计数器和一个相等比较器。在相等比较器中,计数器的计数结果与内置的固定数据(对应计时结束,一般是0,取决于你的设计)比较后,将比较结果(0或1)输入给FSM参与状态转移控制。

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

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

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

image-20231109141043238

善用ce和pe接口

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

1.3 分频器

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

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

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

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

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

image-20231109141826119

不厌其烦的提示

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

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

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

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

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

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

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

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

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

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

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

1.4 按键、开关去抖动

当我们按下开发板上的按键或按钮的时候,极有可能出现一些抖动的信号(相信大家在第三次实验已经大有体会了)。这个问题依然可以通过计数器来解决:

image-20231109143024363

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

2 有限状态机FSM

本次实验中要求设计一个有限状态机FSM。比如有待机、置数、计数三个状态的FSM,接受输入信号st/rst/相等比较器输出的控制,更改状态并输出置数信号pe/计数信号ce/定时结束信号td

注意下图的FSM和CNT以及没有画出的相等比较器实际上是TM的内部模块。在相等比较器中,计数器的计数结果与内置的固定数据(对应计时结束,一般是0,取决于你的设计)比较后,将比较结果(0或1)输入给FSM参与状态转移控制。

要求td信号的产生写成三段式,pc/ce信号的产生写成两段式。

temp.drawio

360截图20241026124338358

2.1 有限状态机的基本概念

有限状态机是一种计算模型,它可以根据输入信号和当前状态,通过状态转移逻辑计算出下一个状态和输出信号。有限状态机由状态寄存器和组合逻辑电路组成,其中状态寄存器用于存储当前状态,组合逻辑电路用于计算下一个状态和输出信号。

2.1.1 状态的概念

状态是有限状态机的核心概念,它是指有限状态机在某一时刻所处的状态,这个状态往往是通过一个寄存器的值来表示的。例如,对于一个4'b1000序列检测器,其状态就可以定义为:

  • S0:初始状态,输入序列为0
  • S1:输入序列为1
  • S2:输入序列为10
  • S3:输入序列为100
  • S4:输入序列为1000

由此,我们可以对所有的状态进行编码,编码有两种方式:

  • 顺序编码:状态的编码按照状态的顺序进行编码,例如上述状态的顺序编码为S0=3'b000,S1=3'b001,S2=3'b010,S3=3'b011,S4=3'b100
  • 独热编码:状态的编码只有一个位为1,其他位为0,例如上述状态的独热编码为S0=5'b00001,S1=5'b00010,S2=5'b00100,S3=5'b01000,S4=5'b10000

独热编码 vs 顺序编码

独热编码和顺序编码各有优劣,独热编码的优势在于输出信号的选择逻辑会减少,但是其缺点在于状态数较多时,编码的位数会很多,而且状态之间的转移逻辑也会变得复杂。顺序编码的优势在于状态数较多时,编码的位数较少,而且状态之间的转移逻辑也会变得简单,但是其缺点在于输出信号的编码器会更加复杂。

2.1.2 状态转移逻辑

状态转移逻辑是有限状态机的核心,它用于计算下一个状态,而下一个状态就是有限状态机的状态寄存器下一个周期会变成的值。一般情况下,我们可以用一个状态转移图来直观地描述状态转移的逻辑,例如下图所示的状态转移图:

image-20231026132847947

上图的状态转移图中有两个状态:S0和S1。当状态为S0时,若输入为1,则转移到S1状态,否则转移到S0状态;当状态为S1时,若输入为0,则转移到S0状态,否则转移到S1状态。我们可以用一个状态转移表来描述这个状态转移逻辑:

状态 输入 下一个状态
S0 0 S0
S0 1 S1
S1 0 S0
S1 1 S1

2.1.3 输出逻辑

输出逻辑是有限状态机的对外接口,有限状态机需要把自己状态反映的内容通过输出信号告知外部模块(例如已经检测到了对应的序列等)。输出逻辑的计算方式有两种:

  • Moore型:输出逻辑只与当前状态有关,与输入信号无关
  • Melay型:输出逻辑与当前状态和输入信号有关

输出逻辑可能会比较复杂,因此我们为了优化时序,也衍生出了两种输出方式:

  • 直接输出:输出逻辑直接使用组合逻辑进行输出。
  • 寄存器输出:输出信号先输入到寄存器里,下个周期通过寄存器来输出。

这几种输出方式各有千秋,下面我们就对其中几种常见的组合进行介绍。

2.2 一段式状态机

在使用verilog进行状态描述的时候,我们主要分为以下三个部分进行描述:

  • 状态寄存器
  • 状态转移逻辑
  • 输出逻辑

一段式状态机,就是将这三部分内容全部用一个always@(posedge clk)进行描述,其电路图可以描述如下:

image-20231026141028910

其中,蓝框就表示了将所有逻辑放在同一个always中进行描述,其模板代码如下:

always @(posedge clk, negedge rstn) begin
    if (!rstn)  begin
        cs <= 复位状态;
        out <= 复位值;
    end
    else begin
    case (cs)
        S0: begin
            if (in条件) begin
                cs <= Si;
                out <= 表达式
            end
    ……
end

一段式状态机将所有的内容都“混为一谈”,但却深受大家的喜爱。

2.3 两段式状态机

两段式状态机,就是将状态状态更新和状态转移、输出分开进行描述,其电路图可以描述如下:

image-20231026141354005

这种状态机使用当前状态决定输出信号,由于输出信号直接通过当前状态和所有输出候选值通过多选器决定,因此输出逻辑的组合延迟比较大。但两段式状态机同时也在很多场合是非常必要的,因为它给出输出很及时,往往在很多场合会有较大的应用价值。其verilog代码如下:

//时序描述CS
always @(posedge clk, negedge rstn) begin
    if (!rstn)  cs <= 复位状态;
    else cs <= ns;
end

//组合描述OUT和NS
always  @*  begin
    out = 默认值;
    ns  =  cs;
    case  (cs)
        S0: begin
            if (in条件) begin
                out = 表达式;
                ns = Si;
            end 
        ……
end

需要注意的是,两段式状态机,也可以使用三个always来实现,但第三段的输出逻辑一定是always @(*)的组合描述:

//时序描述CS
always @(posedge clk, negedge rstn) begin
    if (!rstn)  cs <= 复位状态;
    else cs <= ns;
end

//组合描述NS
always  @*  begin
    ns  =  cs;
    case  (cs)
        S0: begin
            if (in条件) begin
                ns = Si;
            end 
        ……
end

//组合描述OUT
always  @*  begin
    out = 默认值;
    case  (cs)
        S0: begin
            if (in条件) begin
                out = 表达式;
            end 
        ……
end

虽然这样的实现好像也是“三段”,其很多网络教程也称之为三段式状态机,但其和三段式状态机希望让大家达到的效果是不一样的——最后两个always当然可以合并成同一个always,因此我们依然称之为两段式状态机。

2.4 三段式状态机

和两段式状态机有所区别,三段式状态机使用寄存器进行输出,也就是说**第三段输出描述依然使用always@(posedge clk)。在这里,我们衍生出来两种三段式状态机:

  • 根据当前状态进行输出的三段式状态机:这种状态机的第三段输出是根据当前状态进行输出的,其电路图如下:

image-20231026142253471

细心的同学可能发现了:这样的输出会延迟一个周期给出——这在很多情况下是完全无法接受的。很多电路要求输出信号的及时性,一个周期的差距就可能存在很多问题。因此,我们还有另一种三段式状态机:

  • 根据下一个状态进行输出的三段式状态机:这种状态机的第三段输出是根据下一个状态进行输出的,其电路图如下:

image-20231026142432669

这种状态机是通过下一个状态来决定输出的。如果状态机是Moore型状态机,那么输出不会出现任何延迟,且时序较好。但如果状态机是Mealy型状态机,这种设计就会出现问题。如果一定需要当前状态和当前输入同时决定输出,那么只能使用两段式状态机进行设计。

注意不同的赋值

在本次实验中,我们接触到了时序电路中的赋值,它和组合电路不同,需要使用<=进行赋值。这个赋值是非阻塞的,也是就是同一个分支中所有赋值没有先后关系,同时完成赋值。在三段式状态机的状态寄存器和输出逻辑中,我们需要使用<=进行赋值,而不是=进行赋值。

这一点是很多同学在实验中出现问题的地方,需要大家注意。(提示:这是普通班实验检查扣分的重点)一个不需要动脑子的真理就是:

always@(posedge clk)中的赋值一定是<=,其他地方的赋值一定是=

借助这个非阻塞赋值的特点,我们可以完成一些“骚操作”,例如在同一时间交换两个寄存器的值:

always @(posedge clk) begin
    a <= b;
    b <= a;
end

注意

本实验分为两周完成,第一周完成定时器部分(以上内容),在10月31日验收;第二周完成串口部分(以下内容),在11月7日验收。 串口部分尚未发布,实验要求可能有更改。

3 移位器及其应用

移位器事实上就是一个很长的寄存器,当移位使能有效时,它会将自身的值左移或右移一个固定的位宽。移位器的输出就是这个长寄存器的某些位(一般是低若干位)。

移位器一般没有必要真的使用右移运算符来实现——这样的开销是比较大的。我们可以直接使用位拼接来实现移位器的功能。

3.1 串口

串口是最简单的通讯方式之一,我们通过将一个字节的数据逐个位顺序发送,从而实现对于数据的传输。只要通讯双方都能“理解”彼此的数据格式,就可以实现通讯。

那么,串口是如何约定数据格式的呢?我们这里介绍本实验中要求实现的最简单的协议:

对于发送方,面向接收方的这个单比特数据通道在空闲时应当保持为1,这样接收方就知道此刻不是有效数据了。当需要发送数据时,这个通道里的数据会被突然置为0,并保持一个周期——这个周期在接收方看来,就是“全体目光向我看齐,我宣布个事”,这一位数据也被称为起始位。从下一个周期开始,发送方会把要传输的8bit数据从低位到高位依次发送到数据通道里,这个过程会持续8个周期(因为是8位)。

传输之后,串口不能立刻传输下一个数据,而是要强行把数据通道置为1,用来告知发送方“我说完了,你可以走了”。这个过程也会持续一个周期。

image-20231115171510931

在这个过程中,发送方和接收方约定了以下几件事:

  1. 起始位保持1个周期
  2. 传输8bit数据
  3. 结束位保持1个周期
  4. 传输速率都是每秒9600个bit

只要双方都按照这个规则来发送、接收、拼接数据,就可以得到一个完整的8bit数据。

3.2 串行通信发送接口

image-20231115172036664

发送接口左侧是面向需要发送数据的模块的,右侧是面向FPGA开发板上的串行通讯外设的。我们需要实现的功能是:

  • 当需要发送数据的模块将tx_vld置为1时,TIF会从din中接收到需要发送的数据,并将其拼接好起始位和停止位,写入内部的移位寄存器中
  • 移位寄存器每个周期会将最低位的数据发送到txd上,并右移一个bit
  • 当移位寄存器把所有位全部发送完毕后,tx_rdy会被置为1,表示发送完毕,外部模块就可以向TIF发送下一个待发送数据了

发送时序

  • tx_vld和tx_rdy均有效时,数据din前后附加启动和停止位后存入SOR,tx_rdy清零,移位计数器CNT加载常数8
  • 随后SOR通过右移实现串行输出,同时CNT递减
  • CNT等于0时,停止移位和计数,tx_rdy置1

image-20231116125528571

3.3 串行通信接收接口

image-20231116111806085

接收模块的总体逻辑和发送模块是比较类似的,但接受的逻辑更加复杂一点点,涉及到一个重要的概念——采样

何谓“采样”

对于任何一个通信模块而言,我们都不能期望在确定的时钟周期中检测到确定的值。我们通常会用一个速度较快的时钟,对接收到的信号做检测。由于发送接收的模块的时钟是较慢的,而接收模块的时钟较快,所以我们肯定能检测到被发送的信号。同样地,为了避免发送通道中的“杂音”,接收模块需要“确认”收到了正确的数据,这个过程就可以通过稳定检测到信号持续n个周期来实现。

接收时序

  • 当检测到rxd信号由1到0跳变,且8个接收时钟(clk_rx)后检测仍为0,则确认为起始位
  • 接着每隔16个接收时钟,对rxd检测一次,依次作为数据位、校验位和停止位,存入SIR
  • 若未发生停止位或奇偶校验位出错,则将SIR中数据位存入输入缓冲寄存器(Input Buffer Register, IBR),并设置rx_vld有效

image-20231116125320572

只要连续16个周期信号都是稳定的,那么我们就可以认为接收到了1bit数据。我们再通过一个移位器将其拼接好,当所有的数据都返回后,将rxd_valid置为1,表示接收完毕。

注意xdc文件中的信号

在xdc文件中,rxd和txd的接口是如下两行:

set_property -dict { PACKAGE_PIN C4    IOSTANDARD LVCMOS33 } [get_ports { UART_TXD_IN }]; #IO_L7P_T1_AD6P_35 Sch=uart_txd_in
#et_property -dict { PACKAGE_PIN D4    IOSTANDARD LVCMOS33 } [get_ports { UART_RXD_OUT }]; #IO_L11N_T1_SRCC_35 Sch=uart_rxd_out

特别注意,rxd应该接入到UART_TXD_IN, txd应该接入到UART_RXD_OUT。这是因为我们的开发板上的串口是相对PC机而言的,所以我们的接口也应该是相对PC机而言的。