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
- Gang of Four: The Chain of Responsibility Pattern
- UVM's Chain: uvm_report_handler and the Reporting Chain
- Building a Decoder Chain: Address-Range Handlers in a Scoreboard
- Scaling Up: A Handler Registry with Order and Default Sink
- Advanced: Layered Protocol Stacks — PCIe TLP → DLLP → PHY
- Quick Reference
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: assertchain_head != nulland walk the chain at end ofconnect_phaseto verify every handler has either claimed coverage or a non-nullnext. - 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
tbefore deciding to pass — a buggy handler that updatesexpected_sram[t.addr]insideclaim()and then returns 0 corrupts state for downstream handlers. Fix:claim()must be a pure predicate; onlydo_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_handlerthat claims everything and logs asuvm_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 inconnect_phase.function void handle(bus_txn t)— the chain walk. Do not override in normal handlers; the only legitimate override is thedebug_tap_handlernon-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
Comments (0)
Leave a Comment