Skip to content
VLSI Mentor

I²C · Module 21

Building a Reusable I²C VIP

The only honest test of reuse is whether another project can adopt the VIP without editing it. Publishes a UVM structural linter with fifteen checks, every one proven able to fail plus two proven to stay quiet, and reports the three defects it found in this module's own code.

Reuse is the claim everyone makes and almost nobody tests. This chapter proposes a test that is uncomfortable and checkable:

Can another project adopt this VIP without EDITING it?

Not "without rewriting it" — without editing it. Every edit a consumer makes is a fork, and a forked VIP stops receiving fixes on the day it is forked.

1. The Adoption Checklist

Everything a consumer might reasonably need to change, and where it is changed from outside:

what a consumer needs to changehow
the target address the model expectsi2c_env_config.dut_addr
the register count and read-only maski2c_env_config.dut_n_reg, dut_ro_mask
how many participants, and which existi2c_env_config.have_responder, have_scoreboard, have_coverage
which side each agent playsi2c_agent_config.role
the bit period and stretch patiencei2c_agent_config.half_period, stretch_timeout
the responder's behaviouri2c_agent_config.resp_*
any component's implementationthe factory, via a type override

That last row is the one that makes the difference between configurable and extensible. A consumer whose target needs a different reconstruction rule does not edit the monitor — they extend it and register the override:

