L10-Sequential Circuits: Modules with Guarded Interfaces


MIT 6.004 2019 L10 Sequential Circuits: Modules with Guarded Interfaces,由教授Arvind讲述。

这次讲座的主题为:时序电路:带有保护接口的模块

主要内容

  1. 时序电路概述:时序电路具有状态并通过寄存器保存状态。寄存器具有使能信号,只有在使能为真时才可以更改其状态。时序电路用模块表示,在Bluespec中是一个原始模块类型,具有一组明确的接口方法来读取和修改其状态。模块可以重复实例化以创建该类型的对象,即时序电路。

  2. 方法类型:值方法(Value method)不更新模块状态,只观察内部状态;动作方法(Action method)仅更新模块状态,不返回任何值;动作值方法(ActionValue#(t))既更新模块状态也返回一个类型为t的值。所有方法都可以有输入参数,如gcd.start(x,y)

  3. 方法调用:值方法调用本质上是一个表达式,例如mod4counter.readgcd.busy;动作方法调用是一个语句,如mod4counter.inc;动作值方法调用使用左箭头(<-),而不是等号(=),例如let resultGcd <- gcd.getResult

  4. 保护接口:每个方法都有一个“准备就绪”的概念,表示通过其接口的方法只有在准备就绪时才能调用。例如,只有在模块没有忙碌时才能启动GCD计算,而结果只有在准备就绪时才能读取。

在实际的硬件设计和编程实践中,理解这些基础概念对于构建可靠且功能齐全的电路至关重要。通过高级硬件描述语言如Bluespec,设计师能以类似编程的方式来定义和操纵硬件模块,使硬件设计过程更加接近于软件开发的模式。

分页知识点

时序电路:带有保护接口的模块

在计算机硬件设计中,时序电路是一种存储状态信息并在输入信号和时钟信号控制下改变状态的电路。Bluespec是一种高级硬件描述语言,它允许设计师以类似于软件编程的方式来定义和操纵硬件模块。

方法的类型

  • 值方法(Value method):不更新模块的状态,只观察内部状态。
    • 例如:mod4counter.read, gcd.busy, gcd.ready
    • 这些方法通常用于获取模块的当前状态,而不会引起状态变化。
  • 动作方法(Action method):只更新模块的状态,不返回任何值。
    • 例如:mod4counter.inc, gcd.start
    • 这些方法通常包含一个使能线(enable wire),使能线必须为真,方法才会生效,即执行状态更新。
  • 动作值方法(ActionValue#(t)):更新模块的状态并返回类型为 t 的值。
    • 例如:gcd.getResult
    • 这种类型的方法通常结合了状态的更新和信息的返回两种操作。

所有的方法都可以有输入参数,例如 gcd.start(x,y)

方法调用

  • 值方法(Value method) 的调用例子:

    • let counterValue = mod4counter.read; // 读取mod4计数器的值
      Bool isGcdBusy = gcd.busy; // 检查GCD模块是否忙碌
      
  • 动作方法(Action method) 的调用例子:

    • mod4counter.inc; // 使mod4计数器增加
      gcd.start(13,27); // 启动GCD模块计算13和27的最大公约数
      
  • 动作值方法(ActionValue#(t)) 的调用例子:

    • let resultGcd <- gcd.getResult; // 获取GCD计算结果
      
    • 注意这里使用的是 <- 而不是 =
    • 如果写成:

      • let badResultGCD = gcd.getResult; // 这是错误的
        
      • 那么 badResultGCD 的类型将是 ActionValue#(t) 而不是 t。
    • 符号 <- 表示除了返回值外还有副作用(state changes),而 = 只是赋值操作。

在设计时序电路时,了解这些方法的区别至关重要。动作方法和动作值方法可以改变硬件状态,可能会引起电路的动态变化。正确使用这些方法可以确保电路以可预测和稳定的方式运行。在使用Bluespec等高级硬件描述语言时,这些概念帮助设计师以一种更接近于软件开发的方式来思考硬件行为,同时确保硬件设计的精确性和可靠性。

GCD实现

BSV中的GCD

module mkGCD (GCD);
    Reg#(Bit#(32)) x <- mkReg(0);
    Reg#(Bit#(32)) y <- mkReg(0);
    Reg#(Bool) busy_flag <- mkReg(False);

    rule gcd;
        if (x >= y) begin x = x - y; end // subtract
        else if (x != 0) begin x = y; y = x; end // swap
    endrule

    method Action start(Bit#(32) a, Bit#(32) b);
        x = a; y = b; busy_flag = True;
    endmethod

    method ActionValue#(Bit#(32)) getResult;
        busy_flag = False; return y;
    endmethod

    method Bool busy = busy_flag;
    method Bool ready = x==0;
endmodule

类型定义与实例化

  • module mkGCD (GCD); 定义了一个名为mkGCD的模块,它实现了GCD接口。
  • Reg#(Bit#(32)) x <- mkReg(0);Reg#(Bit#(32)) y <- mkReg(0); 分别定义了两个32位寄存器xy,并初始化为0。这两个寄存器将存储进行GCD计算的值。
  • Reg#(Bool) busy_flag <- mkReg(False); 定义了一个布尔寄存器busy_flag来表示模块是否忙碌,并初始化为False

规则定义

  • rule gcd; 定义了一个名为gcd的规则。
    • if (x >= y) begin x <= x - y; end 如果x大于或等于y,则x减去y
    • else if (x != 0) begin x <= y; y <= x; end 如果x不等于0,则交换xy的值。

这里x^t+1y^t+1表示交换操作后的值,它们取当前周期(时间t)xy的值,并在下一个周期(时间t+1)更新它们的值。

方法定义

  • method Action start(Bit#(32) a, Bit#(32) b); 定义了一个动作方法start,用于初始化GCD计算,参数为两个32位的数ab。这个方法将参数值赋给xy,并将busy_flag设置为True,表示开始计算。
  • method ActionValue#(Bit#(32)) getResult; 定义了一个动作值方法getResult,当GCD计算完成时,它返回计算结果并将busy_flag设置为False
  • method Bool busy = busy_flag; 定义了一个值方法busy,返回模块是否忙碌。
  • method Bool ready = (x==0); 定义了一个值方法ready,当x为0时表示GCD计算已准备好被读取。

接口定义

  • interface GCD; 定义了GCD模块的接口,包括startgetResult方法以及busyready的状态查询方法。

规则概念

  • 模块中包含的规则代表了一系列可以在任何时间执行的动作,且当规则执行时,必须同时执行其内部所有的动作。这称为原子性(atomicity),意味着规则内的动作要么全部执行,要么全部不执行,不会出现部分执行的情况。这在硬件设计中至关重要,确保在一个时钟周期内,所有操作要么一起完成要么都不做,避免中间状态的不确定性。

在这个GCD实现中,我们通过规则(rule)来描述GCD计算的核心逻辑,并使用接口方法来启动计算并获取结果。通过busy_flag,我们能够追踪模块是否正在进行计算,并确保不会在一个计算未完成时启动另一个。这个设计演示了时序电路设计中状态管理和并发控制的基本概念,体现了硬件描述语言在设计复杂逻辑时的强大能力和灵活性。

在Bluespec SystemVerilog (BSV) 中,rule中的所有动作(或者说赋值操作)默认是在同一个时钟周期内并行执行的。这意味着你不需要中间变量来交换xy的值,因为赋值会在周期的末尾同时发生。

在BSV的rule内部,写法如下:

if (x != 0) begin 
    x <= y; 
    y <= x; 
end

这里,x <= y;y <= x;的执行看起来像是同时的,但实际上是这样的:

  1. 在当前的时钟周期开始时,读取xy的当前值(我们称之为x_ty_t)。
  2. 执行规则中的所有动作,这里是将y_t赋给x,将x_t赋给y
  3. 在当前时钟周期结束时,所有赋值动作同时生效,xy被更新为新值。

这种行为称为”原子性”操作,在硬件设计中是非常常见的,因为在一个时钟周期内所有的更新都是同时”提交”的。这与许多其他编程语言不同,其他语言通常需要一个临时变量来交换两个变量的值,以防止覆盖其中一个变量而丢失其原始值。在硬件描述语言中,由于我们在描述硬件信号的变化,所有的变化在一个时钟边沿(通常是上升油或者下降沿)发生时会被捕获,并在下一个边沿变化之前一直保持。

动作和双重写入的并行组合

在BSV中,如果一个规则包含可能导致对同一个变量进行多次写入的行动,那么这样的并行组合是非法的,BSV编译器会拒绝这样的程序。

例如,以下是一些包含双重写入潜在问题的规则:

rule one;
    y <= 3; x <= 5; x <= 7; // Double write to x
endrule

rule two;
    y <= 3; if (b) x <= 7; else x <= 5; // No double write
endrule

rule three;
    y <= 3; x <= 5; if (b) x <= 7; // Possibility of a double write
endrule
  • 在规则 one 中,变量 x 被分配了两次,这是非法的,并且会导致编译错误。
  • 在规则 two 中,尽管 x 有两个潜在的赋值,但由于它们在条件语句中,所以在任何执行路径上 x 只会被赋值一次,这是合法的。
  • 在规则 three 中,存在 x 被分配两次的可能性,因为它依赖于条件 b,这也是非法的。

在BSV编程中,规则的设计必须确保不会违反这些约束,以避免竞争条件和不确定的行为。这些规则确保了在BSV设计的硬件中,所有的行动都是原子执行的,要么全部执行,要么全部不执行,这有助于保持硬件行为的一致性和可预测性。


先进先出队列(FIFO)

  • FIFO是一个重要的数据结构,在硬件和软件中广泛用于连接各种组件。
  • 生产者(producer)将值入队到FIFO中。
  • 消费者(consumer)从FIFO中出队值。
  • 出队的值按照入队的顺序排列,即先进先出。
  • 在硬件中,FIFO有固定的大小,通常为1,因此当FIFO满时,生产者会被阻塞;当FIFO空时,消费者会被阻塞。

硬件中的FIFO

先进先出队列接口

interface Fifo#(numeric type size, type t);
    method Bool notFull; // 检查FIFO是否未满
    method Bool notEmpty; // 检查FIFO是否不为空
    method Action enq(t x); // 入队操作,将数据类型为t的值x加入FIFO
    method Action deq; // 出队操作
    method t first; // 查看FIFO中的第一个元素,但不出队
endinterface
  • enq 应当仅在notFull返回True时调用;如果FIFO未满,才能执行入队操作。
  • deqfirst 应当仅在notEmpty返回True时调用;如果FIFO不为空,才能执行出队操作或查看首元素。

模块的接口定义了其类型。在FIFO模块的例子中,接口定义了一个具有大小size和数据类型t的FIFO队列。其中,notFullnotEmpty是布尔方法,用来指示队列是否有空间加入新的元素或是否有元素可以被消费。enq方法是一个行为(Action)方法,允许你将一个元素t x添加到队列中,但前提是队列未满。deq是一个行为方法,用于从队列中删除一个元素,但前提是队列不为空。first是一个值(Value)方法,返回队列中的第一个元素,但不从队列中删除它。

在使用FIFO时,关键是确保在进行enqdeq操作前,检查相应的notFullnotEmpty条件是否满足。这是防止溢出(enqueue到一个已满的FIFO)或下溢(dequeue一个空的FIFO)的基本做法。在硬件设计中,通常使用硬件信号(如这里的布尔方法返回值)来控制这些条件的检查和相应操作的执行,从而确保数据的正确流动和处理。

image-20240420235911558

一个元素的FIFO实现

// 定义一个元素的FIFO模块
module mkFifo(Fifo#(1, t)) provisos (Bits#(t, tSz));
    // 数据寄存器,存储FIFO中的值
    Reg#(t) d <- mkRegU; // 未初始化的数据寄存器
    // 有效位,指示FIFO是否有数据
    Reg#(Bool) v <- mkReg(False); // 初始化为空的FIFO

    // 方法检查FIFO是否未满,即可以接受新的元素
    method Bool notFull;
        return !v;
    endmethod

    // 方法检查FIFO是否不为空,即有元素可出队
    method Bool notEmpty;
        return v;
    endmethod

    // 入队操作:将元素x添加到FIFO中
    method Action enq(t x);
        v := True; // 设置有效位为True,表示FIFO现在有数据
        d := x; // 将新元素存入数据寄存器
    endmethod

    // 出队操作:从FIFO中移除元素
    method Action deq;
        v := False; // 设置有效位为False,表示FIFO现在为空
    endmethod

    // 返回FIFO中的第一个元素,但不移除它
    method t first;
        return d; // 返回数据寄存器中的值
    endmethod
endmodule

FIFO可以存储任何类型的数据,但类型必须是”bitifiable”,即能够用位来表示。在上述BSV代码中,mkFifo模块是一个简单的FIFO实现,它只能存储一个元素。在实现中,使用了一个数据寄存器d来存储元素值,和一个有效位v来指示寄存器内是否有有效的数据。如果v为True,则表示有数据;如果为False,则表示FIFO为空。notFullnotEmpty方法用于检查FIFO的状态,而enqdeq方法用于添加和移除元素。first方法提供对FIFO中第一个元素的访问,但不会改变FIFO的状态。

流式处理函数

当你想要连续处理一系列的数据项时,可以使用FIFO配合函数进行流式处理。以下是一个规则的示例,展示了如何将一个函数应用到通过FIFO传递的数据流中。

rule stream;
    if(inQ.notEmpty && outQ.notFull) begin
        outQ.enq(f(inQ.first)); // 应用函数f到输入队列的首个元素,并将结果入队到输出队列
        inQ.deq; // 将元素从输入队列中出队
    end
endrule

stream规则中,只有当输入队列inQ不为空,并且输出队列outQ未满时,规则才会执行。这使用了布尔逻辑”与”操作(&&),确保只有在满足这两个条件时,才会从输入队列中取出元素,应用函数f,并将结果放入输出队列。这种方法保证了数据在队列之间顺畅地流动,同时避免了队列溢出或下溢的问题。

image-20240421001017278

带保护接口的模块

概述

  • 程序员的生活更简单:在方法定义中包括一些检查(准备好了,满了等),因此用户无需从外部显式测试方法的适用性。
  • 带保护的接口
    • 每个方法都有一个保护(rdy 线)。
    • 一个方法返回的值只有在其保护为真时才有意义。
    • 每个动作方法都有一个启用信号(en 线),并且只有在其保护为真时才能调用(en 可以被设置为真)。
// 定义FIFO接口,带有数据入队、出队和读取第一个元素的方法
// 注意,en和rdy线是隐式的,不需要在接口定义中显式说明
interface Fifo#(numeric type size, type t);
    method Action enq(t x); // 数据入队
    method Action deq;      // 数据出队
    method t first;         // 读取第一个元素
endinterface

每个方法的rdy信号(就绪信号)和动作方法的en信号(启用信号)是隐式存在的,不需要程序员在编写代码时考虑,编译器会自动处理。

单元素FIFO的带保护接口实现

// 实现单元素FIFO模块,使用带保护的方法
module mkFifo(Fifo#(1, t));
    Reg#(t) d <- mkRegU;      // 未初始化的数据寄存器
    Reg#(Bool) v <- mkReg(False); // 初始为空的标志位

    // 入队方法,带有保护表达式,只有当v为False时,即FIFO不满时,才能执行
    method Action enq(t x) if (!v);
        v := True; d := x;    // 将标志位设置为True并存储数据
    endmethod

    // 出队方法,带有保护表达式,只有当v为True时,即FIFO不空时,才能执行
    method Action deq if (v);
        v := False;           // 将标志位设置为False,表示数据已出队
    endmethod

    // 读取第一个元素的方法,带有保护表达式,只有当v为True时才能执行
    method t first if (v);
        return d;             // 返回存储的数据
    endmethod
endmodule

在上述BSV代码中,保护表达式if (!v)if (v)直接附加在方法签名后面,作为保护条件。它们确保只有在FIFO状态正确的情况下,即不满或不空的时候,相关的方法才能执行。注意,缺少分号;if语句转变为保护条件,而非常规的条件分支。保护表达式实际上就是连接到方法的rdy线的内容。

在Bluespec SystemVerilog(BSV)中,if语句用作保护(guard)的条件时,跟随在方法定义之后。当看到if紧跟在方法声明后,且不是以分号结尾时,这个if用作保护这个方法,确保只有在if条件为真时,方法才能执行。保护表达式直接关联到方法的ready信号,这样编译器和硬件都知道只有在条件满足时才能触发方法。

上面的代码中,if语句紧接在方法的参数列表后面,这表明这些if语句是作为保护表达式的。if后面的条件表达式(例如(!v))确定了方法何时可以安全地执行

image-20240421002720006


Table of contents
  1. L10-Sequential Circuits: Modules with Guarded Interfaces
    1. 主要内容
  2. 分页知识点
    1. 时序电路:带有保护接口的模块
    2. 方法的类型
    3. 方法调用
    4. GCD实现
    5. BSV中的GCD
    6. 类型定义与实例化
    7. 规则定义
    8. 方法定义
    9. 接口定义
    10. 规则概念
    11. 动作和双重写入的并行组合
    12. 先进先出队列(FIFO)
    13. 硬件中的FIFO
    14. 一个元素的FIFO实现
    15. 流式处理函数
    16. 带保护接口的模块
      1. 概述
      2. 单元素FIFO的带保护接口实现