SpinalHDL(八):开发仿真测试一条龙

SpinalHDL(八):开发仿真测试一条龙

鉴于被chisel的peek poke test愚弄以后,我一度对Scala上的仿真环境不是特别有信心,当看到spinal.sim的时候觉得可能只是一个能work的demo而已,并没有足够重视。

我用Spinal开发的前几个模块依然走的传统的验证流程,先生成Verilog,然后再用scala生成激励,最后打包丢到Linux服务器上, 用Verilog/SV编写tesbech, 搭建测试比对环境,仿真工具不是VCS就是NcSim这些老牌商业软件。

直到一个偶然的机会,有一些很零碎的模块需要测试,不想再为此搭建一整套仿真环境,我想干脆用spinal.sim 简单做个测试,不测不知道,一测一发不可收拾。

SpinalHDL就是这样的汉子,一次又一次的轻视它,但它又不时的给你惊喜。

  • 不同于iotest的peak poke, 它的后台是verilator, verilator非常强大,性能比起商业软件VCS、NC毫不逊色。
  • spinal.sim 通过包装verilator提供的VPI,能够很方便灵活,实时的跟dut交互通信, 加上Scala本身语言的灵活性,它能做的不比UVM少。

今年chisel貌似后知后觉的回过神了,摒弃iotest,重新设计iotester2, iotester2和spinal.sim的思路很类似了, 后台是verilator, 有兴趣的可以自己试试

接下来我主要借助通信基带的两个模块来介绍一些如何使用SpinalHDL进行仿真测试,以及如何构建组织管理回归你的测试CASE。

对于spinal.sim的基础操作和使用请浏览官方文档Spinal-sim,这里不再赘述。

一:简单的数字混频器

数字下混频模块的顶层如下,有相位步长,相位初始值,输入输出数据

1
2
3
4
5
6
7
8
9
class FreqMixer(cfg: FreqMixerConfig) extends Component{
val io = new Bundle{
val init = in Bool()
val thetaStep = in SInt(cfg.thetaStepQ.width bits)
val thetaInit = in SInt(cfg.thetaAccQ.width bits)
val din = slave Flow(IQ(cfg.diQ.width))
val dout = master(Flow(IQ(cfg.doQ.width)))
}
}

起初你可能像verilog一样一个一个配置参数和灌入激励

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
.compile(new FreqMixer(cfg, true))
.doSimUntilVoid("freqmixSim"){ dut =>
dut.clockDomain.forkStimulus(5) //添加时钟激励,时钟周期为5ns
dut.io.din.valid #= false
dut.io.thetaStep #= thetaStep.fixAsLong(cfg.thetaStepQ)//设置频偏步长
dut.io.thetaInit #= thetaInit.fixAsLong(cfg.thetaAccQ) //设置相位初始值
sleep(100)
dut.clockDomain.waitSampling()

fork {//fork灌入进程
for(x <- source){//将激励one by one灌入
dut.io.din.payload.I #= x.re.toLong
dut.io.din.payload.Q #= x.im.toLong
dut.io.din.valid #= true
dut.clockDomain.waitSampling()
}
sleep(100)
simSuccess() //仿真结束
}

fork {//fork读取比对进程
while(true){
sleep(1)
if(dut.io.dout.valid.toBoolean){
val res = dut.io.dout.payload.toLong
val ref = refs.dequeue //计分板实时取数
dut.clockDomain.waitSampling()
assert(res == ref.fixAsLong(cfg.doQ)) //实时数据比对
}
}
}
}

对于一个功能单一比较小的模块这样可能也能凑合应付,但是对于一个复杂的测试场景复杂的模块顶层,一个一个的去灌入激励,就会变的非常麻烦。我们完全可以将其抽象成4个独立的操作步骤,这些步骤可以串行,也可以通过fork join并行。

1
2
3
4
5
6
7
.compile(new FreqMixer(cfg, true))
.doSimUntilVoid("freqmixSim"){ dut =>
dut.init() // 初始化 包括端口信号初始化,添加时钟激励
dut.forcePara(thetaStep, thetaInit) //配置参数
dut.forkData(source)
dut.forkCompare()
}

新的tb看上去就简洁多了,不光简洁,这些独立出来的函数可以更方便的复用和组合成测试激励。有人可能好奇,这些组合的函数放到哪儿去了?实际你可以把它放到top模块下面,它并不产生电路,所以不用担心。

