Chain of Responsibility in UVM: From Address Decoders to Layered Protocol Stacks

The Strategy pattern showed us how to swap an entire algorithm at runtime — pick one strategy, run it. But many real verification problems aren't single-algorithm decisions. They're chains of partial responsibility: a report needs to walk a hierarchy until someone decides what severity it gets; an incoming bus transaction needs to find the sub-scoreboard that owns its address range; a TLP needs to flow through DLLP and PHY layers before it leaves the testbench. None of these is "pick one." Each is "ask each handler in turn — first one that claims it wins." That's the Chain of Responsibility pattern.

The Problem: The Scoreboard if/else Ladder

You're building a scoreboard for a memory-mapped subsystem. The DUT exposes an SRAM region, a ROM region, and a small GPIO bank — three regions with different checking rules. The first version of check() looks like this.

class mem_scoreboard extends uvm_scoreboard;
  `uvm_component_utils(mem_scoreboard)

  // BAD: every region's dispatch AND checking logic crammed into one function
  function void check(bus_txn t);
    if (t.addr inside {[32'h0000_0000:32'h0000_0FFF]}) begin
      // SRAM check inline
      if (t.write && expected_sram[t.addr] != t.data)
        `uvm_error("SCB", $sformatf("SRAM write mismatch @0x%0h", t.addr))
      else if (!t.write)
        expected_sram[t.addr] = t.data;
    end else if (t.addr inside {[32'h0000_1000:32'h0000_1FFF]}) begin
      // ROM check inline — writes are illegal
      if (t.write)
        `uvm_error("SCB", $sformatf("ROM write attempt @0x%0h", t.addr))
      else if (rom_image[t.addr - 32'h1000] != t.data)
        `uvm_error("SCB", $sformatf("ROM read mismatch @0x%0h", t.addr))
    end else if (t.addr inside {[32'h0000_2000:32'h0000_20FF]}) begin
      // GPIO check inline — sticky bits, partial-write masks, ...
      check_gpio_partial_write(t);
    end else begin
      `uvm_error("SCB", $sformatf("unmapped addr 0x%0h", t.addr))
    end
  endfunction
endclass
  • Adding a new region edits a giant function (Open/Closed violation) — even a small change risks merging conflicts with whoever else touched the ladder this week
  • Each region's check logic is welded to dispatch — you cannot unit-test SRAM checking in isolation without spinning up a full scoreboard
  • Re-ordering priorities (e.g., a shadow region should override SRAM in test mode) means re-shuffling the ladder by hand and re-validating every branch
  • The ladder grows linearly with regions; ten regions later, it is unreadable

What you want is a chain where each handler owns its address range, decides "mine?" → claim, else "pass it on." First handler that claims wins. The scoreboard never knows the concrete handler types — it just hands the transaction to the head of the chain. That separation is the Chain of Responsibility pattern.

Gang of Four: The Chain of Responsibility Pattern

Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it.

classDiagram
    class Handler {
        <<abstract>>
        -next : Handler
        +set_next(h: Handler)
        +handle(req)
        #claim(req) bit
        #do_check(req)
    }
    class ConcreteHandlerA {
        +claim(req) bit
        +do_check(req)
    }
    class ConcreteHandlerB {
        +claim(req) bit
        +do_check(req)
    }
    class ConcreteHandlerC {
        +claim(req) bit
        +do_check(req)
    }
    class DefaultSink {
        +claim(req) bit
        +do_check(req)
    }
    class Client
    Handler <|-- ConcreteHandlerA
    Handler <|-- ConcreteHandlerB
    Handler <|-- ConcreteHandlerC
    Handler <|-- DefaultSink
    Client --> Handler : head of chain
    ConcreteHandlerA --> ConcreteHandlerB : next
    ConcreteHandlerB --> ConcreteHandlerC : next
    ConcreteHandlerC --> DefaultSink : next

Each handler owns one decision — "mine? claim. else pass to next." The Client only knows the head of the chain; chain composition is independent of the handlers themselves. Adding, removing, or reordering handlers is a wiring change, not a Client change. The terminal DefaultSink is what separates a working chain from a silent dropper — without it, an unclaimed request walks off the end into nothing.

Aspect Chain of Responsibility Decorator
Purpose Find which handler owns the request Add behavior to every wrapped target
Termination First handler that claims; rest never run Every layer always runs
Default behavior Pass to next Delegate to wrapped target + add own behavior
Mental model "Who's responsible for this?" "Stack more on top"

Rule of thumb: if every layer should run, you want Decorator. If only one layer should run, you want CoR.

UVM's Chain: uvm_report_handler and the Reporting Chain

A `uvm_info("ID", "msg", UVM_MEDIUM) from a leaf component does not go straight to stdout. It walks a chain. The leaf's uvm_report_handler checks for an ID-specific override; if none, it defers to its parent component's handler; the parent does the same, all the way up to uvm_root; then uvm_root hands the message to the singleton uvm_report_server which finally formats and prints. At each level, the handler asks "do I override action / file / verbosity for this ID or severity? If yes, claim. If no, defer to my parent." That is Chain of Responsibility, written into the reporting subsystem you already use a hundred times a day.

