SpinalWorkshop实验笔记(三)

概述

本文涉及Stream、WavePlayer、UDP、Mandelbrot四个实验。实验地址

最后的这四个实验中的三个都和Stream类息息相关。Stream类最关键的是要掌握它的两个特性:需要握手和实时变化。

  • 需要握手指的是Stream的传输数据需要其valid信号和ready信号均为真,而这两个信号分别由master和slave端控制,也就是由发送端和接收端控制。对于发送端,只有这两个信号同为真了,才能认为载荷被接收端接收了;而对于接收端,只有这两个信号同为真了,才能认为载荷有效可以获取。
  • 实时变化指这两个信号都是wire,不是寄存器,所以不受时钟的控制。这是硬件的一个很重要的逻辑,不能把握手看成像tcp网络协议一样有一个谁先谁后的问题,而是同时的,发送端和接收端都在等待两个信号同为真,一旦条件满足,两边同时做各自的操作:接收端取走载荷,发送端更新状态。

Flow类相当于Stream类的一个简化,把“需要握手”这个特性去掉了,只保留发送端控制的valid信号。

内容

Stream

这个实验比较简单,主要是介绍用流载荷读取内存和两个流的同步可以通过API完成。

  mem.write(io.memWrite.payload.address, io.memWrite.payload.data, io.memWrite.valid)
  val outA = mem.streamReadSync(io.cmdA)
  val joinAB = StreamJoin.arg(outA, io.cmdB)
  io.rsp << joinAB.translateWith(outA.payload ^ io.cmdB.payload)

这个<<是一个语法糖,意思是将右边stream的valid和payload连到左边,左边的ready连到右边。

如果不用API,需要考虑时序,即mem需要一个时钟周期读取,因此A端口的valid信号为真后,需要过一个时钟周期输出端口才能变为valid。

WavePlayer

这个实验单看教程完全懵逼,实际上是要实现一个正弦波发生器。在phase阶段生成横坐标,这个横坐标变换率是rate;在sample阶段生成正弦波,为了效率正弦值先存在rom里面了,所以这一阶段主要做的是用横坐标查表,注意相近的横坐标取相同的正弦值;filter阶段对正弦波进行滤波,用的是一阶低通滤波,网上可以查到公式,主要是解一个微分方程,用数值方法转换成一个迭代的过程。

  val phase = new Area{
    val run = Bool  //Driven later by a WavePlayerMapper (see WavePlayerMapper implementation)
    val rate = UInt(phaseWidth bits) //Driven later by a WavePlayerMapper
    //TODO phase
    val value = Reg(rate) init(0)
    when (run) {
      value := value + rate
    }

  }

  val sampler = new Area{
    //TODO Rom definition with a sinus + it sampling
    val sampleCount = 1 << sampleCountLog2
    val romSamples = for (i <- 0 until sampleCount) yield {
      val sin = Math.sin(2 * Math.PI * i / sampleCount)
      BigInt(((sin + 1) / 2 * ((1 << sampleWidth) - 1)).toLong)
    }
    val rom = Mem(UInt(sampleWidth bits), sampleCount) initBigInt(romSamples)
    val sample = rom.readAsync(phase.value >> (phaseWidth - sampleCountLog2))
  }

  val filter = new Area{
    val bypass = Bool //Driven later by a WavePlayerMapper
    val coef = UInt(filterCoefWidth bits) //Driven later by a WavePlayerMapper
    val value = Sample //Output value of the filter Area
    //TODO first order filter + bypass logic
    val f = Reg(Sample) init(0)
    f := f - ((f * coef) >> filterCoefWidth) + ((sampler.sample * coef) >> filterCoefWidth)
    value := bypass ? sampler.sample | f
  }
}

注意三点:

  1. sampler里生成正弦值时需要对正弦值映射到0到1再乘采样最大值,众所周知正弦值是-1到1,所以是先加1再除以2。
  2. val sample = rom.readAsync(phase.value >> (phaseWidth - sampleCountLog2))就是前面所说的相近的横坐标取相同的正弦值,横坐标的数量比采样点的数量多,所以这一步相当于是phase.value / (phaseCount / sampleCount)
  3. 教程最后一句话说公式里给出的coef是定点数(不是整数),但为什么程序端口里给出的是整数呢?原因是端口里的coef是原来的coef左移filterCoefWidth得到的,就像我们计算1.23+4.56这样的数可以计算123+456再除100一样。所以我们计算f的时候因为f乘左移后的coef足够大,所以可以在计算过程中直接把filterCoefWidth右移回来。

本程序中没有输入输出端口,都是由总线通过地址控制寄存器实现的,前面已经有实验介绍过了busSlaveFactory的使用方法,这里不再赘述:

