Chisel教程——09.Scala和Chisel中的Collections(用集合类实现RISC-V的寄存器文件)

Scala和Chisel中的Collections(用集合类实现RISC-V的寄存器文件)

动机

生成器经常会处理数量可变的对象,不管是IO接口、模块还是测试向量。集合(Collections)是处理这种情况的很重要的构建块。这一小节将会介绍Scala中的集合,并介绍如何用他们实现Chisel生成器。最后,将会利用本节介绍的内容实现RISC-V上的寄存器文件(Register File)。

首先需要注意的是,我们需要添加一个新的引用,因为mutable.ArrayBufferscala.collection里面。

import scala.collection._

生成器和集合

这一小节,将着重讲解生成器的概念,并使用Scala集合作为工具来实现生成器。我们不应将Chisel代码看作一个电路实例,即特定电路的描述,而是将其看作一个电路的生成器。

首先考虑前面讲过的FIR滤波器:

class My4ElementFir(b0: Int, b1: Int, b2: Int, b3: Int) extends Module {
  val io = IO(new Bundle {
    val in = Input(UInt(8.W))
    val out = Output(UInt(8.W))
  })

  val x_n1 = RegNext(io.in, 0.U)
  val x_n2 = RegNext(x_n1, 0.U)
  val x_n3 = RegNext(x_n2, 0.U)
  io.out := io.in * b0.U(8.W) + x_n1 * b1.U(8.W) +
    x_n2 * b2.U(8.W) + x_n3 * b3.U(8.W)
}

这个电路是一种简单的生成器,因为可以生成不同系数的四拍FIR滤波器电路。但是如果想要电路有更多的拍数呢?我们需要通过以下步骤来解决:

  1. 构建一个可配置拍数的FIR的Scala软件模型;
  2. 重新设计这个模型的测试,来确认模型有效;
  3. 重构我们的My4ElementFir以允许可配置拍数;
  4. 用新的测试套件来测试新电路;

用Scala构建可配置的FIR软件模型

下面是一个Scala编写的FIR电路的软件实现:

/**
  * A naive implementation of an FIR filter with an arbitrary number of taps.
  */
class ScalaFirFilter(taps: Seq[Int]) {
  var pseudoRegisters = List.fill(taps.length)(0)

  def poke(value: Int): Int = {
    pseudoRegisters = value :: pseudoRegisters.take(taps.length - 1)
    var accumulator = 0
    for(i <- taps.indices) {
      accumulator += taps(i) * pseudoRegisters(i)
    }
    accumulator
  }
}

从以下几个步骤来看:

  1. Seq:注意taps的类型变成了Seq[Int],这意味着创建这个类的实例时可以传递任意长度的Int序列;
  2. Registers:利用var pseudoRegisters = List.fill(taps.length)(0),我们创建了一个List来存放前面周期的值。选择List是因为加一个元素到头部和从尾部移出一个元素很容易。Scala集合类族的几乎任何成员都可能会被用到。这里我们还讲列表全都初始化为0了;
  3. poke:这个类还添加了一个poke函数,用来模拟将一个输入放入滤波器并循环一个时钟周期的过程;
  4. 寄存器更新:pseudoRegisters = value :: pseudoRegisters.take(taps.length - 1)这一行使用了take方法将列表的最后一个元素取出来,然后使用::列表连接运算符将value加到缩短后的列表的头部;
  5. 计算输出:最后就是一个简单的循环来计算每个元素和对应拍的系数的乘积的累加和。最后一行accumulator返回计算结果;

将测试适配到新的软件FIR模型上

现在需要验证我们的模型是正确的,之前的测试代码只需要改一点点就行了:

val filter = new ScalaFirFilter(Seq(1, 1, 1, 1))

var out = 0

out = filter.poke(1)
println(s"out = $out")
assert(out == 1)  // 1, 0, 0, 0

out = filter.poke(4)
assert(out == 5)  // 4, 1, 0, 0
println(s"out = $out")

out = filter.poke(3)
assert(out == 8)  // 3, 4, 1, 0
println(s"out = $out")

out = filter.poke(2)
assert(out == 10)  // 2, 3, 4, 1
println(s"out = $out")

out = filter.poke(7)
assert(out == 16)  // 7, 2, 3, 4
println(s"out = $out")

out = filter.poke(0)
assert(out == 12)  // 0, 7, 2, 3
println(s"out = $out")

这里的软件FIR模型和之前的My4ElementFir运行效果一致,测试通过。

用软件FIR模型测试FIR电路

现在我们有足够的理由相信我们的软件模型了,我们可以重写测试代码,比较FIR电路的输出和软件模型输出是否一致。显而易见的好处就是,不需要再手动写测试案例了,很快啊!

