Skip to content
code0.HDL

Polymorphism and parameterization

Separate Scala type parameters and their upper bounds from value parameters, then use type templates, configuration objects, optional ports, and by-name module arguments to build related circuits.

In this chapter

  • Write a module with a type parameter, an upper bound for its operations, and a gen template for its ports
  • Use value parameters to build a family of definitions from one class
  • Pass a module as a parameter and read what elaboration built
  • Pick the loosest upper bound a generic body needs, and pass gen as a Chisel type rather than hardware
  • Group parameters in a case class checked with require, and make a port optional with Option
Concepts in this chapter
  • Polymorphism and parameterization
Prerequisite concepts

Every chapter so far has written generators with a fixed shape: an 8-bit counter, a 4-entry table, a multiplexer over UInt. Real designs repeat the same structure for many payloads and sizes. A pipeline register looks the same whether it carries a byte or a packet header, and an accumulator is the same circuit at 4 bits or 32. This chapter follows the Polymorphism and Parameterization explanation of the official Chisel documentation: how Scala type parameters and ordinary constructor arguments let one class describe that whole family of circuits.

Before you start. You need Bundle from Bundles and Vecs, module instantiation from Child modules and generators, RegInit and RegNext from Registers and stepping, the width-expanding add +& from Widths, SInt, and arithmetic, and Mux from Mux, when, and Wire.

Everything in this chapter is decided during elaboration. The Scala compiler resolves type parameters before any circuit exists, and constructor arguments are read while the generator runs. The simulated circuit never sees T or a configuration object; it sees only the ports and nodes those values caused the generator to build.

1. Two kinds of parameter

A Chisel class can be parameterized in two ways, and mistakes in each show up at a different time.

KindExampleDecided byA wrong value is caught by
Type parameterclass Pick[T <: Data](gen: T)The Scala compiler, from the argument's typeThe Scala compiler, through the bound
Value parameterclass Accumulator(cfg: AccConfig)Generator code, while it runsYour own require, during elaboration

A type parameter says "this circuit works for any payload in some family of types". A value parameter is any ordinary Scala value the generator reads: a width, a feature switch, a list of constants.

Check

A FIFO generator needs four settings. Which one must be a hardware input port rather than a constructor argument?

Your answer

2. Type parameters

2.1 Generic functions

A Scala function can take a type parameter in square brackets. Inside the function, T stands for whatever type the caller passes.

Syntax

def name[T <: UpperBound](arg1: T, arg2: T): ResultType = body
def swapIf[T <: Data](c: Bool, a: T, b: T): (T, T) =
  (Mux(c, b, a), Mux(c, a, b))
PartMeaning
[T <: Data]Declares the type parameter T; <: Data is its upper bound
a: T, b: TBoth arguments are declared with the same T
(T, T)The result is a pair of values of that same type
Call swapIf(sel, x, y) with UIntsScala infers T = UInt; with Bundles, the Bundle type

Called with two 8-bit UInts 5 and 8, swapIf returns (5, 8) when c is 0 and (8, 5) when c is 1. The same function body works for SInt, Bundle, or Vec values, because it only uses Mux.

Caution. Declaring both arguments as T does not guarantee that they have the same hardware shape. Scala infers the most specific type that fits both arguments. swapIf(c, 1.U, -1.S) compiles, with T inferred as a common supertype of UInt and SInt, and then stops during elaboration inside Mux with can't create Mux with heterogeneous types. Two UInts of different widths are also accepted: the Mux simply takes the wider width. Chisel checks the shapes at elaboration, not the Scala compiler.

2.2 Upper bounds

The type after <: is the upper bound: the family of types T may be. The bound is a promise in two directions. The body may use only the operations the bound guarantees, and callers may pass only types inside it.

Syntax

[T <: Data]    // any Chisel type
[T <: Bits]    // only flat bit vectors: UInt, SInt, Bool
def lsb[T <: Bits](x: T): Bool = x(0)
BoundAdmitsBody may use
T <: DataUInt, SInt, Bool, Bundle, Vec, enumsMux, :=, asUInt, getWidth: what every Data has
T <: BitsUInt, SInt, BoolThe above plus bit extraction such as x(0) and x(3, 0)

With T <: Bits, lsb(5.U) and lsb(-3.S) both return 1. Write the same body with T <: Data and the compiler reports T does not take parameters: not every Data has bits you can index, since a Bundle does not.