Azvya Education Pvt. Ltd.VLSI Mentor
Extending without editing — the whole point of the factory
   class my_i2c_monitor extends i2c_monitor;
      `uvm_component_utils(my_i2c_monitor)
      // ... the one behaviour that differs
   endclass

   // in the test's build_phase, before the env is built
   i2c_monitor::type_id::set_type_override(my_i2c_monitor::get_type());

Nothing in the VIP changes. That only works because every component in it is created through type_id::create rather than new — which is why 21.11's linter checks for the registration macro that makes a class overridable at all.

2. What Is Deliberately Not in the Package

Azvya Education Pvt. Ltd.VLSI Mentor
i2c_vip_pkg.sv — the whole VIP as one importable unit
   // -----------------------------------------------------------------------------
   // i2c_vip_pkg.sv
   // The whole VIP as one importable unit, and the test of whether it is reusable.
   //
   // NOT EXECUTED -- see i2c_if.sv.
   //
   // THE ONLY HONEST TEST OF REUSE: can another project adopt this without EDITING it?
   // Not "without rewriting it" -- without editing it. Every edit a consumer has to make is
   // a fork, and a forked VIP stops receiving fixes on the day it is forked.
   //
   // So the checklist this package is built against is a list of things a consumer must be
   // able to change from OUTSIDE:
   //
   //   the bus address the reference model expects   -> i2c_env_config.dut_addr
   //   the register map size and read-only mask      -> i2c_env_config.dut_n_reg / ro_mask
   //   how many participants and which are active    -> i2c_env_config.n_agents / have_*
   //   which side each agent plays                   -> i2c_agent_config.role
   //   the bit period and stretch patience           -> i2c_agent_config.half_period / timeout
   //   the responder's behaviour                     -> i2c_agent_config.resp_*
   //   any component's implementation                -> the factory, via type overrides
   //
   // ONE ORDERING CONSTRAINT IS REAL and is the reason this file is a list rather than a
   // set. `include` order matters because a class must be declared before it is referenced:
   // the config objects come first because every component takes one, the item comes before
   // the drivers that are parameterised by it, and the agent comes after the components it
   // builds. Getting this wrong produces an error naming a type rather than an order, and
   // the fix is not where the error points.
   //
   // WHAT IS DELIBERATELY NOT HERE: no DUT, no test, no top-level module. A package that
   // imported its own testbench could not be used by a project with a different one, which
   // is the whole point.
   // -----------------------------------------------------------------------------

   package i2c_vip_pkg;

      import uvm_pkg::*;
      `include "uvm_macros.svh"

      // ---- configuration, first: everything below takes one --------------------
      `include "i2c_agent_config.sv"

      // ---- transactions: the item before anything parameterised by it ----------
      `include "i2c_seq_item.sv"

      // ---- leaf components ----------------------------------------------------
      `include "i2c_master_driver.sv"
      `include "i2c_responder_driver.sv"
      `include "i2c_monitor.sv"
      `include "i2c_error_injector.sv"

      // ---- checking -----------------------------------------------------------
      `include "i2c_predictor.sv"
      `include "i2c_scoreboard.sv"
      `include "i2c_coverage.sv"

      // ---- composition --------------------------------------------------------
      `include "i2c_agent.sv"
      `include "i2c_env.sv"

      // ---- stimulus: the base class before the sequences that extend it --------
      `include "i2c_sequences.sv"
      `include "i2c_error_sequences.sv"

   endpackage

No DUT, no test, and no top-level module. A package that imported its own testbench could not be used by a project with a different one, which is the whole point.

The one real ordering constraint is worth stating because getting it wrong produces a misleading error. A class must be declared before it is referenced, so the configuration objects come first, the transaction item comes before the drivers parameterised by it, and the agent comes after the components it builds. Get the order wrong and the error names a type, not an order — and the fix is not where the error points.

3. The Top Level Is the Consumer's File

Azvya Education Pvt. Ltd.VLSI Mentor
i2c_tb_top.sv — the one file a consuming project writes
   // -----------------------------------------------------------------------------
   // i2c_tb_top.sv
   // The one file a consuming project is EXPECTED to write, which is why it is separate.
   //
   // NOT EXECUTED -- see i2c_if.sv. The DUT instantiated here is Module 18's `i2c_slave`,
   // whose protocol and register behaviour were verified in Module 20 to zero valid
   // non-equivalent survivors. This file shows how the VIP would attach to it.
   //
   // THREE JOBS AND NO MORE: make a clock, connect the interface to the DUT's pins, and
   // publish the interface into the config database so that classes can reach it. Anything
   // else here is something a consumer would have to edit, which is a fork.
   //
   // NOTE THE SHAPE OF THE DUT CONNECTION. The target has no bidirectional port: it takes
   // resolved pin levels IN and emits `*_drive_low` intents OUT, exactly as Chapter 19.1's
   // open-drain model requires. So there is no `inout` anywhere in this testbench, and the
   // wired-AND lives in one place -- inside the interface. A testbench that declared
   // `inout` nets and relied on tri-state resolution would work in simulation and would
   // describe hardware that cannot be built.
   // -----------------------------------------------------------------------------
   `timescale 1ns/1ps

   module i2c_tb_top;

      import uvm_pkg::*;
      import i2c_vip_pkg::*;
      `include "uvm_macros.svh"

      // ---- the clock the TESTBENCH samples on -------------------------------
      // This is NOT the I2C clock. SCL is generated on the bus by whichever participant is
      // acting as controller; this is the sampling clock the interface's clocking blocks
      // use, and it must be substantially faster than SCL or the monitor will miss edges.
      logic clk = 1'b0;
      always #5 clk = ~clk;

      logic rst_n = 1'b0;
      initial begin
         repeat (4) @(posedge clk);
         rst_n = 1'b1;
      end

      // ---- the bus ----------------------------------------------------------
      i2c_if #(.N_DRV(4)) bus (.clk(clk));

      // ---- the design under test -------------------------------------------
      // Module 18's verified target. SYNC_DEPTH is 2 rather than 1 deliberately: at depth 1
      // the target's synchroniser expression `chain[SYNC_DEPTH-1:1]` becomes `chain[0:1]`, a
      // reversed range that does not elaborate. That is a tracked defect in a locked module
      // and it is stated here rather than silently avoided -- choosing a working parameter
      // without saying why is how a known defect becomes an unknown one.
      i2c_slave #(
         .MY_ADDR     (7'h50),
         .N_REG       (8),
         .RO_MASK     (8'h04),
         .IDLE_CYCLES (900),
         .SYNC_DEPTH  (2),
         .CNT_W       (16)
      ) dut (
         .clk            (clk),
         .rst_n          (rst_n),
         .scl_pin        (bus.scl),
         .sda_pin        (bus.sda),
         .scl_drive_low  (bus.dut_scl_drive_low),
         .sda_drive_low  (bus.dut_sda_drive_low),
         .stall_req      (1'b0),
         .reg_flat       (),
         .pointer        (),
         .selected       (),
         .stretching     (),
         .n_phases       (),
         .n_restarts     (),
         .n_writes       (),
         .n_refused      (),
         .n_reads        (),
         .n_aborts       (),
         .n_sda_conflict ()
      );

      // ---- hand the interface to the class world ----------------------------
      // Set on the WILDCARD path so that every component below `uvm_test_top` can reach it.
      // The type in the set must match the type in the get exactly: a mismatch does not
      // fail, `get` simply returns 0 and the handle stays null, and the symptom appears
      // thousands of lines later as a null dereference in a driver.
      i2c_env_config env_cfg;

      initial begin
         env_cfg = i2c_env_config::type_id::create("env_cfg");
         env_cfg.vif             = bus;
         env_cfg.have_responder  = 1'b0;    // the real DUT is the target here
         env_cfg.have_scoreboard = 1'b1;
         env_cfg.have_coverage   = 1'b1;
         env_cfg.dut_addr        = 7'h50;   // a COPY of the datasheet, not read from the DUT
         env_cfg.dut_n_reg       = 8;
         env_cfg.dut_ro_mask     = 8'h04;
         env_cfg.half_period     = 16;
         env_cfg.stretch_timeout = 4000;

         uvm_config_db #(i2c_env_config)::set(null, "uvm_test_top*", "cfg", env_cfg);

         run_test();
      end

      // A hard stop, so a wedged bus is reported as a wedged bus rather than becoming a
      // simulation that never ends. Two of Chapter 20.9's faults hold a line down on
      // purpose, which makes this load-bearing rather than defensive.
      initial begin
         #20_000_000;
         `uvm_fatal("TIMEOUT", "the simulation did not finish -- suspect a held bus line")
      end

   endmodule

