SpinalWorkshop实验笔记(二)


概述

本文涉及Function、Apb3Decoder、Timer、BlackBoxAndClock四个实验。实验地址

内容

Function

本实验的电路分两个阶段:

难点在于识别字符串。在前面的prime实验我们知道SpinalHDL的类型不能转换为scala基础类型,所以字符串索引无法使用。这个时候我们就要借鉴前面的思路:逐个比较,结果合并。对于一个SpinalHDL类型的索引,我们不能将其转换为scala基础类型,但我们可以和scala基础类型比较,比较结果就是SpinalHDL类型了。所以我们可以构造一个scala的int类型的表(整数区间)作为中介,判断是否存在表中有一个元素和当前索引相等,同时这个元素用来索引参数字符串得到的字符和当前Flow中得到的字符相等:

  def patternDetector(str : String) = new Area{
    val hit = False
    // TODO
    val cnt = Counter(str.length)
    when (io.cmd.valid) {
      when((0 until str.length).map(x => cnt === x && io.cmd.payload === str(x)).orR) {
        when (cnt.willOverflowIfInc) {
          hit := True
          cnt.clear
        } otherwise {
          cnt.increment
        }
      } otherwise {
        cnt.clear
      }
    }
  }

注意这里不能写成循环形式:

  for (i <- 0 until str.length) {
      when (x => cnt === x && io.cmd.payload === str(x)) {
        when (cnt.willOverflowIfInc) {
          hit := True
          cnt.clear
        } otherwise {
          cnt.increment
        }
      } otherwise {
        cnt.clear
      }
  }

虽然表面上是等价的,都是把循环/区间展开,但是在下面这种情况下cnt在循环中会改变,整个意思就变了。而电路中又没有break语句用,这个地方如果是在软件中的话应该是非常明显的bug,但硬件上我就花了很长时间才发现,说明还是经验太少了。

获得数据阶段就不是很难了:

  def valueLoader(start : Bool,that : Data)= new Area{
    require(widthOf(that) % widthOf(io.cmd.payload) == 0) //You can make the assumption that the 'that' width is alwas an mulitple of 8
    // TODO
    val bytecnt = widthOf(that) / widthOf(io.cmd.payload)
    val hit = Reg(False) setWhen(start)
    val cnt = Counter(bytecnt)
    val data = Reg(Bits(widthOf(that) bits))
    when (hit && io.cmd.valid) {
      data.subdivideIn(bytecnt slices)(cnt) := io.cmd.payload
      hit.clearWhen(cnt.willOverflowIfInc)
      cnt.increment
    }
    that := data
  }

注意data.subdivideIn(bytecnt slices)(cnt) := io.cmd.payload切片后索引的变量cnt必须符合切片的块数,比如切片块数是8,cnt就必须是3位二进制数。奇怪的是,当that的位数为8的时候,bytecnt等于1,声明出的Counter的范围只有一个数:0。相当于是0位二进制数,非常反直觉的定义,一开始我很纠结,后来发现并不影响结果,但还是觉得很别扭。

Apb3Decoder

这个实验评测需要用到python,注意用来评测的python库cocotb的版本必须是1.4.0及以下的,不然会有兼容性问题。

这个实验主要是要明白这个译码器做什么的,其实很简单,就是在一组子设备中找到对应地址区间的子设备,这个子设备的片选信号等于输入设备(父设备)的片选信号,同时父设备的三个接收信号PRDATA、PREADY和PSLERROR需要接收子设备的相应信号;其他设备就直接连:

  //TODO fully asynchronous apb3 decoder
  io.input.PRDATA := io.outputs(0).PRDATA
  io.input.PREADY := io.outputs(0).PREADY
  if (apbConfig.useSlaveError) {
    io.input.PSLVERROR := io.outputs(0).PSLVERROR
  }
  for (i <- 0 until outputsMapping.length) {
    when (outputsMapping(i).hit(io.input.PADDR)) {
      io.outputs(i).PSEL := io.input.PSEL
      io.input.PRDATA := io.outputs(i).PRDATA
      io.input.PREADY := io.outputs(i).PREADY
      if (apbConfig.useSlaveError) {
        io.input.PSLVERROR := io.outputs(i).PSLVERROR
      }
    } otherwise {
      io.outputs(i).PSEL := 0
    }
    io.outputs(i).PENABLE := io.input.PENABLE
    io.outputs(i).PADDR := io.input.PADDR
    io.outputs(i).PWRITE := io.input.PWRITE
    io.outputs(i).PWDATA := io.input.PWDATA
  }

这里需要注意io.input的三个接收信号在使用时不能空置,即使是在整个for循环中一个子设备都没对上,也要强行赋一个值,不然会报latch error。文档里latch error介绍的是组合逻辑回路错误,实际上出现这种错误更多的原因是线路空置。一个类似的错误是寄存器没有初始化,这种错误一般不会像上面那种可以在生成电路时检测出来,而是直接导致逻辑错误。在普通评测时只会输出一个错误的值,而用python评测时会输出高阻的xxx。可能和两者后端的模拟器有关,前者时verilator,后者是icarus verilog。显然是后者更容易检查出错误,不过我觉得SpinalHDL应该出一个寄存器没赋初值就报错的生成选项,从根本上杜绝这种错误。

Timer

