背景

最近突发奇想,想试一下用 SpinalHDL 做一遍计原实验的感觉,趁机认真学习一下 SpinalHDL。

很多项目是使用 Spinal 开发部分模块,然后在 Verilog 里面例化,和顶层的信号连接起来。但我感觉这样很不优雅,于是想完全用 Spinal 实现 thinpad_top 工程,然后直接使用 Vivado 综合。

顶层信号和接口定义

模块接口

为了尽量和 thinpad_top 原版工程保持一致,我希望顶层信号名字和 Verilog 完全一样,这样可以复用 xdc 的约束。由于 thinpad 有很多外设模块,先使用 Spinal 定义它们的接口,下面是 SRAM 接口的例子:

case class SramPort() extends Bundle {
  val addr = out UInt (20 bits)
  val data = inout Bits (32 bits)
  val be_n = out Bits (4 bits)

  val ce_n = out Bool ()
  val we_n = out Bool ()
  val oe_n = out Bool ()
}

可以注意到,这里使用的命名是 snake_case,而不是 Scala 习惯的 CamelCase,这样 Spinal 添加前缀后,刚好和 Verilog 一样,比如 base_ram_ce_n

配置类

由于很多外设实验的时候用不到,如果加上这些端口,Spinal 会报 NO DRIVER 错误。这里定义一个 ThinpadTopConfig 的类,用于选择性开启一部分外设端口:

case class ThinpadTopConfig(
    CpldUartEnable: Boolean = false,
    BaseRamEnable: Boolean = true,
    ExtRamEnable: Boolean = true,
    Uart0Enable: Boolean = true,
    FlashEnable: Boolean = false,
    Sl811Enable: Boolean = false,
    Dm9kEnable: Boolean = false,
    VideoEnable: Boolean = false
)

顶层接口

最后定义一个总的 ThinpadPorts 类,根据传进来的 config 选择性添加端口就可以了。这里的 clk_50Mreset_btn 暂时先注释掉,后面会提到为什么。

case class ThinpadPorts(config: ThinpadTopConfig) extends Bundle {
  // val clk_50M = in Bool()
  val clk_11M0592 = in Bool ()
  val push_btn = in Bool ()
  // val reset_btn = in Bool()

  val touch_btn = in Bits (4 bits)
  val dip_sw = in Bits (32 bits)
  val leds = out Bits (16 bits)
  val dpy0 = out Bits (8 bits)
  val dpy1 = out Bits (8 bits)

  val uart = if (config.CpldUartEnable) CpldUartPort() else null
  val base_ram = if (config.BaseRamEnable) SramPort() else null
  val ext_ram = if (config.ExtRamEnable) SramPort() else null
  val uart0 = if (config.Uart0Enable) UartPort() else null
  val flash = if (config.FlashEnable) FlashPort() else null
  val sl811 = if (config.Sl811Enable) SL811UsbPort() else null
  val dm9k = if (config.Dm9kEnable) DM9000EthPort() else null
  val video = if (config.VideoEnable) VGAPort() else null
}

顶层 Component

最后,顶层元件里面只需要定义一个 ThinpadPorts 类的 io 就可以了,同时使用 noIoPrefix() 方法,去掉端口名字前面的 io_ 前缀:

case class ThinpadTop() extends Component {
  val config = ThinpadTopConfig()
  val io = new ThinpadPorts(config)
  noIoPrefix()
 
  ...
}

信号名字的小坑

thinpad_top 里面,直连串口的信号名字是 txdrxd,没有前缀。所以这里在定义 UartPort 的时候,手动指定了信号的名字。虽然不是很优雅,但至少是可以工作的:

case class UartPort() extends Bundle {
  val txd = out Bool ()
  val rxd = in Bool ()
  txd.setName("txd")
  rxd.setName("rxd")
}

PLL Blackbox 和时钟域处理

由于 SpinalHDL 没法直接实现 Xilinx FPGA 的 PLL(当然用原语也可以拼出来,但没啥意义),此处使用 BlackBox 功能,引用 Vivado 生成的 IP。

BlackBox 定义

class PLLBlackBox extends BlackBox {
  val io = new Bundle {
    val clk_in1 = in Bool()
    val clk_out1 = out Bool()
    ...
  }

  noIoPrefix()
  setBlackBoxName("pll_example")    // 设置与 Vivado 生成的 IP 名字一致
}

到这里,就可以让 Spinal 在例化 PLLBlackBox 这个模块的时候,生成和 Vivado IP 一样的模块和端口名字了。

仿真时的 PLL BlackBox 处理

还有一个小坑,对于一个空的 BlackBox,如果使用 Spinal + Verilator 去仿真,会报错:

%Error: .../ThinpadTop.v:43:3: Cannot find file containing module: 'pll_example'
%Error: .../ThinpadTop.v:43:3: This may be because there's no search path specified with -I<dir>.
   43 |   pll_example clkCtrl_pll (
      |   ^~~~~~~~~~~

因为此时 Spinal 不会生成一个 Verilog 的 pll_example 模块,那么 Verilator 自然找不到。由于我们也没有这个模块的仿真模型,所以给 PLL BlackBox 加上一些假的逻辑,来模拟一个 “PLL”:

  io.clk_out1 := io.clk_in1
  io.clk_out2 := io.clk_in1
  io.locked := ~io.reset
  spinalSimWhiteBox()

注意里面的 spinalSimWhiteBox() 语句,这个在 Spinal 文档里面并没有介绍,只有 一部分 library 中用到了。增加这个语句后,Spinal 在仿真时也会生成这个模块,这样 Verilator 就不会报错了。

时钟域处理

在这个设计里面,有两个时钟域,一个是输入的 50M 时钟作为默认,另一个是 PLL 产生的(例如 10M)时钟。后者的处理方法比较简单,在文档中也有类似的样例,这里直接贴上代码:

  val clkCtrl = new Area {
    // PLL blackbox
    val pll = new PLLBlackBox
    pll.io.clk_in1 := ClockDomain.current.readClockWire
    pll.io.reset := ClockDomain.current.readResetWire   // 把用户的复位信号直接连到 PLL 的复位端口

    // Clock domains
    val sysClkDomain = ClockDomain.internal(
      name = "sys",
      frequency = FixedFrequency(10 MHz)
    )
    sysClkDomain.clock := pll.io.clk_out1
    sysClkDomain.reset := ResetCtrl.asyncAssertSyncDeassert(    // 异步复位,同步释放
      input = pll.io.locked,    // 使用 PLL 锁定信号作为复位
      clockDomain = sysClkDomain,
      inputPolarity = LOW,      // 注意极性
      outputPolarity = HIGH,
    )
  }

然而,对于前者,由于我们不能替换掉 Spinal 自带的 ClockDomain.current,但还希望控制时钟和复位信号的名字,所以使用 ClockDomain.current.renamePulledWires 方法,指定信号名:

  ClockDomain.current.renamePulledWires(clock = "clk_50M", reset = "reset_btn")

后面的设计中,都是使用 sysClkDomain 这个时钟域,需要写一个 ClockingArea

  val sys = new ClockingArea(clkCtrl.sysClkDomain) {
    // Logic here
  }

结论

经过上面一番折腾后,直接使用 SpinalVerilog 就可以生成 Verilog 代码了,生成的 Verilog 可以直接放进 Vivado 作为顶层模块进行综合,还是比较方便的。

SpinalHDL 在写硬件描述的时候很爽,但是在与其他工具互操作的时候,还是有一些小坑,不过都可以找到解决的办法。