SpinalWorkshop实验笔记(一)


概述

最近在学习SpinalHDL,在github上看到了SpinalHDL实验,于是试着做了做。虽然这些实验的答案在仓库里给出来了,但我是FPGA初学者,虽然会一点verilog却对各种总线一窍不通,也不了解scala,所以即使要理解这些实验也花费了一番功夫。在这里记录一下我做这些实验的感想。本文涉及Counter、PWM、UART、Prime四个实验。

内容

Counter

Counter实验很简单,只要实现一个计数器,并且不要求时钟信号。所以直接按教程里的图片连线即可:

  val r = Reg(UInt(width bits)) init(0)
  r := io.clear ? U(0) | r + 1
  io.value := r
  io.full := r === (1 << width) - 1

注意的点:

PWM

难度陡然上升(当然可能是我太菜)。这个实验的关键是理解SpinalHDL的master/slave模型。我们可以把实现了IMasterSlave的端口集合理解为一个两种用途的端口集合。当它在模块里被声明成master时,集合里某些端口是输入端口,另一些是输出,而被声明成slave时,那些master时的输入端口此时变为输出端口,master的输出端口变为输入端口。也就是所有端口的输入输出方向发生了反转。

由于教程的Component interfaces指定了模块的apb端口集合是slave类型的,所以我们需要APB这个端口集合中哪些端口在被声明成slave时是输入端口,哪些是输出端口。这回我们需要理解这个模块是干啥的,查看波形可以发现这个模块似乎是一个方波发生器,就是比较timer寄存器和dutycycle寄存器的值,前者小于后者输出高电平,否则输出低电平。同时有另外一个需求,就是外面的模块要能读写enable和dutycycle两个寄存器(其实我一开始没看出来,这教程写得不清不楚的)。很容易想出当APB作为slave时,是外界向本模块提供要写入的值,同时读出值。因此,写相关的端口当然是输入,读相关的端口当然是输出。加上一些控制信号PSEL、PENABLE和PADDR也明显是输入进这些模块的,因此就可以写出APB的声明:

//APB interface definition
case class Apb(config: ApbConfig) extends Bundle with IMasterSlave {
  //TODO define APB signals
  val PSEL = Bits(config.selWidth bits)
  val PENABLE = Bool()
  val PWRITE = Bool()
  val PADDR = UInt(config.addressWidth bits)
  val PWDATA = Bits(config.dataWidth bits)
  val PRDATA = Bits(config.dataWidth bits)
  val PREADY = Bool()

  override def asMaster(): Unit = {
    //TODO define direction of each signal in a master mode
    out(PSEL, PENABLE, PWRITE, PADDR, PWDATA)
    in(PRDATA, PREADY)

  }
}

声明里定义端口的输入和输出是通过重写asMaster方法,而且针对的是master声明的,因此需要将我们刚才的讨论倒过来,就是上面的代码。

io端口的声明和logic块很简单,和Counter实验差不多:

  val io = new Bundle{
    val apb = slave(Apb(apbConfig)) //TODO
    val pwm = out Bool() //TODO
  }

  val logic = new Area {
    //TODO define the PWM logic
    val enable = Reg(False)
    val timer = Reg(UInt(timerWidth bits)) init(0)
    when (enable) {
      timer := timer + 1
    }
    val dutycycle = Reg(UInt(timerWidth bits)) init(0)
    val output = Reg(False)
    output := timer < dutycycle
    io.pwm := output
  }

control块就比较复杂,其负责控制那两个寄存器的读写,所以首先需要根据地址来决定读写那个寄存器,同时写寄存器需要收到控制信号的制约,包括片选信号(PSEL)、使能信号(PENABLE)和写信号(PWRITE)。读寄存器直接读,写寄存器需要控制信号均为真才可以写:

  val control = new Area{
    //TODO define the APB slave logic that will make PWM's registers writable/readable
    val doWrite = io.apb.PSEL(0) && io.apb.PENABLE && io.apb.PWRITE
    io.apb.PRDATA := 0
    io.apb.PREADY := True
    switch(io.apb.PADDR){
      is(0){
        io.apb.PRDATA(0) := logic.enable
        when(doWrite){
          logic.enable := io.apb.PWDATA(0)
        }
      }
      is(4){
        io.apb.PRDATA := logic.dutycycle.asBits.resized
        when(doWrite){
          logic.dutycycle := io.apb.PWDATA.asUInt.resized
        }
      }
    }
  }