class my_test extends uvm_test;
  `uvm_component_utils(my_test)

  mem_env env;

  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    env = mem_env::type_id::create("env", this);
  endfunction

  function void start_of_simulation_phase(uvm_phase phase);
    super.start_of_simulation_phase(phase);

    // Top of the chain — applies to every descendant unless a child overrides
    set_report_id_action_hier("PROTO_ERR",
        UVM_DISPLAY | UVM_LOG | UVM_COUNT);

    // Mid-chain override — only this subtree quiets CMP_DBG
    env.scoreboard.set_report_id_verbosity("CMP_DBG", UVM_NONE);

    // Default for everything else: still walks up to uvm_root, then to the server
  endfunction
endclass

When the scoreboard fires `uvm_info("CMP_DBG", "...", UVM_HIGH), its own report handler claims the message and silences it (UVM_NONE). When the scoreboard fires `uvm_error("PROTO_ERR", "..."), its own handler has no override for PROTO_ERR, so the report walks up to env, then to the test, where the test-level override claims it and forces display+log+count. Same component, two different IDs, different links in the chain handle each one.

GoF Role UVM Class / Mechanism
Handler (abstract) uvm_report_handler
ConcreteHandler per-component instance with overrides via set_report_*
next link implicit via component parent pointer (get_parent())
Default sink (terminal) uvm_report_server (singleton)
Request uvm_report_message
Walk trigger process_report_message()

Every UVM user has used CoR — they just called it "report severity overrides." Now the rest of this post builds the same shape from scratch, for problems UVM does not solve out of the box.

Building a Decoder Chain: Address-Range Handlers in a Scoreboard

The reporting chain is built into UVM. For the scoreboard problem from §1, you build the same shape yourself. Three pieces: an abstract addr_handler that defines the chain skeleton (Template Method), one concrete handler per region, and a terminal default_sink_handler that catches anything that walked off the end.

// Abstract base — the chain skeleton. Subclasses override claim() and do_check().
virtual class addr_handler extends uvm_object;
  protected addr_handler next;

  function new(string name = "addr_handler");
    super.new(name);
  endfunction

  function void set_next(addr_handler n);
    next = n;
  endfunction

  // Subclasses override:
  pure virtual function bit  claim(bus_txn t);    // "is this mine?"
  pure virtual function void do_check(bus_txn t); // "what do I do with it?"

  // The chain walk — DO NOT override in subclasses.
  function void handle(bus_txn t);
    if (claim(t)) begin
      do_check(t);
    end
    else if (next != null) begin
      next.handle(t);
    end
    else begin
      `uvm_error("SCB",
        $sformatf("no handler for addr 0x%0h (chain has no default sink)",
                  t.addr))
    end
  endfunction
