SpinalWorkshop实验笔记(二)
概述
本文涉及Function、Apb3Decoder、Timer、BlackBoxAndClock四个实验。实验地址
内容
Function
本实验的电路分两个阶段:
- 识别字符串:用从Flow中获得的字符匹配参数字符串
- 获得数据:匹配成功后,从字符串后面获得一定量的字节构成一个整数输出
难点在于识别字符串。在前面的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)
基本上就是分三步走:
- 定义参数,用addGeneric,指定verilog模块中的parameter
- 定义接口,这里的层次结构要和verilog模块里的端口名相符合,比如上面代码里的io.wr.clk对应的就是verilog里的io_wr_clk,如果要违反命名规范需要特殊设置,文档里也有写
- 映射时钟,将当前的时钟或者你定义的时钟映射到verilog的时钟端口上
然后是电路定义,这个虽然不是重点,但是我却栽了很大的跟头,花了很长时间去研究这个时序。之前verilog课的考试也是栽在时序上。这里我根据波形总结了三条定律:
-
对于when (cond) {xxx}这样的语句,xxx的触发在cond变为高电平之后的下次时钟上升沿。举例来说,假设第一个上升沿cond变成了高电平,那么xxx的第一次触发在第二个上升沿
-
内存的读数据端口会在读地址变化的下次时钟上升沿才产生变化
-
在时钟上升沿时,首先会对内存读数据端口取值,接着读数据端口更新,最后寄存器产生变化
这样我们就可以分析下面的代码了:
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。这里只能用“清零的瞬间”这个条件,因为用高电平还是低电平判断怎么也不合适。
然后有两个注意的地方:
-
布尔型寄存器设置初始值要用RegInit或者Reg(Bool) init(),我以前以为Reg(False)就是赋初值为False了,结果并不是,只是说明这个寄存器和False是同类型而已
-
关于xxx.fall函数,我看SpinalHDL源代码里有两个重载:
/** * Falling edge detection of this with an initial value * @example{{{ val res = myBool.fall(False) }}} * @param initAt the initial value * @return a Bool */ def fall(initAt: Bool): Bool = ! this && RegNext(this).init(initAt) /** Falling edge detection */ def fall(): Bool = ! this && RegNext(this)
实现方式是定义一个寄存器作为当前信号的后继,这样当前信号下降的时候后继寄存器还没下降。我一开始没仔细看,选了后面那个没参数的重载。结果那个后继寄存器没初始化!众所周知,没有初始化会导致一些匪夷所思的结果,我就遇到了诸如when (cond) {do A} otherwise {do B}和when (!cond) {do B} otherwise {do A}结果不一样和计数器只能用Reg不能用Counter之类的诡异问题。最后看了波形才明白,原因是后继寄存器一开始为1,所以只要当前信号是低电平都会返回1。我只想吐槽一句,写第二个重载的人是不是SpinalHDL项目组里的内鬼,这不是纯坑爹吗?事实上,我觉得初始值直接默认为假就可以了,根本就没有初始值为真能返回正确结果的情况。