From d8ed90a4ac020aebec40f6e5b4afa3e0838c4db1 Mon Sep 17 00:00:00 2001 From: fkhaidari Date: Sun, 26 Apr 2026 23:11:37 +0300 Subject: [PATCH] [debug] Add circt_debug_* intrinsics for Chisel type metadata Introduce an opt-in pass that injects CIRCT debug intrinsics carrying Chisel-level type and constructor-parameter metadata through to FIRRTL, so downstream tooling (waveform viewers, debuggers) can recover Bundle field names, Vec shapes, ChiselEnum types and module parameters that are otherwise lost during conversion. The DebugIntrinsics emitter walks the elaborated circuit and appends four kinds of secret intrinsics: - circt_debug_moduleinfo per module (typeName + ctor params) - circt_debug_var per top-level signal, port or memory - circt_debug_subfield per Bundle/Vec element, linked by parent - circt_debug_enumdef per ChiselEnum type (emitted once) Constructor parameters are extracted via runtime reflection and serialised to JSON. Booleans round-trip as native JSON bools; all numeric types (Byte / Short / Int / Long / Float / Double) serialise uniformly as JSON strings -- JSON has no NaN/Infinity literals and Double-backed parsers lose precision on Long > 2^53, so a single string shape avoids both pitfalls and gives downstream sinks one attribute mapping to write. JDK and Scala stdlib classes are not recursed into during nested ctor extraction; their constructors are implementation details and produce no user-meaningful parameters. User toString output embedded in the params payload is capped at 4 KiB to bound emission size against pathological implementations. Emission is disabled by default and activated by adding EmitDebugIntrinsicsAnnotation to the annotation seq programmatically, or by passing `--with-debug-intrinsics` on the CLI to any circt.stage.ChiselStage entry point. Either form schedules the new AddDebugIntrinsics phase between Elaborate and Convert; the phase is a no-op without the annotation. After consuming it, the phase strips EmitDebugIntrinsicsAnnotation from the seq so a second pass over the same annotations does not double-emit intrinsics. Includes unit tests for moduleinfo / var / subfield / enumdef emission across scalar, Bundle, Vec, ChiselEnum, memory and hierarchy cases, plus a docs section in docs/src/explanations/intrinsics.md. Based on work of @rameloni Signed-off-by: fkhaidari --- .../chisel3/debug/CtorParamsPlatform.scala | 20 ++ .../chisel3/debug/CtorParamsPlatform.scala | 40 ++++ .../main/scala/chisel3/debug/DebugMeta.scala | 166 +++++++++++++ .../chisel3/debug/DebugMetaEmitter.scala | 197 +++++++++++++++ .../debug/EmitDebugIntrinsicsAnnotation.scala | 22 ++ docs/src/explanations/intrinsics.md | 79 ++++++ .../stage/phases/AddDebugIntrinsics.scala | 29 +++ src/main/scala/circt/stage/ChiselStage.scala | 2 + src/main/scala/circt/stage/Shell.scala | 4 +- .../debug/DebugCtorParamExtractorSpec.scala | 210 ++++++++++++++++ .../chisel3/debug/DebugDataTypesSpec.scala | 178 ++++++++++++++ .../chisel3/debug/DebugIntrinsicsSpec.scala | 184 ++++++++++++++ .../chisel3/debug/DebugTestCircuits.scala | 226 ++++++++++++++++++ 13 files changed, 1356 insertions(+), 1 deletion(-) create mode 100644 core/src/main/scala-2/chisel3/debug/CtorParamsPlatform.scala create mode 100644 core/src/main/scala-3/chisel3/debug/CtorParamsPlatform.scala create mode 100644 core/src/main/scala/chisel3/debug/DebugMeta.scala create mode 100644 core/src/main/scala/chisel3/debug/DebugMetaEmitter.scala create mode 100644 core/src/main/scala/chisel3/debug/EmitDebugIntrinsicsAnnotation.scala create mode 100644 src/main/scala/chisel3/stage/phases/AddDebugIntrinsics.scala create mode 100644 src/test/scala/chisel3/debug/DebugCtorParamExtractorSpec.scala create mode 100644 src/test/scala/chisel3/debug/DebugDataTypesSpec.scala create mode 100644 src/test/scala/chisel3/debug/DebugIntrinsicsSpec.scala create mode 100644 src/test/scala/chisel3/debug/DebugTestCircuits.scala diff --git a/core/src/main/scala-2/chisel3/debug/CtorParamsPlatform.scala b/core/src/main/scala-2/chisel3/debug/CtorParamsPlatform.scala new file mode 100644 index 00000000000..6af77e8057f --- /dev/null +++ b/core/src/main/scala-2/chisel3/debug/CtorParamsPlatform.scala @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 + +package chisel3.debug + +import scala.reflect.runtime.universe._ + +private[debug] object CtorParamsPlatform { + + def ctorParams(obj: Any): Seq[(String, String)] = { + val ctor = typeOfInstance(obj).typeSymbol.asClass.primaryConstructor + if (ctor == NoSymbol) Seq.empty + else + ctor.asMethod.paramLists.flatten + .filter(!_.name.toString.contains("$outer")) + .map(a => (a.name.toString.trim, a.info.typeSymbol.name.decodedName.toString.trim)) + } + + private def typeOfInstance(obj: Any): Type = + runtimeMirror(obj.getClass.getClassLoader).classSymbol(obj.getClass).toType +} diff --git a/core/src/main/scala-3/chisel3/debug/CtorParamsPlatform.scala b/core/src/main/scala-3/chisel3/debug/CtorParamsPlatform.scala new file mode 100644 index 00000000000..4727dff77e9 --- /dev/null +++ b/core/src/main/scala-3/chisel3/debug/CtorParamsPlatform.scala @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 + +package chisel3.debug + +import logger.LazyLogging + +// Scala 3 has no runtime mirror to pick a primary out of multiple ctors, +// so emit params only for single-ctor classes. +private[debug] object CtorParamsPlatform extends LazyLogging { + + def ctorParams(obj: Any): Seq[(String, String)] = { + val cls = obj.getClass + val ctors = cls.getDeclaredConstructors + if (ctors.length != 1) { + if (ctors.length > 1) + logger.warn(s"ctorParams: ${cls.getName} has ${ctors.length} constructors; omitting `params`") + Seq.empty + } else { + val rawParams = ctors.head.getParameters.toSeq.filter(!_.getName.contains("$outer")) + val namesSynthetic = rawParams.nonEmpty && rawParams.forall(!_.isNamePresent) + val params = rawParams.map(p => (p.getName, simpleTypeName(p.getParameterizedType.getTypeName))) + if (namesSynthetic) + logger.warn( + s"ctorParams: ${cls.getName} has only synthetic parameter names " + + s"(${params.map(_._1).mkString(", ")}); emitted debug metadata will be of limited use. " + + s"Compile user code with a flag that retains parameter names " + + s"(e.g. javac `-parameters`) to recover real names." + ) + params + } + } + + private def simpleTypeName(raw: String): String = { + val noGeneric = raw.takeWhile(_ != '<') + val afterDot = noGeneric.substring(noGeneric.lastIndexOf('.') + 1) + val lastDollar = afterDot.lastIndexOf('$') + val name = if (lastDollar >= 0) afterDot.substring(lastDollar + 1) else afterDot + name.capitalize + } +} diff --git a/core/src/main/scala/chisel3/debug/DebugMeta.scala b/core/src/main/scala/chisel3/debug/DebugMeta.scala new file mode 100644 index 00000000000..27a8b541f9b --- /dev/null +++ b/core/src/main/scala/chisel3/debug/DebugMeta.scala @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 + +package chisel3.debug + +import logger.LazyLogging + +import chisel3._ + +import upickle.{default => json} + +import scala.collection.mutable +import scala.language.existentials +import scala.util.Try +import scala.util.control.NonFatal + +private[debug] case class ClassParam( + name: String, + typeName: String, + value: Option[ujson.Value] = None +) + +private[debug] object ClassParam { + implicit val rw: json.ReadWriter[ClassParam] = json + .readwriter[ujson.Value] + .bimap( + p => + ujson.Obj( + "name" -> p.name, + "typeName" -> p.typeName, + "value" -> p.value.getOrElse(ujson.Null) + ), + j => + ClassParam( + j("name").str, + j("typeName").str, + j.obj.get("value").filterNot(_ == ujson.Null) + ) + ) +} + +private[debug] object CtorParamExtractor { + private[debug] val MaxParamDepth = 8 + private[debug] val MaxRenderedLen = 256 + private[debug] val TruncatedSuffix = "...[truncated]" + + private[debug] def dataToTypeName(data: Data): String = sanitize(data match { + case t: Record => + t.topBindingOpt match { + case Some(binding) => s"${t._bindingToString(binding)}[${t.className}]" + case None => t.className + } + case t => t.toString.split(" ").last + }) + + private def sanitize(s: String): String = + s.replaceAll("[\\p{Cntrl}\"\\\\]", "") + + private[debug] def getCtorParams(target: Any): Seq[ClassParam] = + new CtorParamExtractor().getCtorParams(target) +} + +private[debug] final class CtorParamExtractor extends LazyLogging { + import CtorParamExtractor.{dataToTypeName, MaxParamDepth, MaxRenderedLen, TruncatedSuffix} + + private case class ClassDescriptor( + params: Seq[(String, String)], + accessors: Map[String, java.lang.reflect.Method] + ) + + private val descriptorCache = mutable.HashMap.empty[Class[_], ClassDescriptor] + // Identity-keyed; depth bounded by MaxParamDepth so linear scan beats hashing. + // Reset at every getCtorParams entry; safe because elaboration is single-threaded. + private val visited = mutable.ArrayBuffer.empty[AnyRef] + + private def descriptor(target: Any): ClassDescriptor = { + val cls = target.getClass + descriptorCache.getOrElseUpdate(cls, buildDescriptor(target, cls)) + } + + private def buildDescriptor(target: Any, cls: Class[_]): ClassDescriptor = { + val params = + try CtorParamsPlatform.ctorParams(target) + catch { + case NonFatal(e) => + logger.debug(s"ctorParams failed on ${cls.getName}: ${e.getMessage}") + Seq.empty[(String, String)] + } + val accessors = params.iterator.flatMap { case (name, _) => + try { + val m = cls.getDeclaredMethod(name) + m.setAccessible(true) + Some(name -> m) + } catch { case NonFatal(_) => None } + }.toMap + ClassDescriptor(params, accessors) + } + + private[debug] def getCtorParams(target: Any): Seq[ClassParam] = { + visited.clear() + target match { case ref: AnyRef => visited += ref; case _ => } + getCtorParamsImpl(target, 0) + } + + private def getCtorParamsImpl(target: Any, depth: Int): Seq[ClassParam] = { + val d = descriptor(target) + d.params.map { case (name, typeName) => + ClassParam(name, typeName, paramValue(target, d, name, typeName, depth)) + } + } + + private def paramValue( + obj: Any, + desc: ClassDescriptor, + name: String, + typeName: String, + depth: Int + ): Option[ujson.Value] = + desc.accessors.get(name).flatMap { method => + Try(method.invoke(obj.asInstanceOf[AnyRef])).fold( + e => { logger.debug(s"paramValue: cannot reflect $name: ${e.getMessage}"); None }, + v => Some(renderValue(v, typeName, depth)) + ) + } + + private def renderValue(v: Any, typeName: String, depth: Int): ujson.Value = v match { + case s: scala.collection.Seq[_] if s.exists(_.isInstanceOf[Data]) => + ujson.Str(s.collect { case d: Data => dataToTypeName(d) }.mkString("[", ", ", "]")) + case d: Data => ujson.Str(dataToTypeName(d)) + case b: Boolean => ujson.Bool(b) + case _: Byte | _: Short | _: Int | _: Long | _: Float | _: Double => ujson.Str(v.toString) + case null => ujson.Str("null") + case ref: AnyRef => + if (depth >= MaxParamDepth || visited.exists(_ eq ref) || isOpaqueStdlibClass(ref.getClass)) + ujson.Str(capped(ref.toString)) + else { + visited += ref + val nested = + try getCtorParamsImpl(ref, depth + 1) + finally visited.dropRightInPlace(1) + if (nested.exists(_.value.isDefined)) + ujson.Str( + capped( + s"$typeName(${nested.map(p => p.value.fold(p.name)(vv => s"${p.name}: ${renderJson(vv)}")).mkString(", ")})" + ) + ) + else ujson.Str(capped(ref.toString)) + } + case other => ujson.Str(capped(other.toString)) + } + + private def renderJson(v: ujson.Value): String = v match { + case ujson.Str(s) => s + case ujson.Bool(b) => b.toString + case ujson.Num(n) => if (n.isWhole && !n.isInfinity) n.toLong.toString else n.toString + case other => other.toString + } + + private def capped(s: String): String = + if (s.length <= MaxRenderedLen) s else s.substring(0, MaxRenderedLen) + TruncatedSuffix + + private def isOpaqueStdlibClass(cls: Class[_]): Boolean = { + val name = cls.getName + name.startsWith("java.") || name.startsWith("javax.") || + name.startsWith("sun.") || name.startsWith("scala.") + } +} diff --git a/core/src/main/scala/chisel3/debug/DebugMetaEmitter.scala b/core/src/main/scala/chisel3/debug/DebugMetaEmitter.scala new file mode 100644 index 00000000000..86007867575 --- /dev/null +++ b/core/src/main/scala/chisel3/debug/DebugMetaEmitter.scala @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +package chisel3.debug + +import logger.LazyLogging + +import chisel3._ +import chisel3.internal._ +import chisel3.internal.binding._ +import chisel3.internal.firrtl.ir._ +import chisel3.debug.CtorParamExtractor.dataToTypeName +import chisel3.experimental.{BaseModule, SourceInfo} + +import scala.collection.mutable +import upickle.{default => json} + +/** Injects `circt_debug_*` secret-command intrinsics into an elaborated + * [[Circuit]]. Driven by [[chisel3.stage.phases.AddDebugIntrinsics]]. + */ +private[chisel3] object DebugIntrinsics { + + def generate(circuit: Circuit): Unit = { + val emitter = new ComponentDebugEmitter + circuit.components.foreach(emitter.generate) + } + + private class ComponentDebugEmitter extends LazyLogging { + private val emittedEnums = mutable.HashSet.empty[String] + private val emittedIds = mutable.HashSet.empty[HasId] + + private val ctorExtractor = new CtorParamExtractor + + def generate(component: Component): Unit = component match { + case ctx @ DefModule(id, _, _, _, ports, block) => + processModule(id, ports ++ ctx.secretPorts, block) + case ctx @ DefClass(id, _, ports, block) => + processModule(id, ports ++ ctx.secretPorts, block) + case _: DefBlackBox => () + case _: DefIntrinsicModule => () + case ctx => + throw new InternalErrorException(s"generate: unknown Component type: $ctx") + } + + private def processModule(id: BaseModule, allPorts: Seq[Port], block: Block): Unit = { + emittedIds.clear() + createIntrinsic(id, id._getSourceLocator).foreach(block.addSecretCommand) + allPorts.foreach { p => createIntrinsic(p.id, None, p.sourceInfo).foreach(block.addSecretCommand) } + processBlock(block) + } + + // `When` is special-cased so that we read `elseRegion` only via `hasElse`; + // the getter would otherwise materialise an empty Block on first call. + private def processBlock(block: Block): Unit = block.getCommands().foreach { c => + generate(c).foreach(block.addSecretCommand) + c match { + case w: When => + processBlock(w.ifRegion) + if (w.hasElse) processBlock(w.elseRegion) + case lb: LayerBlock => processBlock(lb.region) + case dc: DefContract => processBlock(dc.region) + case _ => () + } + } + + private def generate(cmd: Command): Seq[Command] = cmd match { + case e: DefPrim[_] => createIntrinsic(e.id, None, e.sourceInfo) + case DefWire(si, id) => createIntrinsic(id, None, si) + case DefReg(si, id, _) => createIntrinsic(id, None, si) + case DefRegInit(si, id, _, _, _) => createIntrinsic(id, None, si) + case DefMemory(si, id, t, size) => createIntrinsicMem(id, t, size, si) + case DefSeqMemory(si, id, t, size, _) => createIntrinsicMem(id, t, size, si) + case FirrtlMemory(si, id, t, size, _, _, _, _, _) => createIntrinsicMem(id, t, size, si) + case _ => Seq.empty + } + + private def hasOpaqueCtor(target: Any): Boolean = target match { + case _: chisel3.Bits | _: chisel3.Clock | _: chisel3.Reset => true + case _: chisel3.experimental.Analog => true + case _: chisel3.Vec[_] | _: chisel3.EnumType => true + case _ => target.getClass.getName == "chisel3.util.MixedVec" + } + + private def extractParams(target: Any): Seq[ClassParam] = + if (hasOpaqueCtor(target)) Nil else ctorExtractor.getCtorParams(target) + + private def paramsAttr(params: Seq[ClassParam]): Seq[(String, Param)] = + if (params.isEmpty) Nil else Seq("params" -> StringParam(json.write(params))) + + private def createIntrinsicMem(target: HasId, innerType: Data, size: BigInt, si: SourceInfo): Seq[Command] = { + val typeName = s"${target.getClass.getSimpleName}[${dataToTypeName(innerType)}[$size]]" + val name = target.getOptionRef + .map(_.localName) + .getOrElse(target match { + case m: MemBase[_] => m.instanceName + case _ => "" + }) + if (name.isEmpty) { + logger.warn(s"createIntrinsicMem: skipping memory with empty name: $target") + return Seq.empty + } + Seq( + DefIntrinsic( + si, + "circt_debug_var", + Nil, + Seq("typeName" -> StringParam(typeName), "name" -> StringParam(name)) ++ paramsAttr(extractParams(target)) + ) + ) + } + + private def createIntrinsic(target: Data, parent: Option[String], si: SourceInfo): Seq[Command] = { + if (!emittedIds.add(target)) return Seq.empty + val typeName = dataToTypeName(target) + val subCmds: Seq[Command] = target match { + case e: EnumType => createEnumDefIntrinsic(e, si).toSeq + case record: Record => + val pn = Some(signalRef(target)) + record.elements.values.flatMap(createIntrinsic(_, pn, si)).toSeq + case vecLike: VecLike[_] => + val pn = Some(signalRef(target)) + vecLike.toSeq.flatMap(e => createIntrinsic(e.asInstanceOf[Data], pn, si)) + case _ => Nil + } + subCmds ++ createDebugIntrinsic(target, typeName, parent, extractParams(target), si).toSeq + } + + private case class EnumVariant(name: String, value: String) + private implicit val enumVariantRW: json.ReadWriter[EnumVariant] = json.macroRW + + private def enumNames(e: EnumType): (String, String) = { + val fqn = e.factory.enumTypeName.stripSuffix("$") + (fqn, fqn.split("\\.").last) + } + + private def createEnumDefIntrinsic(e: EnumType, si: SourceInfo): Option[Command] = { + val (fqn, simple) = enumNames(e) + if (!emittedEnums.add(fqn)) return None + val variants = e.factory.allWithNames.map { case (v, name) => EnumVariant(name, v.litValue.toString) } + Some( + DefIntrinsic( + si, + "circt_debug_enumdef", + Nil, + Seq( + "typeName" -> StringParam(simple), + "fqn" -> StringParam(fqn), + "variants" -> StringParam(json.write(variants)) + ) + ) + ) + } + + private def createIntrinsic(target: BaseModule, si: SourceInfo): Seq[Command] = Seq( + DefIntrinsic( + si, + "circt_debug_moduleinfo", + Nil, + Seq("typeName" -> StringParam(target.desiredName)) ++ paramsAttr(ctorExtractor.getCtorParams(target)) + ) + ) + + private def createDebugIntrinsic( + target: Data, + typeName: String, + parent: Option[String], + params: Seq[ClassParam], + si: SourceInfo + ): Option[Command] = { + val name = signalRef(target) + if (name.isEmpty) return None + // Synthetic `_`-prefixed names don't survive to final FIRRTL. + if (parent.isEmpty && name.startsWith("_")) return None + // firrtl.int.generic operands must be passive. + val ssaOperands: Seq[Arg] = + if (target.direction.isInstanceOf[ActualDirection.Bidirectional]) Nil else Seq(Node(target)) + val intrinsicName = if (parent.isDefined) "circt_debug_subfield" else "circt_debug_var" + val enumParam: Seq[(String, Param)] = target match { + case e: EnumType => + val (fqn, simple) = enumNames(e) + Seq("enumTypeName" -> StringParam(simple), "enumFqn" -> StringParam(fqn)) + case _ => Nil + } + val parentParam = parent.map("parent" -> StringParam(_)).toSeq + Some( + DefIntrinsic( + si, + intrinsicName, + ssaOperands, + Seq("typeName" -> StringParam(typeName), "name" -> StringParam(name)) + ++ parentParam ++ enumParam ++ paramsAttr(params) + ) + ) + } + } + + private def signalRef(d: Data): String = + d.getOptionRef.map(_.localName).getOrElse("") +} diff --git a/core/src/main/scala/chisel3/debug/EmitDebugIntrinsicsAnnotation.scala b/core/src/main/scala/chisel3/debug/EmitDebugIntrinsicsAnnotation.scala new file mode 100644 index 00000000000..cf41a4585bc --- /dev/null +++ b/core/src/main/scala/chisel3/debug/EmitDebugIntrinsicsAnnotation.scala @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 + +package chisel3.debug + +import firrtl.seqToAnnoSeq +import firrtl.annotations.NoTargetAnnotation +import firrtl.options.{HasShellOptions, ShellOption, Unserializable} + +/** Opt-in toggle for [[chisel3.stage.phases.AddDebugIntrinsics]]. + * Activate with `--with-debug-intrinsics` or by adding this annotation. + */ +case object EmitDebugIntrinsicsAnnotation extends NoTargetAnnotation with Unserializable with HasShellOptions { + + override val options: Seq[ShellOption[Unit]] = Seq( + new ShellOption[Unit]( + longOption = "with-debug-intrinsics", + toAnnotationSeq = _ => Seq(EmitDebugIntrinsicsAnnotation), + helpText = "Emit circt_debug_* intrinsics carrying Chisel type metadata", + helpValueName = None + ) + ) +} diff --git a/docs/src/explanations/intrinsics.md b/docs/src/explanations/intrinsics.md index 8b59aa08d51..9c85ba7fc6f 100644 --- a/docs/src/explanations/intrinsics.md +++ b/docs/src/explanations/intrinsics.md @@ -36,3 +36,82 @@ class Foo extends RawModule { val myresult = IntrinsicExpr("MyIntrinsic", UInt(32.W), "STRING" -> "test")(3.U, 5.U) } ``` + +## Debug type-info intrinsics + +Chisel can optionally emit a family of `circt_debug_*` intrinsics that carry +Chisel-level type and constructor-parameter metadata through to FIRRTL, so that +downstream tooling (waveform viewers, debuggers) can reconstruct Bundle, Vec +and `ChiselEnum` type names as well as module parameters that are lost during +conversion to plain FIRRTL. + +The following intrinsics are emitted: + +- `circt_debug_moduleinfo` - one per module, with `typeName` and a JSON-encoded + `params` string describing primary constructor arguments. +- `circt_debug_var` - one per top-level signal, port or memory, with `typeName` + and `name`. +- `circt_debug_subfield` - one per Bundle/Vec element, linked to its parent via + a `parent` attribute. +- `circt_debug_enumdef` - one per `ChiselEnum` type (emitted once per circuit), + listing all variants and their literal values. + +Emission is opt-in and off by default. Enable it by passing +`--with-debug-intrinsics` on the command line, or by adding +`EmitDebugIntrinsicsAnnotation` to the annotation seq programmatically: + +```scala mdoc:compile-only +import circt.stage.ChiselStage + +class MyBundle extends Bundle { + val a = UInt(8.W) + val b = Bool() +} +class MyModule(val width: Int) extends Module { + val io = IO(new Bundle { + val in = Input(new MyBundle) + val out = Output(new MyBundle) + }) + io.out := io.in +} + +// Emit CHIRRTL with circt_debug_* intrinsics interleaved. +val chirrtl = ChiselStage.emitCHIRRTL( + new MyModule(8), + args = Array("--with-debug-intrinsics") +) +``` + +Under the hood this activates the `AddDebugIntrinsics` stage phase, which walks +the elaborated circuit and appends intrinsics as secret commands - user code is +not altered. Without the annotation/flag the phase is a no-op. + +### Notes on reflection + +Constructor-parameter extraction uses Java/Scala reflection on user module and +Bundle classes. Two consequences worth knowing about: + +- **JDK 17+** restricts reflective access to non-public members. The pass calls + `setAccessible(true)` on `val`-accessor methods to read constructor argument + values. On a locked-down JVM this may throw `InaccessibleObjectException`; + the pass catches it and emits parameters without values rather than crashing + the build. To get values back, run with + `--add-opens java.base/=ALL-UNNAMED` for the affected packages, or + use `--with-debug-intrinsics` only in development builds. +- **Scala 3 ctor parameter names**: on Scala 3 the pass reads constructor + parameter names via Java reflection (`Parameter.getName`). Scala 3 does not + emit the JVM `MethodParameters` attribute by default, so `getName` returns + synthetic `arg0`, `arg1`, ... names -- the pass logs a one-shot warning per + affected class. There is currently no built-in scalac flag to fix this; the + warning is informational, and intrinsics still emit parameter types and + values, just without source-level names. Java sources can recover names by + compiling with `javac -parameters`. +- **Nested constructor parameters are extracted recursively** up to a small + depth limit. `toString` is invoked on user objects whose runtime class has + no extractable accessors; arbitrary `toString` implementations therefore + show up in the emitted metadata. The 256-byte cap bounds the *output size* + of `toString`, not its execution cost -- a slow or allocation-heavy + `toString` will slow or OOM the compile. Avoid expensive `toString`s in + classes you expect to feed into debug metadata. Cycles in the object graph + are detected via identity tracking and printed as `toString` rather than + recursed into. diff --git a/src/main/scala/chisel3/stage/phases/AddDebugIntrinsics.scala b/src/main/scala/chisel3/stage/phases/AddDebugIntrinsics.scala new file mode 100644 index 00000000000..36f864f7f9b --- /dev/null +++ b/src/main/scala/chisel3/stage/phases/AddDebugIntrinsics.scala @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +package chisel3.stage.phases + +import chisel3.debug.{DebugIntrinsics, EmitDebugIntrinsicsAnnotation} +import chisel3.stage.ChiselCircuitAnnotation + +import firrtl.options.{Dependency, Phase} +import firrtl.{annoSeqToSeq, seqToAnnoSeq, AnnotationSeq} + +/** No-op unless [[chisel3.debug.EmitDebugIntrinsicsAnnotation]] is present. */ +class AddDebugIntrinsics extends Phase { + override def prerequisites = Seq(Dependency[Elaborate]) + override def optionalPrerequisites = Seq.empty + override def optionalPrerequisiteOf = Seq(Dependency[Convert], Dependency[AddDedupGroupAnnotations]) + override def invalidates(a: Phase) = false + + def transform(annotations: AnnotationSeq): AnnotationSeq = + if (!annotations.contains(EmitDebugIntrinsicsAnnotation)) annotations + else + // Drop the annotation after consumption so a second pass is a no-op + // (the circuit is already mutated; re-running would duplicate intrinsics). + annotations.flatMap { + case EmitDebugIntrinsicsAnnotation => Nil + case a: ChiselCircuitAnnotation => + DebugIntrinsics.generate(a.elaboratedCircuit._circuit) + Seq(a) + case a => Seq(a) + } +} diff --git a/src/main/scala/circt/stage/ChiselStage.scala b/src/main/scala/circt/stage/ChiselStage.scala index ab9309ab668..05fb6a68d3e 100644 --- a/src/main/scala/circt/stage/ChiselStage.scala +++ b/src/main/scala/circt/stage/ChiselStage.scala @@ -34,6 +34,7 @@ class ChiselStage extends Stage { Dependency[chisel3.stage.phases.AddImplicitOutputFile], Dependency[chisel3.stage.phases.AddImplicitOutputAnnotationFile], Dependency[chisel3.stage.phases.AddSerializationAnnotations], + Dependency[chisel3.stage.phases.AddDebugIntrinsics], Dependency[chisel3.stage.phases.Convert], Dependency[chisel3.stage.phases.AddDedupGroupAnnotations], Dependency[circt.stage.phases.AddImplicitOutputFile], @@ -56,6 +57,7 @@ object ChiselStage { private def phase = new PhaseManager( Seq( Dependency[chisel3.stage.phases.Elaborate], + Dependency[chisel3.stage.phases.AddDebugIntrinsics], Dependency[chisel3.stage.phases.Convert], Dependency[chisel3.stage.phases.AddDedupGroupAnnotations], Dependency[circt.stage.phases.AddImplicitOutputFile], diff --git a/src/main/scala/circt/stage/Shell.scala b/src/main/scala/circt/stage/Shell.scala index a858df1c7b0..3f3c3c3186c 100644 --- a/src/main/scala/circt/stage/Shell.scala +++ b/src/main/scala/circt/stage/Shell.scala @@ -2,6 +2,7 @@ package circt.stage +import chisel3.debug.EmitDebugIntrinsicsAnnotation import chisel3.stage.{ ChiselCircuitAnnotation, ChiselGeneratorAnnotation, @@ -56,7 +57,8 @@ trait CLI extends BareShell { this: BareShell => UseSRAMBlackbox, IncludeInlineTestsForModule, IncludeInlineTestsWithName, - SuppressSourceInfoAnnotation + SuppressSourceInfoAnnotation, + EmitDebugIntrinsicsAnnotation ).foreach(_.addOptions(parser)) parser.note("CIRCT (MLIR FIRRTL Compiler) options") diff --git a/src/test/scala/chisel3/debug/DebugCtorParamExtractorSpec.scala b/src/test/scala/chisel3/debug/DebugCtorParamExtractorSpec.scala new file mode 100644 index 00000000000..ddf81c702ab --- /dev/null +++ b/src/test/scala/chisel3/debug/DebugCtorParamExtractorSpec.scala @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 +package chisel3.debug + +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import chisel3._ + +// Method-local classes become `$anon$1`; staticClass lookup needs object scope. +object DebugCtorParamExtractorSpec { + class Foo(val width: Int) + class Bar(val n: Int, val label: String, val flag: Boolean) + class Baz(x: Int) + class Mixed(val a: Int, b: String) + case class MyCaseClass(n: Int, name: String) + class Inner(val x: Int) + class Outer(val inner: Inner) + class A(val v: Int) + class B(val a: A) + class C(val b: B) + class Empty + class WithProtected(protected val n: Int) + class WithPrivate(private val n: Int) + class WithData(val gen: UInt) + class WithSecondary(val a: Int, val b: Int) { + def this(a: Int, b: Int, c: Int, d: Int) = this(a, b) + } + class WithGeneric(val xs: Seq[Int]) + class WithMap(val m: Map[String, Int]) + class WithTypeParam[T](val gen: T) + class WithDoubles(val nan: Double, val pos: Double, val neg: Double, val regular: Double) + class WithFloats(val nan: Float, val pos: Float, val neg: Float) + class WithBigLong(val small: Long, val big: Long) + class SharedSibling(val left: Inner, val right: Inner) + // Non-val ctor: reflection finds no accessor -> falls back to `toString`. + class HugeToString(x: Int) { override def toString: String = "x" * 100000 } + class WithHuge(val payload: HugeToString) +} + +class DebugCtorParamExtractorSpec extends AnyFunSpec with Matchers { + import DebugCtorParamExtractorSpec._ + + private def s(v: String): Option[ujson.Value] = Some(ujson.Str(v)) + private def n(v: Any): Option[ujson.Value] = Some(ujson.Str(v.toString)) + private def b(v: Boolean): Option[ujson.Value] = Some(ujson.Bool(v)) + + describe("val parameters") { + it("extracts name, typeName and value for a single val param") { + CtorParamExtractor.getCtorParams(new Foo(8)) shouldEqual Seq( + ClassParam("width", "Int", n(8)) + ) + } + + it("extracts multiple val params of different types") { + CtorParamExtractor.getCtorParams(new Bar(4, "hello", true)) shouldEqual Seq( + ClassParam("n", "Int", n(4)), + ClassParam("label", "String", s("hello")), + ClassParam("flag", "Boolean", b(true)) + ) + } + } + + describe("non-val parameters") { + it("returns None for value when param has no val") { + CtorParamExtractor.getCtorParams(new Baz(42)) shouldEqual Seq( + ClassParam("x", "Int", None) + ) + } + + it("mixes val and non-val in same constructor") { + CtorParamExtractor.getCtorParams(new Mixed(1, "ignored")) shouldEqual Seq( + ClassParam("a", "Int", n(1)), + ClassParam("b", "String", None) + ) + } + } + + describe("case class") { + it("extracts all fields with values") { + CtorParamExtractor.getCtorParams(MyCaseClass(7, "world")) shouldEqual Seq( + ClassParam("n", "Int", n(7)), + ClassParam("name", "String", s("world")) + ) + } + } + + describe("nested class parameters") { + it("recursively serializes one level of nesting") { + CtorParamExtractor.getCtorParams(new Outer(new Inner(3))) shouldEqual Seq( + ClassParam("inner", "Inner", s("Inner(x: 3)")) + ) + } + + it("handles two levels of nesting") { + val params = CtorParamExtractor.getCtorParams(new C(new B(new A(5)))) + params.head.value shouldEqual s("B(a: A(v: 5))") + } + } + + describe("empty constructor") { + it("returns empty Seq") { + CtorParamExtractor.getCtorParams(new Empty) shouldBe empty + } + } + + describe("protected and private val parameters") { + it("extracts protected val") { + CtorParamExtractor.getCtorParams(new WithProtected(9)) shouldEqual Seq( + ClassParam("n", "Int", n(9)) + ) + } + + it("extracts private val") { + CtorParamExtractor.getCtorParams(new WithPrivate(9)) shouldEqual Seq( + ClassParam("n", "Int", n(9)) + ) + } + } + + describe("Data parameters") { + it("does not crash and returns non-empty value for unbound Data param") { + val params = CtorParamExtractor.getCtorParams(new WithData(UInt(8.W))) + (params should have).length(1) + params.head.name shouldEqual "gen" + params.head.typeName shouldEqual "UInt" + params.head.value should not be empty + } + } + + describe("type names") { + it("strips generic arguments from a Seq type") { + val params = CtorParamExtractor.getCtorParams(new WithGeneric(Seq(1, 2, 3))) + params.head.typeName shouldEqual "Seq" + } + + it("strips generic arguments from a Map type") { + val params = CtorParamExtractor.getCtorParams(new WithMap(Map("a" -> 1))) + params.head.typeName shouldEqual "Map" + } + + it("returns the type-parameter name for an unbound type ref") { + val params = CtorParamExtractor.getCtorParams(new WithTypeParam[Int](42)) + params.head.typeName shouldEqual "T" + } + } + + describe("numeric values") { + // Numbers serialize as strings: JSON has no NaN/Infinity, Double loses + // precision on Long > 2^53. + it("emits Double NaN/Infinity and finite values uniformly as strings") { + val params = CtorParamExtractor.getCtorParams( + new WithDoubles(Double.NaN, Double.PositiveInfinity, Double.NegativeInfinity, 1.5) + ) + val byName = params.map(p => p.name -> p.value).toMap + byName("nan") shouldEqual Some(ujson.Str("NaN")) + byName("pos") shouldEqual Some(ujson.Str("Infinity")) + byName("neg") shouldEqual Some(ujson.Str("-Infinity")) + byName("regular") shouldEqual Some(ujson.Str("1.5")) + } + + it("emits Float NaN/Infinity as strings") { + val params = CtorParamExtractor.getCtorParams( + new WithFloats(Float.NaN, Float.PositiveInfinity, Float.NegativeInfinity) + ) + val byName = params.map(p => p.name -> p.value).toMap + byName("nan") shouldEqual Some(ujson.Str("NaN")) + byName("pos") shouldEqual Some(ujson.Str("Infinity")) + byName("neg") shouldEqual Some(ujson.Str("-Infinity")) + } + + it("emits Long values as strings preserving exact representation") { + val big = (1L << 53) + 1L + val params = CtorParamExtractor.getCtorParams(new WithBigLong(42L, big)) + val byName = params.map(p => p.name -> p.value).toMap + byName("small") shouldEqual Some(ujson.Str("42")) + byName("big") shouldEqual Some(ujson.Str(big.toString)) + } + } + + describe("shared sibling object (not a cycle)") { + // `visited` is path-scoped: a shared sibling must recurse on both visits. + it("recurses into both occurrences of a shared object") { + val cfg = new Inner(7) + val params = CtorParamExtractor.getCtorParams(new SharedSibling(cfg, cfg)) + params.map(p => p.name -> p.value) shouldEqual Seq( + "left" -> s("Inner(x: 7)"), + "right" -> s("Inner(x: 7)") + ) + } + } + + describe("oversized toString") { + it("truncates pathologically long toString output") { + val params = CtorParamExtractor.getCtorParams(new WithHuge(new HugeToString(1))) + val rendered = params.head.value.collect { case ujson.Str(v) => v }.getOrElse("") + rendered.length should be <= CtorParamExtractor.MaxRenderedLen + CtorParamExtractor.TruncatedSuffix.length + rendered should endWith(CtorParamExtractor.TruncatedSuffix) + } + } + + describe("primary vs. secondary constructors") { + // Scala 2 yields ("a","b"); Scala 3 returns empty. Neither must leak secondary params. + it("never reports parameter names that don't correspond to instance accessors") { + val params = CtorParamExtractor.getCtorParams(new WithSecondary(1, 2)) + val names = params.map(_.name) + names should (equal(Seq("a", "b")).or(be(empty))) + names should not contain "c" + names should not contain "d" + } + } +} diff --git a/src/test/scala/chisel3/debug/DebugDataTypesSpec.scala b/src/test/scala/chisel3/debug/DebugDataTypesSpec.scala new file mode 100644 index 00000000000..2b7f1d7adab --- /dev/null +++ b/src/test/scala/chisel3/debug/DebugDataTypesSpec.scala @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +package chisel3.debug + +import chisel3._ +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class DebugDataTypesSpec extends AnyFunSpec with Matchers { + + import DebugTestCircuits.DataTypesCircuits._ + import DebugTestCircuits.{BindingChoice, PortBinding, RegBinding, WireBinding} + import DebugTestUtils._ + + // By-name `chirrtl` so a shared `lazy val` only forces inside `it`. + private def emitAndCheck(label: String, gen: => RawModule)(patterns: Seq[String]): Unit = + it(label)(checkIntrinsics(patterns.map(_ -> 1), emit(gen))) + + private def checkAt(chirrtl: => String, label: String)(patterns: Seq[String]): Unit = + it(label)(checkIntrinsics(patterns.map(_ -> 1), chirrtl)) + + def wrap(b: BindingChoice, t: String): String = b match { + case PortBinding => s"IO[$t]" + case WireBinding => s"Wire[$t]" + case RegBinding => s"Reg[$t]" + } + + def clockResetFor(b: BindingChoice): Seq[String] = + if (b == RegBinding) Seq(varPattern("IO[Clock]", "clock"), varPattern("IO[Reset]", "reset")) + else Seq.empty + + def typeTests(b: BindingChoice): Unit = { + def vp(typ: String, name: String, et: Option[String] = None) = varPattern(wrap(b, typ), name, et) + def sp(typ: String, name: String, parent: String, et: Option[String] = None) = + subfieldPattern(wrap(b, typ), name, parent, et) + + emitAndCheck(s"[$b] should annotate ground types", new TopCircuitGroundTypes(b))( + Seq(vp("UInt<8>", "uint"), vp("SInt<8>", "sint"), vp("Bool", "bool"), vp("UInt<8>", "bits")) + ++ clockResetFor(b) + ++ (if (b != RegBinding) Seq(vp("Analog<1>", "analog")) else Nil) + ) + + emitAndCheck(s"[$b] should annotate bundles", new TopCircuitBundles(b))( + Seq( + vp("AnonymousBundle", "a"), + vp("MyEmptyBundle", "bnd"), + vp("MyBundle", "c"), + sp("UInt<8>", "c.a", "c"), + sp("SInt<8>", "c.b", "c"), + sp("Bool", "c.c", "c") + ) ++ clockResetFor(b) + ) + + emitAndCheck(s"[$b] should annotate nested bundles", new TopCircuitBundlesNested(b))( + Seq( + vp("MyNestedBundle", "a"), + sp("Bool", "a.a", "a"), + sp("MyBundle", "a.b", "a"), + sp("UInt<8>", "a.b.a", "a.b"), + sp("SInt<8>", "a.b.b", "a.b"), + sp("Bool", "a.b.c", "a.b"), + sp("MyBundle", "a.c", "a"), + sp("UInt<8>", "a.c.a", "a.c"), + sp("SInt<8>", "a.c.b", "a.c"), + sp("Bool", "a.c.c", "a.c") + ) ++ clockResetFor(b) + ) + + emitAndCheck(s"[$b] should annotate vecs", new TopCircuitVecs(b))( + Seq( + vp("SInt<23>[5]", "a"), + sp("SInt<23>", "a[0]", "a"), + vp("SInt<23>[3][5]", "bv"), + sp("SInt<23>[3]", "bv[0]", "bv"), + sp("SInt<23>", "bv[0][0]", "bv[0]"), + vp("AnonymousBundle[5]", "c"), + sp("AnonymousBundle", "c[0]", "c"), + sp("UInt<8>", "c[0].x", "c[0]"), + vp("MixedVec", "d"), + sp("UInt<3>", "d.0", "d"), + sp("SInt<10>", "d.1", "d") + ) ++ clockResetFor(b) + ) + + emitAndCheck(s"[$b] should annotate bundle with vec", new TopCircuitBundleWithVec(b))( + Seq( + vp("AnonymousBundle", "a"), + sp("UInt<8>[5]", "a.vec", "a"), + sp("UInt<8>", "a.vec[0]", "a.vec") + ) ++ clockResetFor(b) + ) + } + + describe("Clock and Reset annotations") { + emitAndCheck("should annotate explicit clock and reset types", new TopCircuitClockReset)( + Seq( + varPattern("IO[Clock]", "clock"), + varPattern("IO[Bool]", "syncReset"), + varPattern("IO[Reset]", "reset"), + varPattern("IO[AsyncReset]", "asyncReset") + ) + ) + emitAndCheck("should annotate implicit clock and reset", new TopCircuitImplicitClockReset)( + Seq(varPattern("IO[Clock]", "clock"), varPattern("IO[Bool]", "reset")) + ) + } + + describe("Port (IO) annotations") { typeTests(PortBinding) } + describe("Wire annotations") { typeTests(WireBinding) } + describe("Reg annotations") { typeTests(RegBinding) } + + describe("ChiselEnum annotations") { + lazy val chirrtl = emit(new TopCircuitEnumSimple) + val DTE = Some("DebugTestEnum") + val DTE2 = Some("DebugTestEnum2") + + checkAt(chirrtl, "should emit enumdef once per enum type")( + Seq(enumDefPattern("DebugTestEnum"), enumDefPattern("DebugTestEnum2")) + ) + checkAt(chirrtl, "should annotate enum port with enumTypeName")( + Seq(varPattern("IO[DebugTestEnum]", "e", DTE), varPattern("IO[DebugTestEnum2]", "e2", DTE2)) + ) + checkAt(chirrtl, "should annotate enum subfield with enumTypeName")( + Seq( + subfieldPattern("IO[DebugTestEnum]", "bnd.en", "bnd", DTE), + subfieldPattern("IO[DebugTestEnum2]", "bnd.en2", "bnd", DTE2), + subfieldPattern("IO[UInt<8>]", "bnd.x", "bnd", None) + ) + ) + checkAt(chirrtl, "should annotate enum in vec with enumTypeName")( + Seq( + varPattern("IO[DebugTestEnum[3]]", "v"), + subfieldPattern("IO[DebugTestEnum]", "v[0]", "v", DTE) + ) + ) + } + + describe("Memory annotations") { + import DebugTestCircuits.MemCircuits._ + + val memVar = """intrinsic\(circt_debug_var<[^)]*name\s*=\s*"mem"""" + + it("should annotate Mem as var without parent") { + val chirrtl = emit(new TopCircuitMem(UInt(8.W))) + countOccurrences(chirrtl, memVar) should be(1) + countOccurrences(chirrtl, """circt_debug_subfield""") should be(0) + } + + it("should annotate SyncReadMem as var without parent") { + val chirrtl = emit(new TopCircuitSyncMem(UInt(8.W))) + countOccurrences(chirrtl, memVar) should be(1) + } + } + + describe("MixedVec opaque-ctor handling") { + // Per-element subfield emission already covers MixedVec's `Seq[Data]` ctor arg. + it("does not attach a `params=` attribute to the MixedVec circt_debug_var") { + import DebugTestCircuits.DataTypesCircuits._ + val chirrtl = emit(new TopCircuitVecs(DebugTestCircuits.PortBinding)) + val mixedVecVar = """intrinsic\(circt_debug_var<[^>]*name\s*=\s*"d"[^>]*>""".r + val matched = mixedVecVar + .findFirstIn(chirrtl) + .getOrElse( + fail("no circt_debug_var for MixedVec port `d`") + ) + (matched should not).include("params") + } + } + + describe("Tmp values in when/else") { + emitAndCheck("should annotate named vals inside when blocks", new TopCircuitWhenElse)( + Seq( + varPattern("OpResult[UInt<8>]", "evenSel"), + varPattern("OpResult[UInt<8>]", "selIsOne"), + varPattern("OpResult[UInt<8>]", "oddSel") + ) + ) + } +} diff --git a/src/test/scala/chisel3/debug/DebugIntrinsicsSpec.scala b/src/test/scala/chisel3/debug/DebugIntrinsicsSpec.scala new file mode 100644 index 00000000000..16329f19dd7 --- /dev/null +++ b/src/test/scala/chisel3/debug/DebugIntrinsicsSpec.scala @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 +package chisel3.debug + +import chisel3._ +import chisel3.experimental.IntrinsicModule +import chisel3.experimental.hierarchy.Definition +import chisel3.internal.firrtl.ir +import chisel3.properties.{Class => PropertiesClass} +import chisel3.stage.{ChiselCircuitAnnotation, ChiselGeneratorAnnotation} +import chisel3.stage.phases.{AddDebugIntrinsics, Elaborate} +import circt.stage.ChiselStage +import firrtl.{annoSeqToSeq, seqToAnnoSeq, AnnotationSeq} +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +private class DebugSimpleModule extends Module { + val in = IO(Input(UInt(8.W))) + val out = IO(Output(UInt(8.W))) + out := in +} + +private class DebugParamModule(val width: Int, val hasReset: Boolean) extends Module { + override def desiredName = s"DebugParamModule_w${width}_r$hasReset" + val in = IO(Input(UInt(width.W))) + val out = IO(Output(UInt(width.W))) + if (hasReset) { val reg = RegInit(0.U(width.W)); reg := in; out := reg } + else out := in +} + +private class DebugSubModule extends Module { + val a = IO(Input(UInt(4.W))) + val b = IO(Output(UInt(4.W))) + b := a + 1.U +} + +private class DebugTopModule extends Module { + val in = IO(Input(UInt(4.W))) + val out = IO(Output(UInt(4.W))) + val sub = Module(new DebugSubModule) + sub.a := in + out := sub.b +} + +private class DebugParamBundle(val n: Int) extends Bundle { + val data = UInt(n.W) +} + +private class DebugBundleModule(val width: Int) extends Module { + val io = IO(new DebugParamBundle(width)) +} + +private class DebugMyIntrinsicMod extends IntrinsicModule("my_test_intr") { + val o = IO(Output(Bool())) +} + +private class DebugWithIntrinsic extends RawModule { + val intr = Module(new DebugMyIntrinsicMod) +} + +private class DebugMyClass extends PropertiesClass + +private class DebugWithClass extends RawModule { + val descDef = Definition(new DebugMyClass) +} + +class DebugIntrinsicsSpec extends AnyFunSpec with Matchers { + import DebugTestUtils._ + + describe("circt_debug_moduleinfo") { + + it("emits exactly one moduleinfo per module") { + val chirrtl = emit(new DebugSimpleModule) + countOccurrences(chirrtl, moduleInfoPattern("DebugSimpleModule")) should be(1) + } + + it("emits moduleinfo with typeName for a parametrized module") { + val chirrtl = emit(new DebugParamModule(8, false)) + countOccurrences(chirrtl, moduleInfoPattern("DebugParamModule_w8_rfalse")) should be(1) + } + + it("emits moduleinfo for every module in a hierarchy") { + val chirrtl = emit(new DebugTopModule) + countOccurrences(chirrtl, moduleInfoPattern("DebugTopModule")) should be(1) + countOccurrences(chirrtl, moduleInfoPattern("DebugSubModule")) should be(1) + } + + it("emits moduleinfo with constructor params serialized in params field") { + val chirrtl = emit(new DebugParamModule(5, true)) + val name = "DebugParamModule_w5_rtrue" + countOccurrences(chirrtl, moduleInfoPattern(name)) should be(1) + val params = requireParams(chirrtl, name) + assertParam(params, "width", "Int", 5) // numbers serialize as JSON strings + assertParam(params, "hasReset", "Boolean", true) // Booleans stay as native JSON bool + } + + it("does not emit moduleinfo for blackboxes") { + import DebugTestCircuits.ModuleCircuits._ + val chirrtl = emit(new TopCircuitBlackBox) + countOccurrences(chirrtl, moduleInfoPattern("TopCircuitBlackBox")) should be(1) + countOccurrences(chirrtl, moduleInfoPattern("MyBlackBox")) should be(0) + } + + it("does not emit moduleinfo for IntrinsicModule") { + val chirrtl = emit(new DebugWithIntrinsic) + countOccurrences(chirrtl, moduleInfoPattern("DebugWithIntrinsic")) should be(1) + countOccurrences(chirrtl, moduleInfoPattern("DebugMyIntrinsicMod")) should be(0) + } + + it("emits moduleinfo for chisel3.properties.Class (DefClass)") { + val chirrtl = emit(new DebugWithClass) + countOccurrences(chirrtl, moduleInfoPattern("DebugWithClass")) should be(1) + countOccurrences(chirrtl, moduleInfoPattern("DebugMyClass")) should be(1) + } + + it("emits moduleinfo with params for a module whose port is a parametrized bundle") { + val chirrtl = emit(new DebugBundleModule(16)) + countOccurrences(chirrtl, moduleInfoPattern("DebugBundleModule")) should be(1) + val params = requireParams(chirrtl, "DebugBundleModule") + assertParam(params, "width", "Int", 16) + } + } + + describe("EmitDebugIntrinsicsAnnotation toggle") { + it("emits no circt_debug_* intrinsics by default") { + val chirrtl = ChiselStage.emitCHIRRTL(new DebugSimpleModule) + (chirrtl should not).include("circt_debug_") + } + + it("emits circt_debug_* intrinsics with --with-debug-intrinsics") { + val chirrtl = ChiselStage.emitCHIRRTL(new DebugSimpleModule, args = Array("--with-debug-intrinsics")) + chirrtl should include("circt_debug_moduleinfo") + chirrtl should include("circt_debug_var") + } + } + + describe("AddDebugIntrinsics phase idempotency") { + // Walk the in-memory circuit (rather than re-emitting CHIRRTL) so we count + // exactly the secret commands the phase added -- this is what would double + // if the consume-the-annotation guard regressed. + def countDebugIntrinsics(circuit: ir.Circuit): Int = { + def inBlock(block: ir.Block): Int = { + val direct = (block.getCommands() ++ block.getSecretCommands()).count { + case d: ir.DefIntrinsic => d.intrinsic.startsWith("circt_debug_") + case _ => false + } + val nested = block + .getCommands() + .map { + case w: ir.When => inBlock(w.ifRegion) + (if (w.hasElse) inBlock(w.elseRegion) else 0) + case lb: ir.LayerBlock => inBlock(lb.region) + case dc: ir.DefContract => inBlock(dc.region) + case _ => 0 + } + .sum + direct + nested + } + circuit.components.map { + case dm: ir.DefModule => inBlock(dm.block) + case dc: ir.DefClass => inBlock(dc.block) + case _ => 0 + }.sum + } + + it("consumes EmitDebugIntrinsicsAnnotation so a second pass does not double-emit") { + val elaborated: AnnotationSeq = (new Elaborate) + .transform(Seq(ChiselGeneratorAnnotation(() => new DebugTopModule))) + val withFlag = elaborated :+ EmitDebugIntrinsicsAnnotation + val pass = new AddDebugIntrinsics + + val afterFirst = pass.transform(withFlag) + afterFirst.exists(_ == EmitDebugIntrinsicsAnnotation) shouldBe false + + val cca1 = afterFirst.collectFirst { case a: ChiselCircuitAnnotation => a } + .getOrElse(fail("no ChiselCircuitAnnotation after first pass")) + val countOnce = countDebugIntrinsics(cca1.elaboratedCircuit._circuit) + countOnce should be > 0 + + val afterSecond = pass.transform(afterFirst) + val cca2 = afterSecond.collectFirst { case a: ChiselCircuitAnnotation => a } + .getOrElse(fail("no ChiselCircuitAnnotation after second pass")) + countDebugIntrinsics(cca2.elaboratedCircuit._circuit) shouldEqual countOnce + } + } +} diff --git a/src/test/scala/chisel3/debug/DebugTestCircuits.scala b/src/test/scala/chisel3/debug/DebugTestCircuits.scala new file mode 100644 index 00000000000..d16e5960080 --- /dev/null +++ b/src/test/scala/chisel3/debug/DebugTestCircuits.scala @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: Apache-2.0 + +package chisel3.debug + +import chisel3._ +import chisel3.experimental.{fromStringToStringParam, Analog} +import chisel3.util.MixedVec +import circt.stage.ChiselStage +import org.scalatest.matchers.should.Matchers + +object DebugTestEnum extends ChiselEnum { val EA, EB, EC = Value } +object DebugTestEnum2 extends ChiselEnum { val ED, EE, EF = Value } + +object DebugTestUtils extends Matchers { + + private def q(s: String): String = java.util.regex.Pattern.quote(s) + + def countOccurrences(chirrtl: String, pattern: String): Int = + pattern.r.findAllMatchIn(chirrtl).length + + def getMissingOccurrences(chirrtl: String, pattern: String, expectedLines: Seq[String]): String = { + val lines = chirrtl.split("\n").toSeq + val anyR = (".*" + pattern + ".*").r + val idxs = lines.indices.filter(i => anyR.findFirstIn(lines(i)).isDefined) + val withNext = + idxs.flatMap(i => if (i < lines.length - 1) List(lines(i), lines(i + 1)) else List(lines(i))).distinct + val expRe = expectedLines.map(_.r) + withNext.filterNot(line => expRe.exists(_.findFirstIn(line).isDefined)).mkString("\n") + } + + private def attrPart(name: String, value: String): String = s"""[^)]*$name\\s*=\\s*"${q(value)}"""" + private def enumPart(et: Option[String]): String = et.fold("")(attrPart("enumTypeName", _)) + + private def intrinsic(kind: String, attrs: String*): String = + s"""intrinsic\\(circt_debug_$kind<${attrs.mkString}""" + + def varPattern(typeName: String, name: String, enumTypeName: Option[String] = None): String = + intrinsic("var", attrPart("typeName", typeName), attrPart("name", name), enumPart(enumTypeName)) + + def subfieldPattern(typeName: String, name: String, parent: String, enumTypeName: Option[String] = None): String = + intrinsic( + "subfield", + attrPart("typeName", typeName), + attrPart("name", name), + attrPart("parent", parent), + enumPart(enumTypeName) + ) + + def enumDefPattern(typeName: String): String = intrinsic("enumdef", attrPart("typeName", typeName)) + def moduleInfoPattern(typeName: String): String = intrinsic("moduleinfo", attrPart("typeName", typeName)) + + // Collapse whitespace so regex helpers ignore line wrapping. + def emit(gen: => RawModule): String = + ChiselStage.emitCHIRRTL(gen, args = Array("--with-debug-intrinsics")).replaceAll("\\s+", " ") + + private val moduleInfoBodyRe = """intrinsic\(circt_debug_moduleinfo<(.*?)>\)""".r + private val paramsBodyRe = """params\s*=\s*"((?:[^"\\]|\\.)*)"""".r + + def requireParams(chirrtl: String, typeName: String): String = { + val tnPat = s"""typeName\\s*=\\s*"${q(typeName)}"""".r + moduleInfoBodyRe + .findAllMatchIn(chirrtl) + .map(_.group(1)) + .find(body => tnPat.findFirstIn(body).isDefined) + .flatMap(body => paramsBodyRe.findFirstMatchIn(body).map(_.group(1))) + .getOrElse(fail(s"no circt_debug_moduleinfo for $typeName")) + } + + def assertParam(params: String, name: String, typeName: String, value: Any): Unit = { + val bq = "\\\"" + val valueJson = value match { + case b: Boolean => b.toString + case other => bq + other.toString + bq + } + params should include(s"${bq}name$bq:$bq$name$bq") + params should include(s"${bq}typeName$bq:$bq$typeName$bq") + params should include(s"${bq}value$bq:$valueJson") + } + + def checkIntrinsics(expected: Seq[(String, Int)], chirrtl: String): Unit = { + val names = expected.map(_._1) + expected.foreach { case (pattern, count) => + withClue(s"Pattern '$pattern':\n${getMissingOccurrences(chirrtl, pattern, names)}\n") { + countOccurrences(chirrtl, pattern) should be(count) + } + } + } +} + +object DebugTestCircuits { + + sealed abstract class BindingChoice(label: String) { + def apply[T <: Data](data: T): T + override def toString = label + } + case object PortBinding extends BindingChoice("IO") { def apply[T <: Data](d: T): T = IO(d) } + case object WireBinding extends BindingChoice("Wire") { def apply[T <: Data](d: T): T = Wire(d) } + case object RegBinding extends BindingChoice("Reg") { def apply[T <: Data](d: T): T = Reg(d) } + + abstract class DebugTestModule(bindingChoice: BindingChoice) extends RawModule { + def body: Unit + bindingChoice match { + case RegBinding => + val clock = IO(Input(Clock())) + val reset = IO(Input(Reset())) + withClockAndReset(clock, reset) { body } + case _ => body + } + } + + object ModuleCircuits { + + class TopCircuitBlackBox extends RawModule { + class MyBlackBox extends ExtModule(Map("PARAM1" -> "TRUE", "PARAM2" -> "DEFAULT")) { + val io = IO(new Bundle {}) + } + val myBlackBox1: MyBlackBox = Module(new MyBlackBox) + val myBlackBox2: MyBlackBox = Module(new MyBlackBox) + val myBlackBoxes: Seq[MyBlackBox] = Seq.fill(2)(Module(new MyBlackBox)) + } + } + + object DataTypesCircuits { + + class TopCircuitClockReset extends RawModule { + val clock: Clock = IO(Input(Clock())) + val syncReset: Bool = IO(Input(Bool())) + val reset: Reset = IO(Input(Reset())) + val asyncReset: AsyncReset = IO(Input(AsyncReset())) + } + + class TopCircuitImplicitClockReset extends Module + + class TopCircuitGroundTypes(b: BindingChoice) extends DebugTestModule(b) { + override def body: Unit = { + val uint: UInt = b(UInt(8.W)) + val sint: SInt = b(SInt(8.W)) + val bool: Bool = b(Bool()) + if (b != RegBinding) { + val analog: Analog = b(Analog(1.W)) + } + val bits: UInt = b(Bits(8.W)) + } + } + + class MyEmptyBundle extends Bundle + + class MyBundle extends Bundle { + val a: UInt = UInt(8.W) + val b: SInt = SInt(8.W) + val c: Bool = Bool() + } + + class TopCircuitBundles(b: BindingChoice) extends DebugTestModule(b) { + override def body: Unit = { + val a: Bundle = b(new Bundle {}) + val bnd: MyEmptyBundle = b(new MyEmptyBundle) + val c: MyBundle = b(new MyBundle) + } + } + + class MyNestedBundle extends Bundle { + val a: Bool = Bool() + val b: MyBundle = new MyBundle + val c: MyBundle = Flipped(new MyBundle) + } + + class TopCircuitBundlesNested(b: BindingChoice) extends DebugTestModule(b) { + override def body: Unit = { + val a: MyNestedBundle = b(new MyNestedBundle) + } + } + + class TopCircuitVecs(b: BindingChoice) extends DebugTestModule(b) { + override def body: Unit = { + val a: Vec[SInt] = b(Vec(5, SInt(23.W))) + val bv: Vec[Vec[SInt]] = b(Vec(5, Vec(3, SInt(23.W)))) + val c = b(Vec(5, new Bundle { val x: UInt = UInt(8.W) })) + val d = b(MixedVec(UInt(3.W), SInt(10.W))) + } + } + + class TopCircuitBundleWithVec(b: BindingChoice) extends DebugTestModule(b) { + override def body: Unit = { + val a = b(new Bundle { val vec = Vec(5, UInt(8.W)) }) + } + } + + class TopCircuitWhenElse extends RawModule { + val inSeq = IO(Input(Vec(8, UInt(8.W)))) + val out = IO(Output(UInt(8.W))) + val sel = IO(Input(UInt(math.sqrt(8).ceil.toInt.W))) + val tmp = sel + 1.U + when(sel % 2.U === 0.U) { + val outTmp = inSeq(sel) + val evenSel = outTmp + 1.U + out := evenSel + }.elsewhen(sel === 1.U) { + val outTmp = inSeq(sel) + val selIsOne = outTmp + 1.U + out := selIsOne + }.otherwise { + val outTmp = inSeq(sel) + val oddSel = outTmp + 1.U + out := oddSel + } + } + + class TopCircuitEnumSimple extends RawModule { + val e = IO(Input(DebugTestEnum())) + val e2 = IO(Input(DebugTestEnum2())) + val bnd = IO(Input(new Bundle { + val en = DebugTestEnum() + val en2 = DebugTestEnum2() + val x = UInt(8.W) + })) + val v = IO(Input(Vec(3, DebugTestEnum()))) + } + + } + + object MemCircuits { + class TopCircuitMem[T <: Data](gen: T) extends Module { val mem = Mem(4, gen) } + class TopCircuitSyncMem[T <: Data](gen: T) extends Module { val mem = SyncReadMem(4, gen) } + } +}