概述
最近在学习SpinalHDL,在github上看到了SpinalHDL实验,于是试着做了做。虽然这些实验的答案在仓库里给出来了,但我是FPGA初学者,虽然会一点verilog却对各种总线一窍不通,也不了解scala,所以即使要理解这些实验也花费了一番功夫。在这里记录一下我做这些实验的感想。本文涉及Counter、PWM、UART、Prime四个实验。
内容
Counter
Counter实验很简单,只要实现一个计数器,并且不要求时钟信号。所以直接按教程里的图片连线即可:
val r = Reg(UInt(width bits)) init(0)
r := io.clear ? U(0) | r + 1
io.value := r
io.full := r === (1 << width) - 1
注意的点:
- 比较SpinalHDL的类型需要使用三等于号
- 寄存器声明的时候一定要初始化,不初始化测试会出错
PWM
难度陡然上升(当然可能是我太菜)。这个实验的关键是理解SpinalHDL的master/slave模型。我们可以把实现了IMasterSlave的端口集合理解为一个两种用途的端口集合。当它在模块里被声明成master时,集合里某些端口是输入端口,另一些是输出,而被声明成slave时,那些master时的输入端口此时变为输出端口,master的输出端口变为输入端口。也就是所有端口的输入输出方向发生了反转。
由于教程的Component interfaces指定了模块的apb端口集合是slave类型的,所以我们需要APB这个端口集合中哪些端口在被声明成slave时是输入端口,哪些是输出端口。这回我们需要理解这个模块是干啥的,查看波形可以发现这个模块似乎是一个方波发生器,就是比较timer寄存器和dutycycle寄存器的值,前者小于后者输出高电平,否则输出低电平。同时有另外一个需求,就是外面的模块要能读写enable和dutycycle两个寄存器(其实我一开始没看出来,这教程写得不清不楚的)。很容易想出当APB作为slave时,是外界向本模块提供要写入的值,同时读出值。因此,写相关的端口当然是输入,读相关的端口当然是输出。加上一些控制信号PSEL、PENABLE和PADDR也明显是输入进这些模块的,因此就可以写出APB的声明:
//APB interface definition
case class Apb(config: ApbConfig) extends Bundle with IMasterSlave {
//TODO define APB signals
val PSEL = Bits(config.selWidth bits)
val PENABLE = Bool()
val PWRITE = Bool()
val PADDR = UInt(config.addressWidth bits)
val PWDATA = Bits(config.dataWidth bits)
val PRDATA = Bits(config.dataWidth bits)
val PREADY = Bool()
override def asMaster(): Unit = {
//TODO define direction of each signal in a master mode
out(PSEL, PENABLE, PWRITE, PADDR, PWDATA)
in(PRDATA, PREADY)
}
}
声明里定义端口的输入和输出是通过重写asMaster方法,而且针对的是master声明的,因此需要将我们刚才的讨论倒过来,就是上面的代码。
io端口的声明和logic块很简单,和Counter实验差不多:
val io = new Bundle{
val apb = slave(Apb(apbConfig)) //TODO
val pwm = out Bool() //TODO
}
val logic = new Area {
//TODO define the PWM logic
val enable = Reg(False)
val timer = Reg(UInt(timerWidth bits)) init(0)
when (enable) {
timer := timer + 1
}
val dutycycle = Reg(UInt(timerWidth bits)) init(0)
val output = Reg(False)
output := timer < dutycycle
io.pwm := output
}
control块就比较复杂,其负责控制那两个寄存器的读写,所以首先需要根据地址来决定读写那个寄存器,同时写寄存器需要收到控制信号的制约,包括片选信号(PSEL)、使能信号(PENABLE)和写信号(PWRITE)。读寄存器直接读,写寄存器需要控制信号均为真才可以写:
val control = new Area{
//TODO define the APB slave logic that will make PWM's registers writable/readable
val doWrite = io.apb.PSEL(0) && io.apb.PENABLE && io.apb.PWRITE
io.apb.PRDATA := 0
io.apb.PREADY := True
switch(io.apb.PADDR){
is(0){
io.apb.PRDATA(0) := logic.enable
when(doWrite){
logic.enable := io.apb.PWDATA(0)
}
}
is(4){
io.apb.PRDATA := logic.dutycycle.asBits.resized
when(doWrite){
logic.dutycycle := io.apb.PWDATA.asUInt.resized
}
}
}
}
UART
这个实验只看文档也挺懵的,实际上它描述的采样状态机应该是这样:
-
IDLE:什么也不做,直到满足跳转条件
-
START:等待一定的时间,然后跳转
-
DATA:重复8次以下操作(用bitCounter计数):
- 获取
preSamplingSize + samplingSize + postSamplingSize
个采样tick里rxd信号的值,取其中出现次数超过一半的电平作为本次采样的输出电平(因为samplingSize大于preSamplingSize + samplingSize + postSamplingSize
的一半) - 假设当前是第i次操作,那么采样的输出电平放到输出寄存器的第i-1位
这样重复结束后输出寄存器的值就是采样得到的8位整数
- 获取
-
STOP:等待
preSamplingSize + samplingSize + postSamplingSize
个采样tick,回到IDLE状态
DATA这一步还是比较清晰的,可能实际的电路没有那么稳定,所以才要通过采样一段时间然后用MajorityVote取出现次数超过一半的电平。但是START和STOP为什么要等待这个数量的采样tick我就不明白了,尤其是START的式子preSamplingSize + (samplingSize - 1) / 2 - 1
更是奇怪,不知道怎么回事:
// Statemachine that use all precedent area
val stateMachine = new StateMachine {
//TODO state machine
val value = Reg(io.read.payload)
io.read.valid := False
always {
io.read.payload := value
}
val IDLE: State = new State with EntryPoint {
whenIsActive {
when (sampler.tick && !sampler.value) {
bitTimer.recenter := True
goto(START)
}
}
}
val START = new State {
whenIsActive {
when (bitTimer.tick) {
bitCounter.clear := True
goto(DATA)
}
}
}
val DATA = new State {
whenIsActive {
when (bitTimer.tick) {
value(bitCounter.value) := sampler.value
when (bitCounter.value === 7) {
goto(STOP)
}
}
}
}
val STOP = new State {
whenIsActive {
when (bitTimer.tick) {
io.read.valid := True
goto(IDLE)
}
}
}
}
“等待一定的时间”和“采样一定的时间”这些操作都由bitTimer计算,当recenter信号被触发时,等待的采样tick是preSamplingSize + (samplingSize - 1) / 2 - 1
,在START结束后bitTimer里的计数器减到0自然溢出到(1 << width) - 1
,由于程序前面规定了preSamplingSize + samplingSize + postSamplingSize
必须是2的幂,所以两条式子相等,之后在recenter下一次被触发前计都是按那条式子计算等待时间和采样时间。
另外是Flow的用法,当数据有效时把valid端口置为真,数据传到payload端口。注意寄存器传到payload端口这句必须放在状态机之外或者状态机内的always块里,否则会报“latch”错误。因为payload端口应该是短时间的信号而不是寄存器,所以不能在“某个状态”内赋值。同时,对于bitCounter和bitTimer内的信号进行的操作也是短时间的,在当前状态内是高电平,出了这个状态就又变回低电平了。
最后注意状态机,我用的是文档里介绍的style A写法,这种写法需要注意如果是当前声明的状态被后面声明的状态使用(如当前声明的IDLE在后面声明STOP中用到了),那么当前声明的状态就不能用自动类型推断,不然会报“递归定义”的错误。如上面的IDLE就在声明里显式指出了其类型State。
Prime
代码很简单,但是新手很容易误解。比如我,一开始看见源程序里给出了基于scala基础类型判断质数的函数apply,就在想能不能将传给我的SpinalHDL类型的数转换成基础类型然后调用apply判断,但找不到转换函数。看了答案才意识到这样肯定是不行的,因为apply不能被编译成电路,怎么能用来判断呢。正确方法是利用apply函数构造一个质数表,然后将传给我的数依次和质数表里的数比较,如果其中一个为真就返回真。这种情况下质数表可以转换成一组常量信号,然后用比较器和参数进行比较最后用一个大或门就能计算结果,可以编译成电路的形式,实际上就类似于教程里给出的Verilog代码:
def apply(n : UInt) : Bool = {
//TODO
return (0 until 1 << widthOf(n)).filter(apply(_)).map(n === _).orR
}
这里缩成一行,意思是构造整数区间[0, 1 << n的位宽)(注意是左闭右开区间),将整数区间里的所有数用apply检查一下(这里的apply是针对scala基础类型的),选出是质数的,这样就构造出了一个质数表。然后将质数表里的每个数和n比较一下,相等的为真,不等的为假,这样就构造出了一个布尔表,注意这个布尔,指的是SpinalHDL的Bool而不是scala的Boolean了,因为是UInt参与比较,用的也是三等于号。最后将布尔表或一下,如果表里有一个真值,即n和一个质数相等,则返回真。
另外scala的匿名函数写起来真爽,连lambda关键字还是箭头标识符之类的都不用了,够简洁。