val goldenModel = new ScalaFirFilter(Seq(1, 1, 1, 1))

test(new My4ElementFir(1, 1, 1, 1)) { c =>
  for(i <- 0 until 100) {
    val input = scala.util.Random.nextInt(8)
    val goldenModelResult = goldenModel.poke(input)
    c.io.in.poke(input.U)
    c.io.out.expect(goldenModelResult.U, s"i $i, input $input, gm $goldenModelResult, ${c.io.out.peek().litValue}")
    c.clock.step(1)
  }
}

测试通过,这里共运行了100个周期,检查了用两种方法实现的模型在每一个周期的行为都是同步的。

要注意的事

  1. 要在正确的位置驱动step,软件和硬件的执行是不一样的,很容易就会出错;
  2. 这个测试很弱,因为对IO端口和寄存器的尺寸是很敏感的。实现一个在任意数据位宽上观察封装行为的软件黄金模型是很复杂的。这里我们只能保证在这个值上通过测试了。

参数化的FIR生成器

下面我们创建了一个新的滤波器类,MyManyElementFir接收一个常数Seq用于每拍的系数。这个序列可以有任意数量的元素。此外,还添加了一个bitWidth来控制电路可以处理的数据宽度。为了应对可变长度,我们不得不重构寄存器和创建和连接。代码中所有的方法是用集合函数可用的库的一个简单的子集。后面的部分还会展示更简洁的方法来表达这种行为,以更清晰看出干了些啥。

class MyManyElementFir(consts: Seq[Int], bitWidth: Int) extends Module {
  val io = IO(new Bundle {
    val in = Input(UInt(bitWidth.W))
    val out = Output(UInt(bitWidth.W))
  })

  val regs = mutable.ArrayBuffer[UInt]()
  for(i <- 0 until consts.length) {
      if(i == 0) regs += io.in
      else       regs += RegNext(regs(i - 1), 0.U)
  }
  
  val muls = mutable.ArrayBuffer[UInt]()
  for(i <- 0 until consts.length) {
      muls += regs(i) * consts(i).U
  }

  val scan = mutable.ArrayBuffer[UInt]()
  for(i <- 0 until consts.length) {
      if(i == 0) scan += muls(i)
      else scan += muls(i) + scan(i - 1)
  }

  io.out := scan.last
}

下面讲一下这段代码做了哪些事情:

首先,代码里有三个并行的代码段,分别从第7、13和18行开始。这里使用了一个Scala集合类型ArrayBufferArrayBuffer允许使用+=操作符附加元素到末尾。我们开始创建了一个regs,这个ArrayBuffer的元素是UInt类型。然后在tap上迭代,添加输入作为第一个元素,后面使用RegNext来把后续元素的输入连接到上一个元素(regs(i-1))并初始化为0(0.U)。这些寄存器会存放输入的先前的值,因为需要他们进行计算。

第二部分,我们创建了muls,这是另一个ArrayBuffer,也是个UInt的队列。每个元素都会称为一个结点,第i个元素的值等于regs(i)const(i)的乘积。

第三部分是scan,这一部分累加muls的元素,最后使用scan.last方法获取scan的最后一个元素。这种方法在regs构造过程中比使用regs(i-1)更加优雅一些。

测试参数化的FIR生成器

现在看看新的FIR生成器好不好用,我们用它创建一个类似My4ElementFir的实例,然后测试更多的数据:

val goldenModel = new ScalaFirFilter(Seq(1, 1, 1, 1))

test(new MyManyElementFir(Seq(1, 1, 1, 1), 8)) { c =>
  for(i <- 0 until 100) {
    val input = scala.util.Random.nextInt(8)
    val goldenModelResult = goldenModel.poke(input)
    c.io.in.poke(input.U)
    c.io.out.expect(goldenModelResult.U, s"i $i, input $input, gm $goldenModelResult, ${c.io.out.peek().litValue}")
    c.clock.step(1)
  }
}

测试通过。

更多不同尺寸FIR滤波器的测试

首先创建一个方法r,用来获取一个随机数;然后是方法runOneTest,会为指定taps的滤波器创建一个软件模型和一个硬件模拟;然后至少运行拍数两倍以上的数据经过滤波器:

/** a convenience method to get a random integer
*/
def r(): Int = {
  scala.util.Random.nextInt(1024)
}

/**
  * run a test comparing software and hardware filters
  * run for at least twice as many samples as taps
  */