1
2
3
4
5
6
7
8
9
class FreqMixer(cfg: FreqMixerConfig) extends Component{
val io = new Bundle{
....
}
def init() ....
def forcePara(thetaStep: Long, thetaInit: Long) ....
def forckData(source) ....
def frokCompare().....
}

对于习惯了verilog开发的人,喜欢保持顶层干净,不希望将HDL代码中加入其它非逻辑代码,那么你也可以为FreqMixer专门继承出一个TB类型,将测试相关的代码放入TB,这样在保证能够访问顶层的所有变量方法以外完全不影响顶层模块的代码。

class FreqMixer(cfg: FreqMixerConfig) extends Component{
  val io = new Bundle{
    ....
  }
   ....
}

class FreqMixerTB(cfg: FreqMixerConfig) extends FreqMixer(cfg){//简单继承即可
  //这样FreqMixer内的接口和变量完全可见
  def init(){
     clockDomain.forkStimulus(5) //clockDomain可见
     io.din.valid #= false       //io 可见    
  }
  def forcePara(thetaStep: Long, thetaInit: Long){
     io.thetaStep  #= thetaStep  //配置相位步长
     io.thetaInit  #= thetaInit  //配置相位初始值
  }
  def forkData(source) ....
  def forkCompare().....
}

我们知道对于一个直流信号,添加一个频偏相当于就添加了一个正弦波分量。我们试试以下代码。