endclass
class sram_handler extends addr_handler;
  `uvm_object_utils(sram_handler)

  bit [31:0] expected_sram[bit [31:0]];

  function new(string name = "sram_handler"); super.new(name); endfunction

  virtual function bit claim(bus_txn t);
    return t.addr inside {[32'h0000_0000:32'h0000_0FFF]};
  endfunction

  virtual function void do_check(bus_txn t);
    if (t.write) begin
      expected_sram[t.addr] = t.data;
    end
    else if (expected_sram.exists(t.addr) &&
             expected_sram[t.addr] !== t.data) begin
      `uvm_error("SRAM",
        $sformatf("read mismatch @0x%0h: exp 0x%0h got 0x%0h",
                  t.addr, expected_sram[t.addr], t.data))
    end
  endfunction
endclass
class rom_handler extends addr_handler;
  `uvm_object_utils(rom_handler)

  bit [31:0] rom_image[];  // loaded at build_phase from a hex file

  function new(string name = "rom_handler"); super.new(name); endfunction

  virtual function bit claim(bus_txn t);
    return t.addr inside {[32'h0000_1000:32'h0000_1FFF]};
  endfunction

  virtual function void do_check(bus_txn t);
    int unsigned idx = (t.addr - 32'h0000_1000) >> 2;
    if (t.write) begin
      `uvm_error("ROM",
        $sformatf("illegal write to ROM @0x%0h", t.addr))
    end
    else if (rom_image[idx] !== t.data) begin
      `uvm_error("ROM",
        $sformatf("ROM read mismatch @0x%0h: exp 0x%0h got 0x%0h",
                  t.addr, rom_image[idx], t.data))
    end
  endfunction
endclass
class gpio_handler extends addr_handler;
  `uvm_object_utils(gpio_handler)

  function new(string name = "gpio_handler"); super.new(name); endfunction

  virtual function bit claim(bus_txn t);
    return t.addr inside {[32'h0000_2000:32'h0000_20FF]};
  endfunction

  virtual function void do_check(bus_txn t);
    // GPIO has sticky bits and partial-write masks — see check_gpio_partial_write() body
    check_gpio_partial_write(t);
  endfunction

  extern function void check_gpio_partial_write(bus_txn t);
endclass
class default_sink_handler extends addr_handler;
  `uvm_object_utils(default_sink_handler)

  function new(string name = "default_sink"); super.new(name); endfunction

  virtual function bit  claim(bus_txn t); return 1; endfunction  // always

  virtual function void do_check(bus_txn t);
    `uvm_error("SCB",
      $sformatf("unmapped addr 0x%0h (write=%0b data=0x%0h)",
                t.addr, t.write, t.data))
  endfunction
endclass
class mem_scoreboard extends uvm_scoreboard;
  `uvm_component_utils(mem_scoreboard)

  uvm_analysis_imp #(bus_txn, mem_scoreboard) ap_imp;
  addr_handler chain_head;  // only the head is held; rest walk via next

  function new(string name, uvm_component parent); super.new(name, parent); endfunction

  function void build_phase(uvm_phase phase);
    ap_imp = new("ap_imp", this);
  endfunction

  function void connect_phase(uvm_phase phase);
    sram_handler          sram = sram_handler::type_id::create("sram");
    rom_handler           rom  = rom_handler::type_id::create("rom");
    gpio_handler          gpio = gpio_handler::type_id::create("gpio");
    default_sink_handler  sink = default_sink_handler::type_id::create("sink");

    // Wire the chain: SRAM -> ROM -> GPIO -> default sink
    sram.set_next(rom);
    rom.set_next(gpio);
    gpio.set_next(sink);

    chain_head = sram;
  endfunction

  function void write(bus_txn t);
    chain_head.handle(t);  // dispatch — chain decides who claims it
  endfunction
endclass

A transaction at 0x2050 arrives at chain_head (the SRAM handler). SRAM's claim() returns 0 (out of range). The base handle() defers to next — the ROM handler. ROM's claim() also returns 0. Defer to GPIO. GPIO's claim() returns 1 — the chain stops here, do_check() runs, the txn is processed. The scoreboard never knew which handler owned the request. New region? Add a fourth concrete handler and one set_next() call. Existing handlers are untouched.

Same skeleton, different domain

// Same skeleton, different domain — opcode decode in an ISS scoreboard
class alu_handler         extends inst_handler;
  virtual function bit claim(rv_inst i); return i.opcode inside {OP_ADD, OP_SUB, OP_AND, OP_OR}; endfunction
endclass
class ldst_handler        extends inst_handler;
  virtual function bit claim(rv_inst i); return i.opcode inside {OP_LW, OP_SW, OP_LB, OP_SB}; endfunction
endclass
class branch_handler      extends inst_handler;
  virtual function bit claim(rv_inst i); return i.opcode inside {OP_BEQ, OP_BNE, OP_JAL, OP_JALR}; endfunction
endclass
class illegal_inst_sink   extends inst_handler;
  virtual function bit claim(rv_inst i); return 1; endfunction
  virtual function void do_check(rv_inst i);
    `uvm_error("DECODE", $sformatf("illegal instruction 0x%0h @PC=0x%0h", i.raw, i.pc))
  endfunction