def runOneTest(taps: Seq[Int]) {
  val goldenModel = new ScalaFirFilter(taps)

  test(new MyManyElementFir(taps, 32)) { c =>
    for(i <- 0 until 2 * taps.length) {
      val input = r()
      val goldenModelResult = goldenModel.poke(input)
      c.io.in.poke(input.U)
      c.io.out.expect(goldenModelResult.U, s"i $i, input $input, gm $goldenModelResult, ${c.io.out.peek().litValue}")
      c.clock.step(1)
    }
  }
}

for(tapSize <- 2 until 100 by 10) {
  val taps = Seq.fill(tapSize)(r())  // create a sequence of random coefficients
  runOneTest(taps)
}

测试通过。

500个taps的滤波器(就是玩儿)

下面这行代码会在一个500拍的FIR滤波器上运行测试,可能需要等很久运行才能结束:

runOneTest(Seq.fill(500)(r()))

硬件集合

运行时可配置taps的FIR滤波器

下面的代码添加了一个额外的consts向量到我们FIR生成器的IO接口,允许在电路生成后从外部更改系数。这是通过Chisel中的集合类型Vec完成的。Vec支持很多Scala集合的方法,但是只能包含Chisel的硬件元素。Vec应该只被用于Scala集合不好使的情况,一般有以下两种情形:

  1. 需要在Bundle中创建集合,典型的是要被用作IO接口的Bundle;
  2. 需要通过索引访问作为硬件的一部分的集合(比如寄存器文件);

我们可以利用Vec构建一个运行时可配置taps的FIR滤波器:

class MyManyDynamicElementVecFir(length: Int) extends Module {
  val io = IO(new Bundle {
    val in = Input(UInt(8.W))
    val out = Output(UInt(8.W))
    val consts = Input(Vec(length, UInt(8.W)))
  })

  // Reference solution
  val regs = RegInit(VecInit(Seq.fill(length - 1)(0.U(8.W))))
  for(i <- 0 until length - 1) {
      if(i == 0) regs(i) := io.in
      else       regs(i) := regs(i - 1)
  }
  
  val muls = Wire(Vec(length, UInt(8.W)))
  for(i <- 0 until length) {
      if(i == 0) muls(i) := io.in * io.consts(i)
      else       muls(i) := regs(i - 1) * io.consts(i)
  }

  val scan = Wire(Vec(length, UInt(8.W)))
  for(i <- 0 until length) {
      if(i == 0) scan(i) := muls(i)
      else scan(i) := muls(i) + scan(i - 1)
  }

  io.out := scan(length - 1)
}

测试代码如下:

val goldenModel = new ScalaFirFilter(Seq(1, 1, 1, 1))

test(new MyManyDynamicElementVecFir(4)) { c =>
  c.io.consts(0).poke(1.U)
  c.io.consts(1).poke(1.U)
  c.io.consts(2).poke(1.U)
  c.io.consts(3).poke(1.U)
  for(i <- 0 until 100) {
    val input = scala.util.Random.nextInt(8)
    val goldenModelResult = goldenModel.poke(input)
    c.io.in.poke(input.U)
    c.io.out.expect(goldenModelResult.U, s"i $i, input $input, gm $goldenModelResult, ${c.io.out.peek().litValue}")
    c.clock.step(1)
  }
}

print(getVerilogString(new MyManyDynamicElementVecFir(4)))

测试通过,生成的Verilog代码如下:

module MyManyDynamicElementVecFir(
  input        clock,
  input        reset,
  input  [7:0] io_in,
  output [7:0] io_out,
  input  [7:0] io_consts_0,
  input  [7:0] io_consts_1,
  input  [7:0] io_consts_2,
  input  [7:0] io_consts_3
);
    
  reg [7:0] regs_0; // @[MyModule.scala 71:23]
  reg [7:0] regs_1; // @[MyModule.scala 71:23]
  reg [7:0] regs_2; // @[MyModule.scala 71:23]
  wire [15:0] _muls_0_T = io_in * io_consts_0; // @[MyModule.scala 79:37]
  wire [15:0] _muls_1_T = regs_0 * io_consts_1; // @[MyModule.scala 80:43]
  wire [15:0] _muls_2_T = regs_1 * io_consts_2; // @[MyModule.scala 80:43]
  wire [15:0] _muls_3_T = regs_2 * io_consts_3; // @[MyModule.scala 80:43]
  wire [7:0] muls_1 = _muls_1_T[7:0]; // @[MyModule.scala 77:20 80:28]
  wire [7:0] muls_0 = _muls_0_T[7:0]; // @[MyModule.scala 77:20 79:28]
  wire [7:0] scan_1 = muls_1 + muls_0; // @[MyModule.scala 86:33]
  wire [7:0] muls_2 = _muls_2_T[7:0]; // @[MyModule.scala 77:20 80:28]
  wire [7:0] scan_2 = muls_2 + scan_1; // @[MyModule.scala 86:33]
  wire [7:0] muls_3 = _muls_3_T[7:0]; // @[MyModule.scala 77:20 80:28]
  assign io_out = muls_3 + scan_2; // @[MyModule.scala 86:33]
  always @(posedge clock) begin
    if (reset) begin // @[MyModule.scala 71:23]
      regs_0 <= 8'h0; // @[MyModule.scala 71:23]
    end else begin
      regs_0 <= io_in; // @[MyModule.scala 73:28]
    end
    if (reset) begin // @[MyModule.scala 71:23]
      regs_1 <= 8'h0; // @[MyModule.scala 71:23]
    end else begin
      regs_1 <= regs_0; // @[MyModule.scala 74:28]
    end
    if (reset) begin // @[MyModule.scala 71:23]
      regs_2 <= 8'h0; // @[MyModule.scala 71:23]
    end else begin
      regs_2 <= regs_1; // @[MyModule.scala 74:28]
    end
  end
