Skip to content

6 Sequential Building Blocks

6.1 Chisel 中的寄存器

Chisel 提供了一个便捷的方式来定义和使用寄存器(registers)。在同步设计中,寄存器是存储数据的基本单元,通常由 D 触发器(D flip-flop)构成。Chisel 提供了一些简洁的语法来创建寄存器,使得时序电路的描述变得直观且高效。

寄存器的定义与使用

1. 使用 RegNext 定义寄存器

RegNext 是 Chisel 提供的一种便捷方法,用于实现简单的数据寄存器。它可以将输入信号 d 的值在时钟的上升沿(rising edge)存储,并在下一个时钟周期输出到寄存器的输出 q

定义示例:

scala
val q = RegNext(d)
  • d 表示寄存器的输入信号。
  • q 是寄存器的输出信号。
  • 时钟信号不需要显式声明,Chisel 会隐式地将全局时钟信号连接到寄存器的时钟输入。

在时序电路中,RegNext 的作用类似于将输入值延迟一个时钟周期,并存储到寄存器中。每次时钟的上升沿到来时,寄存器都会自动更新其输出值。

2. 分步骤定义寄存器

在某些情况下,我们可能需要分步骤定义寄存器,包括创建寄存器赋值信号。可以通过两步操作实现:

示例代码:

scala
val delayReg = Reg(UInt(4.W)) // 定义一个 4 位宽度的寄存器
delayReg := delayIn          // 将输入信号 delayIn 赋值给寄存器
  • 第一步Reg(UInt(4.W)) 定义一个 4 位宽度的寄存器,并将寄存器赋予一个名称 delayReg
  • 第二步:通过 := 语法,将输入信号 delayIn 连到寄存器 delayReg 的输入端。

命名规范

  • 寄存器的名称通常包含标识符 Reg,用于区分组合逻辑(combinational logic)与时序逻辑(sequential logic)。
  • 在 Chisel 中,类名首字母通常是大写(CamelCase),而变量名首字母是小写

3. 寄存器复位初始化

在数字电路设计中,寄存器通常需要一个复位值(reset value)。在 Chisel 中,寄存器的复位值可以通过 RegInit 来指定:

示例代码:

scala
val resetReg = RegInit(0.U(4.W)) // 定义一个带有复位值的 4 位寄存器
  • RegInit 用于在寄存器复位时指定初始值。
  • 在上面的代码中,寄存器 resetReg 在复位时会被初始化为 0,宽度为 4 位。
  • 复位信号是隐式的,Chisel 会自动处理默认复位逻辑。

Chisel 中的寄存器复位与使能信号

在时序电路中,复位(reset)使能(enable) 是两个重要的控制信号。复位用于初始化寄存器的值,而使能信号则用于控制寄存器是否更新输入数据。Chisel 提供了灵活的机制来处理这两种情况,包括同步复位使能信号的实现。

1. 寄存器的同步复位

同步复位的实现

在 Chisel 中,复位可以通过 RegInit 来实现。RegInit 允许我们为寄存器提供一个初始值,该值在复位时被加载到寄存器中。

代码示例:

scala
val valReg = RegInit(0.U(4.W))  // 定义一个 4 位寄存器,复位时初始化为 0
valReg := inVal                // 输入信号赋值给寄存器

同步复位寄存器的行为

图 6.2 展示了一个带有同步复位的 D 触发器寄存器。

  • 数据输入 D:寄存器的数据输入。
  • 复位信号 reset:在复位时,寄存器输出初始化值。
  • 时钟信号 clock:寄存器在时钟的上升沿更新输出。

波形图解析(图 6.3):

  1. 在第一个时钟周期,复位信号 reset 被置为高,寄存器输出 regVal 被设置为初始值(0)。
  2. 当复位信号为低时,寄存器开始正常工作,每个时钟周期捕获输入信号 inVal 的值。
  3. 波形显示:在 reset 信号被释放后,寄存器正常捕获输入数据,并在下一个时钟周期更新输出。

2. 带使能信号的寄存器

寄存器使能的实现

在某些设计中,我们需要根据条件决定寄存器是否更新输入数据。Chisel 提供了 RegEnable 关键字,用于实现带有使能信号的寄存器。

代码示例:

scala
val regEnable = RegEnable(inVal, 0.U(4.W), enable)
  • inVal:寄存器的输入数据。
  • 0.U(4.W):寄存器的初始值。
  • enable:使能信号,当 enable 为高时,寄存器更新输入值,否则保持不变。

使能信号的行为

图 6.4 和图 6.5 展示了一个带有使能信号的寄存器:

  • 数据输入 D:寄存器接收的数据。
  • 使能信号 enable:当 enable 为高时,寄存器捕获输入信号,否则保持当前值。
  • 时钟信号 clock:寄存器的更新受时钟上升沿控制。

波形图解析(图 6.5):

  1. 在使能信号 enable 为高时,寄存器输出 regEnable 更新为输入值 inVal
  2. 在使能信号为低时,寄存器保持当前输出值,不发生变化。

代码实现: 如果需要更复杂的逻辑,可以使用 when 语句:

scala
val enableReg = Reg(UInt(4.W))
when (enable) {
  enableReg := inVal
}

3. 复位与使能的结合

在实际设计中,复位和使能信号经常同时出现。Chisel 提供了 RegEnableRegInit 的组合,便于实现带有复位和使能功能的寄存器。

代码示例:

scala
val resetEnableReg = RegEnable(inVal, 0.U(4.W), enable)
  • inVal:输入数据。
  • 0.U(4.W):复位时的初始化值。
  • enable:使能信号。

在复位时,寄存器会初始化为 0。当使能信号为高时,寄存器捕获输入数据,否则保持当前值。

4. 进阶应用:寄存器与表达式结合

