Chisel logo

Module 3.5: Object Oriented Programming

Prev: Functional Programming
Next: Types

Motivation

Scala and Chisel are object-oriented programming languages, meaning code may be compartmentalized into objects. Scala, which is built on Java, inherits many of Java's object-oriented features. However, as we'll see below, there are some differences. Chisel's hardware modules are similar to Verilog's modules, in that they can be instantiated and wired up as single or multiple instances.

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}
import chisel3.experimental._

Object Oriented Programming

This section outlines how Scala implements the object-oriented programming paradigm. So far you have already seen classes, but Scala also has the following features:

Abstract Classes

Abstract classes are just like other programming language implementations. They can define many unimplemented values that subclasses must implement. Any object can only directly inherit from one parent abstract class.

**Example: Abstract Class**

In [ ]:
abstract class MyAbstractClass {
  def myFunction(i: Int): Int
  val myValue: String
}
class ConcreteClass extends MyAbstractClass {
  def myFunction(i: Int): Int = i + 1
  val myValue = "Hello World!"
}
// Uncomment below to test!
// val abstractClass = new MyAbstractClass() // Illegal! Cannot instantiate an abstract class
val concreteClass = new ConcreteClass()      // Legal!

Traits

Traits are very similar to abstract classes in that they can define unimplemented values. However, they differ in two ways:

  • a class can inherit from multiple traits
  • a trait cannot have constructor parameters

**Example: Traits and Multiple Inheritance**
Traits are how Scala implements multiple inheritance, as shown in the example below. MyClass extends from both traits HasFunction and HasValue:

In [ ]:
trait HasFunction {
  def myFunction(i: Int): Int
}
trait HasValue {
  val myValue: String
  val myOtherValue = 100
}
class MyClass extends HasFunction with HasValue {
  override def myFunction(i: Int): Int = i + 1
  val myValue = "Hello World!"
}
// Uncomment below to test!
// val myTraitFunction = new HasFunction() // Illegal! Cannot instantiate a trait
// val myTraitValue = new HasValue()       // Illegal! Cannot instantiate a trait
val myClass = new MyClass()                // Legal!

To inherit multiple traits, chain them like

class MyClass extends HasTrait1 with HasTrait2 with HasTrait3 ...

In general, always use traits over abstract classes, unless you are certain you want to enforce the single-inheritance restriction of abstract classes.

Objects

Scala has a language feature for these singleton classes, called objects. You cannot instantiate an object (no need to call new); you can simply directly reference it. That makes them similar to Java static classes.

**Example: Objects**

In [ ]:
object MyObject {
  def hi: String = "Hello World!"
  def apply(msg: String) = msg
}
println(MyObject.hi)
println(MyObject("This message is important!")) // equivalent to MyObject.apply(msg)

Companion Objects

When a class and an object share the same name and defined in the same file, the object is called a companion object. When you use new before the class/object name, it will instantiate the class. If you don't use new, it will reference the object:

**Example: Companion Object**

In [ ]:
object Lion {
    def roar(): Unit = println("I'M AN OBJECT!")
}
class Lion {
    def roar(): Unit = println("I'M A CLASS!")
}
new Lion().roar()
Lion.roar()

Companion objects are usually used for the following reasons:

  1. to contain constants related to the class
  2. to execute code before/after the class constructor
  3. to create multiple constructors for a class

In the example below, we will instantiate a number of instances of Animal. We want each animal to have a name, and to know its order within all instantiations. Finally, if no name is given, it should get a default name.

In [ ]:
object Animal {
    val defaultName = "Bigfoot"
    private var numberOfAnimals = 0
    def apply(name: String): Animal = {
        numberOfAnimals += 1
        new Animal(name, numberOfAnimals)
    }
    def apply(): Animal = apply(defaultName)
}
class Animal(name: String, order: Int) {
  def info: String = s"Hi my name is $name, and I'm $order in line!"
}

val bunny = Animal.apply("Hopper") // Calls the Animal factory method
println(bunny.info)
val cat = Animal("Whiskers")       // Calls the Animal factory method
println(cat.info)
val yeti = Animal()                // Calls the Animal factory method
println(yeti.info)

