SpinalHDL上板过程记录


背景

最近帮老师做一个硬件项目,使用SpinalHDL实现。实际用起来还是觉得这玩意不错,它能够抽象到“生成Verilog代码”这一层面,通过程序简化生成的逻辑,可以减少很多直接用Verilog需要编写的重复代码。同时它声明的端口名称和硬件逻辑是能够直接对应到Verilog代码的,所以查看波形调试也比较方便,这就比Chisel乃至HLS有优势了。不过Scala我用起来还是不太习惯,主要是它同时包含了函数式和面向对象的大量特性,很多概念我也不是很熟悉,只能勉强着用。

当然,SpinalHDL有个缺点,那就是资料和教程太少,中文和英文的都一样,所以刚开始做项目的时候老师也在担心SpinalHDL开发会不会遇到什么问题。好在最后在FPGA开发板上顺利跑通了。硬件代码本身的开发没什么好说的,主要是仿真跑通后到在真正硬件上跑通颇费了一番周折。所以这里记录一下SpinalHDL上板过程遇到的几个问题,也算是抛砖引玉,希望大佬们补充一下SpinalHDL资料和教程在这方面的空白吧。

片上存储

先说一下SpinalHDL的片上存储模块,也就是最基本的Mem。假设我需要一个宽度为64bits,深度为65536的片上存储,SpinalHDL里是这样写的:

val ram = Mem(Bits(64 bits), 1 << 16)

在Verilog里,它会生成一个寄存器数组:

reg [63:0] ram[0:65535];

正常情况下,Vivado能够自动将这个寄存器数组例化为BRAM,当然,这是有前提的,就是这个寄存器数组的读写逻辑符合BRAM的端口配置,即最多为两个读写端口,否则综合的时候就会报错。那么SpinalHDL里如何指定端口数目呢?其实就是看程序里执行了多少条writereadAsyncreadSyncreadWriteSync方法,一条对应一个端口。前面三个方法对应的是只读/只写端口,最后一个对应读写端口。

注意只读和只写逻辑不会自动合并为读写端口,假设一个模块调用了writereadSyncreadWriteSync三个方法,那么将会生成一个只写端口,一个只读端口和一个读写端口,将无法通过Vivado的综合流程。因此,如果模块中同时包含和读数据相关的信号和写数据相关的信号,又希望这两条信号共用一个读写端口,则只能用readWriteSync方法实现,然后通过多路选择器和读、写使能信号控制读和写的选择。

AxiLite

我们做的项目用的是Zynq系列的开发板。前面的文章也说过,这种开发板上运行的硬件程序主要通过AxiLite总线接收控制信息。AxiLite在SpinalHDL里有支持:

val io = new Bundle {
  val axilite = slave(AxiLite4(addressWidth=6, dataWidth=32))
}
val registers = new AxiLite4SlaveFactory(io.instruction)

即可声明一个控制最多64个32位寄存器的AxiLite总线,之后就可以通过BasSlaveFactory提供的相关方法绑定和控制这些寄存器了,似乎也可以通过RegInterface实现类似的工作,不过我没有尝试。

仿真的话就使用AxiLite4Driver即可:

val axilite = AxiLite4Driver(dut.io.axilite, dut.clockDomain)
axilite.write(0x4, BigInt("2333", 16))
println(axilite.read(0x4).toInt)

AxiLite4Driver文档里好像没有介绍用法,因此需要自己查阅源码

直接把SpinalHDL生成的Verilog放进Vivado里,生成的IP核AxiLite相关的端口都是分散的,在Block Design的时候不会被正确连接,因此还需要使用Vivado的打包功能Create and Package New IP,然后在Ports and Interfaces里创建Axi映射,将Verilog里Axilite对应端口和Vivado里的Axilite信号映射起来,之后在Addressing and Memory里查看刚映射的端口地址有没有被自动配置上,注意自动配置经常会没有更新,这时得多重启几次项目,确保自动配置完成才行。打包完的IP核就可以在Block Design里顺利以Axilite的方式连接了。

另一个需要密切注意的地方就是SpinalHDL的清零端口是高电平清零(reset),这个和Vivado里其他IP核常见的低电平清零(resetn)不一样。因此在使用Block Design的清零信号源的时候,注意是将名字带有reset的信号连到我们IP核的清零端,而不是名字带有resetn的。这一点坑了我非常久,因为清零信号始终为高电平导致IP一直清零,无法正常工作,也不能接收任何外界信号,让我非常的迷惑。

Axi

大量数据的传输,也就是和DRAM的交互需要使用Axi总线。SpinalHDL通过Axi4接口类支持Axi协议,其中包含Axi4arAxi4rAxi4w等多个通道,分别对应Axi的读地址传输、读数据传输、写数据传输等。由于Axi的Burst传输逻辑比较复杂,所以可以用一些别人写好的类,如DmaUnit等(其实我觉得SpinalHDL官方应该提供一些开箱即用的模块,像这个DmaUnit里也存在一些逻辑上的问题,用的时候总觉得胆战心惊的)。另外,注意Axi端口是master,和Axilite端口是slave不一样。

仿真则有官方模块,即AxiMemorySim

val dramSim = AxiMemorySim(dut.io.axi, dut.clockDomain, new AxiMemorySimConfig)
dramSim.memory.loadBinary(0x0, "dram32.bin")
dramSim.start()
// ...
dramSim.memory.saveBinary(0x0, 4096, "output.bin")

Axilite4Driver一样,没有官方文档,需要查阅源码才知道用法。

上板的时候和Axilite一样,需要在Vivado里打包IP功能里创建新的Axi映射,映射对应端口和信号,才能在Block Design里顺利连接。

关于Axi,需要注意的一点是SpinalHDL默认的是Axi4协议,而部分FPGA的PS核只支持最高Axi3协议,这个可以双击PS核查看。这两个协议差别不是很大,只有两点比较重要:一个是两者支持的最高Burst Size不一样,Axi3的比较小,最高为16,所以如果要在这样的FPGA上运行需要将硬件模块的Burst Size也设置成16;另一个是两者的Burst Cache含义不同,因此如果你不是非常熟悉Burst Cache的话,保险起见,直接将Axi读地址和写地址通道的cache端口置0或者直接在Axi的构造参数配置类Axi4Config里将useCache设置为0。我就被后者坑了很久,cache端口设置错了,结果读数据和写数据返回的resp全部为3(0为正常),我看了网上的资料还以为是no slave,查了半天连线和地址映射,一直没想到是缓存的问题。硬件博大精深,像我这种小白出了错只能盲试然后把希望交给运气😥,所以才希望将经验留给后来者,贯彻人人为我为我人人的雷锋精神😎。

总结

这次感觉SpinalHDL确实是蛮好用的,而且也证明了使用SpinalHDL设计硬件原型并在FPGA上跑通的可行性,至少在体系结构的科研方面确实是一个好用的工具。希望我的这篇文章能帮大家解决一些类似的问题,大家也来使用SpinalHDL吧!