凿子-(不太完全)数字逻辑开发的新方法


随着微电子学的发展,RTL设计变得越来越多。 即使使用generate,宏和系统verilog芯片,verilog代码的可重用性也带来很多不便。 然而,Chisel使将对象和函数式编程的全部功能应用于rtl开发成为可能,这是人们期待已久的一步,可以为ASIC和FPGA开发人员提供新鲜的空气。


本文将简要概述主要功能,并考虑一些用户使用情况,我们还将讨论该语言的缺点。 将来,如果您对该主题感兴趣,我们将在更详细的教程中继续该文章。


系统要求


  • 标量基础层
  • verilog和构建数字设计的基本原理。
  • 保持凿子文档方便

我将尝试通过简单的示例来了解凿子的基本知识,但是如果不清楚,可以在这里看看。


至于scala,该备忘单可能有助于快速潜水。


凿子也有类似的东西。


完整的文章代码(以scala sbt项目的形式)可以在此处找到


简单柜台


顾名思义,“在Scala嵌入式语言中构建硬件”凿子是建立在scala之上的硬件描述语言。


然后简要介绍一切工作原理:从凿子上的rtl描述构建硬件图,然后将其转换为firrtl语言的中间描述,然后从firrtl verilog生成内置的后端解释器。


让我们看看一个简单计数器的两种实现。


Verilog:


module SimpleCounter #( parameter WIDTH = 8 )( input clk, input reset, input wire enable, output wire [WIDTH-1:0] out ); reg [WIDTH-1:0] counter; assign out = counter; always @(posedge clk) if (reset) begin counter <= {(WIDTH){1'b0}}; end else if (enable) begin counter <= counter + 1; end endmodule 

凿子:


 class SimpleCounter(width: Int = 32) extends Module { val io = IO(new Bundle { val enable = Input(Bool()) val out = Output(UInt(width.W)) }) val counter = RegInit(0.U(width.W)) io.out <> counter when(io.enable) { counter := counter + 1.U } } 

关于凿子的一点:


  • Module -rtl模块描述的容器
  • Bundle是凿子中的数据结构,主要用于定义接口。
  • io用于确定端口的变量
  • Bool -数据类型,简单的一位信号
  • UInt(width: Width) -无符号整数,构造函数接受信号的位深作为输入。
  • RegInit[T <: Data](init: T)是寄存器构造函数;它在输入处采用复位值,并且具有相同的数据类型。
  • <> -通用信号连接运算符
  • when(cond: => Bool) { /*...*/ } -verilog中的if模拟

稍后我们将讨论哪个verilog产生凿子。 现在,只需比较这两种设计即可。 如您所见,凿子中没有提及clkreset信号。 事实是凿子默认情况下会将这些信号添加到模块中。 counter寄存器的复位值RegInit复位RegInit寄存器构造函数。 Chisel支持具有许多时钟信号的模块,但不久之后就支持了。


计数器有点复杂


让我们进一步讲,使任务复杂一点,例如-我们将使用输入参数以每个通道按位序列的形式制作一个多通道计数器。


让我们从凿子版本开始


 class MultiChannelCounter(width: Seq[Int] = Seq(32, 16, 8, 4)) extends Module { val io = IO(new Bundle { val enable = Input(Vec(width.length, Bool())) val out = Output(UInt(width.sum.W)) def getOut(i: Int): UInt = { val right = width.dropRight(width.length - i).sum this.out(right + width(i) - 1, right) } }) val counters: Seq[SimpleCounter] = width.map(x => Module(new SimpleCounter(x)) ) io.out <> util.Cat(counters.map(_.io.out)) width.indices.foreach { i => counters(i).io.enable <> io.enable(i) } } 

关于scala的一点:


  • width: Seq[Int] MultiChannelCounter类的构造函数的输入参数,类型为Seq[Int] -包含整数元素的序列。
  • Seq是scala中具有明确定义的元素序列的集合类型之一。
  • .map是每个人都熟悉的集合函数,由于对每个元素的相同操作,它能够将一个集合转换为另一个集合,在我们的情况下,整数值序列变成具有相应位深度的SimpleCounter序列。