What's happening here?

  1. Our Animal companion object defines a constant relevant to class Animal:
    val defaultName = "Bigfoot"
    
  2. It also defines a private mutable integer to keep track of the order of Animal instances:
    private var numberOfAnimals = 0
    
  3. It defines two apply methods, which are known as factory methods in that they return instances of the class Animal.
    1. The first creates an instance of Animal using only one argument, name, and uses numberOfAnimals as well to call the Animal class constructor.
      def apply(name: String): Animal = {
           numberOfAnimals += 1
           new Animal(name, numberOfAnimals)
      }
      
    2. The second factory method requires no argument, and instead uses the default name to call the other apply method.
      def apply(): Animal = apply(defaultName)
      
  4. These factory methods can be called naively like this
    val bunny = Animal.apply("Hopper")
    
    which eliminates the need to use the new keyword, but the real magic is that the compiler assumes the apply method any time it sees parentheses applied to an instance or object:
    val cat = Animal("Whiskers")
    
  5. Factory methods, usually provided via companion objects, allow alternative ways to express instance creations, provide additional tests for constructor parameters, conversions, and eliminate the need to use the keyword new. Note that you must call the companion object's apply method for numberOfAnimals to be incremented.

Chisel uses many companion objects, like Module. When you write the following:

val myModule = Module(new MyModule)

you are calling the Module companion object, so Chisel can run background code before and after instantiating MyModule.

Case Classes

Case classes are a special type of Scala class that provides some cool additional features. They are very common in Scala programming, so this section outlines some of their useful features:

  • Allows external access to the class parameters
  • Eliminates the need to use new when instantiating the class
  • Automatically creates an unapply method that supplies access to all of the class Parameters.
  • Cannot be subclassed from

In the following example, we declare three different classes, Nail, Screw, and Staple.

In [ ]:
class Nail(length: Int) // Regular class
val nail = new Nail(10) // Requires the `new` keyword
// println(nail.length) // Illegal! Class constructor parameters are not by default externally visible

class Screw(val threadSpace: Int) // By using the `val` keyword, threadSpace is now externally visible
val screw = new Screw(2)          // Requires the `new` keyword
println(screw.threadSpace)

case class Staple(isClosed: Boolean) // Case class constructor parameters are, by default, externally visible
val staple = Staple(false)           // No `new` keyword required
println(staple.isClosed)

Nail is a regular class, and its parameters are not externally visible because we did not use the val keyword in the argument list. It also requires the new keyword when declaring an instance of Nail.

Screw is declared similarly to Nail, but includes val in the argument list. This allows its parameter, threadSpace, to be visible externally.

By using a case class, Staple gets the benefit of all its parameters being externally visible (without needing the val keyword).

In addition, Staple does not require using new when declaring a case class. This is because the Scala compiler automatically creates a companion object for every case class in your code, which contains an apply method for the case class.

Case classes are nice containers for generators with lots of parameters. The constructor gives you a good place to define derived parameters and validate input.

In [ ]:
case class SomeGeneratorParameters(
    someWidth: Int,
    someOtherWidth: Int = 10,
    pipelineMe: Boolean = false
) {
    require(someWidth >= 0)
    require(someOtherWidth >= 0)
    val totalWidth = someWidth + someOtherWidth
}

Inheritance with Chisel

You've seen Modules and Bundles before, but it's important to realize what's really going on. Every Chisel module you make is a class extending the base type Module. Every Chisel IO you make is a class extending the base type Bundle (or, in some special cases, Bundle's supertype Record). Chisel hardware types like UInt or Bundle all have Data as a supertype. We'll explore using object oriented programming to create hierarchical hardware blocks and explore object reuse. You'll learn more about types and Data in the next Module on type generic generators.

Module

Whenever you want to create a hardware object in Chisel, it needs to have Module as a superclass. Inheritance might not always be the right tool for reuse (composition over inheritance is a common principle), but inheritance is still a powerful tool. Below is an example of creating a Module and connecting multiple instantiations of them together hierarchically.

**Example: Gray Encoder and Decoder**
We'll create a hardware Gray encoder/decoder. The encode or decode operation choice is hardware programmable.

In [ ]:
class NoGlitchCounterIO(bitwidth: Int) extends Bundle {
    val en  = Input(Bool())
    val out = Output(UInt(bitwidth.W))
}

abstract class NoGlitchCounter(val maxCount: Int) extends Module {
    val bitwidth: Int
    val io = IO(new NoGlitchCounterIO(bitwidth))
}

abstract class AsyncFIFO(depth: Int) extends Module {
    val io = IO(new Bundle{
        // write inputs
        val write_clock = Input(Clock())
        val write_enable = Input(Bool())
        val write_data = Input(UInt(32.W))

        // read inputs/outputs
        val read_clock = Input(Clock())
        val read_enable = Input(Bool())
        val read_data = Output(UInt(32.W))

        // FIFO status
        val full = Output(Bool())
        val empty = Output(Bool())
    })
    
