本文涉及Stream、WavePlayer、UDP、Mandelbrot四个实验。实验地址
最后的这四个实验中的三个都和Stream类息息相关。Stream类最关键的是要掌握它的两个特性:需要握手和实时变化。
Flow类相当于Stream类的一个简化,把“需要握手”这个特性去掉了,只保留发送端控制的valid信号。
这个实验比较简单,主要是介绍用流载荷读取内存和两个流的同步可以通过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。
这个实验单看教程完全懵逼,实际上是要实现一个正弦波发生器。在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
}
}
注意三点:
val sample = rom.readAsync(phase.value >> (phaseWidth - sampleCountLog2))
就是前面所说的相近的横坐标取相同的正弦值,横坐标的数量比采样点的数量多,所以这一步相当于是phase.value / (phaseCount / sampleCount)
。本程序中没有输入输出端口,都是由总线通过地址控制寄存器实现的,前面已经有实验介绍过了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)
}
这个实验有两个注意点,一个是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为真时发送端才能更新状态,包括计数器的自增和状态机的转换。
本实验分三个子实验,先说第一个子实验:
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个实验就做完了,个人感觉教程太简洁了,讲得不清不楚,很不适合初学者学习,还是需要完善一下,不过还是感谢设计实验的好心人。至少评测很方便,我也慢慢了解了总线、硬件协议相关的知识,学会通过看波形调试程序了。后面准备研究一下这个项目的项目结构和仿真测试的程序。