6.13. IOBinders

In Chipyard we use a special Parameters key, IOBinders to instantiate IO cells in the ChipTop layer and determine what modules to bind to the IOs of a ChipTop in the TestHarness.

// This type describes a function callable on the TestHarness instance. Its return type is unused.
type TestHarnessFunction = (chipyard.TestHarness) => Seq[Any]
// IOBinders will return a Seq of this tuple, which contains three fields:
//  1. A Seq containing all IO ports created by the IOBinder function
//  2. A Seq containing all IO cell modules created by the IOBinder function
//  3. An optional function to call inside the test harness (e.g. to connect the IOs)
type IOBinderTuple = (Seq[Data], Seq[IOCell], Option[TestHarnessFunction])

case object IOBinders extends Field[Map[String, (Any) => Seq[IOBinderTuple]]](
  Map[String, (Any) => Seq[IOBinderTuple]]().withDefaultValue((Any) => Nil)
)

// This macro overrides previous matches on some Top mixin. This is useful for
// binders which drive IO, since those typically cannot be composed
class OverrideIOBinder[T](fn: => (T) => Seq[IOBinderTuple])(implicit tag: ClassTag[T]) extends Config((site, here, up) => {
  case IOBinders => up(IOBinders, site) + (tag.runtimeClass.toString ->
      ((t: Any) => {
        t match {
          case system: T => fn(system)
          case _ => Nil
        }
      })
  )
})

// This macro composes with previous matches on some Top mixin. This is useful for
// annotation-like binders, since those can typically be composed
class ComposeIOBinder[T](fn: => (T) => Seq[IOBinderTuple])(implicit tag: ClassTag[T]) extends Config((site, here, up) => {
  case IOBinders => up(IOBinders, site) + (tag.runtimeClass.toString ->
      ((t: Any) => {
        t match {
          case system: T => (up(IOBinders, site)(tag.runtimeClass.toString)(system)
            ++ fn(system))
          case _ => Nil
        }
      })
  )
})

This special key solves the problem of duplicating test-harnesses for each different System type. You could just as well create a custom harness module that attaches IOs explicitly. Instead, the IOBinders key provides a map from Scala traits to attachment behaviors. Each IOBinder returns a tuple of three values: the list of ChipTop ports created by the IOBinder, the list of all IO cell modules instantiated by the IOBinder, and an optional function to be called inside the test harness. This function is responsible for instantiating logic inside the TestHarness to appropriately drive the ChipTop IO ports created by the IOBinder. Conveniently, because the IOBinder is generating the port, it may also use the port inside this function, which prevents the BaseChipTop code from ever needing to access the port val, thus having the IOBinder house all port specific code. This scheme prevents the need to have two separate binder functions for each System trait. When creating custom IOBinders it is important to use suggestName to name ports; otherwise Chisel will raise an exception trying to name the IOs. The example IOBinders demonstrate this.

As an example, the WithGPIOTiedOff IOBinder creates IO cells for the GPIO module(s) instantiated in the System, then punches out new Analog ports for each one. The test harness simply ties these off, but additional logic could be inserted to perform some kind of test in the TestHarness.

class WithGPIOTiedOff extends OverrideIOBinder({
  (system: HasPeripheryGPIOModuleImp) => {
    val (ports2d, ioCells2d) = AddIOCells.gpio(system.gpio)
    val harnessFn = (th: chipyard.TestHarness) => { ports2d.flatten.foreach(_ <> AnalogConst(0)); Nil }
    Seq((ports2d.flatten, ioCells2d.flatten, Some(harnessFn)))
  }
})

IOBinders also do not need to create ports. Some IOBinders can simply insert circuitry inside the ChipTop layer. For example, the WithSimAXIMemTiedOff IOBinder specifies that any System which matches CanHaveMasterAXI4MemPortModuleImp will have a SimAXIMem connected inside ChipTop.

class WithSimAXIMem extends OverrideIOBinder({
  (system: CanHaveMasterAXI4MemPort with BaseSubsystem) => {
    val peiTuples = AddIOCells.axi4(system.mem_axi4, system.memAXI4Node)
    // TODO: we are inlining the connectMem method of SimAXIMem because
    //   it takes in a dut rather than seq of axi4 ports
    val harnessFn = (th: chipyard.TestHarness) => {
      peiTuples.map { case (port, edge, ios) =>
        val mem = LazyModule(new SimAXIMem(edge, size = system.p(ExtMem).get.master.size)(system.p))
        Module(mem.module).suggestName("mem")
        mem.io_axi4.head <> port
        }
      Nil
    }
    Seq((peiTuples.map(_._1), peiTuples.flatMap(_._3), Some(harnessFn)))
  }
})

These classes are all Config objects, which can be mixed into the configs to specify IO connection behaviors.

There are two macros for generating these Config``s. ``OverrideIOBinder overrides any existing behaviors set for a particular IO in the Config object. This macro is frequently used because typically top-level IOs drive or are driven by only one source, so a composition of IOBinders does not make sense. The ComposeIOBinder macro provides the functionality of not overriding existing behaviors.