SpinalWorkshop实验笔记(三)


概述

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

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

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

这里有几个注意点:

总结

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