Note. Choose the loosest bound the body actually needs. A tighter bound than necessary rejects useful payloads at compile time; a looser bound than necessary does not compile.

Check

def msb[T <: ???](x: T): Bool = x(x.getWidth - 1). Which is the loosest bound that compiles?

Your answer

2.3 Generic modules and the gen template

A module class can take a type parameter too. To declare ports, though, it needs more than the Scala type T: it needs the exact width and field layout, and a Scala type parameter is not available as a value while the generator runs. Generic Chisel classes therefore take a gen: T argument, a Chisel type object such as UInt(8.W) or new Pixel, and use it as a template.

Syntax

class Name[T <: Data](gen: T) extends Module {
  val io = IO(new Bundle {
    val in  = Input(gen)
    val out = Output(gen)
  })
}
class Passthrough[T <: Data](gen: T) extends Module {
  val io = IO(new Bundle {
    val in  = Input(gen)
    val out = Output(gen)
  })
  val tmp = Wire(gen)
  tmp    := io.in
  io.out := tmp
}

// new Passthrough(SInt(6.W)) builds 6-bit signed ports
Use of genCreates
Input(gen), Output(gen)A port shaped like the template
Wire(gen)A wire shaped like the template
Reg(gen)A register shaped like the template
gen.getWidthThe template's width, as a Scala Int, during elaboration

Each use makes a fresh piece of hardware. The template itself is never connected to anything.

Caution. gen must be a Chisel type, never hardware. UInt(8.W) and new Pixel are types; 0.U(8.W), io.in, and a Wire are hardware. Passing hardware compiles, because a literal is still a UInt to Scala, but elaboration stops with ExpectedChiselTypeException: 'UInt<8>(0)' must be a Chisel type, not hardware. When you only have a hardware value, chiselTypeOf(io.in) returns its type, so Wire(chiselTypeOf(io.in)) is a wire shaped like io.in.

2.4 Example: one class, two payload types

Pick.scala puts the three ideas together. Read it in this order:

  • Pixel is a plain three-field Bundle, 8 bits per field.
  • Choose is a generic function: a Bool and two values of type T in, a Mux of type T out.
  • Pick[T <: Data] takes a template gen and uses it for three ports.
  • The println runs during elaboration and reports which template it received and how wide it is.
Pick.scala
import chisel3._

class Pixel extends Bundle {
  val r = UInt(8.W)
  val g = UInt(8.W)
  val b = UInt(8.W)
}

object Choose {
  // A generic function: both choices must share one type T.
  def apply[T <: Data](sel: Bool, whenFalse: T, whenTrue: T): T =
    Mux(sel, whenTrue, whenFalse)
}

class Pick[T <: Data](gen: T) extends Module {
  // gen is a Chisel type; it only shapes the ports.
  val io = IO(new Bundle {
    val sel = Input(Bool())
    val a   = Input(gen)
    val b   = Input(gen)
    val out = Output(gen)
  })
  // Runs once per instance, after T is already fixed.
  println(s"ELABORATE Pick of ${gen.getClass.getSimpleName}, ${gen.getWidth} bits")
  io.out := Choose(io.sel, io.a, io.b)
}

Tb.scala runs two separate tests. The first builds Pick(UInt(8.W)), pokes 17 and 99 into a and b, and reads out for both sel values. The second builds Pick(new Pixel), pokes all six fields of the two inputs, and prints each output field plus the whole Bundle packed into one UInt.

Tb.scala
import chisel3._
import chiseltest._
import chiseltest.RawTester.test

object Main extends App {
  // The same class, built for a plain 8-bit UInt.
  test(new Pick(UInt(8.W))) { dut =>
    dut.io.a.poke(17.U)
    dut.io.b.poke(99.U)
    for (sel <- Seq(0, 1)) {
      dut.io.sel.poke((sel == 1).B)
      println(s"PICK UInt sel=$sel out=${dut.io.out.peek().litValue}")
    }
  }

  // The same class again, now carrying a three-field Bundle.
  test(new Pick(new Pixel)) { dut =>
    dut.io.a.r.poke(1.U)
    dut.io.a.g.poke(2.U)
    dut.io.a.b.poke(3.U)
    dut.io.b.r.poke(10.U)
    dut.io.b.g.poke(20.U)
    dut.io.b.b.poke(30.U)
    for (sel <- Seq(0, 1)) {
      dut.io.sel.poke((sel == 1).B)
      val o = dut.io.out
      val packed = o.peek().asUInt.litValue.toString(16)
      println(s"PICK Pixel sel=$sel r=${o.r.peek().litValue} g=${o.g.peek().litValue} b=${o.b.peek().litValue} packed=0x$packed")
    }
  }
}

Practice this concept

Use this concept in a short Chisel problem as your next step.

Predict before you run.

  1. What widths do the two ELABORATE lines report?
  2. With sel = 1, which input reaches out in each test?
  3. Packing puts the fields in declaration order with the first field in the most significant bits. What hex value does r=1, g=2, b=3 produce?

Check the result.

Output lineWhat it shows
ELABORATE Pick of UInt, 8 bitsThe first instance was built with T = UInt and 8-bit ports
PICK UInt sel=0, sel=117, then 99: sel = 1 selects b
ELABORATE Pick of Pixel, 24 bitsThe same class built a second, different circuit with 24-bit Bundle ports
PICK Pixel sel=0, sel=1All three fields switch together; packed is 0x10203, then 0xa141e, with r on top

Each ELABORATE line appears right before that test's simulation lines. Scala resolves the type parameter at compile time; during elaboration, gen supplies each instance's hardware shape. The two resulting circuits are as different as two hand-written modules. Nothing generic survives into simulation.

Try it yourself.

  1. Change the bound in class Pick from T <: Data to T <: Bits. The compiler now rejects new Pick(new Pixel) with inferred type arguments [Pixel] do not conform to ... type parameter bounds [T <: chisel3.Bits]. The valid UInt test does not run either, because the whole program must compile first.
  2. Restore the bound and change the first test to new Pick(0.U(8.W)). It compiles, then stops inside Input(gen) with ExpectedChiselTypeException: 'UInt<8>(0)' must be a Chisel type, not hardware.

3. Value parameters

3.1 Constructor arguments and Scala if

The simplest value parameter is an ordinary constructor argument. The generator reads it with plain Scala, including if and loops, and builds different hardware for different values.

Syntax

class Name(param1: Int, param2: Boolean) extends Module { ... }
class Delay(width: Int, stages: Int) extends Module {
  val io = IO(new Bundle {
    val in  = Input(UInt(width.W))
    val out = Output(UInt(width.W))
  })
  io.out := (0 until stages).foldLeft(io.in)((d, _) => RegNext(d))
}

new Delay(8, 3) builds three 8-bit registers in a row; new Delay(16, 1) builds one 16-bit register. Neither width nor stages exists in the circuit. They only decided what the generator built.

Check

What does new Delay(4, 0) build?

Your answer

3.2 Grouping parameters in a case class

When a module has several parameters, collect them in a case class. It gives the parameter set a name, default values, and one place for checks and derived values.

Syntax

case class Params(field1: Type = default, field2: Type = default) {
  require(condition, "message")
  val derived = expression
}
case class BusParams(width: Int = 32, withDebug: Boolean = false) {
  require(width % 8 == 0, s"width must be a multiple of 8, got $width")
  val lanes = width / 8
}
PartMeaning
width: Int = 32A field with a default; BusParams() means 32 bits
require(...)Runs when the object is constructed, before any module uses it
val lanes = width / 8A derived value every module computes the same way
BusParams(64)lanes is 8
BusParams(12)Throws IllegalArgumentException: requirement failed: width must be a multiple of 8, got 12

Note. Put the explanation in the require message. A failed check that names the parameter and its value is far easier to fix than a width error several modules later.

3.3 Optional ports with Option

A Scala if can decide whether a port exists at all. Wrap the port in Some(...) when it exists and use None when it does not.

Syntax

val port = if (condition) Some(IO(Output(Type))) else None
port.foreach(_ := value)   // drive it only if it exists
val debug = if (p.withDebug) Some(IO(Output(UInt(p.width.W)))) else None
debug.foreach(_ := data)
ValueEffect
Some(IO(...))The port is created, and foreach connects it
NoneThe port is absent from the circuit, and foreach does nothing

Code that touches an optional port must handle its absence, in the module and in the testbench alike: use foreach, map, or isDefined, never .get.

3.4 Example: a configuration object and an optional port

Accumulator.scala applies section 3 to a running sum. Read it in this order:

  • AccConfig has two fields and a require that rejects widths below 2.
  • Accumulator uses cfg.width for every port and register width.
  • carry is Some(port) only when cfg.withCarry is true.
  • acc +& io.in is one bit wider than acc. The low bits go back into the register; the top bit drives carry if it exists.
Accumulator.scala
import chisel3._

// Elaboration-time parameters, checked before any hardware exists.
case class AccConfig(width: Int, withCarry: Boolean) {
  require(width >= 2, s"width must be at least 2, got $width")
}

class Accumulator(cfg: AccConfig) extends Module {
  val io = IO(new Bundle {
    val in  = Input(UInt(cfg.width.W))
    val sum = Output(UInt(cfg.width.W))
  })
  // The carry port exists only when the configuration asks for it.
  val carry = if (cfg.withCarry) Some(IO(Output(Bool()))) else None

  val acc = RegInit(0.U(cfg.width.W))
  // +& keeps the carry bit; the register keeps only the low bits.
  val next = acc +& io.in
  acc := next(cfg.width - 1, 0)
  io.sum := acc
  carry.foreach(_ := next(cfg.width))
}

Tb.scala loops over two configurations: 4 bits with a carry port, and 8 bits without. For each it reports whether carry exists, then adds 5, 6, and 7, printing the registered sum and the carry of the pending addition before each clock step.

Tb.scala
import chisel3._
import chiseltest._
import chiseltest.RawTester.test

object Main extends App {
  val configs = Seq(AccConfig(4, withCarry = true), AccConfig(8, withCarry = false))
  // Each configuration elaborates a different circuit.
  for (cfg <- configs) {
    test(new Accumulator(cfg)) { dut =>
      println(s"CONFIG width=${cfg.width} carryPort=${dut.carry.isDefined}")
      for (v <- Seq(5, 6, 7)) {
        dut.io.in.poke(v.U)
        // An absent port is None, so the testbench prints a dash.
        val c = dut.carry.map(_.peek().litValue.toString).getOrElse("-")
        println(s"ADD in=$v sum=${dut.io.sum.peek().litValue} carry=$c")
        dut.clock.step()
      }
      println(s"FINAL sum=${dut.io.sum.peek().litValue}")
    }
  }
}

Practice this concept

Use this concept in a short Chisel problem as your next step.

Predict before you run. Fill in the 4-bit trace yourself, then compare.

Before stepaccio.inacc +& io.incarryNext acc
105505
25611011
311718 (1_0010)12
  1. How many carry values does the 8-bit test print, and what appears in their place?
  2. What final sum does each configuration reach?

Check the result.

Output lineWhat it shows
CONFIG width=4 carryPort=trueThe first configuration built the carry port
ADD in=7 sum=11 carry=111 + 7 = 18 overflows 4 bits; the carry port shows the fifth bit
FINAL sum=2The register kept only the low 4 bits of 18
CONFIG width=8 carryPort=falseThe second configuration has no carry port at all
carry=- on every 8-bit linedut.carry is None, so the testbench's map/getOrElse prints a dash
FINAL sum=188 bits hold 18 without overflow

The final sums are the same three additions taken modulo 16 and modulo 256.

Try it yourself.

  1. Change the first configuration to AccConfig(1, withCarry = true). No circuit is built: constructing the case class throws java.lang.IllegalArgumentException: requirement failed: width must be at least 2, got 1.
  2. In the testbench, replace the map/getOrElse line with dut.carry.get.peek(). The 4-bit test still works, but the 8-bit test throws java.util.NoSuchElementException: None.get.

4. Passing a module as a parameter

A generator can also take another module as its parameter. Declare a Scala trait that lists the ports the outer module needs, and bound the type parameter by both BaseModule and that trait. Pass the child as a by-name argument (=> T) so it is constructed inside the parent's Module(...) call.

Syntax

class Outer[T <: BaseModule with PortTrait](child: => T) extends Module {
  val inner = Module(child)
}
import chisel3._
import chisel3.experimental.BaseModule

trait TwoInOne { def a: UInt; def b: UInt; def y: UInt }

class Wrap[T <: BaseModule with TwoInOne](child: => T) extends Module {
  val io = IO(new Bundle {
    val a = Input(UInt(8.W)); val b = Input(UInt(8.W)); val y = Output(UInt(8.W))
  })
  val inner = Module(child)
  inner.a := io.a; inner.b := io.b; io.y := inner.y
}
PartMeaning
trait TwoInOneThe ports Wrap relies on; each child declares them
T <: BaseModule with TwoInOneT must be a module and must have those ports
child: => TBy-name: new Add runs inside Module(child), not before
inner.a := io.aType-checks because the trait guarantees a