寄存器可以作为表达式的一部分,简化电路描述。例如,通过比较当前输入与上一个时钟周期的值,可以实现边沿检测

代码示例:

scala
val risingEdge = din & !RegNext(din)
  • RegNext(din):将输入信号 din 延迟一个时钟周期。
  • !RegNext(din):取反,得到前一个时钟周期的状态。
  • din & !RegNext(din):当当前输入 din 为高,且前一周期的 din 为低时,表示检测到上升沿

5. 结合寄存器与其他模块

图 6.6 展示了一个寄存器与加法器模块的结合。寄存器的输出被用作加法器的反馈输入,形成了一个简单的累加器(counter)。

代码示例:

scala
val counter = RegInit(0.U(4.W))   // 初始化为 0
counter := counter + 1.U         // 累加操作
  • 在每个时钟周期,寄存器输出加 1,并更新为新的值。
  • 这种结构常用于计数器、状态机等时序逻辑模块。

6.2 计数器(Counters)

计数器是时序电路中最基本的模块之一。它的作用是累加递减一个值,通常将输出反馈到加法器或减法器,再将结果送回寄存器输入端。这样,每个时钟周期都会根据设定的逻辑更新计数值。

1. 基本的自由运行计数器

最简单的计数器是自由运行计数器(free-running counter),它连续从 0 开始计数,达到最大值后重新归零。

示例代码:

scala
val cntReg = RegInit(0.U(4.W)) // 初始化 4 位计数器为 0  
cntReg := cntReg + 1.U         // 每个时钟周期加 1
  • RegInit:用于初始化寄存器的值。这里将计数器初始化为 0。
  • cntReg + 1.U:表示计数器每次加 1。

行为:

  • 计数器 cntReg 的宽度为 4 位,因此它会从 0 计数到 15,然后自动回到 0。
  • 计数器是同步时序逻辑,每个时钟上升沿更新一次。

2. 条件计数器(事件计数)

有时我们希望在特定事件发生时才更新计数器。例如,当 event 信号为高时,计数器才递增。

示例代码:

scala
val cntEventsReg = RegInit(0.U(4.W)) // 初始化计数器  
when(event) {                       // 在事件发生时更新  
  cntEventsReg := cntEventsReg + 1.U
}
  • when(event):表示只有当 event 为高时,计数器才加 1。
  • 这种实现方式可以用于事件触发型计数器,例如检测脉冲信号数量。

示意图(图 6.7): 图 6.7 展示了一个事件计数器的结构:

  1. 数据路径包含加法器和复用器。
  2. event 信号控制加法器输出是否更新到寄存器中。

3. 计数器的向上计数与重置

计数器可以设置一个最大值 N,当计数到达 N 时重新归零。

代码示例:

scala
val cntReg = RegInit(0.U(8.W)) // 初始化为 8 位宽度的 0  
cntReg := cntReg + 1.U         // 每个时钟周期加 1  
when(cntReg === N) {           // 当计数器达到 N 时重置为 0  
  cntReg := 0.U
}
  • cntReg === N:判断计数器是否等于最大值 N
  • 当达到最大值时,计数器的值被重置为 0。

通过 Mux 实现条件复位: Chisel 提供了 Mux 多路选择器来简化逻辑:

scala
val cntReg = RegInit(0.U(8.W))  
cntReg := Mux(cntReg === N, 0.U, cntReg + 1.U)
  • Mux(cond, a, b):若条件 cond 为真,输出 a;否则输出 b
  • 在这里,若 cntReg 等于 N,则输出 0;否则输出 cntReg + 1.U

4. 向下计数器(计数递减)

计数器不仅可以向上计数,还可以实现向下计数。向下计数时,从最大值 N 开始,每次递减 1,当计数到达 0 时重置为最大值。

代码示例:

scala
val cntReg = RegInit(N)         // 初始化计数器为最大值 N  
cntReg := cntReg - 1.U          // 每个时钟周期递减 1  
when(cntReg === 0.U) {          // 当计数器等于 0 时重置为 N  
  cntReg := N
}
  • 向下计数N 开始,每次递减 1。
  • 当计数器值为 0 时,重置为最大值 N

5. 使用函数生成计数器

为了提高代码的可重用性,我们可以将计数器的逻辑封装为一个函数,通过传入参数 n 来动态设置最大计数值。

代码示例:

scala
// 定义一个返回计数器的函数  
def genCounter(n: Int) = {  
  val cntReg = RegInit(0.U(8.W))  
  cntReg := Mux(cntReg === n.U, 0.U, cntReg + 1.U)  
  cntReg  
}

// 使用函数生成不同的计数器  
val count10 = genCounter(10)  // 最大计数值为 10  
val count99 = genCounter(99)  // 最大计数值为 99
  • genCounter:接收一个整数 n 作为最大计数值,返回一个计数器。
  • 可以通过调用 genCounter 轻松生成多个计数器,减少重复代码。

注意:

  • 计数范围是从 0 到 N,包括 N。如果需要计数 10 个时钟周期,N 应设置为 9,以避免off-by-one 错误。

6.2.2 使用计数器生成时序信号

除了用计数器进行事件计数,计数器还经常被用于时间信号生成(timing generation),比如生成 LED 闪烁信号或者其他固定周期的控制信号。在同步电路中,时钟频率是固定的,但通过计数时钟周期,我们可以在逻辑上定义更慢的时钟信号,生成单周期的触发信号(tick)。

1. 基于时钟频率的单周期 tick 信号

单周期 tick 是一种逻辑信号,它在每隔 nn 个时钟周期时产生一个单时钟周期的脉冲。

  • 如果已知时钟频率 fclock 和所需 tick 的频率 ftick,我们可以通过以下公式计算:

n=fclockftick

2. 基本的 tick 计数器

图 6.8 展示了一个生成 每 3 个时钟周期产生一个 tick 信号 的例子。