关于凿子的一点:


  • Vec[T <: Data](gen: T, n: Int): Vec[T] -凿子数据类型,是数组的模拟。
  • Module[T <: BaseModule](bc: => T): T是可实例化模块的必需包装方法。
  • util.Cat[T <: Bits](r: Seq[T]): UInt串联函数,在Verilog中类似{1'b1, 2'b01, 4'h0}

注意端口:
Vec[Bool]大致来说,已经在Vec[Bool] *中部署为一个1位信号的数组,每个通道一个,可以制作UInt(width.length.W)
out扩展为我们所有通道的宽度之和。


可变counters是我们的计数器数组。 我们将每个计数器的enable信号连接到相应的输入端口,并使用内置的util.Cat函数将所有out信号组合为一个信号,然后将其转发到输出。


我们还注意到getOut(i: Int)函数-此函数计算并返回i通道的out信号中的位范围。 这对于使用此类计数器的进一步工作将非常有用。 在verilog中实现这样的操作将不起作用


* Vec不应与Vector混淆,第一个是凿子中的数据数组,第二个是scala中的集合。


为了方便起见,现在让我们尝试在verilog上编写该模块,甚至在systemVerilog上也是如此。


经过思考,我来到了这个选项(很可能它不是唯一的,最理想的选择,但是您总是可以在注释中建议您的实现)。


Verilog
 module MultiChannelCounter #( parameter TOTAL = 4, parameter integer WIDTH_SEQ [TOTAL] = {32, 16, 8, 4} )(clk, reset, enable, out); localparam OUT_WIDTH = get_sum(TOTAL, WIDTH_SEQ); input clk; input reset; input wire [TOTAL - 1 : 0] enable; output wire [OUT_WIDTH - 1 :0] out; genvar j; generate for(j = 0; j < TOTAL; j = j + 1) begin : counter_generation localparam OUT_INDEX = get_sum(j, WIDTH_SEQ); SimpleCounter #( WIDTH_SEQ[j] ) SimpleCounter_unit ( .clk(clk), .reset(reset), .enable(enable[j]), .out(out[OUT_INDEX + WIDTH_SEQ[j] - 1: OUT_INDEX]) ); end endgenerate function automatic integer get_sum; input integer array_width; input integer array [TOTAL]; integer counter = 0; integer i; begin for(i = 0; i < array_width; i = i + 1) counter = counter + array[i]; get_sum = counter; end endfunction endmodule 

它看起来已经令人印象深刻。 但是,如果可以,我们走得更远,并通过具有寄存器访问权限的流行的wishbone接口进行操作。


捆绑接口


Wishbone是类似于AMBA APB的小型总线,主要用于开放源IP内核。


Wiki上的更多详细信息: https : //ru.wikipedia.org/wiki/Wishbone


因为 凿子为我们提供了Bundle类型的数据容器,将总线包装在一个容器中是有意义的,以后可以在任何凿子项目中使用它。


 class wishboneMasterSignals( addrWidth: Int = 32, dataWidth: Int = 32, gotTag: Boolean = false) extends Bundle { val adr = Output(UInt(addrWidth.W)) val dat_master = Output(UInt(dataWidth.W)) val dat_slave = Input(UInt(dataWidth.W)) val stb = Output(Bool()) val we = Output(Bool()) val cyc = Output(Bool()) val sel = Output(UInt((dataWidth / 8).W)) val ack_master = Output(Bool()) val ack_slave = Input(Bool()) val tag_master: Option[UInt] = if(gotTag) Some(Output(Bool())) else None val tag_slave: Option[UInt] = if(gotTag) Some(Input(Bool())) else None def wbTransaction: Bool = cyc && stb def wbWrite: Bool = wbTransaction && we def wbRead: Bool = wbTransaction && !we override def cloneType: wishboneMasterSignals.this.type = new wishboneMasterSignals(addrWidth, dataWidth, gotTag).asInstanceOf[this.type] } 

关于scala的一点:


  • Option -scala中的可选数据包装器,可以是元素或NoneOption[UInt]Some(UInt(/*...*/))None ,在参数化信号时很有用。

似乎没什么异常。 只是向导对接口的描述,除了一些信号和方法:


tag_mastertag_slave是叉骨协议中的可选通用信号,如果gotTag参数为true ,我们将看到它们。


wbTransactionwbWritewbRead用于简化总线的工作。


cloneType所有参数化的[T <: Bundle]类的必需类型克隆方法


但是我们还需要一个从属接口,让我们看看如何实现它。


 class wishboneSlave( addrWidth: Int = 32, dataWidth: Int = 32, tagWidht: Int = 0) extends Bundle { val wb = Flipped(new wishboneMasterSignals(addrWidth , dataWidth, tagWidht)) override def cloneType: wishboneSlave.this.type = new wishboneSlave(addrWidth, dataWidth, tagWidht).asInstanceOf[this.type] } 

正如您可能从名称中猜到的那样, Flipped方法翻转了界面,现在我们的向导界面变成了从属界面,我们为向导添加了相同的类。


 class wishboneMaster( addrWidth: Int = 32, dataWidth: Int = 32, tagWidht: Int = 0) extends Bundle { val wb = new wishboneMasterSignals(addrWidth , dataWidth, tagWidht) override def cloneType: wishboneMaster.this.type = new wishboneMaster(addrWidth, dataWidth, tagWidht).asInstanceOf[this.type] } 

好了,就是这样,界面已经准备好了。 但是在编写处理程序之前,让我们先看看如何使用这些接口,以防我们需要进行切换或带有大量叉骨接口的东西。


 class WishboneCrossbarIo(n: Int, addrWidth: Int, dataWidth: Int) extends Bundle { val slaves = Vec(n, new wishboneSlave(addrWidth, dataWidth, 0)) val master = new wishboneMaster(addrWidth, dataWidth, 0) } class WBCrossBar extends Module { val io = IO(new WishboneCrossbarIo(1, 32, 32)) io.master <> io.slaves(0) // ... } 

这是交换机的一个小空白。 声明类型为Vec[wishboneSlave]的接口很方便,您可以使用相同的<>运算符连接这些接口。 在管理大量信号时有用的凿子芯片。


通用总线控制器


如前所述,关于功能和对象编程的功能,我们将尝试应用它。 进一步,我们将以trait的形式讨论通用叉骨总线控制器的实现,这对于带有wishboneSlave总线的任何模块都是某种mixin,对于该模块,您只需要定义一个存储卡并在生成过程中将trait控制器与其混合即可。


实作


对于那些仍然热情的人

让我们继续处理程序的实现。 这很简单,可以立即对单个事务作出响应,以防从地址池中掉出,返回零。


让我们分部分进行分析:


  • 每笔交易都需要确认


     val io : wishboneSlave = /* ... */ val wb_ack = RegInit(false.B) when(io.wb.wbTransaction) { wb_ack := true.B }.otherwise { wb_ack := false.B } wb_ack <> io.wb.ack_slave 

  • 我们以数据回应阅读
     val wb_dat = RegInit(0.U(io.wb.dat_slave.getWidth.W)) // getWidth   when(io.wb.wbRead) { wb_dat := MuxCase(default = 0.U, Seq( (io.wb.addr === ADDR_1) -> data_1, (io.wb.addr === ADDR_3) -> data_2, (io.wb.addr === ADDR_3) -> data_2 )) } wb_dat <> io.wb.dat_slave 

    • MuxCase[T <: Data] (default: T, mapping: Seq[(Bool, T)]): T是Verilog *中case类型的内置协调方案。

在verilog中会是什么样子:


  always @(posedge clock) if(reset) wb_dat_o <= 0; else if(wb_read) case (wb_adr_i) `ADDR_1 : wb_dat_o <= data_1; `ADDR_2 : wb_dat_o <= data_2; `ADDR_3 : wb_dat_o <= data_3; default : wb_dat_o <= 0; endcase } 

*通常,在这种情况下,为了进行参数化,这是一个小技巧,凿子具有标准的设计,如果编写更简单的内容,则更好。


 switch(x) { is(value1) { // ... } is(value2) { // ... } } 

好吧,记录


  when(io.wb.wbWrite) { data_4 := Mux(io.wb.addr === ADDR_4, io.wb.dat_master, data_4) } 

  • Mux[T <: Data](cond: Bool, con: T, alt: T): T常规多路复用器

我们嵌入类似于多通道计数器的内容,挂起用于通道管理的寄存器和帽子。 但是在这里,它与通用WB总线控制器近在咫尺,我们将向其传输这种存储卡:


  val readMemMap = Map( ADDR_1 -> DATA_1, ADDR_2 -> DATA_2 /*...*/ ) val writeMemMap = Map( ADDR_1 -> DATA_1, ADDR_2 -> DATA_2 /*...*/ ) 

对于这样的任务, trait会帮助我们-像Sala中的mixins。 主要任务是使readMemMap: [Int, Data]看起来像Seq( -> ) ,如果可以在存储卡中传输基址和数据数组,那也很好。


  val readMemMap = Map( ADDR_1_BASE -> DATA_SEQ, ADDR_2 -> DATA_2 /*...*/ ) 

将扩展为类似的内容,其中WB_DAT_WIDTH是数据的宽度(以字节为单位)


  val readMemMap = Map( ADDR_1_BASE + 0 * (WB_DAT_WIDHT)-> DATA_SEQ_0, ADDR_1_BASE + 1 * (WB_DAT_WIDHT)-> DATA_SEQ_1, ADDR_1_BASE + 2 * (WB_DAT_WIDHT)-> DATA_SEQ_2, ADDR_1_BASE + 3 * (WB_DAT_WIDHT)-> DATA_SEQ_3 /*...*/ ADDR_2 -> DATA_2 /*...*/ ) 

为了实现这一点,我们编写了一个从Map[Int, Any]Seq[(Bool, UInt)]的转换器函数。 您必须使用Scala模式算术。


  def parseMemMap(memMap: Map[Int, Any]): Seq[(Bool, UInt)] = memMap.flatMap { case(addr, data) => data match { case a: UInt => Seq((io.wb.adr === addr.U) -> a) case a: Seq[UInt] => a.map(x => (io.wb.adr === (addr + io.wb.dat_slave.getWidth / 8).U) -> x) case _ => throw new Exception("WRONG MEM MAP!!!") } }.toSeq 

最后,我们的特征将如下所示:


 trait wishboneSlaveDriver { val io : wishboneSlave val readMemMap: Map[Int, Any] val writeMemMap: Map[Int, Any] val parsedReadMap: Seq[(Bool, UInt)] = parseMemMap(readMemMap) val parsedWriteMap: Seq[(Bool, UInt)] = parseMemMap(writeMemMap) val wb_ack = RegInit(false.B) val wb_dat = RegInit(0.U(io.wb.dat_slave.getWidth.W)) when(io.wb.wbTransaction) { wb_ack := true.B }.otherwise { wb_ack := false.B } when(io.wb.wbRead) { wb_dat := MuxCase(default = 0.U, parsedReadMap) } when(io.wb.wbWrite) { parsedWriteMap.foreach { case(addrMatched, data) => data := Mux(addrMatched, io.wb.dat_master, data) } } wb_dat <> io.wb.dat_slave wb_ack <> io.wb.ack_slave def parseMemMap(memMap: Map[Int, Any]): Seq[(Bool, UInt)] = { /*...*/} } 

关于scala的一点:


  • io , readMemMap, writeMemMaptrait 'a的抽象字段,必须在将其混合到的类中定义。

使用方法


为了将我们的trait与模块混合,必须满足几个条件:


  • io应该继承wishboneSlave
  • 需要声明两个存储卡readMemMapwriteMemMap

 class WishboneMultiChannelCounter extends Module { val BASE = 0x11A00000 val OUT = 0x00000100 val S_EN = 0x00000200 val H_EN = 0x00000300 val wbAddrWidth = 32 val wbDataWidth = 32 val wbTagWidth = 0 val width = Seq(32, 16, 8, 4) val io = IO(new wishboneSlave(wbAddrWidth, wbDataWidth, wbTagWidth) { val hardwareEnable: Vec[Bool] = Input(Vec(width.length, Bool())) }) val counter = Module(new MultiChannelCounter(width)) val softwareEnable = RegInit(0.U(width.length.W)) width.indices.foreach(i => counter.io.enable(i) := io.hardwareEnable(i) && softwareEnable(i)) val readMemMap = Map( BASE + OUT -> width.indices.map(counter.io.getOut), BASE + S_EN -> softwareEnable, BASE + H_EN -> io.hardwareEnable.asUInt ) val writeMemMap = Map( BASE + S_EN -> softwareEnable ) } 

我们创建了softwareEnable寄存器,并通过hardwareEnable输入信号将其添加到“和”中,并启用counter[MultiChannelCounter]


我们声明了两个用于读取和写入的存储卡: readMemMap writeMemMap ,有关结构的更多详细信息,请参见上一章。
在读取存储卡中,我们传输每个通道的计数器值*, softwareEnablehardwareEnable 。 为了记录,我们仅提供softwareEnable寄存器。


* width.indices.map(counter.io.getOut) -一个奇怪的设计,我们将对其进行分部分分析。


  • width.indices将返回带有元素索引的数组,即 如果width.length == 4width.indices = {0, 1, 2, 3}
  • {0, 1, 2, 3}.map(counter.io.getOut) -给出如下内容:
    { counter.io.getOut(0), counter.io.getOut(1), /*...*/ }

现在,对于凿子上的任何模块,我们都可以声明用于读取和写入的存储卡,并在生成时简单地连接通用叉骨总线控制器,如下所示:


 class wishbone_multicahnnel_counter extends WishboneMultiChannelCounter with wishboneSlaveDriver object countersDriver extends App { Driver.execute(Array("-td", "./src/generated"), () => new wishbone_multicahnnel_counter ) } 

wishboneSlaveDriver这正是我们在扰流器下描述的特征混合。


当然,此版本的通用控制器还远远不是最终版本,而是相反。 其主要目标是演示在凿子上开发rtl的可能方法之一。 凭借scala的所有功能,此类方法可能更大,因此每个开发人员都有自己的创造力领域。 是的,除了以下几点,没有什么地方可以特别受到启发的:


  • 本地凿工具库,关于它的进一步介绍,您可以在其中查看模块和接口的继承
  • https://github.com/freechipsproject/rocket-chip-risc-v整个内核都是在凿子上实现的,前提是您非常了解scala,对于没有半升的初学者,正如他们所说,您将需要很长时间来理解。 没有有关该项目内部结构的正式文件。

多时钟域


如果我们要手动控制时钟并用凿子复位信号该怎么办。 直到最近,还不能做到这一点,但是在最新版本之一中,出现了对withClock {}withReset {}withClockAndReset {} 。 让我们看一个例子:


 class DoubleClockModule extends Module { val io = IO(new Bundle { val clockB = Input(Clock()) val in = Input(Bool()) val out = Output(Bool()) val outB = Output(Bool()) }) val regClock = RegNext(io.in, false.B) regClock <> io.out val regClockB = withClock(io.clockB) { RegNext(io.in, false.B) } regClockB <> io.outB } 

  • regClock将由标准clock信号计时并由标准复位复位的寄存器
  • regClockB您通过io.clockB信号猜出了同一寄存器的时钟,但是将使用标准复位。

如果我们要完全删除标准clockreset信号,则可以使用实验功能RawModule (没有标准时钟和重置信号的模块,每个人都必须手动控制)。 一个例子:


 class MultiClockModule extends RawModule { val io = IO(new Bundle { val clockA = Input(Clock()) val clockB = Input(Clock()) val resetA = Input(Bool()) val resetB = Input(Bool()) val in = Input(Bool()) val outA = Output(Bool()) val outB = Output(Bool()) }) val regClockA = withClockAndReset(io.clockA, io.resetA) { RegNext(io.in, false.B) } regClockA <> io.outA val regClockB = withClockAndReset (io.clockB, io.resetB) { RegNext(io.in, false.B) } regClockB <> io.outB } 

实用程序库


凿子带来的令人愉悦的收获并不止于此。 它的创建者努力工作,并编写了一个小型但非常有用的库,其中包含小型接口,模块和函数。 奇怪的是,Wiki上没有库的描述,但是您可以在开始时看到备忘单链接(最后两个部分)


接口:


  • DecoupledIO是常用的就绪/有效接口。
    DecoupledIO(UInt(32.W)) -将包含以下信号:
    val ready = Input(Bool())
    val valid = Output(Bool())
    val data = Output(UInt(32.W))
  • ValidIO仅在未readyValidIO相同

模块:


  • Queue -同步FIFO模块非常有用,界面看起来像
    val enq: DecoupledIO[T] -反向的DecoupledIO
    val deq: DecoupledIO[T] -常规的val deq: DecoupledIO[T]
    val count: UInt队列中的数据量
  • Pipe -延迟模块,插入第n个寄存器片
  • ArbiterDecoupledIO接口上的仲裁器,具有许多亚种,其仲裁类型不同
    val in: Vec[DecoupledIO[T]] -输入接口的数组
    val out: DecoupledIO[T]
    val chosen: UInt selected val chosen: UInt显示选定的频道

从对github的讨论中可以了解到的-在全局计划中,该模块库有大量扩展:例如异步FIFO,LSSFR,分频器,FPGA的PLL模板; 各种接口; 他们的控制器等等。


凿子钳工


应该提到在凿子中进行测试的可能性,目前有两种测试方法:


  • peekPokeTesters纯粹的模拟测试,可以测试设计的逻辑
  • hardwareIOTeseters已经变得更加有趣,因为 通过这种方法,您将获得一个生成的teset工作台,其中包含您在凿子上编写的测试,即使您拥有Verilator,您甚至还会得到一个时间表。


    但是到目前为止,测试方法尚未最终确定,讨论仍在进行中。 将来,最有可能出现通用工具,用于测试和测试,也有可能在凿子上书写。 但是现在,您可以看看这里已经有什么以及如何使用它



凿子的缺点


这并不是说凿子是一种通用工具,每个人都应该使用它。 他可能与开发阶段的所有项目一样,都有其缺点,为完整起见,值得一提。


第一个也许也是最重要的缺点是缺少异步刷新。 足够重,但是可以通过多种方式解决,其中之一是基于verilog的脚本,这些脚本将同步重置变为异步。 这很容易做到,因为 生成的verilog中的所有构造always非常统一。


许多人认为,第二个缺点是生成的Verilog无法读取,因此,调试很复杂。 但是,让我们用一个简单的计数器来看一下示例中生成的代码


产生的verilog
 `ifdef RANDOMIZE_GARBAGE_ASSIGN `define RANDOMIZE `endif `ifdef RANDOMIZE_INVALID_ASSIGN `define RANDOMIZE `endif `ifdef RANDOMIZE_REG_INIT `define RANDOMIZE `endif `ifdef RANDOMIZE_MEM_INIT `define RANDOMIZE `endif module SimpleCounter( input clock, input reset, input io_enable, output [7:0] io_out ); reg [7:0] counter; reg [31:0] _RAND_0; wire [8:0] _T_7; wire [7:0] _T_8; wire [7:0] _GEN_0; assign _T_7 = counter + 8'h1; assign _T_8 = _T_7[7:0]; assign _GEN_0 = io_enable ? _T_8 : counter; assign io_out = counter; `ifdef RANDOMIZE integer initvar; initial begin `ifndef verilator #0.002 begin end `endif `ifdef RANDOMIZE_REG_INIT _RAND_0 = {1{$random}}; counter = _RAND_0[7:0]; `endif // RANDOMIZE_REG_INIT end `endif // RANDOMIZE always @(posedge clock) begin if (reset) begin counter <= 8'h0; end else begin if (io_enable) begin counter <= _T_8; end end end endmodule 

乍一看,即使在中等大小的设计中,生成的Verilog也可以推开,但让我们看一下。


  • RANDOMIZE定义-(在使用凿子测试仪进行测试时可能会派上用场)-通常无用,但它们不会特别干扰
  • 正如我们看到的端口名称一样,寄存器被保留
  • _GEN_0对我们来说是一个无用的变量,但对于解释器来说是必需的,以生成verilog。 我们也没有注意它。
  • 剩下_T_7和_T_8,生成的Verilog中的所有组合逻辑将以变量_T的形式逐步呈现。

最重要的是,调试所需的所有端口,寄存器和接线都不要使用凿子。 而且,如果您不仅查看verilog,还查看凿子,那么很快调试过程将变得与使用纯verilog一样容易。


结论


在现代现实中,RTL的开发(无论是在学术环境之外的asic还是fpga)已经从仅使用纯手写Verilog代码发展为一种或另一种生成脚本,无论是小型tcl脚本还是具有许多功能的整个IDE。


反过来,Chisel是用于数字逻辑开发和测试的语言的逻辑开发。 假设他在这个阶段还远远不够完美,但是已经能够提供机会弥补他的缺点。 , .

Source: https://habr.com/ru/post/zh-CN419413/


All Articles