UART

这个实验只看文档也挺懵的,实际上它描述的采样状态机应该是这样:

DATA这一步还是比较清晰的,可能实际的电路没有那么稳定,所以才要通过采样一段时间然后用MajorityVote取出现次数超过一半的电平。但是START和STOP为什么要等待这个数量的采样tick我就不明白了,尤其是START的式子preSamplingSize + (samplingSize - 1) / 2 - 1更是奇怪,不知道怎么回事:

  // Statemachine that use all precedent area
  val stateMachine = new StateMachine {
    //TODO state machine
    val value = Reg(io.read.payload)
    io.read.valid := False
    always {
      io.read.payload := value
    }
    val IDLE: State = new State with EntryPoint {
      whenIsActive {
        when (sampler.tick && !sampler.value) {
          bitTimer.recenter := True
          goto(START)
        }
      }
    }
    val START = new State {
      whenIsActive {
        when (bitTimer.tick) {
          bitCounter.clear := True
          goto(DATA)
        }
      }
    }
    val DATA = new State {
      whenIsActive {
        when (bitTimer.tick) {
          value(bitCounter.value) := sampler.value
          when (bitCounter.value === 7) {
            goto(STOP)
          }
        }
      }
    }
    val STOP = new State {
      whenIsActive {
        when (bitTimer.tick) {
          io.read.valid := True
          goto(IDLE)
        }
      }
    }
  }

“等待一定的时间”和“采样一定的时间”这些操作都由bitTimer计算,当recenter信号被触发时,等待的采样tick是preSamplingSize + (samplingSize - 1) / 2 - 1,在START结束后bitTimer里的计数器减到0自然溢出到(1 << width) - 1,由于程序前面规定了preSamplingSize + samplingSize + postSamplingSize必须是2的幂,所以两条式子相等,之后在recenter下一次被触发前计都是按那条式子计算等待时间和采样时间。

另外是Flow的用法,当数据有效时把valid端口置为真,数据传到payload端口。注意寄存器传到payload端口这句必须放在状态机之外或者状态机内的always块里,否则会报“latch”错误。因为payload端口应该是短时间的信号而不是寄存器,所以不能在“某个状态”内赋值。同时,对于bitCounter和bitTimer内的信号进行的操作也是短时间的,在当前状态内是高电平,出了这个状态就又变回低电平了。

最后注意状态机,我用的是文档里介绍的style A写法,这种写法需要注意如果是当前声明的状态被后面声明的状态使用(如当前声明的IDLE在后面声明STOP中用到了),那么当前声明的状态就不能用自动类型推断,不然会报“递归定义”的错误。如上面的IDLE就在声明里显式指出了其类型State。

Prime

代码很简单,但是新手很容易误解。比如我,一开始看见源程序里给出了基于scala基础类型判断质数的函数apply,就在想能不能将传给我的SpinalHDL类型的数转换成基础类型然后调用apply判断,但找不到转换函数。看了答案才意识到这样肯定是不行的,因为apply不能被编译成电路,怎么能用来判断呢。正确方法是利用apply函数构造一个质数表,然后将传给我的数依次和质数表里的数比较,如果其中一个为真就返回真。这种情况下质数表可以转换成一组常量信号,然后用比较器和参数进行比较最后用一个大或门就能计算结果,可以编译成电路的形式,实际上就类似于教程里给出的Verilog代码:

  def apply(n : UInt) : Bool = {
    //TODO
    return (0 until 1 << widthOf(n)).filter(apply(_)).map(n === _).orR
  }

这里缩成一行,意思是构造整数区间[0, 1 << n的位宽)(注意是左闭右开区间),将整数区间里的所有数用apply检查一下(这里的apply是针对scala基础类型的),选出是质数的,这样就构造出了一个质数表。然后将质数表里的每个数和n比较一下,相等的为真,不等的为假,这样就构造出了一个布尔表,注意这个布尔,指的是SpinalHDL的Bool而不是scala的Boolean了,因为是UInt参与比较,用的也是三等于号。最后将布尔表或一下,如果表里有一个真值,即n和一个质数相等,则返回真。

另外scala的匿名函数写起来真爽,连lambda关键字还是箭头标识符之类的都不用了,够简洁。