代码示例:

scala
val tickCounterReg = RegInit(0.U(32.W))       // 定义一个 32 位宽度的计数器  
val tick = tickCounterReg === (N-1).U         // 判断计数器是否达到最大值  

tickCounterReg := tickCounterReg + 1.U        // 计数器加 1  
when (tick) {                                 // 当 tick 信号为真时重置计数器  
  tickCounterReg := 0.U
}

代码解析:

  1. tickCounterReg:这是一个寄存器,用于计数时钟周期。初始值设为 0。
  2. tick:当 tickCounterReg 计数到 N−1N-1 时,tick 信号为高(true),产生单周期脉冲。
  3. 每次时钟周期,tickCounterReg 加 1。当达到最大值 N−1N-1 时,计数器重置为 0。

3. 生成更慢的逻辑时钟信号

由上面的单周期 tick 信号,我们可以将其作为使能信号,触发其他更慢的计数器。这样可以生成更低频率的时钟信号(逻辑时钟)。

示例代码:

scala
val lowFrequentCntReg = RegInit(0.U(4.W))     // 定义一个低频计数器  
when (tick) {                                // 使用 tick 信号作为使能  
  lowFrequentCntReg := lowFrequentCntReg + 1.U
}

代码解析:

  1. tick 信号:当 tick 信号为高时,触发低频计数器 lowFrequentCntReg 更新。
  2. 低频计数器:每 nn 个时钟周期,低频计数器的值加 1,从而实现一个逻辑时钟。

波形解析(图 6.9):

  • 时钟 clock:系统时钟信号。
  • tick 信号:每隔 nn 个时钟周期产生一个单周期的高脉冲。
  • 低频计数器 slow cnt:在 tick 信号的使能下递增。

4. 应用场景

这种通过计数器生成的逻辑时钟信号,具有广泛的应用,包括:

  1. LED 闪烁:通过控制 LED 亮灭的时间间隔,实现 LED 的频率闪烁。
  2. 波特率生成:为串行通信接口(如 UART)生成特定的波特率时钟。
  3. 多路显示控制:生成用于 7 段数码管显示的扫描时钟。
  4. 去抖动信号:通过时间间隔判断输入开关信号的稳定性,去除抖动。
  5. 分频器:通过 tick 信号将高频时钟分频成更低的时钟频率。

5. 注意事项:显式宽度定义

在定义计数器时,建议显式地设置寄存器的位宽,而不是依赖 Chisel 的自动宽度推断(width inference)。

原因:

  1. 自动推断可能导致意外的位宽。例如,复位值 0.U 默认可能是单比特宽度,而在实际应用中需要更高的位宽。
  2. 显式定义宽度可以避免计数器溢出或位宽不匹配的问题。

示例代码:

scala
val tickCounterReg = RegInit(0.U(32.W))  // 显式定义为 32 位宽度

6.2.3 高效计数器(The Nerd Counter)

高效的计数器设计旨在优化资源利用,特别是在 FPGA 和 ASIC 的实现中。标准的计数器通常需要以下资源:

  1. 一个寄存器:用于存储计数值。
  2. 一个加法器或减法器:实现计数的增减。
  3. 一个比较器:用于判断计数器是否达到指定值。

在向上计数时,需要将当前计数值与一个固定值进行比较,比较器的资源消耗依赖于输入位宽。在向下计数时,比较器实现通常涉及大型 NOR 门AND 门,特别是判断值是否等于 0。

然而,聪明的硬件设计者可以进一步优化计数器。如果我们从 N-2 递减到 -1,就可以避免全位比较。

  • 负数在二进制表示中,其最高位(MSB)为 1。
  • 正数的最高位为 0。

因此,我们只需要检查计数值的最高位是否为 1,就可以判断计数器是否等于 -1。这种技巧可以减少比较器的复杂度,优化硬件资源。

高效计数器的 Chisel 实现

代码示例:

scala
val MAX = (N - 2).S(8.W)   // 设置计数器的初始值(最大值)  
val cntReg = RegInit(MAX)  // 计数器初始化为最大值  
io.tick := false.B         // 初始化 tick 信号  

cntReg := cntReg - 1.S     // 每时钟周期递减 1  
when(cntReg(7)) {          // 当最高位(MSB)为 1 时  
  cntReg := MAX            // 重置计数器为最大值  
  io.tick := true.B        // 输出 tick 信号  
}

代码解析:

  1. MAX:计数器的最大初始值,为 N−2N-2,数据类型为有符号整数(S)。

  2. 递减操作:每个时钟周期,计数器值减 1。

  3. 最高位判断 :

    cntReg(7)

    表示计数器的最高位。

    • 当最高位为 1 时,表示计数器等于 -1。
    • 此时,重置计数器为初始值 MAX 并输出 tick 信号。

资源优化

  • 只需检查最高位,避免了对所有位的比较。
  • 适用于资源受限的硬件设计,如 FPGA 和 ASIC。

6.2.4 定时器(A Timer)

定时器是一种特殊的计数器,常用于实现单次触发功能(one-shot timer)。它类似于厨房定时器:设定时间,按下开始按钮,计数器递减到 0 时发出信号。

定时器的工作原理

定时器的实现依赖以下逻辑:

  1. 初始加载:通过信号 load 将输入值 din 加载到计数器中。
  2. 计数递减:当 load 取消后,计数器开始每个时钟周期递减。
  3. 触发信号:当计数器值为 0 时,发出完成信号 done
  4. 停止计数:计数器到达 0 后停止工作。

示意图(图 6.10):

  • 加法器实现计数递减(-1)。

  • 多路选择器(Select)决定计数器的输入:

    • load 信号有效,加载输入值 din
    • 否则,计数器值递减。
  • 比较器判断计数器值是否为 0,并输出完成信号 done

Chisel 定时器实现

代码示例(Listing 6.1):

scala
val cntReg = RegInit(0.U(8.W))           // 定义 8 位宽度的计数器,初始化为 0  
val done = cntReg === 0.U               // 定义完成信号,当计数器为 0 时为真  

val next = WireDefault(0.U)             // 定义一个中间信号 next  
when (load) {                           // 如果 load 信号有效  
  next := din                           // 将输入值 din 加载到计数器  
} .elsewhen (!done) {                   // 如果未完成计数  
  next := cntReg - 1.U                  // 计数器值递减  
}                                      

cntReg := next                          // 更新寄存器值

代码解析:

  1. cntReg:定义一个 8 位寄存器,用于存储当前计数值。

  2. done:比较计数器值是否为 0,用于表示计数完成。

  3. next 信号 :通过when/elsewhen控制计数器的输入:

    • load 有效时,加载输入值 din
    • 否则,在 done 未完成的情况下递减计数值。
  4. 寄存器更新:将 next 信号赋值给 cntReg,实现计数器状态更新。

定时器的波形与应用

PWM(脉宽调制)示例(图 6.11):

  • 定时器可以用来控制输出脉冲的时间宽度,实现脉宽调制(PWM)信号。
  • PWM 信号广泛用于:
    1. 控制 LED 亮度。
    2. 调节电机转速。
    3. 生成音频信号。

6.2.5 脉宽调制 (Pulse-Width Modulation)

脉宽调制(Pulse-Width Modulation,简称 PWM)是一种信号调制技术,其输出信号具有恒定周期,但可以通过调节高电平持续时间(占空比)来控制信号的调制宽度。

1. PWM 的基本概念

PWM 信号的关键特性包括:

  1. 周期:PWM 信号的周期是固定的。

  2. 占空比(Duty Cycle) :占空比表示信号在一个周期内保持高电平的时间比例。

    • 例如,占空比为 25% 表示信号在一个周期内高电平持续 25% 的时间。

如图 6.11 所示:

  • 每两个周期的占空比分别为 25%、50% 和 75%。
  • 脉宽可在 25% 到 75% 之间调节。

低通滤波器(Low-Pass Filter)可将 PWM 信号平滑转换为模拟信号,实现简单的数字-模拟转换(DAC)。例如,PWM 可用于调节 LED 亮度,经过滤波后,LED 亮度与占空比成正比。

2. PWM 信号的生成

PWM 生成函数

Chisel 提供了简洁的代码结构,可以通过计数器和比较器生成 PWM 信号。

代码示例:

scala
def pwm(nrCycles: Int, din: UInt) = {
  val cntReg = RegInit(0.U(unsignedBitLength(nrCycles - 1).W))
  cntReg := Mux(cntReg === (nrCycles - 1).U, 0.U, cntReg + 1.U)
  din > cntReg
}

val din = 3.U
val dout = pwm(10, din)

代码解析:

  1. nrCycles:定义 PWM 信号的周期(总时钟周期数)。

  2. din:表示占空比(脉宽控制值),即 PWM 信号高电平持续的周期数。

  3. 计数器 cntReg

    • 每个时钟周期自增 1。
    • 当计数器值达到最大周期数 nrCycles - 1 时,重置为 0,开始新一轮计数。
  4. PWM 输出 :

    • 比较 dincntReg,如果 din > cntReg,则输出高电平;否则输出低电平。
    • 这样实现了占空比的动态调整。

3. PWM 信号的应用

调光 LED 示例

PWM 信号可以用于控制 LED 的亮度,通过改变占空比,LED 的平均电压发生变化,从而改变亮度。

  • 占空比 25%:LED 较暗。
  • 占空比 50%:LED 半亮。
  • 占空比 75%:LED 较亮。

4. 动态 PWM 生成

在某些应用中,PWM 的占空比需要动态改变,例如:

  1. LED 渐变亮度。
  2. 模拟三角波输出。

代码示例:

scala
val FREQ = 100000000        // 100 MHz 时钟输入  
val MAX = FREQ / 1000       // 生成 1 kHz 信号  

val modulationReg = RegInit(0.U(32.W))  // PWM 模块计数器  
val upReg = RegInit(true.B)             // 控制计数方向  

when (modulationReg < FREQ.U && upReg) {
  modulationReg := modulationReg + 1.U  // 计数递增  
} .elsewhen (modulationReg === FREQ.U && upReg) {
  upReg := false.B                     // 改变方向  
} .elsewhen (modulationReg > 0.U && !upReg) {
  modulationReg := modulationReg - 1.U  // 计数递减  
} .otherwise {
  upReg := true.B                      // 改回递增  
}

// 通过 PWM 输出信号  
val sig = pwm(MAX, modulationReg >> 10) // 将 modulationReg 右移 10 位生成低频信号

代码解析:

  1. modulationReg:动态调整 PWM 占空比,通过增加或减少寄存器值控制脉宽。
  2. upReg:用于改变计数方向,切换占空比增加或减少。
  3. 右移操作modulationReg >> 10 将高频调制信号分频,产生更低频率的 PWM 信号。
  4. PWM 输出:通过 pwm 函数生成 PWM 信号,sig 是最终输出。

5. 关键技术要点

  1. 计数器生成周期信号
    • 使用计数器实现 PWM 的固定周期。
    • 每次计数器溢出时重置为 0,产生一个新的 PWM 周期。
  2. 占空比调节
    • 通过比较器判断输入脉宽值 din 和当前计数值,动态生成高低电平。
  3. 低通滤波器
    • 平滑 PWM 信号,获得连续的模拟输出电压。
    • 应用于 DAC(数字-模拟转换)、LED 调光等场景。
  4. 动态调节 PWM
    • 使用递增/递减寄存器动态调整占空比,生成渐变效果(如 LED 呼吸灯)。