Three jobs: make a clock, connect the interface to the DUT's pins, and publish the interface into the configuration database. Anything else here would be something a consumer has to edit.

Two details in it are worth pointing at.

There is no inout anywhere. Module 18's target takes resolved pin levels in and emits drive intents out — the shape 19.1 argued for — so nothing in this environment needs tri-state resolution. A VIP for a DUT with genuine inout pins would need a pad model between the two.

The sampling clock is not SCL. SCL is generated on the bus by whichever participant is acting as controller. The interface's clocking blocks sample on a separate, much faster clock, and confusing the two produces a monitor that samples once per bit and misses every edge it needs.

4. The Structural Linter

With no compiler and no simulator, the available evidence is structural. This is the tool that produces it.

Azvya Education Pvt. Ltd.VLSI Mentor
uvmlint.py — fifteen structural checks for UVM source that cannot be compiled
#!/usr/bin/env python3
"""
uvmlint.py -- a structural checker for UVM source that cannot be compiled here.

WHY THIS EXISTS. Module 21 publishes a UVM I2C verification IP. No UVM library and
no UVM-capable simulator exist in this environment (run probe/capability.sh), so the
code is labelled NOT EXECUTED. That label is honest and it is not evidence. This
script is the evidence that can be produced: it checks the STRUCTURAL properties a
UVM reviewer checks by eye, mechanically, on every file, every time.

WHAT IT CANNOT DO. It is not a compiler and it is not a simulator. It cannot tell you
the code elaborates, that a sequence terminates, or that a driver's timing is legal.
It checks a specific list of defects, named in CHECKS below, and nothing else. A pass
means those defects are absent -- not that the code works.

EVERY CHECK HAS A FALSE-POSITIVE TEST. Module 19 shipped a constraint linter claiming
six checks of which two never fired. lintspec/ holds one deliberately defective
specimen per check plus a clean control, and `--selftest` asserts each specimen trips
its own check and the control trips nothing. A linter with no failing cases in its own
test set is a script whose passing output means nothing.
"""
import io, os, re, sys, glob

