Chisel logo

Module 2.2: Combinational Logic

Prev: Your First Chisel Module
Next: Control Flow

Motivation

In this section you will see how to use Chisel components to implement combinational logic. We will demonstrate how three of the basic Chisel types: UInt- unsigned integer; SInt - signed integer, and Bool - true or false may be connected and operated upon. Notice how all Chisel variables are declared as Scala vals. Never use a Scala var for a hardware construct, since the construct itself may never change once defined; only its value may change when running the hardware. Wires may be used for parameterized types.

Setup

In [ ]:
val path = System.getProperty("user.dir") + "/source/load-ivy.sc"
interp.load.module(ammonite.ops.Path(java.nio.file.FileSystems.getDefault().getPath(path)))
In [ ]:
import chisel3._
import chisel3.util._
import chisel3.iotesters.{ChiselFlatSpec, Driver, PeekPokeTester}

Common Operators

Now that you understand how Modules are constructed, let's make some hardware! Take a look at the empty module below.

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

We've called our class MyModule, and it extends Module. This means it gets mapped to a hardware module in Verilog. Our MyModule module has one input and one output. The input is a 4-bit unsigned integer (UInt), and so is the output.

**Example: Scala and Chisel Operators Look the Same**
Let's look at different operations we can perform on data.

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

  val two  = 1 + 1
  println(two)
  val utwo = 1.U + 1.U
  println(utwo)
  
  io.out := io.in
}
println(getVerilog(new MyModule))

We create two vals. The first adds two Scala Ints, so println prints out the integer 2. The second val adds two Chisel UInts together, so println sees this as a hardware node and prints out the type name and pointer ([email protected]). Note that 1.U is a type cast from a Scala Int (1) to a Chisel UInt literal.

We need to drive the output to something, so we just connect it to the input for now, as with the passthrough module in the previous tutorial.

**Example: Incompatible Operation**
What happens if we add a Chisel 1.U to the literal 1? These types are incompatible, as the former is a hardware wire of value 1, while the latter is a Scala value of 1. So Chisel will give a type mismatch error.

In [ ]:
class MyModuleTwo extends Module {
  val io = IO(new Bundle {
    val in  = Input(UInt(4.W))
    val out = Output(UInt(4.W))
  })

  val twotwo = 1.U + 1
  println(twotwo)
  
  io.out := io.in
}
println(getVerilog(new MyModule))

It's important to remember the distinction between types when performing operations. Scala is a strongly typed language, so any type casting must be explicit.

**Example: More Chisel Operators**
Other common operations are subtraction and multiplication. These are handled on unsigned integers as expected. Let's see these in action. We show the Verilog, though there's some underlying Chisel features that obfuscate the simple code we would expect.

In [ ]:
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
}
println(getVerilog(new MyOperators))

And here's a sample tester for the above operations. Instead of using an anonymous tester class like in the previous tutorial, we'll create an explicit tester class. This is just an alternative way of writing a tester.

In [ ]:
class MyOperatorsTester(c: MyOperators) extends PeekPokeTester(c) {
  expect(c.io.out_add, 5)
  expect(c.io.out_sub, 1)
  expect(c.io.out_mul, 8)
}
assert(Driver(() => new MyOperators) {c => new MyOperatorsTester(c)})
println("SUCCESS!!")

**Example: Mux and Concatenation**
In addition to addition, subtraction, and multplication, Chisel has mux and concatenation operators. These are shown below. The Mux operates like a traditional ternary operator, with the order (select, value if true, value if false). Note that true.B and false.B are the preferred ways to create Chisel Bool literals. The Cat ordering is MSB then LSB (where B refers to bit or bits), and only takes two arguments. Concatenating more than two values requires multiple Cat calls or advanced Chisel and Scala features covered in later sections.

In [ ]:
class MyOperatorsTwo 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) // should return 3.U, since s is true
  io.out_cat := Cat(2.U, 1.U)    // concatenates 2 (b10) with 1 (b1) to give 5 (101)
}

println(getVerilog(new MyOperatorsTwo))
class MyOperatorsTwoTester(c: MyOperatorsTwo) extends PeekPokeTester(c) {
  expect(c.io.out_mux, 3)
  expect(c.io.out_cat, 5)
}
assert(Driver(() => new MyOperatorsTwo) {c => new MyOperatorsTwoTester(c)})
println("SUCCESS!!")

Notice how the Verilog contains constants instead of actual mux or concatenation logic. This is because FIRRTL transformations have simplified the circuit, eliminating obvious logic.