endclass

// Wiring: alu -> ldst -> branch -> illegal_inst_sink

Pitfalls

  • Forgetting set_next() wiring — silent drop on the first miss. The chain truncates at the unwired handler; downstream handlers are unreachable. Fix: assert chain_head != null and walk the chain at end of connect_phase to verify every handler has either claimed coverage or a non-null next.
  • Two handlers that both claim — first one reached in chain order wins. If overlapping ranges are intentional (e.g., a debug shadow), document the precedence; if accidental, the second handler is dead code. Fix: in connect_phase, sweep test addresses across handlers and assert exactly one claims.
  • Handler mutates t before deciding to pass — a buggy handler that updates expected_sram[t.addr] inside claim() and then returns 0 corrupts state for downstream handlers. Fix: claim() must be a pure predicate; only do_check() may mutate state.
  • No terminal sink — unmapped traffic is silently dropped. The scoreboard reports "all good" while real bugs walk past. Fix: always end the chain with default_sink_handler that claims everything and logs as uvm_error.

Scaling Up: A Handler Registry with Order and Default Sink

Hand-wiring four handlers in connect_phase is fine for one scoreboard. With ten regions across three sub-blocks, and per-test variations (debug shadow handlers in some tests, MMIO trap handlers in others), the scoreboard's connect_phase becomes the new ladder. The fix mirrors the Strategy post's protocol_registry, with one CoR-specific addition: chain order is a first-class concern, and the registry must terminate every chain with a default sink.

class handler_registry;
  // Singleton accessor
  static function handler_registry get();
    static handler_registry inst;
    if (inst == null) inst = new();
    return inst;
  endfunction

  // Internal map: string key -> uvm_object_wrapper (factory type handle)
  protected uvm_object_wrapper type_map[string];

  // Register a handler type under a key — call at package elaboration time
  function void register(string key, uvm_object_wrapper handler_type);
    if (type_map.exists(key))
      `uvm_warning("REG", $sformatf("handler_registry: overwriting key '%s'", key))
    type_map[key] = handler_type;
  endfunction

  // Build a chain in declared order; terminate with default_key.
  // Each call returns a fresh chain of fresh instances — never shared.
  function addr_handler build_chain(string ordered_keys[$], string default_key);
    addr_handler head;
    addr_handler prev;
    addr_handler tail_sink;

    if (!type_map.exists(default_key))
      `uvm_fatal("REG",
        $sformatf("handler_registry: default_key '%s' not registered", default_key))

    foreach (ordered_keys[i]) begin
      addr_handler h;
      uvm_object   obj;
      if (!type_map.exists(ordered_keys[i]))
        `uvm_fatal("REG",
          $sformatf("handler_registry: unknown key '%s'", ordered_keys[i]))
      obj = type_map[ordered_keys[i]].create_object(ordered_keys[i]);
      if (!$cast(h, obj))
        `uvm_fatal("REG",
          $sformatf("handler_registry: '%s' is not an addr_handler", ordered_keys[i]))
      if (head == null) head = h;
      else              prev.set_next(h);
      prev = h;
    end

    // Always terminate with the default sink — never returned without it.
    begin
      uvm_object obj = type_map[default_key].create_object(default_key);
      if (!$cast(tail_sink, obj))
        `uvm_fatal("REG",
          $sformatf("handler_registry: default '%s' is not an addr_handler", default_key))
      if (head == null) head = tail_sink;
      else              prev.set_next(tail_sink);
    end

    return head;
  endfunction
endclass
package mem_handlers_pkg;
  import uvm_pkg::*;
  import bus_handlers_pkg::*;  // addr_handler base + handler_registry

  // ... sram_handler / rom_handler / gpio_handler / default_sink_handler ...

  initial begin
    handler_registry::get().register("sram",    sram_handler::get_type());
    handler_registry::get().register("rom",     rom_handler::get_type());
    handler_registry::get().register("gpio",    gpio_handler::get_type());
    handler_registry::get().register("default", default_sink_handler::get_type());
  end
endpackage
class instrumented_test extends uvm_test;
  `uvm_component_utils(instrumented_test)

  mem_env env;

  function void build_phase(uvm_phase phase);
    string_q chain;  // string_q is typedef'd to string[$] — see mem_handler_keys

    super.build_phase(phase);

    // This test inserts a debug tap at the head of the chain for traffic logging
    chain = '{ "tap", "sram", "rom", "gpio" };
    uvm_config_db#(string_q)::set(this, "env.scoreboard", "chain_order",   chain);
    uvm_config_db#(string)  ::set(this, "env.scoreboard", "chain_default", "default");

    env = mem_env::type_id::create("env", this);
  endfunction