CHECKS = {
 "monitor_drives":   "a passive component assigns to a bus drive signal",
 "driver_item_done": "get_next_item without a matching item_done",
 "create_in_connect":"a component is created in connect_phase instead of build_phase",
 "connect_in_build": "a port is connected in build_phase instead of connect_phase",
 "ap_unconnected":   "an analysis port is declared but never connected",
 "vif_no_null_check":"a virtual interface is used with no null check",
 "config_db_type":   "a config_db set has no get of the same type",
 "missing_utils":    "a component or object class has no registration macro",
 "missing_super":    "a phase method does not call its super",
 "seq_item_protocol":"start_item without a matching finish_item",
 "no_objection":     "a test run_phase starts a sequence without an objection",
 "monitor_hard_delay":"a monitor uses a hard # delay instead of sampling an edge",
 "nonrand_protocol": "a protocol field in a sequence item is not rand",
 "paren_imbalance":  "a statement's parentheses or brackets do not balance",
 "block_imbalance":  "a block keyword has no matching end keyword",
}

# ---------------------------------------------------------------------------
# SYNTAX HYGIENE. This is emphatically NOT a compiler, and the distinction matters:
# nothing here establishes that the code elaborates. But this module's source cannot
# be compiled AT ALL in this environment, which removes the one tool that normally
# catches a mistyped bracket -- and a stray `)` was in fact typed into the master
# driver and passed all thirteen structural checks, because a structural checker has
# no opinion about punctuation. These two checks close that specific hole and claim
# nothing further.
# ---------------------------------------------------------------------------
PAIRS = [("(", ")"), ("[", "]"), ("{", "}")]
BLOCKS = [("class", "endclass"), ("task", "endtask"), ("function", "endfunction"),
          ("interface", "endinterface"), ("package", "endpackage")]

def syntax_hygiene(path):
    raw = io.open(path, encoding="utf-8").read()
    src = strip_comments(raw)
    out = []
    # strip string literals: a ")" inside a message is not punctuation we can count
    nostr = re.sub(r'"(?:[^"\\]|\\.)*"', '""', src)
    # Running depth across the WHOLE file, not per line. The first version compared
    # counts line by line and flagged three closing lines of multi-line argument lists,
    # where the opener was simply on an earlier line -- a checker wrong in the noisy
    # direction, which is how a checker gets switched off. Depth going NEGATIVE is
    # unambiguous: a closer arrived with nothing open. That still catches the stray ")"
    # that was actually typed, because there the depth was zero.
    depth = {a: 0 for a, b in PAIRS}
    line_no = 1
    for ch in nostr:
        if ch == "\n":
            line_no += 1
            continue
        for a, b in PAIRS:
            if ch == a:
                depth[a] += 1
            elif ch == b:
                depth[a] -= 1
                if depth[a] < 0:
                    out.append(("paren_imbalance", os.path.basename(path), "-",
                                "a '%s' with nothing open" % b, line_no))
                    depth[a] = 0      # resync so one typo is one finding
    for a, b in PAIRS:
        if depth[a] != 0:
            out.append(("paren_imbalance", os.path.basename(path), "-",
                        "%d unclosed '%s' at end of file" % (depth[a], a), 0))
    for a, b in BLOCKS:
        na = len(re.findall(r'(?<![\w.`])' + a + r'\b(?!\s*:)', nostr))
        nb = len(re.findall(r'\b' + b + r'\b', nostr))
        # `virtual class` and similar make the opener count fuzzy, so only report a
        # DEFICIT of end keywords, which is the direction that is always an error.
        if nb > na:
            out.append(("block_imbalance", os.path.basename(path), "-",
                        "%d %s vs %d %s" % (na, a, nb, b), 0))
    return out