在该代码示例中,modulationReg 的值被 右移 10 位modulationReg >> 10),这是为了将较高频率的 时钟信号 降低到一个 合适的频率,用于 PWM 输出。

为什么要右移?

  1. 时钟频率较高
    • FREQ = 100 MHz,表示系统输入时钟为 100 MHz。
    • 这样的高频率对于直接生成低频 PWM 信号是不合适的,因为我们需要一个较低频率的 PWM 信号,比如 1 kHz。
  2. 右移的作用
    • 右移操作本质上是将计数值进行 除法。例如,x >> 1 等于 x / 2,而 x >> 10 等于 x / 1024
    • 在这里,modulationReg 是一个动态递增或递减的值,右移 10 位会将其值 缩小 2^10 = 1024 倍,从而产生一个较低频率的调制信号。
  3. 分频逻辑
    • 输入时钟频率为 100 MHz,假设 modulationReg 逐渐递增到 FREQ(即 100,000,000),
    • 如果我们直接使用 modulationReg 作为输入,PWM 频率会非常高。
    • 右移 10 位后,计数范围变为 FREQ / 1024 ≈ 97,656,从而有效地将时钟频率 分频

为什么选择右移 10 位?

选择 右移 10 位 的原因是工程设计中需要一个 合适的 PWM 分辨率与频率 之间的平衡:

  1. 目标 PWM 频率
    • 代码中使用 MAX = FREQ / 1000 生成一个 1 kHz 的信号作为 PWM 基准周期。
    • 右移 10 位将 modulationReg 的动态范围缩小到一个可以适配 PWM 输出的较低值。
  2. 平衡分辨率与性能
    • 分辨率:右移位数越少,PWM 的占空比调节分辨率越高,但输出频率也越高。
    • 频率:右移位数越多,输出频率降低,但分辨率也相应降低。
    • 选择右移 10 位 是为了在 PWM 信号的 动态变化输出频率 之间找到平衡点。
  3. 实际应用需求
    • 比如 LED 调光时,1 kHz 的 PWM 频率通常足够了,因为人眼无法分辨超过几百 Hz 的闪烁。
    • 同时,通过 modulationReg 逐渐变化,可以实现 平滑的亮度调节 效果(如呼吸灯)。

这里再解释一下“右移位数越少,PWM 的占空比调节分辨率越高,但输出频率也越高”这个概念。

1. PWM 的分辨率是什么?

PWM 分辨率是指占空比的最小变化量,即能够表示的占空比的精细程度

  • 分辨率越高:表示 PWM 占空比可以有更细小的调整步进,控制更加精确。
  • 分辨率越低:表示 PWM 占空比的调整步进较大,无法进行细微的控制。

分辨率与计数器位数的关系: PWM 的分辨率和用于计数的位数有关。如果计数器的位数是 N,分辨率为 1/2N1 / 2^N,这意味着占空比的最小步进是 1 / 2N2^N

2. 为什么右移位数越少,分辨率越高?

在 PWM 信号中,右移操作会缩小计数值的范围,从而降低分辨率。

  • 右移少(例如右移 2 位)
    • 计数值范围较大,例如 210=10242^{10} = 1024 级。
    • 占空比的最小步进是 1/10241 / 1024,可以实现更高的分辨率,控制更加细致。
  • 右移多(例如右移 10 位)
    • 计数值范围较小,例如 20=12^{0} = 1 或 23=82^3 = 8 级。
    • 占空比的最小步进较大,例如 1/81 / 8,只能粗略地调节占空比,分辨率较低。

总结: 右移越少,计数器值的范围越大,占空比可以在更小的步进上变化,因此分辨率越高

3. 为什么右移位数越少,输出频率越高?

PWM 输出频率与计数器的范围成反比,计数器范围越大,PWM 输出频率越低;反之,计数器范围越小,PWM 输出频率越高。

  • 右移少
    • 计数器的最大值较大,需要更多的时钟周期来完成一次 PWM 周期。
    • 例如,210=10242^{10} = 1024 个时钟周期对应一个 PWM 周期,频率较低。
  • 右移多
    • 计数器的最大值较小,PWM 周期缩短,需要的时钟周期变少,频率上升。
    • 例如,23=82^3 = 8 个时钟周期对应一个 PWM 周期,频率较高。

4. 结合两者关系

  • 右移位数少
    • 分辨率高:占空比可以细致地调节。
    • 频率低:因为计数器范围较大,一个周期需要更多时钟周期。
  • 右移位数多
    • 分辨率低:占空比只能粗略调节,最小步进较大。
    • 频率高:因为计数器范围小,一个周期需要的时钟周期少,输出频率变高。

5. 总结解释

“右移位数越少,PWM 的占空比调节分辨率越高,但输出频率也越高” 是一种描述 相对的控制精度与频率关系 的现象。

  1. 分辨率高:计数器值的位数越多,步进越小,控制精度越高。
  2. 频率高:位数少(右移多)导致周期缩短,PWM 输出频率更高,但精度下降。

因此,当右移位数少时,虽然输出周期较长(频率较低),但占空比的步进精度更高,PWM 控制更细致。

6.3 移位寄存器 (Shift Registers)

移位寄存器 是一组按顺序连接的触发器,每个触发器的输出连接到下一个触发器的输入。在每个时钟周期,数据在寄存器内逐位移动(移位)。这种结构常用于:

  1. 数据延迟:实现简单的时间延迟。
  2. 串并转换:将串行数据转换为并行数据,或将并行数据转换为串行数据。
  3. 数据存储和传输:在通信接口(如 UART)中实现数据接收和发送。

1. 基本移位寄存器

图 6.13 展示了一个 4 位移位寄存器,其中数据从左至右依次移位。

Chisel 实现:

scala
val shiftReg = Reg(UInt(4.W))     // 定义 4 位寄存器  
shiftReg := shiftReg(2, 0) ## din // 低 3 位与新输入 din 拼接  
val dout = shiftReg(3)            // 输出最高位

代码解析:

  1. 寄存器定义shiftReg 是一个 4 位宽度的寄存器。

  2. 数据移位 :

    • shiftReg(2, 0) 获取寄存器的低 3 位。
    • ## din 将输入 din 追加到寄存器的最低位。
  3. 输出shiftReg(3) 提取寄存器的最高位作为输出。

行为说明:

  • 每个时钟周期,数据从输入 din 移入寄存器的最低位,其他位向右移动。
  • 最高位的数据会被输出 dout

2. 带并行输出的移位寄存器

串入并出(Serial-In Parallel-Out, SIPO)结构用于将串行输入数据转换为并行输出数据。例如,在串行通信接收器中,每个时钟周期接收一位数据,最终输出一个完整的数据字。

图 6.13 展示了一个 4 位带并行输出的移位寄存器

Chisel 实现:

scala
val outReg = RegInit(0.U(4.W))    // 初始化 4 位寄存器为 0  
outReg := serIn ## outReg(3, 1)   // 新数据拼接寄存器高 3 位  
val q = outReg                    // 并行输出寄存器内容

代码解析:

  1. 寄存器初始化outReg 初始化为 0。

  2. 右移操作 :

    • outReg(3, 1) 获取寄存器的高 3 位。
    • serIn ## outReg(3, 1) 将输入串行数据 serIn 移入最高位,其他数据向右移。
  3. 输出outReg 保存了完整的 4 位数据字,可作为并行输出。

功能说明:

  • 每接收 4 位串行数据,寄存器 outReg 就包含了一个完整的并行数据字。

3. 带并行加载的移位寄存器

并入串出(Parallel-In Serial-Out, PISO)结构用于将并行输入数据转换为串行输出数据。例如,在串行通信发送器中,将完整的数据字逐位发送出去。

图 6.14 展示了一个 4 位带并行加载功能的移位寄存器

Chisel 实现:

scala
val loadReg = RegInit(0.U(4.W))  // 初始化 4 位寄存器为 0  

when (load) {                   // 当 load 信号有效时,加载并行输入数据  
  loadReg := d                  // 将并行数据 d 加载到寄存器  
} .otherwise {                  // 否则进行右移操作  
  loadReg := 0.U ## loadReg(3, 1) // 右移一位,最高位填 0  
}

val serOut = loadReg(0)         // 串行输出最低位

代码解析:

  1. 寄存器初始化loadReg 初始化为 0。

  2. 并行加载:当 load 信号有效时,将输入数据 d 加载到寄存器中。

  3. 右移操作 :

    • loadReg(3, 1) 获取寄存器的高 3 位。
    • 0.U ## loadReg(3, 1) 将高 3 位右移,并在最高位填 0。
  4. 串行输出loadReg(0) 输出寄存器的最低位。

功能说明:

  • load 信号有效时,寄存器加载并行数据。
  • load 信号无效时,寄存器按时钟周期右移一位,并输出最低位数据。
  • 最终实现将 4 位并行数据逐位发送。

4. 移位寄存器的应用

移位寄存器在数字电路中有广泛应用:

  1. 串行通信 :

    • SIPO:将接收的串行数据转换为并行数据。
    • PISO:将并行数据转换为串行数据发送。
  2. 延迟线:通过移位实现固定的时间延迟。

  3. 滤波器:用于 FIR 滤波器等数字信号处理应用。

  4. 数据存储:将数据存储并按时钟移位传输。

  5. 控制信号扩展:将单个控制信号移位生成多个输出。

6.4 存储器 (Memory)

存储器是一种用于存储大量数据的硬件结构。在 Chisel 中,存储器可以通过寄存器集合Reg)或向量(Vec)构建。然而,当数据量增大时,这种实现方式在硬件资源消耗方面显得十分昂贵。因此,实际硬件设计中,较大的存储器通常通过SRAM(静态随机存储器)来实现。

在 FPGA 设计中,存储器常用的结构包括片上存储块(on-chip memory blocks),也称为Block RAM(BRAM)。这些存储块可以组合起来构建更大的存储器。FPGA 上的存储器通常具有以下特点:

  1. 单端口存储器:一个读端口和一个写端口。
  2. 双端口存储器:提供两个端口,可以在运行时切换为读或写模式。

同步存储器(Synchronous Memory)是 FPGA 和 ASIC 设计中最常用的存储器类型。同步存储器的输入信号(如读地址、写地址、写数据和写使能)都通过时钟同步操作,读数据通常会在设定地址后的下一个时钟周期输出。

1. 同步存储器的结构

图 6.15 展示了一个 同步存储器 的结构:

  • 读端口:包含一个输入(rdAddr,读地址)和一个输出(rdData,读数据)。

  • 写端口 :包含三个输入信号:

    1. wrAddr:写地址。
    2. wrData:写数据。
    3. wrEna:写使能信号,当 wrEna 为高时,数据被写入存储器。

特点

  • 存储器的输入信号(地址、数据和使能)都通过时钟同步寄存器进行存储。
  • 读操作的结果会在下一个时钟周期输出。

2. Chisel 中的同步存储器实现

Chisel 提供了 SyncReadMem 关键字,用于构建同步存储器。

示例代码(Listing 6.2):

scala
class Memory() extends Module {
  val io = IO(new Bundle {
    val rdAddr = Input(UInt(10.W))   // 读地址,10 位宽度  
    val rdData = Output(UInt(8.W))   // 读数据,8 位宽度  
    val wrAddr = Input(UInt(10.W))   // 写地址  
    val wrData = Input(UInt(8.W))    // 写数据  
    val wrEna  = Input(Bool())       // 写使能信号  
  })