dut.forcePara((2*Pi/40).fixAsLong(SQ(32,28), 0)//设置2Pi/40的,相当于一个正玄波周期40个样点
dut.forkData(List.fill(1000)(0.5+0*j).fixAsLong(SQ(8,7)))  //灌入直流,SQ(8,7)量化

当给dut灌入直流,输出出现完美的正弦波
pic1
我们再做点好玩的事情,给输入信号添加一些高斯白噪声。

val source = List.fill(1000)(0.5+0*j)
val sourceWithNoise = AWGN(source, snr = 3) //添加一组信噪比为3dB的高斯白噪声
dut.forkData(soureWithNoise.fixAsLong)
dut.forcePara((2*Pi/200).fixAsLong(SQ(32,28), 0)//同时把周期设大一点

pic2

当我们把snr设置到-3db是,2倍的噪声已经对信号造成严重的变形

val sourceWithNoise = AWGN(source, snr = -3) //信噪比设为 -3dB

pic3

当然我们还可以调整接受机输入的功率以便放到合适的AD范围内,一般蜂窝通信的AD功率增益会调整到-9 ~-12dB左右, 而像GPS接收机位宽比较低,可能只有2bit,会强制饱和30%,把它调到1.549db以便获得最佳效果

val sourceWithNoise = GainTo(AWGN(source, snr = -3), -9) //信噪比-3dB,功率增益调到 -9dB

pic4

用2bit不太容易看出来,用一个8bit,把增益控制在1.549db,可以看出明显的饱和截断
pic5
给一个输入信号加点噪声用analog显示,本身并没有什么实际意义,但是通过这个演示我想说明的,通过简单的几行像伪代码一样的代码可以及时的反应到DUT上,这在以前是不可想象的,传统的IC开发流程里面,需要算法组帮你生成激励,硬件设计人员很难自己构造功能性的case。现在scala平台上你可以将复杂的参考设计和硬件设计完美结合,实时仿真,形成闭环。我最近在做BD/GPS双模基带开发,所有的硬件代码,Golden参考模型,测试向量全部都在Scala平台上完成,可以很方便的构造各种case,比如一次发多颗星,一次跟踪不同的卫星,每颗卫星的信噪比,频偏,码相位构造都能够方便配置。往常对于一些稍微特殊的需求或者case,你需要协调算法同事帮你产生,算法平台的侧重点可能更多的关注性能极限仿真,所以有些对算法平台来说并不是非常方便,同时组间的需求响应周期也比较大。这时候你有scala写的参考平台(这个会跟算法对齐),这些任务可以快速闭环的在自己的平台上完成。

添加总线读写函数

我们开发的模块经常会有AHB, 或者APB总线接口作为寄存器配置接口,一般情况下自测的时候不会为了配置参数再去给他添加一个VIP,但是你直接操作dut上总线接口的信号,那显然非常的啰嗦,如果能在端口上直接 dut.write(0x02, 0x32322)操作那就方便多了

class GPSTop extends Component{
  val io = new Bundle{ 
    ...
    val ahb = AHBLite3(AHBLite3Config(32,32))        
  } 
}

不过我找了一下spinal.sim, 目前并没有这样的函数,如果你不想麻烦作者立马给你加上,或者自己暂时也不想动spinal的源码,你完全可以在本地通过隐式扩展,悄悄的给已有的class扩展方法。如果这些方法足够通用,并且在你本地经过充分的验证,你也可以给spinal提个PR merge 到合适的地方去。

implicit class AhbLite3Extends(ahb: AhbLite3){ //给AHBlite3隐式扩展simWrite, simReda方法 
    import spinal.core._
    import spinal.core.sim._

    def simWrite(addr: Long, data: BigInt)(cd: ClockDomain) = {
      cd.waitSampling()// 等待时钟沿
      ahb.HSEL   #= true
      ahb.HWRITE #= true
      ahb.HREADY #= true
      ahb.HADDR  #= addr
      ahb.HTRANS #= 2
      cd.waitSampling()// 等待时钟沿
      ahb.HWDATA #= data
      while(!ahb.HREADYOUT.toBoolean){cd.waitSampling()}  //握手等待
    }

    def simRead(addr: Long)(cd: ClockDomain): BigInt = {
      cd.waitSampling() // 等待时钟沿
      ahb.HSEL   #= true
      ahb.HWRITE #= false
      ahb.HREADY #= true
      ahb.HADDR  #= addr
      ahb.HTRANS #= 2
      cd.waitSampling()// 等待时钟沿
      while(!ahb.HREADYOUT.toBoolean){cd.waitSampling()}// 握手等待
      sleep(0)
      ahb.HRDATA.toLong
    }
}

然后在顶层模块集成来的TB里添加write, read 方法,还可以进一步的包装,此时你可以像写C语言一样对你的模块进行配置。

class GPSTopTB extends GPSTOP{
  def write(addr: BigInt, data: BigInt) = io.ahb.simWrite(addr, data)(this.clockDomain)
  def read(addr: BigInt): BigInt = io.ahb.simRead(addr)(this.clockDomain)

  def forceGPSPara(cs: GPSCase) = { //可以进一步封装寄存器配置函数
    write(0x00,  cs.para.modes)
    write(0x04,  cs.para.parameter0)
    write(0x08,  cs.para.parameter0)
    write(0x0c,  cs.para.intEnables)
    .......
    write(0x40,  cs.para.....)
  }
}

二:验证CASE规划测试回归

传统的IC开发流程中,由测试case都输出统计到一个文档,一般是excel表格。我们不光需要维护表格,还要维护生成的激励,以及验证环境,这些东西分散在不同的地方,而且有些case或者仿真环境也不一定纳入版本控制。

所以我们经常有这样的遭遇,对于已开发完成的一个IP,一年半载以后你要重新把它跑起来,有时候不得不重新调试环境,有时候找不到case,有些case文件很大,时间长了会把它清理掉,这时候你需要重新找算法帮你生成,反正把原有得模块跑起来有时候会是一件比较费事的事情,所以如果有一种办法能够清晰的管理并维护验证CASE,并且能够不受时间,不受平台限制,在任何时刻能够快速回归浮现,对IC开发人员是一件非常享受的事情。

验证case分为两类

  • 一类是交互类,异常的测试,
  • 还有一类是算法功能测试,由算法来生成向量集

交互异常测试,不过目前我会把它交给验证人员去仔细的敲打, 他们可能更专业。在scala上做UVM那样的事情也完全具备条件。SpinalSIM也没准备要替代传统的验证Flow,不管开发自测多模充分,我们还是会让验证去做最后的把关。

但是功能性的case,验证人员对你的功能case的构造并不一定特别清楚,我们再指定验证规划的时候这类case往往由设计和算法来一起出,所以这类case设计人员更为清楚,让设计评估测试更为合适, 如果我们在前期经过充分的功能性测试,在交付软件的时候比较稳定收敛,这也会直接加速验证的总体收敛进度,进而加快项目进度。

下面我会展示第二类的case.

当我们开发完HDL代码以后,需要造一些常规case,和边界case, 不同于传统的ICflow,这些case 代码会跟HDL放到一个project下面, 它并不会生成数据,非常清晰简洁。即便很长时间过去以后你依然能够方便的再把它们跑起来。

//GPS发送case, 卫星编号, 信噪比,位置偏移, 频偏一目了然, 实现细节全部封装在对应的类里面
object GPSTxCases {
  val case00: List[SatCase] = List(
    SatCase(Sat(GPS,  1), ChannelCase(snr = 100, chipOffset = 0, Fdoppler = 0 Hz)))

    ....
  val case04 : List[SatCase] = List(
    SatCase(Sat(GPS,  1 ), ChannelCase(snr =  0, chipOffset = 399 , Fdoppler = 10   Hz)),
    SatCase(Sat(GPS,  31), ChannelCase(snr =  0, chipOffset = 100 , Fdoppler = 500 kHz)),
    SatCase(Sat(GPS,  7),  ChannelCase(snr =  0, chipOffset = 100 , Fdoppler = 500 kHz)),
    SatCase(Sat(GPS,  15), ChannelCase(snr = -2, chipOffset = 2047, Fdoppler = 27   Hz)))
    ...
}

跟踪测试case, 可以有发送数据 和 跟踪jobs组合而成,除了构造典型case以外,构造随机Case也是不是任何难题。

object GPSTECases {
  val case00 = TECase(GPS,
    sendcase = GPSTxCases.case00,
    jobs     = GPSTEJobs.case00,
    maxMs    = 32
  )

  val case01 = TECase(GPS,
    sendcase = GPSTxCases.case01,
    jobs     = GPSTEJobs.case01,
    maxMs    = 32
  )

回归集通过层层包装最后寥寥几行,干干净净,再也看不到在dut上force一大堆的信号让你迷惑, 配合IDEA的变量函数追踪功能,你也很容易看明白该case具体进行哪些测试,有些人看到上面的Sat, GPSTEjobs 可能不清楚这些具体是干什么的,不明觉厉,这时候你别忘了IDEA的 Ctrl+B跳转大法 ,scala 只要你看不懂的函数或者各种类似于 :=,<-< 的符号,尽管大胆Ctrl+B进入,维多利亚不再有秘密。

最后,这些case能直接很方便的添加到持续集成CI集(CI持续集成在传统的IC开发流程中使用并不是很广泛,但是scala 开发HDL的时候CI还是非常有必要,原因后面会单独出一篇介绍)

class TESimRegression extends  FunSuite {
  test("tesim_case00"){
    val s = new TESim(GnssConfig(),
      GPSTECases.case00,//force GPS信号以及跟踪job
      BDTECases.case00, //force BD信号以及跟踪job 
      withWave = false,
      dumpNode = false)
    s.sim(ms = 30)
  }

  ...

  test("tesim_caseN"){
  ...
  }
}

三:开源工具

目前开发的模块全部都是基于SpinalHDL + IDEA社区版 + verilator + gtkWave

这4个都是开源免费软件,最最重要的是:跨平台

之前我们可能只能在Linux上做RTL开发,现在你在windows, mac上开发完全OK, 没有任何障碍。

虽然是免费开源软件,相比商业EDA工具看上去像草台班子,但实际使用上这套开源软件并不是你印象中的学术玩具,或者是实验性质的东西,它是能体现生产力的工具。

verilator 相比VCS速度上几乎没区别,唯一的gtkWave相比verdi要弱一些,我自己本来是一个重度的Verid依赖着,以为离开verdi 都不会看波形,经过一段时间的适应gtkWave好像也没那么糟糕,配合上IDEA的追踪,并且SpinalHDL生成的代码会原封不动的保留信号名,所以快速定位追踪波形没有任何问题。当你遍历回归时,可以关闭文件dump,以及波形,速度会更快。

当然为SpinalHDL添加VCS、irun后台也是完全没有问题的,这样就可以dump fsdb,使用verdi了,不过我印象当中vcs,verdi应该只有Linux版本。Linux 多半会在公司的服务器上,一般也不会联网。所以对于个人来讲想学习或者做一些好玩的东西,没有平台限制可能更为方便。

评论