CLASS_RE = re.compile(r'^\s*(virtual\s+)?class\s+(\w+)(?:\s*#\s*\([^)]*\))?\s*(?:extends\s+([\w:#()\s,]+?))?\s*;',
                      re.M)
METHOD_RE = re.compile(r'^\s*(?:virtual\s+|static\s+|protected\s+|local\s+)*'
                       r'(task|function)\s+(?:automatic\s+)?(?:void\s+|\w+\s+)?(\w+)\s*(?:\([^;]*?\))?\s*;',
                       re.M)

def strip_comments(s):
    s = re.sub(r'/\*.*?\*/', lambda m: "\n"*m.group(0).count("\n"), s, flags=re.S)
    return re.sub(r'//[^\n]*', '', s)

def blocks(src, regex, endword):
    """Yield (name, base, body, startline) for each top-level block."""
    out = []
    for m in regex.finditer(src):
        depth, i = 0, m.end()
        # find the matching endword at nesting depth 0
        pat = re.compile(r'\b(class|endclass)\b' if endword == "endclass"
                         else r'\b(task|function|endtask|endfunction)\b')
        j = m.end()
        while True:
            n = pat.search(src, j)
            if not n:
                j = len(src); break
            w = n.group(1)
            if w.startswith("end"):
                if depth == 0:
                    j = n.start(); break
                depth -= 1
            else:
                depth += 1
            j = n.end()
        out.append((m, src[i:j]))
    return out

def gather(paths):
    """Facts that cannot be established from one file.

    A VIP is many files: an analysis port is declared in the agent and connected in the
    env, a config_db is set by the test and got by the driver, and the item type a
    sequence randomises is named in a different file from the class itself. Three checks
    were wrong while they looked at one file at a time -- they reported a port as
    unconnected because the connection was next door. Gather first, then check."""
    g = {"stim_items": set(), "gets": set(), "connects": ""}
    for path in paths:
        src = strip_comments(io.open(path, encoding="utf-8").read())
        # the item type a sequence randomises, or a driver is parameterised by
        for t in re.findall(r'uvm_(?:sequence|driver|seq_item_pull_port|sequencer)\s*#\s*\(\s*([\w:]+)', src):
            g["stim_items"].add(t)
        for t in re.findall(r'uvm_config_db\s*#\s*\(\s*([^)]+?)\s*\)\s*::\s*get', src):
            g["gets"].add(re.sub(r'\s+', '', t))
        g["connects"] += src
    return g