  val mem = SyncReadMem(1024, UInt(8.W))  // 定义 1 KiB 的同步存储器

  io.rdData := mem.read(io.rdAddr)       // 读数据操作

  when(io.wrEna) {                       // 写数据操作  
    mem.write(io.wrAddr, io.wrData)  
  }
}

代码解析:

  1. 接口定义 :

    • rdAddr:读地址输入。
    • rdData:读数据输出。
    • wrAddr:写地址输入。
    • wrData:写数据输入。
    • wrEna:写使能信号,当为高时触发写操作。
  2. 存储器构造 :

    • SyncReadMem(1024, UInt(8.W)) 定义一个 1 KiB 的存储器(1024 个 8 位宽度的数据单元)。
  3. 读操作 :

    • mem.read(io.rdAddr) 通过输入的读地址读取数据,结果在下一个时钟周期输出。
  4. 写操作 :

    • when(io.wrEna) 判断写使能信号,当有效时,将数据写入指定地址。

3. 读写冲突问题(Read-During-Write Behavior)

在同步存储器中,如果在同一个时钟周期内,写操作与读操作发生在同一地址,会出现读写冲突。这种情况下,存储器的读出值可能有以下三种行为:

  1. 新写入值:输出刚写入的数据。
  2. 旧值:输出写入前的原始数据。
  3. 未定义:输出混合了新旧值的数据。

在 Chisel 中,SyncReadMem 的默认行为是未定义的。因此,读写冲突时,用户需要额外设计数据前递逻辑(forwarding circuit)来确保正确的读值。

4. 数据前递逻辑(Forwarding Circuit)

数据前递逻辑用于解决读写冲突,确保在同一地址发生写操作时,读出正确的值(新写入值)。

实现方法:

  • 比较读地址和写地址,如果两者相等且写使能有效,则直接将写入数据送到读数据输出,而不是通过存储器读取。

5. 存储器的硬件资源

在 FPGA 上,存储器可以通过以下方式实现:

  1. LUT(查找表)实现的小型存储器:适用于较小容量的数据存储。
  2. Block RAM(BRAM):用于中等到大型存储器,资源开销较小且性能优越。
  3. 分布式存储器:将存储器分布在 FPGA 的逻辑单元中,适用于灵活存储需求。

在 ASIC 设计中,存储器通过SRAM 宏单元实现,由专用的存储器编译器生成。

6.4.1 带前递逻辑的存储器 (Forwarding in Memory)

在同步存储器中,读写冲突是一个常见的问题。当在同一个时钟周期内,对同一个地址执行写操作和读操作时,存储器的行为通常未定义,可能导致读出旧数据或不稳定的数据。

为了解决这个问题,我们可以引入数据前递逻辑(forwarding logic)。前递逻辑的主要作用是:

  • 当检测到读写地址相等且写使能有效时,直接将写入数据前递到读输出端,而不是从存储器中读取数据。
  • 这样可以确保在读写冲突时,读出最新写入的数据。

1. 带前递逻辑的存储器结构

图 6.16 展示了一个带前递逻辑的同步存储器

  • 输入信号 :

    • wrAddr:写地址
    • wrData:写数据
    • wrEna:写使能信号
    • rdAddr:读地址
  • 前递逻辑 :

    • 比较写地址 wrAddr 和读地址 rdAddr,判断它们是否相等。
    • 如果相等且写使能有效,前递写数据到输出端 rdData
    • 否则,正常读取存储器中的数据。

2. 带前递逻辑的 Chisel 实现

代码示例 (Listing 6.3):

scala
class ForwardingMemory() extends Module {
  val io = IO(new Bundle {
    val rdAddr = Input(UInt(10.W))    // 读地址
    val rdData = Output(UInt(8.W))    // 读数据
    val wrAddr = Input(UInt(10.W))    // 写地址
    val wrData = Input(UInt(8.W))     // 写数据
    val wrEna  = Input(Bool())        // 写使能信号
  })

  val mem = SyncReadMem(1024, UInt(8.W)) // 定义 1 KiB 的同步存储器

  // 前递逻辑的寄存器存储写数据和判断条件
  val wrDataReg = RegNext(io.wrData)            // 存储写入数据  
  val doForwardReg = RegNext(io.wrAddr === io.rdAddr && io.wrEna) // 判断读写地址是否相等且写使能有效

  val memData = mem.read(io.rdAddr)             // 从存储器读取数据  

  when(io.wrEna) {                              // 写数据操作
    mem.write(io.wrAddr, io.wrData)
  }

  // 根据前递逻辑选择输出数据:前递数据或存储器数据
  io.rdData := Mux(doForwardReg, wrDataReg, memData)
}

代码解析:

  1. 前递逻辑的实现 :

    • doForwardReg:判断条件是否成立(读写地址相等且写使能有效)。
    • wrDataReg:存储写入的数据,用于前递。
  2. 数据读取 :

    • mem.read(io.rdAddr) 从存储器中读取数据,存储器的读取在下一个时钟周期生效。
  3. 数据写入 :

    • 使用 when(io.wrEna) 确保写操作只在写使能有效时发生。
  4. 前递数据选择 :

    • 使用 Mux 选择最终的读出数据。
    • 如果前递条件成立,输出写入数据 wrDataReg;否则,输出存储器的读数据 memData

3. 关键要点

  1. 前递逻辑的核心
    • 判断读写地址是否相等。
    • 通过写使能信号确认当前存在写操作。
    • 使用寄存器存储写入数据和前递条件,确保同步时序。
  2. 前递数据路径
    • 当发生读写冲突时,直接将写数据前递到读输出端,避免从存储器中读取旧数据。
  3. Chisel 中的实现
    • 使用 RegNext 延迟写数据和前递条件,匹配存储器的同步读时序。
    • 使用 Mux 实现前递数据与存储器数据的选择。