endclass
function void mem_scoreboard::connect_phase(uvm_phase phase);
  string_q chain_order;
  string   default_key;

  if (!uvm_config_db#(string_q)::get(this, "", "chain_order", chain_order))
    chain_order = '{ "sram", "rom", "gpio" };  // sensible fallback
  if (!uvm_config_db#(string)::get(this, "", "chain_default", default_key))
    default_key = "default";

  chain_head = handler_registry::get().build_chain(chain_order, default_key);
endfunction

Dynamic Insertion

A debug_tap_handler that never claims; it just logs every transaction and passes through. Drop it at the head of the chain when you need a non-intrusive trace of every txn the scoreboard sees, pull it out when you don't. The env stays untouched.

class debug_tap_handler extends addr_handler;
  `uvm_object_utils(debug_tap_handler)

  function new(string name = "tap"); super.new(name); endfunction

  virtual function bit claim(bus_txn t); return 0; endfunction  // never claims

  virtual function void do_check(bus_txn t); endfunction         // never called

  // Override handle() to log, then defer to next — this is the one legitimate
  // reason to override handle() in a subclass: a non-claiming pass-through
  // that participates in the walk without owning any txns.
  function void handle(bus_txn t);
    `uvm_info("TAP",
      $sformatf("addr=0x%0h write=%0b data=0x%0h", t.addr, t.write, t.data),
      UVM_HIGH)
    if (next != null) next.handle(t);
  endfunction
endclass

Compile-Time-Safe Keys

package mem_handler_keys;
  // Wrap the queue type in a typedef so uvm_config_db can take it as a parameter.
  typedef string string_q[$];

  parameter string SRAM    = "sram";
  parameter string ROM     = "rom";
  parameter string GPIO    = "gpio";
  parameter string TAP     = "tap";
  parameter string DEFAULT = "default";
endpackage

A typo in a string literal ("srma" for "sram") becomes a runtime fatal — usually deep into a long simulation. Define keys as package constants and a typo becomes a compile error. The string_q typedef is required because uvm_config_db#(...) needs a complete type as its parameter — string[$] written inline is a declarator, not a type.

Advanced: Layered Protocol Stacks — PCIe TLP → DLLP → PHY

Address-range decoding is one shape of CoR. Layered protocol stacks are another, and the one most DV engineers spend their careers fighting. Each protocol layer owns one slice of packet processing, hands the rest to the next layer, and the layers compose into a chain. PCIe is the canonical example: a TLP (Transaction-Layer Packet) gets wrapped by DLLP (Data-Link Layer) framing, which gets wrapped by PHY (Physical Layer) encoding before any bits hit the wire. Reverse on RX. Each layer is a CoR handler that knows what it owns and what to forward.

Layer Owns Forwards
TLP header fields (TYPE, FMT, LEN), data payload, ECRC builds packet bytes, hands down
DLLP sequence number, LCRC, flow-control credits, ack/nack adds DLL framing, hands down
PHY STP/END framing, 8b/10b or 128b/130b encoding, scrambling drives serial bytes

One thing to flag: in a protocol stack, every layer always runs (TLP, DLLP, and PHY each own a slice of the work — none of them is allowed to "skip"). That looks more like Decorator at a glance. The reason we still call it Chain of Responsibility is the composition: layers are wired with next pointers, can be added or swapped at runtime, and each layer only knows the next link, not the full stack. We drop the claim() predicate here because it is always 1, and each layer just processes its own slice and forwards.

// Base for the protocol-stack CoR variant — every layer claims,
// but each layer owns one slice of the work and forwards the rest.
virtual class proto_layer extends uvm_object;
  protected proto_layer next;
  function void set_next(proto_layer n); next = n; endfunction

  pure virtual task tx(packet p);  // outbound: process my layer, then forward down
  pure virtual task rx(packet p);  // inbound:  forward up first, then process my layer

  function new(string name = "proto_layer"); super.new(name); endfunction
endclass
class tlp_layer_handler extends proto_layer;
  `uvm_object_utils(tlp_layer_handler)
  function new(string name = "tlp"); super.new(name); endfunction

  // TX: TLP is the head of the TX chain — build header / payload / ECRC, then forward down.
  virtual task tx(packet p);
    p.append_tlp_header();
    p.append_payload();
    p.append_ecrc();
    if (next != null) next.tx(p);
  endtask

  // RX: TLP is the tail of the RX chain — strip header, validate ECRC, deliver. No forward.
  virtual task rx(packet p);
    p.validate_ecrc();
    p.strip_tlp_header();
    deliver_to_analysis_port(p);
  endtask
endclass
class dllp_layer_handler extends proto_layer;
  `uvm_object_utils(dllp_layer_handler)
  function new(string name = "dllp"); super.new(name); endfunction

  virtual task tx(packet p);
    p.assign_sequence_number();
    p.append_lcrc();
    p.update_flow_control_credit();
    if (next != null) next.tx(p);
  endtask

  virtual task rx(packet p);
    p.validate_lcrc();
    p.process_ack_nack();
    p.strip_sequence_number();
    if (next != null) next.rx(p);
  endtask
endclass
class phy_layer_handler extends proto_layer;
  `uvm_object_utils(phy_layer_handler)
  function new(string name = "phy"); super.new(name); endfunction

  virtual task tx(packet p);
    p.frame_with_stp_end();
    p.encode_128b130b();
    p.scramble();
    drive_serial(p);
  endtask

  virtual task rx(packet p);
    sample_serial(p);
    p.descramble();
    p.decode_128b130b();
    p.strip_stp_end_framing();
    if (next != null) next.rx(p);
  endtask
endclass
function void pcie_agent::connect_phase(uvm_phase phase);
  // TX chain: TLP -> DLLP -> PHY -> wire
  tx_tlp  = tlp_layer_handler ::type_id::create("tx_tlp");
  tx_dllp = dllp_layer_handler::type_id::create("tx_dllp");
  tx_phy  = phy_layer_handler ::type_id::create("tx_phy");
  tx_tlp.set_next(tx_dllp);
  tx_dllp.set_next(tx_phy);

  // RX chain: PHY -> DLLP -> TLP -> analysis port
  rx_phy  = phy_layer_handler ::type_id::create("rx_phy");
  rx_dllp = dllp_layer_handler::type_id::create("rx_dllp");
  rx_tlp  = tlp_layer_handler ::type_id::create("rx_tlp");
  rx_phy.set_next(rx_dllp);
  rx_dllp.set_next(rx_tlp);

  // Driver hands outbound packets to the TX chain head
  driver.tx_chain_head = tx_tlp;
  // Monitor hands inbound bits to the RX chain head
  monitor.rx_chain_head = rx_phy;
endfunction
sequenceDiagram
    participant Test
    participant Driver
    participant TX_TLP  as tx_tlp_layer_handler
    participant TX_DLLP as tx_dllp_layer_handler
    participant TX_PHY  as tx_phy_layer_handler
    participant Wire    as Serial Wire
    participant RX_PHY  as rx_phy_layer_handler
    participant RX_DLLP as rx_dllp_layer_handler
    participant RX_TLP  as rx_tlp_layer_handler
    participant Scoreboard

    Test->>Driver: tx(memory_write_tlp)
    Driver->>TX_TLP: tx(packet)
    TX_TLP->>TX_TLP: append header / payload / ECRC
    TX_TLP->>TX_DLLP: tx(packet)
    TX_DLLP->>TX_DLLP: assign seq# / append LCRC / update FC
    TX_DLLP->>TX_PHY: tx(packet)
    TX_PHY->>TX_PHY: frame STP/END / 128b130b / scramble
    TX_PHY->>Wire: serial bytes

    Wire->>RX_PHY: serial bytes (DUT response)
    RX_PHY->>RX_PHY: descramble / decode / strip framing
    RX_PHY->>RX_DLLP: rx(packet)
    RX_DLLP->>RX_DLLP: validate LCRC / process ack-nack / strip seq#
    RX_DLLP->>RX_TLP: rx(packet)
    RX_TLP->>RX_TLP: validate ECRC / strip header
    RX_TLP->>Scoreboard: completion txn

Eight patterns. Eight orthogonal concerns. Factory builds, Adapter translates, Decorator adds, Facade simplifies, Proxy mediates, Observer broadcasts, Strategy swaps, Chain of Responsibility delegates until claimed. None replaces another.

The protocol stack uses CoR for layer composition, Factory for layer creation (type_id::create), and the layers themselves are Decorators in the strict GoF sense (each adds behavior to the packet on its way through). Real testbenches compose patterns; pattern names describe intent, not file boundaries.

Quick Reference

GoF Role → UVM Mapping

GoF Role Reporting Chain (UVM-native) Custom Chain (built in this post)
Handler (abstract) uvm_report_handler addr_handler / proto_layer
ConcreteHandler per-component overrides via set_report_* sram_handler, rom_handler, gpio_handler, tlp_layer_handler, ...
next link implicit via get_parent() explicit set_next() field
Default sink (terminal) uvm_report_server (singleton) default_sink_handler
Request uvm_report_message bus_txn / packet
Walk trigger process_report_message() addr_handler::handle() / proto_layer::tx()
Chain assembly implicit (component hierarchy) handler_registry::build_chain(ordered_keys, default_key)

addr_handler API Cheatsheet

  • pure virtual function bit claim(bus_txn t) — return 1 if this handler owns the txn. Must be a pure predicate; do not mutate state.
  • pure virtual function void do_check(bus_txn t) — perform the actual check / update for claimed txns.
  • function void set_next(addr_handler n) — link to the next handler in the chain. Wired in connect_phase.
  • function void handle(bus_txn t) — the chain walk. Do not override in normal handlers; the only legitimate override is the debug_tap_handler non-claiming pass-through.
  • handler_registry::get().register(key, type) — register a handler type at package elaboration time.
  • handler_registry::get().build_chain(ordered_keys[$], default_key) — build a fresh chain in declared order, terminated with default sink.

Common Mistakes

Mistake Fix
Forgot set_next() wiring Wire chain in connect_phase; assert chain_head != null and walk the chain to verify every link
No terminal sink Always end the chain with a default_sink_handler that claims everything and logs as uvm_error
Two handlers both claim First claimer in chain order wins. Make ordering explicit in build_chain() and document the precedence
Handler mutates request before passing claim() must be a pure predicate; only do_check() may mutate state
Cycle in chain (handler points back to ancestor) Validate at build time — walk next pointers, detect repeats; build_chain() prevents this by construction
Stateful handler shared across drivers handler_registry::build_chain() returns fresh instances per call — never reuse a handler across chains
Overriding handle() instead of claim() / do_check() The base handle() already implements dispatch (claim → check → delegate to next). Concrete handlers override only claim() and do_check()

Previous: Strategy Pattern — Protocol-agnostic traffic with adapters and strategy classes

Next: Command Pattern — From sequence items to replayable stimulus

Author
Milan Kubavat
Sharing knowledge about silicon verification, hardware design, and engineering insights.

Comments (0)

Leave a Comment