def lint_file(path, g=None):
    if g is None:
        g = gather([path])
    raw = io.open(path, encoding="utf-8").read()
    src = strip_comments(raw)
    findings = []
    def add(chk, cls, msg, body=None, needle=None):
        line = 0
        if needle:
            k = raw.find(needle)
            if k >= 0: line = raw[:k].count("\n") + 1
        findings.append((chk, os.path.basename(path), cls, msg, line))

    # ---- whole-file facts -------------------------------------------------
    sets = re.findall(r'uvm_config_db\s*#\s*\(\s*([^)]+?)\s*\)\s*::\s*set', src)
    norm = lambda t: re.sub(r'\s+', '', t)
    file_gets = g["gets"]

    for m, body in blocks(src, CLASS_RE, "endclass"):
        cls      = m.group(2)
        base     = (m.group(3) or "").strip()
        # An ABSTRACT class must NOT be registered with the factory: you cannot create one,
        # and `uvm_object_utils` on a virtual class generates a create() that will not
        # compile. So the registration checks below do not apply to it. The first version of
        # this check reported the abstract sequence base class, which would have led someone
        # to "fix" correct code by adding a macro that breaks it.
        is_abstract = bool(m.group(1))
        low  = body

        is_component = bool(re.search(r'uvm_(component|driver|monitor|sequencer|agent|env|test|scoreboard|subscriber)', base))
        is_object    = bool(re.search(r'uvm_(object|sequence_item|sequence)\b', base))
        is_monitor   = "monitor" in cls.lower() or "uvm_monitor" in base
        is_driver    = "driver" in cls.lower() or "uvm_driver" in base
        is_test      = "uvm_test" in base
        is_seq       = re.search(r'uvm_sequence\b', base) is not None

        # --- registration macros -------------------------------------------
        if is_component and not is_abstract and not re.search(r'`uvm_component(_param)?_utils', low):
            add("missing_utils", cls, "extends a uvm_component with no `uvm_component_utils")
        if is_object and not is_component and not is_abstract and not re.search(r'`uvm_object(_param)?_utils', low):
            add("missing_utils", cls, "extends a uvm_object with no `uvm_object_utils")

        # --- phase bodies ---------------------------------------------------
        for mm, mbody in blocks(low, METHOD_RE, "endtask"):
            mname = mm.group(2)
            if mname in ("build_phase", "connect_phase", "run_phase", "end_of_elaboration_phase"):
                if not re.search(r'super\s*\.\s*' + mname, mbody):
                    add("missing_super", cls, "%s does not call super.%s" % (mname, mname))
            if mname == "connect_phase" and re.search(r'type_id\s*::\s*create', mbody):
                add("create_in_connect", cls, "creates a component in connect_phase")
            # Any .connect() at all, not a port-named one. The first version of this
            # check required an underscore before the port name, so `mon.ap.connect(...)`
            # -- the most ordinary form there is -- slipped past it and the check never
            # fired for any input. lintspec/selftest.py is what found that.
            if mname == "build_phase" and re.search(r'\.\s*connect\s*\(', mbody):
                add("connect_in_build", cls, "connects a port in build_phase")
            if is_monitor and re.search(r'(?<![\w.])#\s*\d', mbody):
                add("monitor_hard_delay", cls, "%s uses a hard # delay" % mname)
            if is_driver and mname.endswith("_phase"):
                g = len(re.findall(r'get_next_item\s*\(', mbody))
                d = len(re.findall(r'item_done\s*\(', mbody))
                if g and g != d:
                    add("driver_item_done", cls,
                        "%d get_next_item vs %d item_done" % (g, d))
            if is_seq and mname == "body":
                si = len(re.findall(r'start_item\s*\(', mbody))
                fi = len(re.findall(r'finish_item\s*\(', mbody))
                if si != fi:
                    add("seq_item_protocol", cls,
                        "%d start_item vs %d finish_item" % (si, fi))
            if is_test and mname == "run_phase":
                if re.search(r'\.\s*start\s*\(', mbody) and not re.search(r'raise_objection', mbody):
                    add("no_objection", cls, "starts a sequence with no raised objection")

        # --- passivity: a monitor must not drive ---------------------------
        if is_monitor:
            for dm in re.finditer(r'\bvif\s*\.\s*(\w*(?:drive|_low|_oe|_en)\w*)\s*(<=|=)(?!=)', low):
                add("monitor_drives", cls, "assigns vif.%s" % dm.group(1), needle=dm.group(0))

        # --- virtual interface null check ----------------------------------
        if re.search(r'\bvif\s*\.', low) and not re.search(r'vif\s*==\s*null', low):
            add("vif_no_null_check", cls, "uses vif with no `vif == null` check")

        # --- analysis ports declared but never connected -------------------
        for ap in re.findall(r'uvm_analysis_port\s*#\s*\([^)]*\)\s+(\w+)\s*;', low):
            if not re.search(re.escape(ap) + r'\s*\.\s*connect\s*\(', g["connects"]) and \
               not re.search(r'\.\s*connect\s*\(\s*[\w.]*' + re.escape(ap), g["connects"]):
                add("ap_unconnected", cls, "analysis port %s is never connected" % ap)

        # --- sequence item protocol fields should be rand -------------------
        # ONLY for an item some sequence or driver is parameterised by. An OBSERVATION
        # class also extends uvm_sequence_item and its fields must NOT be rand --
        # randomising a measurement is meaningless. The first version of this check
        # flagged all seven fields of the observed-transaction class, which is the
        # checker being wrong rather than the code.
        if is_object and re.search(r'uvm_sequence_item', base) and cls in g["stim_items"]:
            for fm in re.finditer(r'^\s*(?!rand\b|randc\b)(?:bit|logic|byte)\s*(?:\[[^\]]*\]\s*)?(\w+)\s*;',
                                  low, re.M):
                add("nonrand_protocol", cls, "field %s is not rand" % fm.group(1))

    # ---- config_db symmetry ----------------------------------------------
    for t in sets:
        if norm(t) not in file_gets:
            findings.append(("config_db_type", os.path.basename(path), "-",
                             "config_db set of type %s has no matching get" % t.strip(), 0))
    return findings

