Chisel实战之以FIFO为例(三)——几种FIFO的变体的Chisel实现
上一篇文章对实现串口通信的最小系统做了简单阐述,然后从发送端开始,实现了基于单字节FIFO缓冲区的发送端,接着类似地实现了接收端,最后将二者综合在一起,实现了发送“Hello World!”信息的串口通信demo。虽然代码量相对之前来说多了很多,但是因为模块化设计且利用了Chisel的特性,因此条理也很清晰。这一篇文章,我们继续实现FIFO队列的几种变体,进一步实践Chisel开发。
参数化FIFO
为了实现各种FIFO的变体,我们首先实现一个FIFO的抽象类,它的参数是Chisel的类型参数,使得FIFO的缓冲区可以缓冲任意类型的Chisel数据。和之前一样,还有个参数depth
用于指定缓冲区的深度。
abstract class Fifo[T <: Data](gen: T, val depth: Int) extends Module {
val io = IO(new FifoIO(gen))
assert(depth > 0, "Number of buffer elements needs to be larger than 0.")
}
这一部分的第一篇文章中,我们已经定义过了一些常用信号的名字,比如write
、full
、din
、read
、empty
和dout
等。类似的缓冲区输入输出包含数据和两个握手信号,比如在FIFO不full
的时候write
。
不过一般来说我们都会用更一般化的握手协议,即ready-write
接口。比如说,我们当FIFO为ready
的时候可以入队(写)一个元素到FIFO,而在写数据的一端,有valid
信号的时候才能写数据。之前也说过,由于ready-valid
接口太常见了,所以Chisel提供了下面的接口定义:
class DecoupledIO[T <: Data](gen: T) extends Bundle {
val ready = Input(Bool())
val valid = Output(Bool())
val bits = Output(gen)
}
那么我们就可以用这个DecoupledIO
接口定义我们的FIFO接口FifoIO
:一个入队端口enq
和一个出队端口deq
,这两个端口都使用了ready-valid
接口。DecoupledIO
接口是从写数据端(生产者)的视角定义的,因此入队端口的信号应该用Flipped()
翻转方向:
class FifoIO[T <: Data](private val gen: T) extends Bundle {
val enq = Flipped(new DecoupledIO(gen))
val deq = new DecoupledIO(gen)
}
有了抽象基类Fifo
和接口FifoIO
,我们就可以专注于针对不同参数的不同FIFO实现的优化了,比如速度、面积、功耗或单纯的简单性。
重新设计实现Bubble FIFO
现在我们可以用标准的ready-valid
接口重新定义前面实现过的Bubble FIFO了,并且可以用Chisel的数据类型来参数化。
下面就是重构的带ready-valid
接口的Bubble FIFO的实现。
class BubbleFifo[T <: Data](gen: T, depth: Int) extends Fifo(gen: T, depth: Int) {
private class Buffer() extends Module {
val io = IO(new FifoIO(gen))
val fullReg = RegInit(false.B)
val dataReg = Reg(gen)
when (fullReg) {
when (io.deq.ready) {
fullReg := false.B
}
} .otherwise {
when (io.enq.valid) {
fullReg := true.B
dataReg := io.enq.bits
}
}
io.enq.ready := !fullReg
io.deq.valid := fullReg
io.deq.bits := dataReg
}
private val buffers = Array.fill(depth) {
Module(new Buffer())
}
for (i <- 0 until depth - 1) {
buffers(i + 1).io.enq <> buffers(i).io.deq
}
io.enq <> buffers(0).io.enq
io.deq <> buffers(depth - 1).io.deq
}
需要注意的是,我们把Buffer
组件实现为BubbleFifo
内部的一个私有类,这个helper类仅在这个组件中需要,因此我们就在里面实现,避免污染命名空间。这个Buffer
类也简化了,避免了使用FSM,而是只用了一个比特位fullReg
来标注缓冲区的状态:满或者空。
这个BubbleFifo
简单又好理解,使用的资源也是最小的。然而,每个缓冲区的阶段都需要在空和满之间来回切换,因此这个FIFO的最大带宽是两时钟周期一个字。
可以考虑让缓冲区的两端在生产者valid
且消费者ready
的时候允许接受一个新的字,然而这就引入了一条从消费者握手信号到生产者握手信号的组合逻辑通路,这就违反了ready-valid
协议的语义了,只能考虑新的方法。
双缓冲FIFO(Double Buffer FIFO)
一种解决方案是在Buffer寄存器满了的时候仍然保持ready
。为了在消费者还没有ready
的时候接收一个新字,我们需要另一个Buffer,我们称之为影子寄存器(Shadow Register)。当Buffer为满的时候,新来的数据存放在影子寄存器里面,同时ready
信号取消设置。当消费者重新变为ready
的时候,数据从数据寄存器传送到消费者,同时影子寄存器中的数据移动到数据寄存器。实现如下:
class DoubleBufferFifo[T <: Data](gen: T, depth: Int) extends Fifo(gen: T, depth: Int) {
private class DoubleBuffer extends Module {
val io = IO(new FifoIO(gen))
val empty :: one :: two :: Nil = Enum(3)
val stateReg = RegInit(empty)
val dataReg = Reg(gen)
val shadowReg = Reg(gen)
switch(stateReg) {
is (empty) {
when (io.enq.valid) {
stateReg := one
dataReg := io.enq.bits
}
}
is (one) {
when (io.deq.ready && !io.enq.valid) {
stateReg := empty
}
when (io.deq.ready && io.enq.valid) {
stateReg := one
dataReg := io.enq.bits
}
when (!io.deq.ready && io.enq.valid) {
stateReg := two
shadowReg := io.enq.bits
}
}
is (two) {
when (io.deq.ready) {
dataReg := shadowReg
stateReg := one
}
}
}
io.enq.ready := (stateReg === empty || stateReg === one)
io.deq.valid := (stateReg === one || stateReg === two)
io.deq.bits := dataReg
}
private val buffers = Array.fill((depth+1)/2) {
Module(new DoubleBuffer())
}
for (i <- 0 until (depth+1)/2 - 1) {
buffers(i + 1).io.enq <> buffers(i).io.deq
}
io.enq <> buffers(0).io.enq
io.deq <> buffers((depth+1)/2 - 1).io.deq
}
可以看到,现在每个DoubleBuffer
都可以存储两个条目,因此我们只需要一半的深度,再考虑到向上取整,因此深度为(depth+1)/2
。每个DoubleBuffer
都有两个寄存器,dataReg
和shadowReg
,消费者总是从dataReg
接收数据。DoubleBuffer
有三种状态,empty
、one
和two
,分别表示缓冲区内被占用的寄存器数量。DoubleBuffer
为ready
的时候就可以接收新数据了,即状态empty
或one
的时候。而DoubleBuffer
为one
或two
的时候,都表示有有效的数据,即valid
被设置。
如果我们全速运行这个FIFO且消费者总是ready
的,那么双缓冲就会稳定在状态one
。只有在消费者不ready
之后,双缓冲的队列就满了,才会进入two
状态。然而,和单缓冲的Bubble FIFO相比较,缓冲区容量一致的情况下,队列的重启只需要一半的时钟周期数,因此数据通过双缓冲FIFO的延时也是单缓冲Bubble FIFO的一半。
基于寄存器内存的FIFO
软件中的队列通常在单线程中被一串顺序代码使用。我们用队列来实现生产者线程和消费者线程的解耦合。在这个设定下,固定尺寸的FIFO队列通常实现为循环缓冲区。两个指针分别指向队列在内存中的读和写的位置,当指针达到队列的结尾时,又会重新回到队列的开始。如果两个指针到达了同一个位置,那说明队列要么是空的,要么是满的。为了区分空和满两种状态还需要一个额外的标志位。
我们也可以在硬件中实现类似的基于内存的FIFO队列。对于小的队列而言,可以使用寄存器堆,也就是Reg(Vec())
。下面的代码实现了基于基于寄存器的内存读写指针的FIFO队列:
class RegFifo[T <: Data](gen: T, depth: Int) extends Fifo(gen: T, depth: Int) {
def counter(depth: Int, incr: Bool): (UInt, UInt) = {
val cntReg = RegInit(0.U(log2Ceil(depth).W))
val nextVal = Mux(cntReg === (depth-1).U, 0.U, cntReg + 1.U)
when (incr) {
cntReg := nextVal
}
(cntReg, nextVal)
}
// 基于寄存器的内存
val memReg = Reg(Vec(depth, gen))
val incrRead = WireDefault(false.B)
val incrWrite = WireDefault(false.B)
val (readPtr, nextRead) = counter(depth, incrRead)
val (writePtr, nextWrite) = counter(depth, incrWrite)
val emptyReg = RegInit(true.B)
val fullReg = RegInit(false.B)
when (io.enq.valid && !fullReg) {
memReg(writePtr) := io.enq.bits
emptyReg := false.B
fullReg := nextWrite === readPtr
incrWrite := true.B
}
when (io.deq.ready && !emptyReg) {
fullReg := false.B
emptyReg := nextRead === writePtr
incrRead := true.B
}
io.deq.bits := memReg(readPtr)
io.enq.ready := !fullReg
io.deq.valid := !emptyReg
}
由于代码中的两个指针行为是一样的,都是自增然后再到达缓冲区末尾时回到起始位置,因此我们定义了一个函数counter
用于生成封装好的计数器。使用log2Ceil(depth).W
可以计算计数器的位宽。计数器的nextVal
要么自增1要么回到0。计数器仅在输入incr
为true.B
的时候才自增,即更新为nextVal
的值。
此外,因为我们还需要使用nextVal
(自增或回到0),用于比较两个指针,所以counter
函数的返回值不仅有寄存器,还有nextVal
。在Scala中,这种返回值叫做元组(tuple),就是个简单地把多个值包装到一起的容器。创建两个元素的元组的语法就是在括号里面用一个逗号分开:
val t = (v1, v2)
反过来我们也可以从元组中提取元素到变量:
val (x1, x2) = t
对于内存,我们用的是Chisel数据类型gen
的向量的寄存器,即Reg(Vec(depth, gen))
。我们定义了两个信号用于读写指针的自增,同时用counter
函数创建了读写指针。当两个指针相等的时候,缓冲区就要么是空的要么是满的,所以我们创建了两个标志寄存器来表示空或者满。
当生产者设置valid
信号,且FIFO不满的时候:
- 向缓冲区中写;
- 确保
emptyReg
取消设置; - 如果写指针下一拍和读指针指向同一个元素了那就设置
fullReg
; - 为写计数器设置自增信号;
当消费者设置ready
信号,且FIFO不空的时候:
- 确保
fullReg
取消设置; - 如果读指针下一拍和写指针指向同一个元素了那就设置
emptyReg
; - 为读寄存器设置自增信号;
FIFO的输出就是当前读指针指向的元素,ready
和valid
信号就简单的根据fullReg
和emptyReg
设置就行了。
基于片上内存的FIFO
上一节的FIFO使用的是寄存器堆来表示内存,对于小的FIFO队列很好用。但对于大一点的FIFO队列,最好还是用片上内存(On-chip Memory)实现。下面的代码就实现了基于同步内存的FIFO:
class MemFifo[T <: Data](gen: T, depth: Int) extends Fifo(gen: T, depth: Int) {
def counter(depth: Int, incr: Bool): (UInt, UInt) = {
val cntReg = RegInit(0.U(log2Ceil(depth).W))
val nextVal = Mux(cntReg === (depth-1).U, 0.U, cntReg + 1.U)
when (incr) {
cntReg := nextVal
}
(cntReg, nextVal)
}
val mem = SyncReadMem(depth, gen)
val incrRead = WireDefault(false.B)
val incrWrite = WireDefault(false.B)
val (readPtr, nextRead) = counter(depth, incrRead)
val (writePtr, nextWrite) = counter(depth, incrWrite)
val emptyReg = RegInit(true.B)
val fullReg = RegInit(false.B)
val idle :: valid :: full :: Nil = Enum(3)
val stateReg = RegInit(idle)
val shadowReg = Reg(gen)
// 写FIFO的处理是一样的
when (io.enq.valid && !fullReg) {
mem.write(writePtr, io.enq.bits)
emptyReg := false.B
fullReg := nextWrite === readPtr
incrWrite := true.B
}
// 读基于内存的FIFO时要处理一个时钟周期的延迟
val data = mem.read(readPtr)
switch(stateReg) {
is(idle) {
when(!emptyReg) {
stateReg := valid
fullReg := false.B
emptyReg := nextRead === writePtr
incrRead := true.B
}
}
is(valid) {
when(io.deq.ready) {
when(!emptyReg) {
stateReg := valid
fullReg := false.B
emptyReg := nextRead === writePtr
incrRead := true.B
}.otherwise {
stateReg := idle
}
}.otherwise {
shadowReg := data
stateReg := full
}
}
is(full) {
when(io.deq.ready) {
when(!emptyReg) {
stateReg := valid
fullReg := false.B
emptyReg := nextRead === writePtr
incrRead := true.B
}.otherwise {
stateReg := idle
}
}
}
}
io.deq.bits := Mux(stateReg === valid, data, shadowReg)
io.enq.ready := !fullReg
io.deq.valid := stateReg === valid || stateReg === full
}
基于内存的FIFO的读写指针的处理和基于寄存器的FIFO的处理是一样的。不过,同步片上内存会在读的下一个时钟周期才传出去,而寄存器文件的读在同一个周期内就能读到。
因此,我们定义了一些额外的FSM和一个影子寄存器来处理这个延迟。我们从内存中读出队列顶端的值给输出端口,如果这个值不被消费的话,那就把它存放到影子寄存器shadowReg
中。FSM有三种状态,分别表示:
empty
:FIFO队列为空;valid
:有一个有效的数据从FIFO队列中读出;full
:FIFO队列的头部两个元素前一个在影子寄存器,另一个在data
;
基于内存的FIFO可以有效地在队列中存储更大量的数据,数据通过的延迟也很短。在这个设计中,FIFO的输出可能直接来自内存读。如果这个数据路径是项目中的关键路径,那我们可以很容易地通过组合两个FIFO把我们的设计流水化,如下面的代码所示:
class CombFifo[T <: Data](gen: T, depth: Int) extends Fifo(gen:T, depth: Int) {
val memFifo = Module(new MemFifo(gen, depth))
val bufferFifo = Module(new DoubleBufferFifo(gen, 2))
io.enq <> memFifo.io.enq
memFifo.io.deq <> bufferFifo.io.enq
bufferFifo.io.deq <> io.deq
}
上面的代码中,我们在基于内存的FIFO的输出端加上了一个单阶段的双缓冲FIFO来将内存读路径和输出解耦合。
题外话:作为缓冲区的多时钟内存
在多时钟域的大型设计中,我们可以需要一种安全地从一个域向另一个域传递数据的方法。之前我们已经用同步来解决过这个问题了,而另一种方法就是使用多时钟内存作为两个域之间的缓冲区。
Chisel通过withClock
和withClockAndReset
来支持多时钟设计。所有在withClock(clk)
块内定义的存储元素都由clk
驱动。对于多时钟内存,内存模块应该定义在withClock
块之外,同时每个端口都应该有他们自己的withClock
块。一个参数化的多时钟内存可以看作是下面的代码:
class MemoryIO(val n: Int, val w: Int) extends Bundle {
val clk = Input(Bool())
val addr = Input(UInt(log2Up(n).W))
val datai = Input(UInt(w.W))
val datao = Output(UInt(w.W))
val en = Input(Bool())
val we = Input(Bool())
}
class MultiClockMemory(ports: Int, n: Int = 1024, w: Int = 32) extends Module {
val io = IO(new Bundle {
val ps = Vec(ports, new MemoryIO(n, w))
})
val ram = SyncReadMem(n, UInt(w.W))
for (i <- 0 until ports) {
val p = io.ps(i)
withClock(p.clk.asClock) {
val datao = WireDefault(0.U(w.W))
when(p.en) {
datao := ram(p.addr)
when(p.we) {
ram(p.addr) := p.datai
}
}
p.datao := datao
}
}
}
很自然地,使用这些多时钟内存会引入对可以同时执行的操作的约束。两个或以上的接口不可以向同一个地址写,类似地,我们也必须定义清楚read-under-write的行为。内存应该要么配置为写优先,这样输入数据就可以直接转发到读端口,要么配置为读优先,这样旧的内存的值就会在读端口读到。不过还是要小心的是,ChiselTest
对多时钟的支持还不是很好,我们还是需要手动切换时钟信号来强制驱动时钟。
结语
Chisel实战的FIFO部分到这里就结束了,这一篇文章从基本的参数化FIFO抽象类开始,重新设计了Bubble FIFO,进一步实现了双缓冲FIFO。而类似于软件中的队列,我们基于读写指针也可以实现FIFO,我们分别基于寄存器堆和片上内存实现了基于读写指针的FIFO。最后,我们还简单地提了提多时钟内存,虽然基本上用不上,但需要有这个概念。下一部分,我们将以RISC-V处理器的设计实现为例,实战基于Chisel的处理器的设计、仿真和测试。