Chisel时序电路(二)——Chisel计数器(Counter)详解
上一篇文章我们学习了时序电路中最基础的寄存器,在Chisel中是如何实现并使用的。而时序电路中还有一种十分常见的结构,它就是计数器(Counter)。计数器在数字设计中很常见,可以用于循环计数、性能计数等场合,几乎是必不可少的组件。从本质上来说,计数器也是一个寄存器,这一篇文章我们就共同学习一下Chisel中的计数器及其波形特性以及包括定时器、脉冲宽度调制在内的应用。
Chisel中的计数器和事件计数器
从本质上来说,计数器也是一个寄存器。只不过计数器中寄存器的输出会连接到一个加法器,加法器的另一个输入为计数器的步进值,一般为1,而加法器的输出就是寄存器的输入,下图就展示了一个自由运行(free-running)的计数器:
一个自由运行的4位计数器会从0
变化到15
,然后又重新从0
开始计数,计数器复位时的初始值应该是已知的。在Chisel中实现如下:
val cntReg = RegInit(0.U(4.W))
cntReg := cntReg + 1.U
如果我们希望通过计数器来统计事件发生的次数,那我们用一个条件更新就行了,如果事件发生就自增,否则计数器值不变。事件计数器的示意图如下:
在Chisel中可以这么实现一个4位的事件计数器:
val cntEventsReg = RegInit(0.U(4.W))
when(event) {
cntEventsReg := cntEventsReg + 1.U
}
向上计数/向下计数
向上计数直到某个特定值,然后再重新从零开始,如果用上面的实现方式,那么计数器只会在达到
2
n
−
1
2^n-1
2n−1后回到0
开始计数,而不能够指定特定值。所以我们需要把寄存器的值和限定的最大常量进行比较,用一个when
条件语句就行了:
val cntReg = RegInit(0.U(8.W))
cntReg := cntReg + 1.U
when(cntReg === N) {
cntReg := 0.U
}
或者这么写更好懂一点:
val cntReg = RegInit(0.U(8.W))
when(cntReg === N) {
cntReg := 0.U
}.otherwise {
cntReg := cntReg + 1.U
}
当然了,我们也可以用一个Mux来代替这种when/otherwise
结构:
val cntReg = RegInit(0.U(8.W))
cntReg := Mux(cntReg === N, 0.U, cntReg + 1.U)
上面的是向上计数的计数器,我们我们想从某个最大值值开始向下计数,直到0
,再复位到最大值,也就是个循环倒数的计数器,用Chisel实现如下:
val cntReg = RegInit(N)
cntReg := Mux(cntReg === 0.U, N, cntReg - 1.U)
如果在项目中,计数器需要在很多地方用到,但它们的最大值可能不一样,那我们可以写一个带参数的函数来生成计数器,这种方法在Chisel开发中很常:
// 返回计数器的函数
def genCounter(n: Int) = {
val cntReg = RegInit(0.U(8.W))
cntReg := Mux(cntReg === n.U, 0.U, cntReg + 1.U)
cntReg
}
// 可以直接用这个的函数创建各种不同上限的计数器
val counter10 = genCounter(10)
val counter99 = genCounter(99)
genCounter
函数的最后一行是函数的返回值,这里返回的就是cntReg
。
需要注意的是,上面所有计数器的例子中,计数器的值都是在0
到N
间的,包括0
和N
。如果们需要计数10个时钟周期,那我们就应该用一个0
到9
的计数器,把N
设置为10
的话就出问题了。
用计数器生成时序
计数器除了用于统计事件,还常用于生成新的时钟概念(作为墙上时钟时间的时间)。对于同步电路而言,时钟频率是固定的,电路会在每个时钟周期前进。所以说在数字电路中是没有时间的概念的,我们只能数时钟周期数。如果我们知道时钟频率,我们就可以生成定时发生的事件,比如以某个频率闪烁LED灯的电路,又比如某位读者在前面博文中提到的LED流水灯的电路。
通常的方法是用频率
f
t
i
c
k
f_{tick}
ftick来生成我们的电路中所需要的单周期的tick,这个tick会每n
个时钟周期发生一次,其中
n
=
f
c
l
o
c
k
/
f
t
i
c
k
n=f_{clock}/f_{tick}
n=fclock/ftick,tick就是得到的一个时钟周期的长度。这个tick并不会作为派生的时钟,而会作为逻辑上以
f
t
i
c
k
f_{tick}
ftick为频率工作的电路中寄存器的使能信号。下面的图就是每三个时钟周期一个tick的例子:
在下面的代码里面,我们描述了一个从0
计数到N-1
的计数器,达到最大值的时候tick
的值会维持一个时钟周期的true
,然后计数器复位为0
,而在这个从0
计数到N-1
的过程中我们就生成了一个每N
个时钟周期发生的逻辑tick:
val tickCounterReg = RegInit(0.U(32.W))
val tick = tickCounterReg === (N-1).U
tickCounterReg := Mux(tick, 0.U, tickCounterReg + 1.U)
这里每n
个时钟周期发生的tick的逻辑时序可以用于驱动我们电路中其他以这个更慢的逻辑时钟工作的部分。比如下面的代码中,我们基于上面的tick构造了一个每n
个时钟周期自增一次的慢计数器:
val lowFrequCntReg = RegInit(0.U(4.W))
when (tick) {
lowFrequCntReg := lowFrequCntReg + 1.U
}
下图是这个慢计数器和tick的波形图:
那这种更慢的逻辑时钟用处挺大的,比如用于LED灯的闪烁和流水灯、生成串口总线的波特率、生成用于七段数码管显示器多路复用的信号、用于按钮和开关去抖动的输入下采样等。
虽然宽度推理会给出寄存器的宽度,但是最好还是显式指定寄存器的宽度和类型,这个已经提过很多次了。显式的宽度定义可以避免复位为0.U
的时候,产生一个宽度为1的计数器的意外情况。
高手是怎么写计数器的
有时候我们会特别执着于优化,比如我们向设计给我们的计数器生成或tick生成设计一个高度优化的版本。标准的计数器会需要这些资源:一个寄存器、一个加法器(或者减法器)以及一个比较器。对于寄存器和加法器我们没什么可以优化的,但是比较器可以。如果我们向上计数,那每次都会和一个上限值进行比较,那会比较一个很长的串。比较器可以由位串中的0的反相器和一个大的与门构成。如果是向下比较到0,那这个零比较器直接就是个大的或非门,在ASIC里面那就比常数比较器更便宜一点了。而在FPGA里面,逻辑是通过查找表构造的,所以跟0比较还是跟1比较并没有区别,所以向上计数和向下计数也没有区别。
不过我们还是可以稍微优化一下的,在硬件设计里面有借鉴之处。目前不管向上计数还是向下计数,都是需要比较所有的位的。那我们如果从N-2
计数到-1
呢?负数的最高有效位是1
,而正数的最高有效位是0
,那么我们只需要检查这一位就可以检测到计数器是否到达-1
了。那么优化的计数器来了,这是高手才能写出来的:
val MAX = (N - 2).S(8.W)
val cntReg = RegInit(MAX)
io.tick := false.B
cntReg := cntReg - 1.S
when(cntReg(7)) {
cntReg := MAX
io.tick := true.B
}
定时器
除了上面的循环计数器,我们还能创建一个只响一次的定时器。这种定时器就类似大家在厨房用的那种,比如煮个鸡蛋,设定倒计时十分钟,然后按一下开始,十分钟后响铃。数字电路中的定时器加载的时间是时钟周期数,会向下计数直到0,而到0的时候定时器就会设置一个完成信号了。下图就是定时器的示意图:
寄存器可以通过设置信号load
从din
加载一个值。当load
信号置零的时候,会选择向下计数的值作为next
(即cntReg - 1
)。当计数器达到0
的时候,信号done
会被设置,计数器会通过选择0
作为输入来停止计数。
下面的代码就是定时器的Chisel实现:
val cntReg = RegInit(0.U(8.W))
val done = cntReg === 0.U
next = WireDefault(0.U)
when(load) {
next := din
} .elsewhen (!done) {
next := cntReg - 1.U
} .otherwise {
next := 0.U
}
cntReg := next
这里我们用到了一个8位的寄存器reg
,复位值为0.U
。布尔值done
是reg
的值与0.U
比较的结果。为了寄存器输入的Mux,我们引入了一个线网next
,其默认值为0.U
,然后when/elsewhen
语句块引入了Mux对应的输入和选择功能。信号load
的优先级高于自减的选择。最后一行代码把多选器的输入next
和寄存器reg
的输入连接了起来。
如果我们希望代码更简洁的话,我们可以直接把多选的结果赋值给reg
,不需要使用中间的线网变量next
。
用计数器实现脉冲宽度调制
脉冲宽度调制(PWM,Pulse-Width Modulation)是一个信号处理的术语,用于将信号调制为常量周期且占空比在一定范围内的信号。
下图是一个PWM信号:
图中箭头所指之处都是脉冲周期的起始位置。信号为high的时间在周期中所占的比例,也叫做占空比。在前两个脉冲周期,占空比为25%,接着两个是50%的,最后两个是75%的,脉冲宽度被调制在25%到75%之间。
给PWM信号加上一个低通滤波器可以得到一个简单的DA转换器(数模转换器),这个低通滤波器可以与电阻和电容一样简单。
下面的代码会每十个时钟周期生成三个时钟周期的high信号:
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)
上面代码中,pwm
函数是PWM生成器,可复用且轻量级。这个函数有两个参数,一个是Scala整数用于配置PWM的时钟周期数(nrCycles
),另一个是Chisel线网变量din
用于给定占空周期数,即PWM输出信号的脉冲宽度。我们用一个Mux和一个寄存器来表达计数器。函数的最后一行比较计数器的值和输入值din
进行比较,依次来返回PWM信号。还是老样子,Chisel函数的最后一行表达式就是返回值,本例就是将线网连接到了一个比较器上。
再看看一些细节,初始化寄存器的时候我们用到了unsignedBitLength(n)
函数,来指定寄存器cntReg
需要表示n
及n
以下的无符号整数所需的位数(即
⌊
l
o
g
2
(
n
)
⌋
+
1
\lfloor log_2(n)\rfloor+1
⌊log2(n)⌋+1)。Chisel里面还有signedBitLength
函数,用于有符号数的情况。
另一个PWM的应用场景是LED呼吸灯,这种情况下眼睛就充当了低通滤波器的角色。下面的例子是使用三角形函数(不是三角函数!)驱动上面的pwm
函数扩展来的,可以得到一个可持续变化强度的LED灯:
val FREQ = 100000000 // 一个100MHz的时钟输入
val MAX = FREQ/1000 // 1kHz
val modulationReg = RegInit(0.U(32.W))
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
}
// modReg除以1024,约等于1kHz
val sig = pwm(MAX, modulationReg >> 10)
看起来有点复杂了,下面解释一下。我们用到了两个寄存器来调制,一是modulationReg
,用于向上、向下计数,二是upReg
作为向上或向下计数的标志位。我们向上计数到我们的时钟输入(本例中为100MHz),再向下计数到0,这就得到了一个0.5Hz的信号,即一秒上,一秒下,两秒一个周期。代码里面很长的when/elsewhen/otherwise
代码块用于向上向下计数和方向变换。
由于我们的PWM要生成1kHz的信号,所以我们需要把调制信号除以1000。又因为除法的实现非常昂贵,所以我们这里用了一个移位来代替,因为
2
10
=
1024
2^{10} = 1024
210=1024和1000非常接近。前面我们已经定义了PWM生成器函数,这里就可以直接调用函数来实例化一个了。线网sig
就表示调制好了的PWM信号。
如果现在把sig
信号作为控制LED通断的信号,那么这个LED灯会每
1
/
1000
1/1000
1/1000秒亮一次,亮的时间(脉冲宽度)在01秒内递增,在12秒内递减,这个高频率的点亮时间的变化会在人眼形成亮度的变化,也就是在01秒内亮度递增,在12秒内亮度递减。要注意这里的脉冲宽度的变化是线性的,即周期三角波一样的,有兴趣的可以把信号的示意图画出来,我这里简单地画了一个,有点抽象,但今天已经写了很多了,这张图就不做标记了,大家可以自己去尝试理解。蓝色部分就是LED灯亮的部分,可以看到每个
1
/
1000
1/1000
1/1000秒亮的时间是越来越长的。
结语
这一篇主要学习了计数器以及基于计数器延伸出来的高级用法,比如生成时序、定时器、PWM等等。内容有点丰富,理解上难度也逐渐有了难度,还是需要好好理解消化的。不过对于处理器设计而言,最难理解的PWM部分其实一般是用不到的,但实现DSP的话可能就用得到了。如果这一篇文章的内容都理解了,那肯定是没有任何坏处的,对Chisel和数字设计的理解也会更进一步。相比较而言,下一节说的移位寄存器就简单多了,但在数字设计中也是很重要的存在,一点要认真学。