6 Sequential Building Blocks
6.1 Chisel 中的寄存器
Chisel 提供了一个便捷的方式来定义和使用寄存器(registers)。在同步设计中,寄存器是存储数据的基本单元,通常由 D 触发器(D flip-flop)构成。Chisel 提供了一些简洁的语法来创建寄存器,使得时序电路的描述变得直观且高效。
寄存器的定义与使用
1. 使用 RegNext
定义寄存器
RegNext
是 Chisel 提供的一种便捷方法,用于实现简单的数据寄存器。它可以将输入信号 d
的值在时钟的上升沿(rising edge)存储,并在下一个时钟周期输出到寄存器的输出 q
。
定义示例:
val q = RegNext(d)
d
表示寄存器的输入信号。q
是寄存器的输出信号。- 时钟信号不需要显式声明,Chisel 会隐式地将全局时钟信号连接到寄存器的时钟输入。
在时序电路中,RegNext
的作用类似于将输入值延迟一个时钟周期,并存储到寄存器中。每次时钟的上升沿到来时,寄存器都会自动更新其输出值。
2. 分步骤定义寄存器
在某些情况下,我们可能需要分步骤定义寄存器,包括创建寄存器和赋值信号。可以通过两步操作实现:
示例代码:
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
来指定:
示例代码:
val resetReg = RegInit(0.U(4.W)) // 定义一个带有复位值的 4 位寄存器
RegInit
用于在寄存器复位时指定初始值。- 在上面的代码中,寄存器
resetReg
在复位时会被初始化为0
,宽度为 4 位。 - 复位信号是隐式的,Chisel 会自动处理默认复位逻辑。
Chisel 中的寄存器复位与使能信号
在时序电路中,复位(reset) 和 使能(enable) 是两个重要的控制信号。复位用于初始化寄存器的值,而使能信号则用于控制寄存器是否更新输入数据。Chisel 提供了灵活的机制来处理这两种情况,包括同步复位和使能信号的实现。
1. 寄存器的同步复位
同步复位的实现
在 Chisel 中,复位可以通过 RegInit
来实现。RegInit
允许我们为寄存器提供一个初始值,该值在复位时被加载到寄存器中。
代码示例:
val valReg = RegInit(0.U(4.W)) // 定义一个 4 位寄存器,复位时初始化为 0
valReg := inVal // 输入信号赋值给寄存器
同步复位寄存器的行为
图 6.2 展示了一个带有同步复位的 D 触发器寄存器。
- 数据输入
D
:寄存器的数据输入。 - 复位信号
reset
:在复位时,寄存器输出初始化值。 - 时钟信号
clock
:寄存器在时钟的上升沿更新输出。
波形图解析(图 6.3):
- 在第一个时钟周期,复位信号
reset
被置为高,寄存器输出regVal
被设置为初始值(0)。 - 当复位信号为低时,寄存器开始正常工作,每个时钟周期捕获输入信号
inVal
的值。 - 波形显示:在
reset
信号被释放后,寄存器正常捕获输入数据,并在下一个时钟周期更新输出。
2. 带使能信号的寄存器
寄存器使能的实现
在某些设计中,我们需要根据条件决定寄存器是否更新输入数据。Chisel 提供了 RegEnable
关键字,用于实现带有使能信号的寄存器。
代码示例:
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):
- 在使能信号
enable
为高时,寄存器输出regEnable
更新为输入值inVal
。 - 在使能信号为低时,寄存器保持当前输出值,不发生变化。
代码实现: 如果需要更复杂的逻辑,可以使用 when
语句:
val enableReg = Reg(UInt(4.W))
when (enable) {
enableReg := inVal
}
3. 复位与使能的结合
在实际设计中,复位和使能信号经常同时出现。Chisel 提供了 RegEnable
和 RegInit
的组合,便于实现带有复位和使能功能的寄存器。
代码示例:
val resetEnableReg = RegEnable(inVal, 0.U(4.W), enable)
inVal
:输入数据。0.U(4.W)
:复位时的初始化值。enable
:使能信号。
在复位时,寄存器会初始化为 0
。当使能信号为高时,寄存器捕获输入数据,否则保持当前值。
4. 进阶应用:寄存器与表达式结合
寄存器可以作为表达式的一部分,简化电路描述。例如,通过比较当前输入与上一个时钟周期的值,可以实现边沿检测。
代码示例:
val risingEdge = din & !RegNext(din)
RegNext(din)
:将输入信号din
延迟一个时钟周期。!RegNext(din)
:取反,得到前一个时钟周期的状态。din & !RegNext(din)
:当当前输入din
为高,且前一周期的din
为低时,表示检测到上升沿。
5. 结合寄存器与其他模块
图 6.6 展示了一个寄存器与加法器模块的结合。寄存器的输出被用作加法器的反馈输入,形成了一个简单的累加器(counter)。
代码示例:
val counter = RegInit(0.U(4.W)) // 初始化为 0
counter := counter + 1.U // 累加操作
- 在每个时钟周期,寄存器输出加 1,并更新为新的值。
- 这种结构常用于计数器、状态机等时序逻辑模块。
6.2 计数器(Counters)
计数器是时序电路中最基本的模块之一。它的作用是累加或递减一个值,通常将输出反馈到加法器或减法器,再将结果送回寄存器输入端。这样,每个时钟周期都会根据设定的逻辑更新计数值。
1. 基本的自由运行计数器
最简单的计数器是自由运行计数器(free-running counter),它连续从 0 开始计数,达到最大值后重新归零。
示例代码:
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
信号为高时,计数器才递增。
示例代码:
val cntEventsReg = RegInit(0.U(4.W)) // 初始化计数器
when(event) { // 在事件发生时更新
cntEventsReg := cntEventsReg + 1.U
}
when(event)
:表示只有当event
为高时,计数器才加 1。- 这种实现方式可以用于事件触发型计数器,例如检测脉冲信号数量。
示意图(图 6.7): 图 6.7 展示了一个事件计数器的结构:
- 数据路径包含加法器和复用器。
event
信号控制加法器输出是否更新到寄存器中。
3. 计数器的向上计数与重置
计数器可以设置一个最大值 N
,当计数到达 N
时重新归零。
代码示例:
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
多路选择器来简化逻辑:
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 时重置为最大值。
代码示例:
val cntReg = RegInit(N) // 初始化计数器为最大值 N
cntReg := cntReg - 1.U // 每个时钟周期递减 1
when(cntReg === 0.U) { // 当计数器等于 0 时重置为 N
cntReg := N
}
- 向下计数从
N
开始,每次递减 1。 - 当计数器值为 0 时,重置为最大值
N
。
5. 使用函数生成计数器
为了提高代码的可重用性,我们可以将计数器的逻辑封装为一个函数,通过传入参数 n
来动态设置最大计数值。
代码示例:
// 定义一个返回计数器的函数
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 个时钟周期时产生一个单时钟周期的脉冲。
- 如果已知时钟频率
和所需 tick 的频率 ,我们可以通过以下公式计算:
2. 基本的 tick 计数器
图 6.8 展示了一个生成 每 3 个时钟周期产生一个 tick 信号 的例子。
代码示例:
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
}
代码解析:
tickCounterReg
:这是一个寄存器,用于计数时钟周期。初始值设为 0。tick
:当tickCounterReg
计数到 N−1N-1 时,tick
信号为高(true),产生单周期脉冲。- 每次时钟周期,
tickCounterReg
加 1。当达到最大值 N−1N-1 时,计数器重置为 0。
3. 生成更慢的逻辑时钟信号
由上面的单周期 tick 信号,我们可以将其作为使能信号,触发其他更慢的计数器。这样可以生成更低频率的时钟信号(逻辑时钟)。
示例代码:
val lowFrequentCntReg = RegInit(0.U(4.W)) // 定义一个低频计数器
when (tick) { // 使用 tick 信号作为使能
lowFrequentCntReg := lowFrequentCntReg + 1.U
}
代码解析:
tick
信号:当 tick 信号为高时,触发低频计数器lowFrequentCntReg
更新。- 低频计数器:每 nn 个时钟周期,低频计数器的值加 1,从而实现一个逻辑时钟。
波形解析(图 6.9):
- 时钟
clock
:系统时钟信号。 - tick 信号:每隔 nn 个时钟周期产生一个单周期的高脉冲。
- 低频计数器
slow cnt
:在 tick 信号的使能下递增。
4. 应用场景
这种通过计数器生成的逻辑时钟信号,具有广泛的应用,包括:
- LED 闪烁:通过控制 LED 亮灭的时间间隔,实现 LED 的频率闪烁。
- 波特率生成:为串行通信接口(如 UART)生成特定的波特率时钟。
- 多路显示控制:生成用于 7 段数码管显示的扫描时钟。
- 去抖动信号:通过时间间隔判断输入开关信号的稳定性,去除抖动。
- 分频器:通过 tick 信号将高频时钟分频成更低的时钟频率。
5. 注意事项:显式宽度定义
在定义计数器时,建议显式地设置寄存器的位宽,而不是依赖 Chisel 的自动宽度推断(width inference)。
原因:
- 自动推断可能导致意外的位宽。例如,复位值
0.U
默认可能是单比特宽度,而在实际应用中需要更高的位宽。 - 显式定义宽度可以避免计数器溢出或位宽不匹配的问题。
示例代码:
val tickCounterReg = RegInit(0.U(32.W)) // 显式定义为 32 位宽度
6.2.3 高效计数器(The Nerd Counter)
高效的计数器设计旨在优化资源利用,特别是在 FPGA 和 ASIC 的实现中。标准的计数器通常需要以下资源:
- 一个寄存器:用于存储计数值。
- 一个加法器或减法器:实现计数的增减。
- 一个比较器:用于判断计数器是否达到指定值。
在向上计数时,需要将当前计数值与一个固定值进行比较,比较器的资源消耗依赖于输入位宽。在向下计数时,比较器实现通常涉及大型 NOR 门 和 AND 门,特别是判断值是否等于 0。
然而,聪明的硬件设计者可以进一步优化计数器。如果我们从 N-2
递减到 -1
,就可以避免全位比较。
- 负数在二进制表示中,其最高位(MSB)为 1。
- 正数的最高位为 0。
因此,我们只需要检查计数值的最高位是否为 1,就可以判断计数器是否等于 -1。这种技巧可以减少比较器的复杂度,优化硬件资源。
高效计数器的 Chisel 实现
代码示例:
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 信号
}
代码解析:
MAX
:计数器的最大初始值,为 N−2N-2,数据类型为有符号整数(S
)。递减操作:每个时钟周期,计数器值减 1。
最高位判断 :
cntReg(7)
表示计数器的最高位。
- 当最高位为 1 时,表示计数器等于 -1。
- 此时,重置计数器为初始值
MAX
并输出tick
信号。
资源优化:
- 只需检查最高位,避免了对所有位的比较。
- 适用于资源受限的硬件设计,如 FPGA 和 ASIC。
6.2.4 定时器(A Timer)
定时器是一种特殊的计数器,常用于实现单次触发功能(one-shot timer)。它类似于厨房定时器:设定时间,按下开始按钮,计数器递减到 0 时发出信号。
定时器的工作原理
定时器的实现依赖以下逻辑:
- 初始加载:通过信号
load
将输入值din
加载到计数器中。 - 计数递减:当
load
取消后,计数器开始每个时钟周期递减。 - 触发信号:当计数器值为 0 时,发出完成信号
done
。 - 停止计数:计数器到达 0 后停止工作。
示意图(图 6.10):
加法器实现计数递减(
-1
)。多路选择器(
Select
)决定计数器的输入:- 若
load
信号有效,加载输入值din
。 - 否则,计数器值递减。
- 若
比较器判断计数器值是否为 0,并输出完成信号
done
。
Chisel 定时器实现
代码示例(Listing 6.1):
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 // 更新寄存器值
代码解析:
cntReg
:定义一个 8 位寄存器,用于存储当前计数值。done
:比较计数器值是否为 0,用于表示计数完成。next
信号 :通过when/elsewhen
控制计数器的输入:- 当
load
有效时,加载输入值din
。 - 否则,在
done
未完成的情况下递减计数值。
- 当
寄存器更新:将
next
信号赋值给cntReg
,实现计数器状态更新。
定时器的波形与应用
PWM(脉宽调制)示例(图 6.11):
- 定时器可以用来控制输出脉冲的时间宽度,实现脉宽调制(PWM)信号。
- PWM 信号广泛用于:
- 控制 LED 亮度。
- 调节电机转速。
- 生成音频信号。
6.2.5 脉宽调制 (Pulse-Width Modulation)
脉宽调制(Pulse-Width Modulation,简称 PWM)是一种信号调制技术,其输出信号具有恒定周期,但可以通过调节高电平持续时间(占空比)来控制信号的调制宽度。
1. PWM 的基本概念
PWM 信号的关键特性包括:
周期:PWM 信号的周期是固定的。
占空比(Duty Cycle) :占空比表示信号在一个周期内保持高电平的时间比例。
- 例如,占空比为 25% 表示信号在一个周期内高电平持续 25% 的时间。
如图 6.11 所示:
- 每两个周期的占空比分别为 25%、50% 和 75%。
- 脉宽可在 25% 到 75% 之间调节。
低通滤波器(Low-Pass Filter)可将 PWM 信号平滑转换为模拟信号,实现简单的数字-模拟转换(DAC)。例如,PWM 可用于调节 LED 亮度,经过滤波后,LED 亮度与占空比成正比。
2. PWM 信号的生成
PWM 生成函数
Chisel 提供了简洁的代码结构,可以通过计数器和比较器生成 PWM 信号。
代码示例:
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)
代码解析:
nrCycles
:定义 PWM 信号的周期(总时钟周期数)。din
:表示占空比(脉宽控制值),即 PWM 信号高电平持续的周期数。计数器
cntReg
:- 每个时钟周期自增 1。
- 当计数器值达到最大周期数
nrCycles - 1
时,重置为 0,开始新一轮计数。
PWM 输出 :
- 比较
din
和cntReg
,如果din > cntReg
,则输出高电平;否则输出低电平。 - 这样实现了占空比的动态调整。
- 比较
3. PWM 信号的应用
调光 LED 示例
PWM 信号可以用于控制 LED 的亮度,通过改变占空比,LED 的平均电压发生变化,从而改变亮度。
- 占空比 25%:LED 较暗。
- 占空比 50%:LED 半亮。
- 占空比 75%:LED 较亮。
4. 动态 PWM 生成
在某些应用中,PWM 的占空比需要动态改变,例如:
- LED 渐变亮度。
- 模拟三角波输出。
代码示例:
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 位生成低频信号
代码解析:
modulationReg
:动态调整 PWM 占空比,通过增加或减少寄存器值控制脉宽。upReg
:用于改变计数方向,切换占空比增加或减少。- 右移操作:
modulationReg >> 10
将高频调制信号分频,产生更低频率的 PWM 信号。 - PWM 输出:通过
pwm
函数生成 PWM 信号,sig
是最终输出。
5. 关键技术要点
- 计数器生成周期信号:
- 使用计数器实现 PWM 的固定周期。
- 每次计数器溢出时重置为 0,产生一个新的 PWM 周期。
- 占空比调节:
- 通过比较器判断输入脉宽值
din
和当前计数值,动态生成高低电平。
- 通过比较器判断输入脉宽值
- 低通滤波器:
- 平滑 PWM 信号,获得连续的模拟输出电压。
- 应用于 DAC(数字-模拟转换)、LED 调光等场景。
- 动态调节 PWM:
- 使用递增/递减寄存器动态调整占空比,生成渐变效果(如 LED 呼吸灯)。
在该代码示例中,
modulationReg
的值被 右移 10 位(modulationReg >> 10
),这是为了将较高频率的 时钟信号 降低到一个 合适的频率,用于 PWM 输出。为什么要右移?
- 时钟频率较高:
FREQ = 100 MHz
,表示系统输入时钟为 100 MHz。- 这样的高频率对于直接生成低频 PWM 信号是不合适的,因为我们需要一个较低频率的 PWM 信号,比如 1 kHz。
- 右移的作用:
- 右移操作本质上是将计数值进行 除法。例如,
x >> 1
等于x / 2
,而x >> 10
等于x / 1024
。- 在这里,
modulationReg
是一个动态递增或递减的值,右移 10 位会将其值 缩小 2^10 = 1024 倍,从而产生一个较低频率的调制信号。- 分频逻辑:
- 输入时钟频率为 100 MHz,假设
modulationReg
逐渐递增到FREQ
(即 100,000,000),- 如果我们直接使用
modulationReg
作为输入,PWM 频率会非常高。- 右移 10 位后,计数范围变为
FREQ / 1024 ≈ 97,656
,从而有效地将时钟频率 分频。为什么选择右移 10 位?
选择 右移 10 位 的原因是工程设计中需要一个 合适的 PWM 分辨率与频率 之间的平衡:
- 目标 PWM 频率:
- 代码中使用
MAX = FREQ / 1000
生成一个 1 kHz 的信号作为 PWM 基准周期。- 右移 10 位将
modulationReg
的动态范围缩小到一个可以适配 PWM 输出的较低值。- 平衡分辨率与性能:
- 分辨率:右移位数越少,PWM 的占空比调节分辨率越高,但输出频率也越高。
- 频率:右移位数越多,输出频率降低,但分辨率也相应降低。
- 选择右移 10 位 是为了在 PWM 信号的 动态变化 和 输出频率 之间找到平衡点。
- 实际应用需求:
- 比如 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 的占空比调节分辨率越高,但输出频率也越高” 是一种描述 相对的控制精度与频率关系 的现象。
- 分辨率高:计数器值的位数越多,步进越小,控制精度越高。
- 频率高:位数少(右移多)导致周期缩短,PWM 输出频率更高,但精度下降。
因此,当右移位数少时,虽然输出周期较长(频率较低),但占空比的步进精度更高,PWM 控制更细致。
6.3 移位寄存器 (Shift Registers)
移位寄存器 是一组按顺序连接的触发器,每个触发器的输出连接到下一个触发器的输入。在每个时钟周期,数据在寄存器内逐位移动(移位)。这种结构常用于:
- 数据延迟:实现简单的时间延迟。
- 串并转换:将串行数据转换为并行数据,或将并行数据转换为串行数据。
- 数据存储和传输:在通信接口(如 UART)中实现数据接收和发送。
1. 基本移位寄存器
图 6.13 展示了一个 4 位移位寄存器,其中数据从左至右依次移位。
Chisel 实现:
val shiftReg = Reg(UInt(4.W)) // 定义 4 位寄存器
shiftReg := shiftReg(2, 0) ## din // 低 3 位与新输入 din 拼接
val dout = shiftReg(3) // 输出最高位
代码解析:
寄存器定义:
shiftReg
是一个 4 位宽度的寄存器。数据移位 :
shiftReg(2, 0)
获取寄存器的低 3 位。## din
将输入din
追加到寄存器的最低位。
输出:
shiftReg(3)
提取寄存器的最高位作为输出。
行为说明:
- 每个时钟周期,数据从输入
din
移入寄存器的最低位,其他位向右移动。 - 最高位的数据会被输出
dout
。
2. 带并行输出的移位寄存器
串入并出(Serial-In Parallel-Out, SIPO)结构用于将串行输入数据转换为并行输出数据。例如,在串行通信接收器中,每个时钟周期接收一位数据,最终输出一个完整的数据字。
图 6.13 展示了一个 4 位带并行输出的移位寄存器。
Chisel 实现:
val outReg = RegInit(0.U(4.W)) // 初始化 4 位寄存器为 0
outReg := serIn ## outReg(3, 1) // 新数据拼接寄存器高 3 位
val q = outReg // 并行输出寄存器内容
代码解析:
寄存器初始化:
outReg
初始化为 0。右移操作 :
outReg(3, 1)
获取寄存器的高 3 位。serIn ## outReg(3, 1)
将输入串行数据serIn
移入最高位,其他数据向右移。
输出:
outReg
保存了完整的 4 位数据字,可作为并行输出。
功能说明:
- 每接收 4 位串行数据,寄存器
outReg
就包含了一个完整的并行数据字。
3. 带并行加载的移位寄存器
并入串出(Parallel-In Serial-Out, PISO)结构用于将并行输入数据转换为串行输出数据。例如,在串行通信发送器中,将完整的数据字逐位发送出去。
图 6.14 展示了一个 4 位带并行加载功能的移位寄存器。
Chisel 实现:
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) // 串行输出最低位
代码解析:
寄存器初始化:
loadReg
初始化为 0。并行加载:当
load
信号有效时,将输入数据d
加载到寄存器中。右移操作 :
loadReg(3, 1)
获取寄存器的高 3 位。0.U ## loadReg(3, 1)
将高 3 位右移,并在最高位填 0。
串行输出:
loadReg(0)
输出寄存器的最低位。
功能说明:
- 在
load
信号有效时,寄存器加载并行数据。 - 在
load
信号无效时,寄存器按时钟周期右移一位,并输出最低位数据。 - 最终实现将 4 位并行数据逐位发送。
4. 移位寄存器的应用
移位寄存器在数字电路中有广泛应用:
串行通信 :
- SIPO:将接收的串行数据转换为并行数据。
- PISO:将并行数据转换为串行数据发送。
延迟线:通过移位实现固定的时间延迟。
滤波器:用于 FIR 滤波器等数字信号处理应用。
数据存储:将数据存储并按时钟移位传输。
控制信号扩展:将单个控制信号移位生成多个输出。
6.4 存储器 (Memory)
存储器是一种用于存储大量数据的硬件结构。在 Chisel 中,存储器可以通过寄存器集合(Reg
)或向量(Vec
)构建。然而,当数据量增大时,这种实现方式在硬件资源消耗方面显得十分昂贵。因此,实际硬件设计中,较大的存储器通常通过SRAM(静态随机存储器)来实现。
在 FPGA 设计中,存储器常用的结构包括片上存储块(on-chip memory blocks),也称为Block RAM(BRAM)。这些存储块可以组合起来构建更大的存储器。FPGA 上的存储器通常具有以下特点:
- 单端口存储器:一个读端口和一个写端口。
- 双端口存储器:提供两个端口,可以在运行时切换为读或写模式。
同步存储器(Synchronous Memory)是 FPGA 和 ASIC 设计中最常用的存储器类型。同步存储器的输入信号(如读地址、写地址、写数据和写使能)都通过时钟同步操作,读数据通常会在设定地址后的下一个时钟周期输出。
1. 同步存储器的结构
图 6.15 展示了一个 同步存储器 的结构:
读端口:包含一个输入(
rdAddr
,读地址)和一个输出(rdData
,读数据)。写端口 :包含三个输入信号:
wrAddr
:写地址。wrData
:写数据。wrEna
:写使能信号,当wrEna
为高时,数据被写入存储器。
特点
- 存储器的输入信号(地址、数据和使能)都通过时钟同步寄存器进行存储。
- 读操作的结果会在下一个时钟周期输出。
2. Chisel 中的同步存储器实现
Chisel 提供了 SyncReadMem
关键字,用于构建同步存储器。
示例代码(Listing 6.2):
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)
}
}
代码解析:
接口定义 :
rdAddr
:读地址输入。rdData
:读数据输出。wrAddr
:写地址输入。wrData
:写数据输入。wrEna
:写使能信号,当为高时触发写操作。
存储器构造 :
SyncReadMem(1024, UInt(8.W))
定义一个 1 KiB 的存储器(1024 个 8 位宽度的数据单元)。
读操作 :
mem.read(io.rdAddr)
通过输入的读地址读取数据,结果在下一个时钟周期输出。
写操作 :
when(io.wrEna)
判断写使能信号,当有效时,将数据写入指定地址。
3. 读写冲突问题(Read-During-Write Behavior)
在同步存储器中,如果在同一个时钟周期内,写操作与读操作发生在同一地址,会出现读写冲突。这种情况下,存储器的读出值可能有以下三种行为:
- 新写入值:输出刚写入的数据。
- 旧值:输出写入前的原始数据。
- 未定义:输出混合了新旧值的数据。
在 Chisel 中,SyncReadMem
的默认行为是未定义的。因此,读写冲突时,用户需要额外设计数据前递逻辑(forwarding circuit)来确保正确的读值。
4. 数据前递逻辑(Forwarding Circuit)
数据前递逻辑用于解决读写冲突,确保在同一地址发生写操作时,读出正确的值(新写入值)。
实现方法:
- 比较读地址和写地址,如果两者相等且写使能有效,则直接将写入数据送到读数据输出,而不是通过存储器读取。
5. 存储器的硬件资源
在 FPGA 上,存储器可以通过以下方式实现:
- LUT(查找表)实现的小型存储器:适用于较小容量的数据存储。
- Block RAM(BRAM):用于中等到大型存储器,资源开销较小且性能优越。
- 分布式存储器:将存储器分布在 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):
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)
}
代码解析:
前递逻辑的实现 :
doForwardReg
:判断条件是否成立(读写地址相等且写使能有效)。wrDataReg
:存储写入的数据,用于前递。
数据读取 :
mem.read(io.rdAddr)
从存储器中读取数据,存储器的读取在下一个时钟周期生效。
数据写入 :
- 使用
when(io.wrEna)
确保写操作只在写使能有效时发生。
- 使用
前递数据选择 :
- 使用
Mux
选择最终的读出数据。 - 如果前递条件成立,输出写入数据
wrDataReg
;否则,输出存储器的读数据memData
。
- 使用
3. 关键要点
- 前递逻辑的核心:
- 判断读写地址是否相等。
- 通过写使能信号确认当前存在写操作。
- 使用寄存器存储写入数据和前递条件,确保同步时序。
- 前递数据路径:
- 当发生读写冲突时,直接将写数据前递到读输出端,避免从存储器中读取旧数据。
- Chisel 中的实现:
- 使用
RegNext
延迟写数据和前递条件,匹配存储器的同步读时序。 - 使用
Mux
实现前递数据与存储器数据的选择。
- 使用
4. 前递逻辑的硬件结构
图 6.16 展示了前递逻辑的硬件结构:
数据路径 :
- 正常路径:从存储器中读取数据。
- 前递路径:直接将写数据前递到读输出端。
控制逻辑 :
- 通过比较写地址和读地址,生成前递使能信号。
多路选择器 :
- 根据前递使能信号,选择最终的输出数据。
5. 读写冲突行为
在同步存储器中,读写冲突的行为可以分为三种情况:
- 前递逻辑解决冲突:输出最新写入的数据(如本例中的实现)。
- 默认行为未定义:没有前递逻辑时,存储器可能输出未定义值。
- 使用特殊存储器资源:部分 FPGA 提供专门的读写冲突解决机制(如 Xilinx 的 LUTRAM)。
6.5 练习
本节提供了几个针对计数器、PWM 波形生成以及状态控制的练习,目的是让你更熟练地使用 Chisel 进行硬件描述设计。以下是练习的详细解析及实现要点。
1. 7 段显示器与 4 位计数器
目标:
- 使用一个 4 位计数器 作为输入,将其连接到 7 段显示器,从
0
计数到F
(16 进制)。 - 由于 FPGA 时钟频率较高(如 50 MHz 或 100 MHz),需要使用额外的计数器来减速显示更新速度。
- 每 500 毫秒生成一个单周期的 tick 信号,用于作为 4 位计数器的使能信号。
实现步骤:
创建一个减速计数器 该计数器负责生成 500 毫秒周期的单周期 tick 信号。
示例代码:
scalaval 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)
4 位计数器 使用 tick 信号作为使能信号,驱动 4 位计数器进行计数。
示例代码:
scalaval countReg = RegInit(0.U(4.W)) when(tick) { countReg := countReg + 1.U // 每 500ms 计数一次 }
7 段显示器驱动 使用查找表(LUT)将计数器值转换为 7 段显示器对应的信号。
示例代码:
scalaval 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 亮度随时间变化。
实现步骤:
三角函数 PWM 信号生成 使用一个向上向下计数的计数器,生成三角波输出:
scalaval 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
PWM 输出比较器 通过比较器生成 PWM 输出信号:
scalaval counter = RegInit(0.U(8.W)) counter := counter + 1.U val pwmOut = pwmSignal > counter // 占空比控制
驱动 LED 使用生成的 PWM 信号控制 LED 亮度。
3. 选择器电路
目标:
- 使用 switch-case 语句控制输出
dout
,根据输入sel
选择不同的输出值。
实现代码:
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
,实现累加、清零和加减运算。
实现代码:
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 } // 减法操作
}
总结
通过本节的练习,你可以掌握以下核心设计技巧:
- 计数器设计:实现基本计数器和带使能信号的计数器。
- PWM 波形生成:利用计数器或查找表动态生成 PWM 信号,控制占空比。
- 选择器电路:使用
switch
语句实现不同逻辑控制。 - 累加器:带寄存器的选择器电路,支持清零、累加和减法操作。
这些练习能够帮助你熟练运用 Chisel 进行硬件描述,构建实际应用中的基本模块,如 LED 控制、7 段显示器、PWM 驱动等。