def main():
    selftest = "--selftest" in sys.argv
    paths = [a for a in sys.argv[1:] if not a.startswith("-")]
    files = []
    for p in paths:
        files.extend(sorted(glob.glob(os.path.join(p, "*.sv"))) if os.path.isdir(p) else [p])
    g = gather(files)
    allf = []
    for f in files:
        allf.extend(lint_file(f, g))
        allf.extend(syntax_hygiene(f))
    if not selftest:
        w = max([len(x[2]) for x in allf] + [10])
        for chk, fn, cls, msg, line in allf:
            print("  %-19s %-26s %-*s %s%s" % (chk, fn, w, cls, msg,
                  (" [line %d]" % line) if line else ""))
        print()
        print("files checked : %d" % len(files))
        print("checks applied: %d" % len(CHECKS))
        print("findings      : %d" % len(allf))
        return 1 if allf else 0
    return 0

if __name__ == "__main__":
    sys.exit(main())

Thirteen checks are architectural — a passive component that assigns a drive signal, a driver whose get_next_item has no matching item_done, a component created in connect_phase, a port connected in build_phase, an analysis port nothing consumes, a virtual interface used without a null check, a config_db set with no matching get, a missing registration macro, a phase that skips its super, a sequence that never finishes its item, a test that starts a sequence with no objection, a monitor with a hard delay, a stimulus field that is not rand.

Two are syntax hygiene, and they exist for a specific reason:

5. Every Check Is Proven Able to Fail

This is the part that makes the linter's output mean anything.

connect_in_build is the check worth dwelling on, because it reproduced Module 19's failure exactly. Its first version required an underscore before the port name, so mon.ap.connect(...) — the most ordinary form there is — slipped past and the check never fired for any input. The selftest is what found it.

6. Three Defects the Tooling Found in This Module

Reporting these is more useful than the green summary.

Two transcription defects in the driver, found by 21.3's traceability check rather than by the linter: a dropped half-period wait that shortened every bit in every transfer, and an acknowledge sample relocated off the midpoint of the high period. Neither is visible to any structural check. The second one also required extending the tool before the fix meant anything — the check could not originally see where a sample lands, and a check that cannot see a difference cannot be used to claim there is none.

One syntax defect, found by the hygiene check: the stray bracket described in Section 4.

Three defects in the checkers themselves, found by their own self-tests: the connect_in_build pattern that never matched, a non-rand check that reported all seven fields of the observation class, and a paren check that flagged three correct multi-line declarations. The first would have produced a silently useless check; the second and third would have produced noise that gets a tool disabled.

The VIP that three projects forked in its first year