4. 前递逻辑的硬件结构

图 6.16 展示了前递逻辑的硬件结构:

  1. 数据路径 :

    • 正常路径:从存储器中读取数据。
    • 前递路径:直接将写数据前递到读输出端。
  2. 控制逻辑 :

    • 通过比较写地址和读地址,生成前递使能信号。
  3. 多路选择器 :

    • 根据前递使能信号,选择最终的输出数据。

5. 读写冲突行为

在同步存储器中,读写冲突的行为可以分为三种情况:

  1. 前递逻辑解决冲突:输出最新写入的数据(如本例中的实现)。
  2. 默认行为未定义:没有前递逻辑时,存储器可能输出未定义值。
  3. 使用特殊存储器资源:部分 FPGA 提供专门的读写冲突解决机制(如 Xilinx 的 LUTRAM)。

6.5 练习

本节提供了几个针对计数器、PWM 波形生成以及状态控制的练习,目的是让你更熟练地使用 Chisel 进行硬件描述设计。以下是练习的详细解析及实现要点。

1. 7 段显示器与 4 位计数器

目标:

  • 使用一个 4 位计数器 作为输入,将其连接到 7 段显示器,从 0 计数到 F(16 进制)。
  • 由于 FPGA 时钟频率较高(如 50 MHz 或 100 MHz),需要使用额外的计数器来减速显示更新速度。
  • 每 500 毫秒生成一个单周期的 tick 信号,用于作为 4 位计数器的使能信号。

实现步骤:

  1. 创建一个减速计数器 该计数器负责生成 500 毫秒周期的单周期 tick 信号。

    示例代码:

    scala
    val slowCounter = RegInit(0.U(26.W))  // 假设 50 MHz 时钟,26 位足够计数 500ms  
    val tick = slowCounter === 25000000.U // 每 500ms 产生一个 tick 信号  
    
    slowCounter := Mux(tick, 0.U, slowCounter + 1.U)
  2. 4 位计数器 使用 tick 信号作为使能信号,驱动 4 位计数器进行计数。

    示例代码:

    scala
    val countReg = RegInit(0.U(4.W))  
    when(tick) {  
      countReg := countReg + 1.U  // 每 500ms 计数一次  
    }
  3. 7 段显示器驱动 使用查找表(LUT)将计数器值转换为 7 段显示器对应的信号。

    示例代码:

    scala
    val segLUT = VecInit(
      "b00111111".U, // 0
      "b00000110".U, // 1
      "b01011011".U, // 2
      "b01001111".U, // 3
      "b01100110".U, // 4
      "b01101101".U, // 5
      "b01111101".U, // 6
      "b00000111".U, // 7
      "b01111111".U, // 8
      "b01101111".U, // 9
      "b01110111".U, // A
      "b01111100".U, // B
      "b00111001".U, // C
      "b01011110".U, // D
      "b01111001".U, // E
      "b01110001".U  // F
    )
    val segOut = segLUT(countReg) // 7 段显示器输出

2. PWM 波形生成

目标:

  • 使用一个生成器函数生成 PWM 波形,调整 占空比(duty cycle)。
  • 通过三角函数(通过计数器实现)或正弦函数(通过查找表实现)动态控制 PWM 信号。
  • 将 PWM 信号用于驱动 LED,使 LED 亮度随时间变化。

实现步骤:

  1. 三角函数 PWM 信号生成 使用一个向上向下计数的计数器,生成三角波输出:

    scala
    val pwmReg = RegInit(0.U(8.W))
    val upReg = RegInit(true.B)  
    
    when(upReg) {  
      pwmReg := pwmReg + 1.U  
      when(pwmReg === 255.U) { upReg := false.B }  
    }.otherwise {  
      pwmReg := pwmReg - 1.U  
      when(pwmReg === 0.U) { upReg := true.B }  
    }
    val pwmSignal = pwmReg
  2. PWM 输出比较器 通过比较器生成 PWM 输出信号:

    scala
    val counter = RegInit(0.U(8.W))  
    counter := counter + 1.U  
    val pwmOut = pwmSignal > counter // 占空比控制
  3. 驱动 LED 使用生成的 PWM 信号控制 LED 亮度。

3. 选择器电路

目标:

  • 使用 switch-case 语句控制输出 dout,根据输入 sel 选择不同的输出值。

实现代码:

scala
val dout = WireDefault(0.U)  

switch(sel) {  
  is(0.U) { dout := 0.U }  
  is(1.U) { dout := 11.U }  
  is(2.U) { dout := 22.U }  
  is(3.U) { dout := 33.U }  
  is(4.U) { dout := 44.U }  
  is(5.U) { dout := 55.U }  
}

4. 带寄存器的选择器电路

目标:

  • 扩展选择器电路,加入一个寄存器 regAcc,实现累加、清零和加减运算。

实现代码:

scala
val regAcc = RegInit(0.U(8.W))  

switch(sel) {  
  is(0.U) { regAcc := regAcc }      // 保持当前值  
  is(1.U) { regAcc := 0.U }         // 清零  
  is(2.U) { regAcc := regAcc + din } // 加法操作  
  is(3.U) { regAcc := regAcc - din } // 减法操作  
}

总结

通过本节的练习,你可以掌握以下核心设计技巧:

  1. 计数器设计:实现基本计数器和带使能信号的计数器。
  2. PWM 波形生成:利用计数器或查找表动态生成 PWM 信号,控制占空比。
  3. 选择器电路:使用 switch 语句实现不同逻辑控制。
  4. 累加器:带寄存器的选择器电路,支持清零、累加和减法操作。

这些练习能够帮助你熟练运用 Chisel 进行硬件描述,构建实际应用中的基本模块,如 LED 控制、7 段显示器、PWM 驱动等。