Chisel tutorial - 03 Combinatorial logic in chisel (chisel 3 cheat sheet attached at the end)

Chisel combinational logic

This section describes how to use Chisel components to implement combinatorial logic.

This section will demonstrate how three basic Chisel types (UInt, unsigned integer; SInt, signed integer; Bool, Boolean) can be connected and operated.

It should be noted that all Chisel variables are declared as Scala val. never use var in scala to implement hardware construction, because the construction itself will not change after definition, and only its value can be changed when running on hardware. Wires can be used for parameterized types.

Common operators

Additive implementation

First, construct a Module, which will not be described in detail:

import chisel3._

class MyModule extends Module {
  val io = IO(new Bundle {
    val in  = Input(UInt(4.W))
    val out = Output(UInt(4.W))
  })
}

Based on this, we can use various operators on the data:

import chisel3._

class MyModule extends Module {
  val io = IO(new Bundle {
    val in = Input(UInt(4.W))
    val out = Output(UInt(4.W))
  })

  io.out := io.in

  val two = 1 + 1
  println(two)
  val utwo = 1.U + 1.U
  println(utwo)
}

object MyModule extends App {
  println(getVerilogString(new MyModule()))
}

The output is as follows:

You can see that the first addition val two = 1 + 1 prints 2, while the second addition val utwo = 1 U + 1. The result of u printing is mymodule utwo: OpResult[UInt<1>]. The reason is that the first is to add two Scala integers, and the latter is to add the UINTS of two Chisel. Therefore, it is regarded as a hardware node when printing, and the output pointer and type name. Note that 1 U is the uint literal that converts Scala's Int1 to Chisel.

In addition, although the test content has nothing to do with the input and output, the output should also be connected to the, otherwise an error will be reported:

If the data types on both sides of the operator do not match, an error will also be reported, such as:

class MyModule extends Module {
  val io = IO(new Bundle {
    val in  = Input(UInt(4.W))
    val out = Output(UInt(4.W))
  })

  val two = 1.U + 1
  println(two)
  io.out := io.in
}

Errors that cause type mismatches:

Therefore, it is necessary to clear the differences between different types when performing operations. Scala is a strongly typed language, so all transformations must be explicit.

Realization of subtraction, multiplication and division

Look at other operators:

import chisel3._

class MyOperators extends Module {
  val io = IO(new Bundle {
    val in      = Input(UInt(4.W))
    val out_add = Output(UInt(4.W))
    val out_sub = Output(UInt(4.W))
    val out_mul = Output(UInt(4.W))
  })

  io.out_add := 1.U + 4.U
  io.out_sub := 2.U - 1.U
  io.out_mul := 4.U * 2.U
}

object MyOperators extends App {
  println(getVerilogString(new MyOperators()))
}

The output result is:

module MyOperators(
  input        clock,
  input        reset,
  input  [3:0] io_in,
  output [3:0] io_out_add,
  output [3:0] io_out_sub,
  output [3:0] io_out_mul
);
  wire [1:0] _io_out_sub_T_1 = 2'h2 - 2'h1; // @[MyModule.scala 12:21]
  wire [4:0] _io_out_mul_T = 3'h4 * 2'h2; // @[MyModule.scala 13:21]
  assign io_out_add = 4'h5; // @[MyModule.scala 11:14]
  assign io_out_sub = {{2'd0}, _io_out_sub_T_1}; // @[MyModule.scala 12:14]
  assign io_out_mul = _io_out_mul_T[3:0]; // @[MyModule.scala 13:14]
endmodule

MyModuleTest.scala is as follows:

import chisel3._
import chiseltest._
import org.scalatest.flatspec.AnyFlatSpec

class MyModuleTest extends AnyFlatSpec with ChiselScalatestTester {
  behavior of "MyOperators"
  it should "get right results" in {
    test(new MyOperators) {c =>
      c.io.out_add.expect(5.U)
      c.io.out_sub.expect(1.U)
      c.io.out_mul.expect(8.U)
    }
    println("SUCCESS!!")
  }
}

The test results passed.

Mux and Concatenation

Chisel has built-in multiple selection operator Mux and splicing operator Cat.

Mux is similar to the traditional ternary operator. The parameters are (condition, value when true, value when false). It is recommended to use true B and false B to create a Boolean value in Chisel.

The two parameters of Cat are high order (MSB) and low order (LSB), but only two parameters can be accepted. If you want to splice multiple values, you need to nest multiple Cat or use more advanced features.

Usage examples are as follows:

import chisel3._
import chisel3.util._

class MyOperators extends Module {
  val io = IO(new Bundle {
    val in      = Input(UInt(4.W))
    val out_mux = Output(UInt(4.W))
    val out_cat = Output(UInt(4.W))
  })

  val s = true.B
  io.out_mux := Mux(s, 3.U, 0.U)
  io.out_cat := Cat(2.U, 1.U)
}

object MyOperators extends App {
  println(getVerilogString(new MyOperators()))
}

The output is as follows:

module MyOperators(
  input        clock,
  input        reset,
  input  [3:0] io_in,
  output [3:0] io_out_mux,
  output [3:0] io_out_cat
);
  assign io_out_mux = 4'h3; // @[MyModule.scala 12:14]
  assign io_out_cat = 4'h5; // @[MyModule.scala 13:14]
endmodule

Note that the generated Verilog code does not have the combinational logic implementation of mux or concat at all, but the assignment of two constants.

This is because the circuit is simplified and some obvious logic is eliminated in the process of FIRRTL conversion.

Test:

import chisel3._
import chiseltest._
import org.scalatest.flatspec.AnyFlatSpec

class MyModuleTest extends AnyFlatSpec with ChiselScalatestTester {
  behavior of "MyOperators"
  it should "get right results" in {
    test(new MyOperators) {c =>
       c.io.out_mux.expect(3.U)
      c.io.out_cat.expect(5.U)
    }
    println("SUCCESS!!")
  }
}

The test passed.

For a list of Chisel operators, please refer to Chisel cheatsheet , the complete list and implementation details can be referred to Chisel API.

practice

Implement multiply add operation (MAC)

The multiplication and addition operation is realized. The input is 4-bit unsigned integers A, B and C, and the output is 8-bit unsigned integer (A * B) + C. the test is passed:

test(new MAC) { c =>
  val cycles = 100
  import scala.util.Random
  for (i <- 0 until cycles) {
    val in_a = Random.nextInt(16)
    val in_b = Random.nextInt(16)
    val in_c = Random.nextInt(16)
    c.io.in_a.poke(in_a.U)
    c.io.in_b.poke(in_b.U)
    c.io.in_c.poke(in_c.U)
    c.io.out.expect((in_a * in_b + in_c).U)
  }
}
println("SUCCESS!!")

The answer is as follows:

import chisel3._
import chisel3.util._

class MAC extends Module {
  val io = IO(new Bundle {
    val in_a  = Input(UInt(4.W))
    val in_b  = Input(UInt(4.W))
    val in_c  = Input(UInt(4.W))
    val out = Output(UInt(8.W))
  })

  io.out := (io.in_a * io.in_b) + io.in_c
}

object MyOperators extends App {
  println(getVerilogString(new MAC()))
}

The output is as follows:

module MAC(
  input        clock,
  input        reset,
  input  [3:0] io_in_a,
  input  [3:0] io_in_b,
  input  [3:0] io_in_c,
  output [7:0] io_out
);
  wire [7:0] _io_out_T = io_in_a * io_in_b; // @[MyModule.scala 12:22]
  wire [7:0] _GEN_0 = {{4'd0}, io_in_c}; // @[MyModule.scala 12:33]
  assign io_out = _io_out_T + _GEN_0; // @[MyModule.scala 12:33]
endmodule

The test passed.

Arbiter

The above arbiter is used to arbitrate the data in FIFO into two parallel processing units. The rules are as follows:

  1. If both processing units are empty, it will be sent to PE0 first;
  2. If at least one is available, the arbiter should tell the FIFO that it is ready to accept data;
  3. Before asserting that the data is valid, wait for PE to assert that it is ready;
  4. Tip: binary operators may be required to implement;

Templates and tests are as follows:

class Arbiter extends Module {
  val io = IO(new Bundle {
    // FIFO
    val fifo_valid = Input(Bool())
    val fifo_ready = Output(Bool())
    val fifo_data  = Input(UInt(16.W))
    
    // PE0
    val pe0_valid  = Output(Bool())
    val pe0_ready  = Input(Bool())
    val pe0_data   = Output(UInt(16.W))
    
    // PE1
    val pe1_valid  = Output(Bool())
    val pe1_ready  = Input(Bool())
    val pe1_data   = Output(UInt(16.W))
  })

  /*
  Fill in the corresponding code here
  */
}

test(new Arbiter) { c =>
  import scala.util.Random
  val data = Random.nextInt(65536)
  c.io.fifo_data.poke(data.U)
  
  for (i <- 0 until 8) {
    c.io.fifo_valid.poke((((i >> 0) % 2) != 0).B)
    c.io.pe0_ready.poke((((i >> 1) % 2) != 0).B)
    c.io.pe1_ready.poke((((i >> 2) % 2) != 0).B)

    c.io.fifo_ready.expect((i > 1).B)
    c.io.pe0_valid.expect((i == 3 || i == 7).B)
    c.io.pe1_valid.expect((i == 5).B)
    
    if (i == 3 || i ==7) {
      c.io.pe0_data.expect((data).U)
    } else if (i == 5) {
      c.io.pe1_data.expect((data).U)
    }
  }
}
println("SUCCESS!!")

Observe:

  1. There are two inputs from FIFO: FIFO valid and FIFO data. One output to FIFO informs that it is ready;
  2. There are two outputs to PE: data and informing PE whether the data is valid, and whether the input of one PE is ready;

Then our ideas are as follows:

  1. Pass signal pe0_ready and pe1_ready confirm fifo_ready (whether there is free PE);
  2. If FIFO data is valid, if PE0 is ready, let PE0 (set pe0_valid);
  3. If the FIFO data is valid, PE0 is not ready and PE1 is ready, let PE1 (set pe1_valid);
  4. Data is provided at the same time, but only PE is set_ Valid is useful;

The answer is as follows:

import chisel3._
import chisel3.util._

class Arbiter extends Module {
  val io = IO(new Bundle {
    // FIFO
    val fifo_valid = Input(Bool())
    val fifo_ready = Output(Bool())
    val fifo_data  = Input(UInt(16.W))
    
    // PE0
    val pe0_valid  = Output(Bool())
    val pe0_ready  = Input(Bool())
    val pe0_data   = Output(UInt(16.W))
    
    // PE1
    val pe1_valid  = Output(Bool())
    val pe1_ready  = Input(Bool())
    val pe1_data   = Output(UInt(16.W))
  })

  io.fifo_ready := io.pe0_ready || io.pe1_ready
  io.pe0_valid := io.fifo_valid & io.pe0_ready
  io.pe1_valid := io.fifo_valid & io.pe1_ready & !io.pe0_ready
  io.pe0_data := io.fifo_data
  io.pe1_data := io.fifo_data
}

object Arbiter extends App {
  println(getVerilogString(new Arbiter()))
}

The output is as follows:

module Arbiter(
  input         clock,
  input         reset,
  input         io_fifo_valid,
  output        io_fifo_ready,
  input  [15:0] io_fifo_data,
  output        io_pe0_valid,
  input         io_pe0_ready,
  output [15:0] io_pe0_data,
  output        io_pe1_valid,
  input         io_pe1_ready,
  output [15:0] io_pe1_data
);
  assign io_fifo_ready = io_pe0_ready | io_pe1_ready; // @[MyModule.scala 22:33]
  assign io_pe0_valid = io_fifo_valid & io_pe0_ready; // @[MyModule.scala 23:33]
  assign io_pe0_data = io_fifo_data; // @[MyModule.scala 25:15]
  assign io_pe1_valid = io_fifo_valid & io_pe1_ready & ~io_pe0_ready; // @[MyModule.scala 24:48]
  assign io_pe1_data = io_fifo_data; // @[MyModule.scala 26:15]
endmodule

The test passed.

Parametric adder

This part of the exercise will reflect the powerful feature of Chisel - the ability of parameterization.

Here, it is required to construct a parametric adder, which can saturate when overflow occurs and obtain stage results. For example, for 4-bit integer addition, 15 + 15 can get either 15 or 14, depending on the parameters given.

The template is as follows:

class ParameterizedAdder(saturate: Boolean) extends Module {
  val io = IO(new Bundle {
    val in_a = Input(UInt(4.W))
    val in_b = Input(UInt(4.W))
    val out  = Output(UInt(4.W))
  })

  /*
  Fill in the corresponding code here
  */
}

for (saturate <- Seq(true, false)) {
  test(new ParameterizedAdder(saturate)) { c =>
    // 100 random tests
    val cycles = 100
    import scala.util.Random
    import scala.math.min
    for (i <- 0 until cycles) {
      val in_a = Random.nextInt(16)
      val in_b = Random.nextInt(16)
      c.io.in_a.poke(in_a.U)
      c.io.in_b.poke(in_b.U)
      if (saturate) {
        c.io.out.expect(min(in_a + in_b, 15).U)
      } else {
        c.io.out.expect(((in_a + in_b) % 16).U)
      }
    }
    
    // ensure we test saturation vs. truncation
    c.io.in_a.poke(15.U)
    c.io.in_b.poke(15.U)
    if (saturate) {
      c.io.out.expect(15.U)
    } else {
      c.io.out.expect(14.U)
    }
  }
}
println("SUCCESS!!")

Observing the above template, the parameter passed in is called saturate, and the type is Boolean in Scala, not Boolean in Chisel. Therefore, what we need to create here is not a saturated and truncated hardware, but a generator, which can either generate a saturated adder or a truncated adder, which has been determined at the time of compilation.

Then it should be noted that the input and output are 4-bit UInt, and Chisel has built-in width reasoning, according to cheatsheet It is said that the bit width of the conventional addition result is equal to the widest of the two inputs, that is, the calculation of the item can only get a 4-bit connection:

val sum = io.in_a + io.in_b

In order to check whether the result needs to be saturated, the addition result needs to be put into a 5bit connection.

According to the description of the cheatsheet, you can use the + & operator:

val sum = io.in_a +& io.in_b

Finally, if a 4-bit UInt is connected to a 5-bit UInt, the most significant bit will be automatically truncated. Using this feature, the truncation result can be easily obtained for the unsaturated adder.

The answer is as follows:

import chisel3._
import chisel3.util._

class ParameterizedAdder(saturate: Boolean) extends Module {
  val io = IO(new Bundle {
    val in_a = Input(UInt(4.W))
    val in_b = Input(UInt(4.W))
    val out  = Output(UInt(4.W))
  })

  val sum = io.in_a +& io.in_b
  if (saturate) {
    io.out := Mux(sum > 15.U, 15.U, sum)
  } else {
    io.out := sum
  }
}

object ParameterizedAdder extends App {
  println(getVerilogString(new ParameterizedAdder(true)))
  println(getVerilogString(new ParameterizedAdder(false)))
}

The generated Verilog is as follows:

// saturation
module ParameterizedAdder(
  input        clock,
  input        reset,
  input  [3:0] io_in_a,
  input  [3:0] io_in_b,
  output [3:0] io_out
);
  assign io_out = 4'hf; // @[MyModule.scala 13:12]
endmodule

// truncation
module ParameterizedAdder(
  input        clock,
  input        reset,
  input  [3:0] io_in_a,
  input  [3:0] io_in_b,
  output [3:0] io_out
);
  wire [4:0] sum = io_in_a + io_in_b; // @[MyModule.scala 11:21]
  assign io_out = sum[3:0]; // @[MyModule.scala 15:12]
endmodule

The test passed.

Chisel3 Cheat Sheet

Finally, the Cheat Sheet of Chisel3 is attached:


Keywords: Scala Back-end

Added by Cerebral Cow on Sat, 12 Feb 2022 07:03:56 +0200