这个实验有两个重点,一个是BusSlaveFactory对象的使用。我一开始一直不明白这个对象是干嘛用的,毕竟是fpga初学者。目前看来的作用应该是为地址映射提供便利,像前面pwm实验中就有根据地址读写电路内寄存器的需求,用了这个对象映射一个地址就是一句话的事;另一个是一连串信号源的处理,父模块可以将一连串信号都传入子模块由子模块来进行计算和连接:

   def driveFrom(busCtrl : BusSlaveFactory,baseAddress : BigInt)(ticks : Seq[Bool],clears : Seq[Bool]) = new Area {
    //TODO phase 2
    val clear = False

    val ticksEnable = busCtrl.createReadAndWrite(Bits(ticks.length bits), baseAddress + 0, 0) init(0)
    val clearsEnable = busCtrl.createReadAndWrite(Bits(clears.length bits), baseAddress + 0, 16) init(0)
    busCtrl.driveAndRead(io.limit, baseAddress + 4)
    clear.setWhen(busCtrl.isWriting(baseAddress + 4))
    busCtrl.read(io.value, baseAddress + 8)
    clear.setWhen(busCtrl.isWriting(baseAddress + 8))

    io.tick := (ticksEnable & ticks.asBits).orR
    io.clear := (clearsEnable & clears.asBits).orR | clear
  }

createReadAndWrite创建一个可读可写的寄存器并映射;driveAndRead是映射一个已有的端口,并设置为可读可写;read也是映射一个已有的端口,但设置为只读。isWriting用于捕获对地址的写请求。

计时器的电路代码非常简单:

  //TODO phase 1
  val v = Counter(width bits)
  when (io.clear) {
    v.clear
  } elsewhen (io.tick && v =/= io.limit) {
    v.increment
  }
  io.full := v === io.limit
  io.value := v

BlackBoxAndClock

本实验的重点是blackbox的使用,这部分SpinalHDL的文档写得非常详细,所以实际上不难:

  // TODO define Generics
  addGeneric("wordWidth", wordWidth)
  addGeneric("addressWidth", addressWidth)

  // TODO define IO
  val io = new Bundle {
    val wr = new Bundle {
      val clk = in Bool
      val en   = in Bool
      val addr = in UInt(addressWidth bit)
      val data = in Bits(wordWidth bit)
    }
    val rd = new Bundle {
      val clk = in Bool
      val en   = in Bool
      val addr = in UInt(addressWidth bit)
      val data = out Bits(wordWidth bit)
    }
  }

  // TODO define ClockDomains mappings
  mapClockDomain(writeClock, io.wr.clk)
  mapClockDomain(readClock, io.rd.clk)

基本上就是分三步走:

  1. 定义参数,用addGeneric,指定verilog模块中的parameter
  2. 定义接口,这里的层次结构要和verilog模块里的端口名相符合,比如上面代码里的io.wr.clk对应的就是verilog里的io_wr_clk,如果要违反命名规范需要特殊设置,文档里也有写
  3. 映射时钟,将当前的时钟或者你定义的时钟映射到verilog的时钟端口上

然后是电路定义,这个虽然不是重点,但是我却栽了很大的跟头,花了很长时间去研究这个时序。之前verilog课的考试也是栽在时序上。这里我根据波形总结了三条定律:

  1. 对于when (cond) {xxx}这样的语句,xxx的触发在cond变为高电平之后的下次时钟上升沿。举例来说,假设第一个上升沿cond变成了高电平,那么xxx的第一次触发在第二个上升沿

  2. 内存的读数据端口会在读地址变化的下次时钟上升沿才产生变化

  3. 在时钟上升沿时,首先会对内存读数据端口取值,接着读数据端口更新,最后寄存器产生变化

这样我们就可以分析下面的代码了:

  val sumArea = new ClockingArea(sumClock){                                                                              // TODO define the memory read + summing logic
    val sum = Reg(io.sum.value) init(0)
    io.sum.value := sum

    val readAddr = Counter(widthOf(io.wr.addr) bits)
    var cntEnable = RegInit(False)
    val sumEnable = RegNext(cntEnable) init(False)
    ram.io.rd.en := cntEnable
    ram.io.rd.addr := readAddr

    when (io.sum.start) {
      cntEnable.set
      readAddr.clear
      sum.clearAll
    }

    when (cntEnable) {
      readAddr.increment
    }
    when (sumEnable) {
      sum := sum + ram.io.rd.data.asUInt
      cntEnable.clearWhen(readAddr.willOverflowIfInc)
    }
    io.sum.done.clear
    io.sum.done.setWhen(sumEnable.fall(False))
  }

设io.sum.start被触发是第0时钟上升沿,根据定律1,cntEnable被置1和readAddr清零是第1上升沿。则在第2上升沿,readAddr变为1,且sumEnable紧随cntEnable被置1,又根据定律2,这时ram.io.rd.data才是地址为0的值。在第3上升沿,根据定律3,首先取ram.io.rd.data的值加到sum上,然后ram.io.rd.data更新为地址为1的值,最后readAddr变为2。在第4上升沿,同样地址为1的值被加到sum上,再更新内存读端口,再更新计数器,以此类推。

可以看出,每次加在sum寄存器上的值是当前计数器减2作为地址得到的值,这也是为什么要两个enable的原因。对于结束时的信号,也得仔细分析,设readAddr被加到0xFF的那个上升沿为第0上升沿,这时sum加上了地址为0xFD的内存值,然后端口更新为地址为0xFE的内存值。根据定律1,在第1上升沿,cntEnable清零,同时sum加上地址为0xFE的内存值,端口更新为地址为0xFF的内存值,readAddr更新为0。这时io.sum.done不能置1,因为sum还没加完。在第2上升沿,sumEnable随之清零,同时sum加上地址为0xFF的内存值,端口更新,readAddr已经不会加了,io.sum.done在sumEnable清零的瞬间置1。这里只能用“清零的瞬间”这个条件,因为用高电平还是低电平判断怎么也不合适。

然后有两个注意的地方: