6.6. Keys, Traits, and Configs

You have probably seen snippets of Chisel referencing Keys, Traits, and Configs by this point. This section aims to elucidate the interactions between these Chisel/Scala components, and provide best practices for how these should be used to create a parameterized design and configure it.

We will continue to use the GCD example.

6.6.1. Keys

Keys specify some parameter which controls some custom widget. Keys should typically be implemented as Option types, with a default value of None that means no change in the system. In other words, the default behavior when the user does not explicitly set the key should be a no-op.

Keys should be defined and documented in sub-projects, since they generally deal with some specific block, and not system-level integration. (We make an exception for the example GCD widget).

case object GCDKey extends Field[Option[GCDParams]](None)

The object within a key is typically a case class XXXParams, which defines a set of parameters which some block accepts. For example, the GCD widget’s GCDParams parameterizes its address, operand widths, whether the widget should be connected by Tilelink or AXI4, and whether the widget should use the blackbox-Verilog implementation, or the Chisel implementation.

case class GCDParams(
  address: BigInt = 0x2000,
  width: Int = 32,
  useAXI4: Boolean = false,
  useBlackBox: Boolean = true)

Accessing the value stored in the key is easy in Chisel, as long as the implicit p: Parameters object is being passed through to the relevant module. For example, p(GCDKey).get.address returns the address field of GCDParams. Note this only works if GCDKey was not set to None, so your Chisel should check for that case!

6.6.2. Traits

Typically, most custom blocks will need to modify the behavior of some pre-existing block. For example, the GCD widget needs the Top module to instantiate and connect the widget via Tilelink, generate a top-level gcd_busy port, and connect that to the module as well. Traits let us do this without modifying the existing code for the Top, and enables compartmentalization of code for different custom blocks.

Top-level traits specify that the Top has been parameterized to read some custom Key and optionally instantiate and connect a widget defined by that Key. Traits should not mandate the instantiation of custom logic. In other words, traits should be written with CanHave semantics, where the default behavior when the Key is unset is a no-op.

Top-level traits should be defined and documented in subprojects, alongside their corresponding Keys. The traits should then be added to the Top being used by Chipyard.

Below we see the traits for the GCD example. The Lazy trait connects the GCD module to the Diplomacy graph, while the Implementation trait causes the Top to instantiate an additional port and concretely connect it to the GCD module.

trait CanHavePeripheryGCD { this: BaseSubsystem =>
  private val portName = "gcd"

  // Only build if we are using the TL (nonAXI4) version
  val gcd = p(GCDKey) match {
    case Some(params) => {
      if (params.useAXI4) {
        val gcd = LazyModule(new GCDAXI4(params, pbus.beatBytes)(p))
        pbus.toSlave(Some(portName)) {
          gcd.node :=
          AXI4Buffer () :=
          TLToAXI4 () :=
          // toVariableWidthSlave doesn't use holdFirstDeny, which TLToAXI4() needsx
          TLFragmenter(pbus.beatBytes, pbus.blockBytes, holdFirstDeny = true)
        }
        Some(gcd)
      } else {
        val gcd = LazyModule(new GCDTL(params, pbus.beatBytes)(p))
        pbus.toVariableWidthSlave(Some(portName)) { gcd.node }
        Some(gcd)
      }
    }
    case None => None
  }
}
// DOC include end: GCD lazy trait

// DOC include start: GCD imp trait
trait CanHavePeripheryGCDModuleImp extends LazyModuleImp {
  val outer: CanHavePeripheryGCD
  val gcd_busy = outer.gcd match {
    case Some(gcd) => {
      val busy = IO(Output(Bool()))
      busy := gcd.module.io.gcd_busy
      Some(busy)
    }
    case None => None
  }
}

These traits are added to the default Top in Chipyard.

class Top(implicit p: Parameters) extends System
  with CanHavePeripheryUARTAdapter // Enables optionally adding the UART print adapter
  with HasPeripheryUART // Enables optionally adding the sifive UART
  with HasPeripheryGPIO // Enables optionally adding the sifive GPIOs
  with CanHavePeripheryBlockDevice // Enables optionally adding the block device
  with CanHavePeripheryInitZero // Enables optionally adding the initzero example widget
  with CanHavePeripheryGCD // Enables optionally adding the GCD example widget
  with CanHavePeripherySerial // Enables optionally adding the TSI serial-adapter and port
  with CanHavePeripheryIceNIC // Enables optionally adding the IceNIC for FireSim
  with CanHaveBackingScratchpad // Enables optionally adding a backing scratchpad
{
  override lazy val module = new TopModule(this)
}

class TopModule[+L <: Top](l: L) extends SystemModule(l)
  with HasPeripheryGPIOModuleImp
  with HasPeripheryUARTModuleImp
  with CanHavePeripheryBlockDeviceModuleImp
  with CanHavePeripheryGCDModuleImp
  with CanHavePeripherySerialModuleImp
  with CanHavePeripheryIceNICModuleImp
  with CanHavePeripheryUARTAdapterModuleImp
  with DontTouch

6.6.3. Mixins

Mixins set the keys to a non-default value. Together, the collection of Mixins which define a configuration generate the values for all the keys used by the generator.

For example, the WithGCDMixin is parameterized by the type of GCD widget you want to instantiate. When this mixin is added to a config, the GCDKey is set to a instance of GCDParams, informing the previously mentioned traits to instantiate and connect the GCD widget appropriately.

/**
 * Mixin to add a GCD peripheral
 */
class WithGCD(useAXI4: Boolean, useBlackBox: Boolean) extends Config((site, here, up) => {
  case GCDKey => Some(GCDParams(useAXI4 = useAXI4, useBlackBox = useBlackBox))
})

We can use this mixin when composing our configs.

class GCDTLRocketConfig extends Config(
  new WithTSI ++
  new WithNoGPIO ++
  new WithUART ++
  new WithGCD(useAXI4=false, useBlackBox=false) ++          // Use GCD Chisel, connect Tilelink
  new WithBootROM ++
  new freechips.rocketchip.subsystem.WithNoMMIOPort ++
  new freechips.rocketchip.subsystem.WithNoSlavePort ++
  new freechips.rocketchip.subsystem.WithInclusiveCache ++
  new freechips.rocketchip.subsystem.WithNBigCores(1) ++
  new freechips.rocketchip.system.BaseConfig)

6.6.4. BuildTop

The BuildTop key is special, because sometimes, we need to instantiate TestHarness modules to interface with a custom widget. The BuildTop key provides a function which can call some method of the Top to instantiate these TestHarness modules. Since the BuildTop key is called from the TestHarness, these modules will appear in the TestHarness. The config system also lets the BuildTop key look recursively into previous definitions of itself. This enables composability of the Top configurations.

For example, conside a config that contains the mixins WithGPIO ++ WithTSI. We need to instantiate the TSI serial adapter, and connect it to the success signal of our TestHarness. We also need to instantiate the GPIO pins, and tie their inputs to 0 in the TestHarness, since we currently cannot drive the GPIOs in simulation.

/**
 * Mixin to add an offchip TSI link (used for backing memory)
 */
class WithTSI extends Config((site, here, up) => {
  case SerialKey => true
  case BuildTop => (clock: Clock, reset: Bool, p: Parameters, success: Bool) => {
    val top = up(BuildTop, site)(clock, reset, p, success)
    success := top.connectSimSerial()
    top
  }
})
/**
 * Mixin to add GPIOs and tie them off outside the DUT
 */
class WithGPIO extends Config((site, here, up) => {
  case PeripheryGPIOKey => Seq(
    GPIOParams(address = 0x10012000, width = 4, includeIOF = false))
  case BuildTop => (clock: Clock, reset: Bool, p: Parameters, success: Bool) => {
    val top = up(BuildTop, site)(clock, reset, p, success)
    // TODO: Currently FIRRTL will error if the GPIO input
    // pins are unconnected, so tie them to 0.
    // In future IO cell blackboxes will replace this with
    // more correct functionality
    for (gpio <- top.gpio) {
      for (pin <- gpio.pins) {
        pin.i.ival := false.B
      }
    }
    top
  }
})

When WithGPIO ++ WithTSI is evaluated right to left, the call to up(BuildTop, site) in WithGPIO will reference the function defined in the BuildTop key of WithTSI. Thus, at elaboration time, when the BuildTop function is called by the TestHarness, first the BuildTop function in WithTSI will be evaluated. This connects the success signal of the TestHarness to the SerialAdapter enabled by WithTSI. Then, the rest of the code in the BuildTop function of WithGPIO will execute, tieing off the top-level GPIO input pins. Thus the evaluation of the BuildTop functions in a completed config is “right-to-left”, matching how the evaluation of the mixins at compile-time is also “right-to-left”.

Warning

In some cases, the ordering and duplication of mixins which extend BuildTop will have unintended consequences. For example, WithTSI ++ WithTSI will attempt to generate and connect two SimSerial widgets in the TestHarness, which will likely break the simulation. In general, you should avoid attaching multiple mixins which interface to the same top-level ports.

Note

Readers who want more information on the configuration system may be interested in reading Context-Dependent-Environments.