//Area capable to map a WavePlayer on a BusSlaveFactory
class WavePlayerMapper(bus : BusSlaveFactory, wavePlayer : WavePlayer) extends Area{
  bus.driveAndRead(wavePlayer.phase.run, address = 0x00) init(False)
  //TODO phase.rate, phase.value, filter.bypass, filter.coef mapping
  bus.drive(wavePlayer.phase.rate, address = 0x04) init(0)
  bus.read(wavePlayer.phase.value, address = 0x08) init(0)                                                             bus.driveAndRead(wavePlayer.filter.bypass, address = 0x10) init(True)
  bus.drive(wavePlayer.filter.coef, address = 0x14) init(0)
}

UDP

这个实验有两个注意点,一个是stream的使用,另一个是两个端口的同步问题。关键是后者,传过来包的头信息和传过来包的内容是两个stream分别控制的,而且包的内容经常不止一个字节,所以要接收多次;而stream又有一个特性,就是只要valid和ready同为真,对发送者来说就是载荷已经被接收了,可能就会发送下一条信息。所以如果两个stream都是“有包就收”,就会导致包的内容和包的头信息不同步的问题。正确方法是先接收包内容,直到接收完,再接收包的头信息:

    val idle : State = new State with EntryPoint{
      whenIsActive{
        // TODO Check io.rx.cmd dst port
        when (io.rx.data.valid) {
          io.rx.data.ready.set
          when (rxFirst) {
            rxFirstData := io.rx.data.payload.fragment
            rxFirst.clear
          }
          when (io.rx.data.payload.last) {
            ip := io.rx.cmd.payload.ip
            srcPort := io.rx.cmd.payload.srcPort
            goto(helloHeader)
            io.rx.cmd.ready.set
          }
        }
      }
    }

这里我用rxFirst表明当前是不是包的第一个字节,用rxFirstData记录这个字节,直到io.rx.data.payload.last为真,说明包接收完了,这才记录包头信息并跳转状态。后面就很清晰了:

    //Check the hello protocol Header
    val helloHeader = new State{
      val isHello = Reg(UInt(2 bits)) init(0)
      whenIsActive {
        // TODO check that the first byte of the packet payload is equals to Hello.discoveringCmd
        when (rxFirstData === Hello.discoveringCmd) {
          goto (discoveringRspTx)
        } otherwise {
          goto (idle)
        }
        rxFirst.set
      }
    }

    //Send an discoveringRsp packet
    val discoveringRspTx = new StateParallelFsm(
      discoveringRspTxCmdFsm,
      discoveringRspTxDataFsm
    ){
      whenCompleted{
        //TODO return to IDLE
        goto(idle)
      }
    }
  //Inner FSM of the discoveringRspTx state
  lazy val discoveringRspTxCmdFsm = new StateMachine{
    val sendCmd = new State with EntryPoint{
      whenIsActive{
        //TODO send one io.tx.cmd transaction
        io.tx.cmd.payload.ip := ip
        io.tx.cmd.payload.srcPort := helloPort
        io.tx.cmd.payload.dstPort := srcPort
        io.tx.cmd.payload.length := helloMessage.length + 1
        io.tx.cmd.valid.set
        when (io.tx.cmd.ready) {
          exit
        }
      }
    }
  }

  //Inner FSM of the discoveringRspTx state
  lazy val discoveringRspTxDataFsm = new StateMachine{
    val sendHeader = new State with EntryPoint{
      whenIsActive{
        //TODO send the io.tx.cmd header (Hello.discoveringRsp)
        io.tx.data.payload := Hello.discoveringRsp
        io.tx.data.valid.set
        when (io.tx.data.ready) {
          goto(sendMessage)
        }
      }
    }

    val sendMessage = new State{
      val counter = Reg(UInt(log2Up(helloMessage.length) bits))
      onEntry{
        counter := 0
      }
      whenIsActive{
        //TODO send the message on io.tx.cmd header
        io.tx.data.valid.set
        io.tx.data.payload := hello(counter)
        when (counter === counter.maxValue) {
          io.tx.data.payload.last.set
          when (io.tx.data.ready) {
            exit
          }
        } otherwise {
          when (io.tx.data.ready) {
            counter := counter + 1
          }
        }
      }
    }                                                                                                         }

后面两个是子自动机,所以要正确使用exit退出。另一个值得注意的是必须等到ready为真时发送端才能更新状态,包括计数器的自增和状态机的转换。

Mandelbrot