// Register and memory initialization
...
endmodule

实现32位RISC-V处理器的寄存器文件

寄存器文件(Register File)是构建处理器的一个重要组件。寄存器文件指的是一个数组的寄存器,可以通过一组读/写端口来从寄存器中读取值或向寄存器中写入值。每个端口都由一个地址字段和数据字段构成。

RISCV指令集定义了多种ISA(Instruction Set Architecture,指令集架构)的变体,其中最简单的子集就是RV32I。RV32I有32个32位的寄存器,其中从索引为0的寄存器(即第一个寄存器)读取的值始终为0,不管怎么往里面写值也不会读到其他值,这个寄存器很好用!

现在要为RV32I实现一个寄存器文件,要求具有单个写端口和一个可配置数量的读端口,且写寄存器仅在wen(write enable,写使能)信号设置上了才能执行。实现如下:

class RegisterFile(readPorts: Int) extends Module {
  require(readPorts >= 0)
  val io = IO(new Bundle{
    val wen   = Input(Bool())
    val waddr = Input(UInt(5.W))
    val wdata = Input(UInt(32.W))
    val raddr = Input(Vec(readPorts, UInt(5.W)))
    val rdata = Output(Vec(readPorts, UInt(32.W)))
  })

  // 32个  初始化为0的32位数  形成数组  构成寄存器
  val reg = RegInit(VecInit(Seq.fill(32)(0.U(32.W))))

  when (io.wen) {
    reg(io.waddr) := io.wdata
  }

  for (i <- 0 until readPorts) {
    when (io.raddr(i) === 0.U) {
      io.rdata(i) := 0.U
    } .otherwise {
      io.rdata(i) := reg(io.raddr(i))
    }
  }
}

输出的Verilog代码太长就不放了,下面是测试代码:

test(new RegisterFile(2) ) { c =>
  def readExpect(addr: Int, value: Int, port: Int = 0): Unit = {
    c.io.raddr(port).poke(addr.U)
    c.io.rdata(port).expect(value.U)
  }
  def write(addr: Int, value: Int): Unit = {
    c.io.wen.poke(true.B)
    c.io.wdata.poke(value.U)
    c.io.waddr.poke(addr.U)
    c.clock.step(1)
    c.io.wen.poke(false.B)
  }
  // 每个寄存器都应该是初始化为0的
  for (i <- 0 until 32) {
    readExpect(i, 0, port = 0)
    readExpect(i, 0, port = 1)
  }

  // 往每个寄存器写入值 5 * addr + 3
  for (i <- 0 until 32) {
    write(i, 5 * i + 3)
  }

  // 检查有没有写进去,寄存器0始终读到0
  for (i <- 0 until 32) {
    readExpect(i, if (i == 0) 0 else 5 * i + 3, port = i % 2)
  }
}

测试通过。

这样RV32I的寄存器文件就实现了,实现和测试都比较容易。但目前来说这里Chisel的寄存器文件的实现乍一看并没有比Verilog方便多少,毕竟Verilog也是可以有寄存器数组的。而这里Chisel体现的优势主要在于以下三点:

  1. 初始化等写法方便,还可以利用循环减少重复性工作;
  2. 可以轻易实现参数化的模块生成器,比如这里就参数化了读端口;
  3. 测试方便,不用写复杂的TestBench,也避免了各种连线什么的;

小结与展望

在实际实现一个处理器这类复杂系统的过程中,Chisel会体现出更加明显的优势。这个教程系列结束后,我也会开始一个新的系列——用Chisel实现一个RISC-V的处理器,从最基础的RV32I开始,方向是最高支持RV64GC的可配置的处理器,最终目标是支持运行操作系统!

  • 4
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值