吃透Chisel语言.38.Chisel实战之以FIFO为例(三)——几种FIFO的变体的Chisel实现

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.")
}

这一部分的第一篇文章中,我们已经定义过了一些常用信号的名字,比如writefulldinreademptydout等。类似的缓冲区输入输出包含数据和两个握手信号,比如在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都有两个寄存器,dataRegshadowReg,消费者总是从dataReg接收数据。DoubleBuffer有三种状态,emptyonetwo,分别表示缓冲区内被占用的寄存器数量。DoubleBufferready的时候就可以接收新数据了,即状态emptyone的时候。而DoubleBufferonetwo的时候,都表示有有效的数据,即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。计数器仅在输入incrtrue.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不满的时候:

  1. 向缓冲区中写;
  2. 确保emptyReg取消设置;
  3. 如果写指针下一拍和读指针指向同一个元素了那就设置fullReg
  4. 为写计数器设置自增信号;

当消费者设置ready信号,且FIFO不空的时候:

  1. 确保fullReg取消设置;
  2. 如果读指针下一拍和写指针指向同一个元素了那就设置emptyReg
  3. 为读寄存器设置自增信号;

FIFO的输出就是当前读指针指向的元素,readyvalid信号就简单的根据fullRegemptyReg设置就行了。

基于片上内存的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有三种状态,分别表示:

  1. empty:FIFO队列为空;
  2. valid:有一个有效的数据从FIFO队列中读出;
  3. 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通过withClockwithClockAndReset来支持多时钟设计。所有在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的处理器的设计、仿真和测试。

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值