    def makeCounter(maxCount: Int): NoGlitchCounter

    // add extra bit to counter to check for fully/empty status
    assert(isPow2(depth), "AsyncFIFO needs a power-of-two depth!")
    val write_counter = withClock(io.write_clock) {
        val count = makeCounter(depth * 2)
        count.io.en := io.write_enable && !io.full
        count.io.out
    }
    val read_counter = withClock(io.read_clock) {
        val count = makeCounter(depth * 2)
        count.io.en := io.read_enable && !io.empty
        count.io.out
    }

    // synchronize
    val sync = withClock(io.read_clock) { ShiftRegister(write_counter, 2) }

    // status logic goes here
}
In [ ]:
class GrayCounter(val bitwidth: Int) extends NoGlitchCounter(bitwidth) {
    // todo
}

class RingCounter(maxCount: Int) extends NoGlitchCounter(maxCount) {
    // todo
}
In [ ]:
import scala.math.pow

// create a module
class GrayCoder(bitwidth: Int) extends Module {
  val io = IO(new Bundle{
    val in = Input(UInt(bitwidth.W))
    val out = Output(UInt(bitwidth.W))
    val encode = Input(Bool()) // decode on false
  })
  
  when (io.encode) { //encode
    io.out := io.in ^ (io.in >> 1.U)
  } .otherwise { // decode, much more complicated
    io.out := Seq.fill(log2Ceil(bitwidth))(Wire(UInt(bitwidth.W))).zipWithIndex.fold((io.in, 0)){
      case ((w1: UInt, i1: Int), (w2: UInt, i2: Int)) => {
        w2 := w1 ^ (w1 >> pow(2, log2Ceil(bitwidth)-i2-1).toInt.U)
        (w2, i1)
      }
    }._1
  }
}

Give it a whirl!

In [ ]:
// test our gray coder
val bitwidth = 4
Driver(() => new GrayCoder(bitwidth)) {
  c => new PeekPokeTester(c) {
  
    def toBinary(i: Int, digits: Int = 8) =
      String.format("%" + digits + "s", i.toBinaryString).replace(' ', '0')

    println("Encoding:")
    for (i <- 0 until pow(2, bitwidth).toInt) {
      poke(c.io.in, i)
      poke(c.io.encode, true)
      step(1)
      println(s"In = ${toBinary(i, bitwidth)}, Out = ${toBinary(peek(c.io.out).toInt, bitwidth)}")
    }
    
    println("Decoding:")
    for (i <- 0 until pow(2, bitwidth).toInt) {
      poke(c.io.in, i)
      poke(c.io.encode, false)
      step(1)
      println(s"In = ${toBinary(i, bitwidth)}, Out = ${toBinary(peek(c.io.out).toInt, bitwidth)}")
    }
  }
}

Gray codes are often used in asynchronous interfaces. Usually Gray counters are used rather than fully-featured encoders/decoders, but we'll the above module to simplify things. Below is an example AsyncFIFO, built using the above Gray coder. The control logic and tester is left as an exercise for later on. For now, look at how the Gray coder is instantiated multiple times and connected.

In [ ]:
class AsyncFIFO(depth: Int = 16) extends Module {
  val io = IO(new Bundle{
    // write inputs
    val write_clock = Input(Clock())
    val write_enable = Input(Bool())
    val write_data = Input(UInt(32.W))
    
    // read inputs/outputs
    val read_clock = Input(Clock())
    val read_enable = Input(Bool())
    val read_data = Output(UInt(32.W))
    
    // FIFO status
    val full = Output(Bool())
    val empty = Output(Bool())
  })
  
  // add extra bit to counter to check for fully/empty status
  assert(isPow2(depth), "AsyncFIFO needs a power-of-two depth!")
  val write_counter = withClock(io.write_clock) { Counter(io.write_enable && !io.full, depth*2)._1 }
  val read_counter = withClock(io.read_clock) { Counter(io.read_enable && !io.empty, depth*2)._1 }
  
  // encode
  val encoder = new GrayCoder(write_counter.getWidth)
  encoder.io.in := write_counter
  encoder.io.encode := true.B
  
  // synchronize
  val sync = withClock(io.read_clock) { ShiftRegister(encoder.io.out, 2) }
  
  // decode
  val decoder = new GrayCoder(read_counter.getWidth)
  decoder.io.in := sync
  decoder.io.encode := false.B
  
  // status logic goes here
  
}

You're done!

Return to the top.