本实验分三个子实验,先说第一个子实验:

  val x0 = Reg(g.fixType)
  val y0 = Reg(g.fixType)
  val iter = Reg(g.iterationType) init(0)
  io.cmd.ready.clear
  io.rsp.valid.clear
  io.rsp.payload.iteration.clearAll

  val fsm = new StateMachine {
    val idle: State = new State with EntryPoint {
      whenIsActive {
        when (io.cmd.valid) {
          io.cmd.ready.set
          x0 := io.cmd.payload.x
          y0 := io.cmd.payload.y
          goto(calc)
        }
      }
    }
    val calc = new State {
      val x = Reg(g.fixType)
      val y = Reg(g.fixType)
      onEntry {
        x := 0
        y := 0
        iter.clearAll
      }
      whenIsActive {
        when (x * x + y * y < 4 && iter < g.iterationLimit) {
          x := (x * x - y * y + x0).truncated
          y := (((x * y) << 1) + y0).truncated
          iter := iter + 1
        } otherwise {
          goto(waitRsp)
        }
      }
    }
    val waitRsp = new State {
      whenIsActive {
        io.rsp.valid.set
        io.rsp.payload.iteration := iter
        when (io.rsp.ready) {
          goto(idle)
        }
      }
    }
  }

经过前面的实验,stream已经很熟悉了,所以代码很简单,注意的注意是定点数的使用,两个注意点,一个是定点数不能隐式截取,比如2位乘2位结果是4位,要存回2位的数据类型,整数就自动截取了,而定点数不行,必须用truncated,对整数部分和小数部分分别截取到对应的范围;二是定点数直接和scala数据类型计算比较麻烦,像y := (((x * y) << 1) + y0).truncated,如果要乘2,则必须先创建一个对应位数的值为2的定点数,不过这里很容易绕过,要么左移一位,要么用加法都可以代替。定点数和整数一样左移几位相当于乘对应的2次幂。

第二个实验要求并行,除了上面算单个坐标的电路,需要额外加个分配器和集合器:

case class Dispatcher[T <: Data](dataType : T,outputsCount : Int) extends Component{
  val io = new Bundle {
    val input = slave Stream(dataType)
    val outputs = Vec(master Stream(dataType),outputsCount)
  }
  // TODO
  val cnt = Counter(outputsCount)
  for (i <- io.outputs) {
    i.valid.clear
    i.payload := io.input.payload
  }
  io.outputs(cnt).valid := io.input.valid
  io.input.ready := io.outputs(cnt).ready
  when (io.outputs(cnt).fire) {
    cnt.increment
  }
}

// TODO Define the Arbiter component (similar to the Dispatcher)
case class Arbiter[T <: Data](dataType : T,inputsCount : Int) extends Component{
  val io = new Bundle {
    val inputs = Vec(slave Stream(dataType),inputsCount)
    val output = master Stream(dataType)
  }
  val cnt = Counter(inputsCount)
  for (i <- io.inputs) {
    i.ready.clear
  }
  io.output.valid := io.inputs(cnt).valid
  io.output.payload := io.inputs(cnt).payload
  io.inputs(cnt).ready := io.output.ready
  when (io.output.fire) {
    cnt.increment
  }
}

注意不要让线路空置,另外这里要用一个计数器控制整体坐标的输入和输出顺序。最后总电路的连接:

  //TODO instantiate all components
  val dispatcher = Dispatcher(PixelTask(g), coreCount)
  val solver = List.fill(coreCount)(PixelSolver(g))
  val arbiter = Arbiter(PixelResult(g), coreCount)

  //TODO interconnect all that stuff
  io.cmd >> dispatcher.io.input
  for (i <- 0 until coreCount) {
    dispatcher.io.outputs(i) >> solver(i).io.cmd
    solver(i).io.rsp >> arbiter.io.inputs(i)
  }
  arbiter.io.output >> io.rsp

第三个子实验要求对第一个子实验实现流水。这里用flow充当流水的阶段存储,为什么要用flow不能用reg呢,原因是flow可以很方便地控制各阶段的时钟周期。教程里面假设乘法阶段的周期数是加法阶段的两倍,那么如果用reg的话,乘法阶段的输出寄存器就需要用一组reg和一组regnext。而flow按照前面说的“实时变化”,valid和payload都是wire类型的,但是可以用stage函数将它转成类似reg的形式,即下个时钟上升沿才变化,如果用stage.stage,则是下下各时钟上升沿才变化,相当于是reg和regnext连起来了。也就是说,flow控制延迟多少个周期,就调用多少次stage就行了,编译到verilog就是一连串寄存器相连,但是在spinalhdl写起来就比定义一连串寄存器方便得多:

  val inserterContext = Flow(InserterContext())
  val mulStageContext = Flow(MulStageContext())
  val addStageContext = Flow(AddStageContext())
  val routerContext = Flow(RouterContext())
  io.cmd.ready.clear
  io.rsp.valid.clear
  io.rsp.payload.iteration.clearAll

  val inserter = new Area{
    val freeId = Counter(1 << idWidth,inc = io.cmd.fire)
    val input = routerContext
    val output = inserterContext
    output.valid := input.valid || io.cmd.valid
    when ((!input.valid) && io.cmd.valid) {
      io.cmd.ready.set
      output.id := freeId
      output.x0 := io.cmd.payload.x
      output.y0 := io.cmd.payload.y
      output.iteration.clearAll
      output.done.clear
      output.x := 0
      output.y := 0
    } otherwise {
      output.payload.assignSomeByName(input.payload)
    }
  }