Pitfall — a constant that should have been configuration
Buggy Code
// A VIP that works, is well structured, and gets forked by every project that
// adopts it. The reason is a handful of literals:
//
//    class i2c_predictor extends uvm_component;
//       localparam bit [6:0] MY_ADDR = 7'h50;      // this part's address
//       localparam int       N_REG   = 8;
//       localparam bit [7:0] RO_MASK = 8'h04;
//    endclass
//
//    class i2c_master_driver extends uvm_driver #(i2c_seq_item);
//       localparam int HALF = 16;                  // this project's bus rate
//    endclass
//
// Project B has a device at 0x68 with 32 registers. There is no way in from the
// outside, so B edits i2c_predictor.sv -- and now B is on a fork.
//
// Six months later a real defect is fixed in the monitor. B does not get the fix,
// because taking it means re-doing their edits, and nobody is sure which files
// they changed. The VIP has become three VIPs.
Pitfall — a linter whose checks had never fired
Buggy Code
// A structural linter for the VIP, advertising six checks. It runs in CI, it is
// green, and it is trusted.
//
//    # check: a port connected in build_phase
//    if phase == "build_phase" and re.search(r'\w+_(port|export|ap)\.connect', body):
//        report("connect_in_build")
//
// The pattern requires an UNDERSCORE before the port name. The ordinary form is:
//
//    mon.ap.connect(sb.txn_imp);         // 'mon.ap' -- a DOT, not an underscore
//
// so the check has never matched anything, for any input, ever. Two of the six
// checks are in this state. The report is green because four checks work and two
// are incapable of firing -- and from the outside those are indistinguishable.
//
// This is not hypothetical: chapter 19.6 shipped exactly this, six advertised
// checks of which two never fired.

7. What 21.11 Settled

Reuse means no edits. Every constant a consumer might need becomes a configuration field; every behaviour they might need becomes a factory override. Grepping for localparam and bare literals turns that into a checklist.

The package excludes the testbench, because including it would exclude every consumer with a different one.

Structural checking is the evidence available without a simulator, and it is bounded: fifteen named defects, absent. Not elaboration, not termination, not timing.

A checker needs testing in both directions. Fifteen checks proven able to fail, two must-not-fire cases proven quiet, and three defects found in the checkers themselves — one of which would have been a permanently dead check, exactly reproducing Module 19's failure.

A tool that cannot see a property cannot certify it. The relocated sample was reported identical until the comparison was taught to look.

8. What Module 21 Settled

Eleven chapters that take Module 20's architecture and express it as a production environment — with one honest constraint running through all of them: none of this code has been executed, and the module says so in every chapter rather than once in a footnote.

What that constraint forced turned out to be the module's most useful content. With no simulator available, the only options were to assert correctness or to find checks that do not need one — and the checks that do not need one found three real defects in the published code, plus three more in the checkers themselves. A dropped half-period wait would have shortened every bit of every transfer. A stray bracket would have shipped in a file no tool here can compile.

The architecture carried across intact: two transaction classes that are siblings rather than parent and child; passivity in a modport rather than a parameter; a monitor with its own framing and no rate; a predictor fed by observation and contract but never by the design; two verdict counters rather than one flag; faults as bus events with consequences rather than flags with requests. Each of those was argued and simulated in Module 20, which is why this module could spend its pages on packaging.

And the packaging raised decisions Module 20 never had to make. Passive means not built, because a guard protects only the statements it encloses. Configuration is handed down, because independent lookups can bind two components in one agent to different objects. An accumulator must be a fresh object, because publishing hands over a handle. A responder gets no sequencer, because it has nothing to arbitrate. An abstract base class must not be registered with the factory. None of those exist in a module-based environment; all of them are ways a class-based one fails silently.

Module 22 — Assertions, Coverage and Corner Cases takes what this module deliberately set aside: protocol rules as temporal properties rather than procedural checks, and coverage as a closure argument rather than a handful of diagnostic bins. Both need somewhere to live, and that is what these eleven chapters built.

Continue learning