For a more complete list of Chisel operators, see the Chisel cheatsheet. For the most complete list of operators and their implementation details, look through the Chisel API.


Exercises

To complete these exercises, you may need to look through the Chisel cheatsheet.

**Exercise: MAC**
Create a Chisel module that implements the multiply accumulate function, (A*B)+C, and passes the testbench.

In [ ]:
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))
  })

  ???
}
class MACTester(c: MAC) extends PeekPokeTester(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)
    poke(c.io.in_a, in_a)
    poke(c.io.in_b, in_b)
    poke(c.io.in_c, in_c)
    expect(c.io.out, in_a*in_b+in_c)
  }
}
assert(Driver(() => new MAC) {c => new MACTester(c)})
println("SUCCESS!!")
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
}

**Exercise: Arbiter**
The following circuit arbitrates data coming from a FIFO into two parallel processing units. The FIFO and processing elements (PEs) communicate with ready-valid interfaces. Construct the arbiter to send data to whichever PE is ready to receive data, prioritizing PE0 if both are ready to receive data. Remember that the arbiter should tell the FIFO that it's ready to receive data when at least one of the PEs can receive data. Also, wait for a PE to assert that it's ready before asserting that the data are valid. You will likely need binary operators to complete this exercise.

In [ ]:
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))
  })

  ???  
}
class ArbiterTester(c: Arbiter) extends PeekPokeTester(c) {
  import scala.util.Random
  val data = Random.nextInt(65536)
  poke(c.io.fifo_data, data)
  
  for (i <- 0 until 8) {
    poke(c.io.fifo_valid, (i>>0)%2)
    poke(c.io.pe0_ready,  (i>>1)%2)
    poke(c.io.pe1_ready,  (i>>2)%2)

    expect(c.io.fifo_ready, i>1)
    expect(c.io.pe0_valid,  i==3 || i==7)
    expect(c.io.pe1_valid,  i==5)
    
    if (i == 3 || i ==7) {
      expect(c.io.pe0_data, data)
    } else if (i == 5) {
      expect(c.io.pe1_data, data)
    }
  }
}
assert(Driver(() => new Arbiter) {c => new ArbiterTester(c)})
println("SUCCESS!!")
  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

**Exercise: Parameterized Adder (Optional)**
This optional exercise exposes you to one of the most powerful features of Chisel, it's parameterization capabilities. To demonstrate this, we'll construct a parameterized adder that can either saturate the output when overflow occurs, or truncate the results (i.e. wrap around).

First, look at the Module below. The parameter we pass into it is called saturate and has type Scala Boolean. This is not a Chisel Bool. So, we're not creating a single hardware adder that can either saturate or truncate, but rather we're creating a generator that produces either a saturating hardware adder or a truncating hardware adder. The decision is made at compile time.

Next, notice the inputs and outputs are all 4-bit UInts. Chisel has built-in width inferencing, and if you look at the cheatsheet, you'll see that the bitwidth of a normal summation is equal to the maximum bitwidth of the two inputs. This means that

val sum = io.in_a + io.in_b

will make sum a 4-bit wire, and the value will be the truncated result for 4-bit inputs. To check if the summation should saturate, you need to place the result in a 5-bit wire. This can be done with the +& summation, as seen on the cheatsheet.

val sum = io.in_a +& io.in_b

Finally, note that connecting a 4-bit UInt wire to a 5-bit UInt wire will truncate the MSB by default. You can use this to easily truncate the 5-bit sum for the non-saturating adder.

In [ ]:
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))
  })

  ???
}
class ParameterizedAdderTester(c: ParameterizedAdder, saturate: Boolean) extends PeekPokeTester(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)
    poke(c.io.in_a, in_a)
    poke(c.io.in_b, in_b)
    if (saturate) {
      expect(c.io.out, min(in_a+in_b, 15))
    } else {
      expect(c.io.out, (in_a+in_b)%16)
    }
  }
  
  // ensure we test saturation vs. truncation
  poke(c.io.in_a, 15)
  poke(c.io.in_b, 15)
  if (saturate) {
    expect(c.io.out, 15)
  } else {
    expect(c.io.out, 14)
  }
}
for (saturate <- Seq(true, false)) {
  assert(Driver(() => new ParameterizedAdder(saturate)) {c => new ParameterizedAdderTester(c, saturate)})
}
println("SUCCESS!!")
  val sum = io.in_a +& io.in_b
  if (saturate) {
    io.out := Mux(sum > 15.U, 15.U, sum)
  } else {
    io.out := sum
  }

You're done!

Return to the top.