With an adding RawModule and a subtracting one (a RawModule is a module without the implicit clock and reset), new Wrap(new Add) and new Wrap(new Sub) build two different circuits around the same wrapper: for inputs 9 and 4 they output 13 and 5. A plain Module bound would not let the compiler check inner.a.

5. Common mistakes

5.1 Passing hardware as gen

// Wrong: 0.U(8.W) is a hardware literal
val p = Module(new Pick(0.U(8.W)))

// Right: pass the Chisel type
val p = Module(new Pick(UInt(8.W)))

The wrong line compiles and then fails during elaboration with ExpectedChiselTypeException: ... must be a Chisel type, not hardware. If you only have a hardware value, pass chiselTypeOf(value).

5.2 A bound too loose for the body

// Wrong: not every Data can be indexed
def lsb[T <: Data](x: T): Bool = x(0)

// Right: bit extraction needs Bits
def lsb[T <: Bits](x: T): Bool = x(0)

The wrong version fails to compile with T does not take parameters. The bound must promise every operation the body uses.

5.3 Branching on hardware with Scala if

// Wrong: io.c is a hardware Bool, unknown during elaboration
io.y := (if (io.c) 1.U else 2.U)

// Right: a hardware choice is a Mux (or when)
io.y := Mux(io.c, 1.U, 2.U)

The compiler reports type mismatch; found: chisel3.Bool, required: Boolean. Scala if is for parameters that are known while the generator runs, like cfg.withCarry; Mux and when are for signals that change while the circuit runs.

5.4 Calling .get on an optional port

// Wrong: throws when the configuration omits the port
println(dut.carry.get.peek().litValue)

// Right: handle the absent case
println(dut.carry.map(_.peek().litValue.toString).getOrElse("-"))

.get on None throws java.util.NoSuchElementException: None.get. It works in one configuration and fails in another, which makes the bug easy to miss.

6. Summary

TopicKey point
Type parameter[T <: Bound]; Scala infers T from the arguments at each call
Upper boundData admits every Chisel type; Bits adds bit extraction but rejects Bundles
Same TNot a shape guarantee; Mux checks the hardware types during elaboration
gen templateA Chisel type used by Input, Output, Wire, Reg; never hardware
chiselTypeOfGets the Chisel type of a hardware value
Value parameterPlain Scala; read with if and loops while the generator runs
case classNames, defaults, require checks, and derived values in one place
Optional portSome(IO(...)) or None; access with foreach/map, never .get
Module parameterT <: BaseModule with Trait, passed by name as => T

7. Check your understanding

  1. Pick already has the type parameter T. Why does it also need gen?

    Show answer

    A port needs an exact width and field layout, and the Scala type parameter is not available as a value while the generator runs. gen is a Chisel type object that carries that information, so Input(gen) can build a port of the right shape.

  2. T <: Bits stops the Pixel test at compile time, but Pick(0.U(8.W)) fails only during elaboration. Why the difference?

    Show answer

    The bound is a Scala type rule, and Pixel is not a Bits, so the compiler rejects it. 0.U(8.W) has Scala type UInt, which satisfies the bound; only Chisel can tell that it is hardware rather than a type, and it checks that when Input(gen) runs.

  3. In the 4-bit accumulator, which node produces the carry, and why is it one bit wider than the register?

    Show answer

    next = acc +& io.in. The +& operator returns a result one bit wider than its widest operand, so the sum of two 4-bit values is 5 bits. Bit 4 is the carry and bits 3 to 0 go back into the register.

  4. Does Choose(sel, 1.U, -1.S) compile? What happens when it runs?

    Show answer

    It compiles: Scala infers a common supertype of UInt and SInt for T. Elaboration then stops inside Mux with can't create Mux with heterogeneous types.

  5. You add AccConfig(6, withCarry = true) as a third configuration. What final sum and carry pattern do you expect for the inputs 5, 6, 7?

    Show answer

    6 bits hold up to 63, so 5 + 6 + 7 = 18 never overflows. The test prints carryPort=true, a carry of 0 on all three lines, and a final sum of 18.

Checkpoint

Run the example and compare it with your prediction

Sources

Licences and notices for the sources this site follows