这里直接根据input.valid判断有没有循环流过来的计算任务,valid为假说明没有流过来的计算任务,也就是该流水线没充满,需要添加一个新的。剩下的代码一起给出:

  val mulStage = new Area{
    val input = inserterContext.stage
    val output = mulStageContext
    output.valid := input.valid
    output.payload.assignSomeByName(input.payload)
    output.xx := (input.x * input.x).truncated
    output.yy := (input.y * input.y).truncated
    output.xy := (input.x * input.y).truncated
  }

  val addStage = new Area{
    val input = mulStageContext.stage.stage
    val output = addStageContext
    output.valid := input.valid
    output.payload.assignSomeByName(input.payload)
    output.x := input.xx - input.yy + input.x0
    output.y := input.xy + input.xy + input.y0
    output.done.allowOverride
    output.iteration.allowOverride
    output.done := input.done || input.xx + input.yy >= 4 || input.iteration === iterationLimit
    output.iteration := input.iteration + (!output.done).asUInt
  }

  val router = new Area{
    val wantedId = Counter(1 << idWidth,inc = io.rsp.fire)
    val input = addStageContext.stage
    val output = routerContext
    output.payload.assignSomeByName(input.payload)
    when (inserter.input.done && wantedId === input.id) {
      io.rsp.valid := input.valid
      io.rsp.payload.iteration := input.iteration
    }
    output.valid := input.valid && (!input.done || wantedId =/= input.id || !io.rsp.fire)
  }

这里有几个注意点:

  • 判断“done”信号的阶段,这里放在addStage,因为可以用上上一阶段的输出,但是我试了即使放在router,并在这个阶段计算乘法,总时间反而减少了,可能原因是乘法实际并不花多少时间吧。

  • 可能有的人会有疑问,routerContext的valid信号是随addStageContext的valid信号控制的,但是第一阶段又通过valid信号判断是加入新任务还是继续计算前面的任务,这样如果addStageContext没有就绪是不是会导致错误地加入新任务吗?答案是不会,因为这里的flow不是输入接口,而是作为阶段之间传输的寄存器,基本可以保障在流水线充满的时候时钟上升时valid始终为真。所以最后一行判断input.valid仅仅是确定流水线是否充满,如果input.valid为假说明流水线没充满,这时当然要加入新任务。

  • assignSomeByName是Bundle类下的一个函数,作用是自动将参数端口集合里的所有端口连到调用类端口集合的所有同名端口上。如果找不到同名端口就不连,非常智能。如果调用这个函数之后有些个别函数不想这样连,可以用allowOverride函数取消这个端口的自动连接然后自己连。注意这个函数只能连Bundle子类声明的所有端口,像实验模板里声明的:

      trait Context{
        val id        = UInt(idWidth bits)
        val x0,y0     = fixType
        val iteration = UInt(iterationWidth bits)
        val done      = Bool
      }
      case class InserterContext() extends Bundle with Context{
        val x,y = fixType
      }
    

    这样InserterContext声明的端口用assignSomeByName连接就只会连x和y两个端口,Context特性里哪些属性就不会连上,所以答案里改成了这样的声明:

      class Context extends Bundle{
        val id        = UInt(idWidth bits)
        val x0,y0     = fixType
        val iteration = UInt(iterationWidth bits)
        val done      = Bool
      }
    
      case class InserterContext() extends Context{
        val x,y = fixType
      }
    

    我一开始没注意这个变化,结果一直提示我那几个端口空置,找又找不到,坑死了。

总结

至此,SpinalHDL的12个实验就做完了,个人感觉教程太简洁了,讲得不清不楚,很不适合初学者学习,还是需要完善一下,不过还是感谢设计实验的好心人。至少评测很方便,我也慢慢了解了总线、硬件协议相关的知识,学会通过看波形调试程序了。后面准备研究一下这个项目的项目结构和仿真测试的程序。

posted @ 2021-12-22 20:14  YuanZiming  阅读(526)  评论(0编辑  收藏  举报