-
Notifications
You must be signed in to change notification settings - Fork 21
Object Oriented Programming
Prev: Functional Programming
Next: Types
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.
val path = System.getProperty("user.dir") + "/source/load-ivy.sc"
interp.load.module(ammonite.ops.Path(java.nio.file.FileSystems.getDefault().getPath(path)))
import chisel3._
import chisel3.util._
import chisel3.iotesters.{ChiselFlatSpec, Driver, PeekPokeTester}
import chisel3.experimental._
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
* Traits
* Objects
* Companion Objects
* Case 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
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:
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
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
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:
to contain constants related to the class
to execute code before/after the class constructor
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.
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?
Our Animal companion object defines a constant relevant to class Animal:
val defaultName = "Bigfoot"
It also defines a private mutable integer to keep track of the order of Animal instances:
private var numberOfAnimals = 0
It defines two apply methods, which are known as factory methods in that they return instances of the class Animal.
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)
}
The second factory method requires no argument, and instead uses the default name to call the other apply method.
def apply(): Animal = apply(defaultName)
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")
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.
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.
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.
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
}
class GrayCounter(val bitwidth: Int) extends NoGlitchCounter(bitwidth) {
// todo
}
class RingCounter(maxCount: Int) extends NoGlitchCounter(maxCount) {
// todo
}
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!
// 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.
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.