这一部分,我们将以FIFO以及FIFO的各种变体为例,进行Chisel数字设计的实战。这一部分的实战会以小规模的数字设计为例,比如一个FIFO Buffer,它是大规模数字设计中的常用构件块。再比如串行接口(串口),它本身也可以用到FIFO Buffer。这一篇文章就从最基本的FIFO Buffer开始。
写端(发送端)和读端(接收端)之间可以通过在它们之间构建个缓冲区(Buffer)来解耦合。最常见的一种Buffer就是先进先出缓冲区(First-In First-Out Buffer,FIFO Buffer)。下图就展示了发送端(Writer)、FIFO Buffer和接收端(Reader)之间的信号连接:

数据由发送端在write有效时通过din放到FIFO里面,而接收端在read有效时通过dout来从FIFO中读取数据。
FIFO刚开始是空的,由empty信号给出空状态。从空的FIFO中读取数据是未定义的行为。当数据一直在往FIFO里面写但是不被读的话,FIFO就会满,由full信号给出满状态。向一个满的FIFO中写数据通常会被忽略,数据也会被丢弃。换句话来说,empty信号和full信号充当了握手信号的角色。
可能有这么两种FIFO的实现:
对于小的缓冲区(最多10个元素)而言,由独立的寄存器链接到一起形成的缓冲区的链条组织成的FIFO实现起来更简单,需要的资源也很少。
我们首先从定义FIFO分别在发送端和接收端侧的IO接口开始,假定数据的尺寸是通过size可配置的。
首先是发送端侧的接口,规定写数据为din并由write信号使能,信号full指示FIFO的状态,执行发送端的流控制。发送端侧接口代码如下:
class WriterIO(size: Int) extends Bundle {
val write = Input(Bool())
val full = Output(Bool())
val din = Input(UInt(size.W))
}
然后是接收端侧的接口,规定数据从dout输入并由read信号使能,信号empty指示FIFO的状态,并执行接收端的控制流。接收端侧接口代码如下:
class ReaderIO(size: Int) extends Bundle {
val read = Input(Bool())
val empty = Output(Bool())
val dout = Output(UInt(size.W))
}
接下来就可以实现一个简单的单FIFO Buffer了,也就是只能存放一个数据的FIFO缓冲区。这个Buffer有一个WriterIO类型的入队端口enq和一个ReaderIO类型的出队端口deq。Buffer的状态机是一个保存数据的寄存器dataReg和一个简单的FSM状态寄存器stateReg。这个FSM只有两种状态,要么empty要么full。如果Buffer是empty的,给定write信号会向寄存器中写入输入数据并将状态切换为full,如果Buffer是full的,给定read信号会读出寄存器中的数据并将状态切换为empty。实现如下:
class FIFORegister(size: Int) extends Module {
val io = IO(new Bundle {
val enq = new WriterIO(size)
val deq = new ReaderIO(size)
})
val empty :: full :: Nil = Enum(2)
val stateReg = RegInit(empty)
val dataReg = RegInit(0.U(size.W))
when(stateReg === empty) {
when(io.enq.write) {
stateReg := full
dataReg := io.enq.din
}
} .elsewhen(stateReg === full) {
when(io.deq.read) {
stateReg := empty
dataReg := 0.U // 单纯方便在波形图中看是不是空了
}
} .otherwise {
// 也应该没有“否则”的状态了
}
io.enq.full := stateReg === full
io.deq.empty := stateReg === empty
io.deq.dout := dataReg
}
上面的FIFORegister的缓冲区只能存放一个数据,这一小节我们实现可定义深度的FIFO,即完整的FIFO,将会实现为BubbleFifo类,它的接口和FIFORegister的接口是一样的,但是它有两个参数,一个是用于给定数据宽度的size,另一个是用于给定Buffer深度的depth。
我们可以用depth个FIFORegister来构建深度为depth的气泡FIFO Buffer。我们将这些单个的Buffer放到一个Scala的Array里面来创建多Buffer。不过Scala的数组没有硬件意义,只是提供了一个用来引用被创建的Buffer的容器。然后我们可以用一个for循环将这些Buffer链接起来。第一个Buffer的入队端链接到完整的FIFO的入队IO上,同样,最后一个Buffer的出队端也链接到完整的FIFO的出队IO上。实现代码如下:
class BubbleFifo(size: Int, depth: Int) extends Module {
val io = IO(new Bundle {
val enq = new WriterIO(size)
val deq = new ReaderIO(size)
})
val buffers = Array.fill(depth) {
Module(new FIFORegister(size))
}
for (i <- 0 until depth - 1) {
buffers(i + 1).io.enq.din := buffers(i).io.deq.dout
buffers(i + 1).io.enq.write := buffers(i).io.deq.read
buffers(i).io.deq.read := ~buffers(i + 1).io.enq.full
}
io.enq <> buffers(0).io.enq
io.deq <> buffers(depth - 1).io.deq
}
这个通过把多个单Buffer串联起来实现FIFO的方法叫做气泡FIFO,即Bubble FIFO,因为数据跟气泡一样穿过队列。
这一篇文章介绍了FIFO Buffer的概念,然后用Chisel实现了单Buffer的FIFO,接着又用单Buffer实现了完整的FIFO Buffer,即气泡Buffer。这种方法很简单,在数据率低于时钟频率的时候很好用,比如在作为串口的解耦合缓冲区的时候,下一篇文章就会介绍在串口中使用Bubble FIFO。然而在数据率达到时钟频率的时候,Bubble FIFO就存在两个限制了:一是因为每个Buffer的状态都在empty和full之间来回切换,这就意味着FIFO存在每个字两时钟周期的吞吐上限;二是数据需要像气泡一样穿过完整的FIFO,从因此从输如到输出的时延至少为Buffer的深度。对于这两个问题,在下下篇文章中也会得到解决。