SystemVerilog and UVM Callbacks - Hooks, Veto Flows & Choosing the Right Extension Seam
You need to corrupt the CRC on one packet in five hundred. The VIP driver computing that CRC is a 300-line protocol engine you didn't write. Option one: edit the VIP — congratulations, you now maintain a fork forever. Option two: extend the driver and factory-override it — to change one line you must first understand which of the 300 you're allowed to touch, and your override breaks on the next VIP update. Option three: do it from the sequence — impossible, the CRC is computed inside the driver, after your sequence let go. The vendor anticipated this. In the documentation: "pre_drive callback: invoked before transmission; may modify the transaction." Four lines of code later you're corrupting exactly the packets you want, the VIP is untouched, and the next VIP update costs you nothing.
That's what callbacks are: published extension points — places where a component's author said "user code may run here" — implemented with nothing more exotic than polymorphism and a queue. This post builds the mechanism by hand first, shows that UVM's callback framework is that same hand-built machine industrialized, and then climbs to the material the 2016 version never reached: veto flows, registration semantics, and the architectural question that actually matters — which of UVM's four extension seams should you use when?
`uvm_do_callbacks_exit_on in a table without ever showing it — both fixed. A "when NOT to use callbacks" section is new.The Mechanism, Built by Hand
A callback system is three pieces: a base class whose virtual methods are the hooks, a queue of registered callback objects, and hook-invocation loops at the published points. Nothing else:
// 1. The hook contract — empty virtual methods (see the hook pattern
// in the Abstract Classes post: these are do-nothing defaults on purpose)
class driver_callback;
virtual task pre_drive(ref transaction tr); endtask // may modify tr
virtual task post_drive(ref transaction tr); endtask // may observe tr
endclass
// 2. The component owns a queue and calls the hooks at published points
class driver;
driver_callback callbacks[$];
function void add_callback(driver_callback cb);
callbacks.push_back(cb);
endfunction
task drive(transaction tr);
foreach (callbacks[i]) callbacks[i].pre_drive(tr); // hook point 1
// ... 300 lines of protocol engine ...
foreach (callbacks[i]) callbacks[i].post_drive(tr); // hook point 2
endtask
endclass
// 3. User code extends the contract and registers
class crc_corrupt_cb extends driver_callback;
int unsigned rate = 500;
virtual task pre_drive(ref transaction tr);
if ($urandom_range(rate-1) == 0) begin
tr.crc_error = 1;
$display("[CB] corrupting CRC on this packet");
end
endtask
endclass
Dispatch does all the work: the driver's loop calls pre_drive through base-class handles, and each registered object's override runs. The driver never learns what its users invented. This is the whole idea — hold onto it, because the next section is the same picture with UVM part numbers.
The Bridge: UVM Callbacks Are That Queue
callbacks[$] queue becomes the central pool uvm_callbacks#(T,CB); add_callback() becomes uvm_callbacks#(T,CB)::add(); the foreach loop becomes `uvm_do_callbacks. What the framework adds is what your queue lacks: a central registry (register from anywhere, including before you hold a component handle), per-type and per-instance registration, ordering control, type-safety macros, and debug introspection. Understand the queue and the macros stop being incantations.
flowchart LR
CB["my_driver_callback
extends uvm_callback
(hook contract)"] --> POOL["uvm_callbacks#(my_driver,
my_driver_callback)
(the central queue)"]
TEST["Test:
::add(drv, cb)"] --> POOL
POOL --> DRV["my_driver
`uvm_do_callbacks
(the foreach loop)"]
style CB fill:#e0f2fe,stroke:#0284c7
style POOL fill:#fef3c7,stroke:#d97706
style DRV fill:#d1fae5,stroke:#059669
The three-file pattern
// FILE 1 — VIP ships the hook contract
class my_driver_callback extends uvm_callback;
`uvm_object_utils(my_driver_callback)
function new(string name = "my_driver_callback"); super.new(name); endfunction
virtual task pre_drive(my_driver drv, my_seq_item tr); endtask
virtual task post_drive(my_driver drv, my_seq_item tr); endtask
endclass
// FILE 2 — VIP driver publishes the hook points
class my_driver extends uvm_driver #(my_seq_item);
`uvm_component_utils(my_driver)
`uvm_register_cb(my_driver, my_driver_callback) // forget this = silent no-op
virtual task run_phase(uvm_phase phase);
forever begin
seq_item_port.get_next_item(req);
`uvm_do_callbacks(my_driver, my_driver_callback, pre_drive(this, req))
drive_item(req);
`uvm_do_callbacks(my_driver, my_driver_callback, post_drive(this, req))
seq_item_port.item_done();
end
endtask
endclass
// FILE 3 — the test registers user callbacks
class crc_error_test extends base_test;
`uvm_component_utils(crc_error_test)
// The idiom that keeps this readable at scale: typedef the pool once
typedef uvm_callbacks#(my_driver, my_driver_callback) drv_cb_pool;
virtual function void end_of_elaboration_phase(uvm_phase phase);
crc_corrupt_cb cb = crc_corrupt_cb::type_id::create("cb");
drv_cb_pool::add(env.agt.drv, cb); // per-instance: THIS driver only
// drv_cb_pool::add(null, cb); // per-type: EVERY my_driver
endfunction
endclass
That last pair of lines is the first thing the 2016 version never mentioned and the first thing that bites in practice: add(null, cb) registers type-wide — every my_driver in the environment, including ones inside VIP instances you forgot exist, runs your callback. Per-instance registration needs the component handle, which is why registration usually lives in end_of_elaboration_phase — after build has constructed the hierarchy, before run starts consuming transactions.
The Macro Toolbox — Including the One Nobody Demonstrates
| Macro | Purpose |
|---|---|
`uvm_register_cb(T, CB) | Type-pair registration — the license for the pool to serve this pair |
`uvm_do_callbacks(T, CB, METHOD) | Invoke METHOD on every registered callback, in order |
`uvm_do_callbacks_exit_on(T, CB, METHOD, VAL) | Invoke until a callback returns VAL — the veto/abort form |
`uvm_set_super_type(T, ST) | Let a derived component inherit its base's registered callbacks — see Advanced |
`uvm_do_callbacks_exit_on is the interesting one — it turns callbacks from observers into voters. The canonical use: giving callbacks the power to drop a transaction:
// Hook contract: bit-returning function, 1 = "keep going", 0 = "drop it"
class my_filter_callback extends uvm_callback;
`uvm_object_utils(my_filter_callback)
virtual function bit keep_transaction(my_driver drv, my_seq_item tr);
return 1; // default: no veto
endfunction
endclass
// Driver: stop polling callbacks the moment one votes 0
task run_phase(uvm_phase phase);
bit keep;
forever begin
seq_item_port.get_next_item(req);
keep = 1;
`uvm_do_callbacks_exit_on(my_driver, my_filter_callback,
keep_transaction(this, req), 0)
if (keep) drive_item(req);
else `uvm_info("DRV", "transaction vetoed by callback", UVM_HIGH)
seq_item_port.item_done();
end
endtask
Drop-lists for error recovery testing, address filtering, bandwidth throttling — all four-line callbacks against this one published veto point. (UVM's own report machinery runs on exactly this pattern; the full circle closes in the Advanced section.)
When NOT to Use Callbacks
The 2016 version sold callbacks; the 2026 version has watched an over-callbacked VIP die. The failure mode: a driver with eleven hook points, tests registering seven callbacks each, execution order mattering but documented nowhere — behavior became a function of registration history, unknowable from reading any single file. Callbacks obscure control flow by design (that's what "the driver never learns what its users invented" costs), so the seam has to earn that price. The decision framework:
| You want to… | Right seam | Why not a callback |
|---|---|---|
| Observe traffic passively (coverage, logging, checking) | Analysis port → subscriber | Observation shouldn't sit in the driver's execution timeline; a slow hook stalls the bus |
| Replace a component's whole behavior | Factory override | That's not augmentation; swapping the algorithm wants a new class, not twelve hooks |
| Vary parameters, modes, delays | Config object | Data, not code — a knob beats a hook |
| Inject/modify/veto at a point mid-algorithm you can't otherwise reach | Callback | — this is the one job the other seams can't do |
Corollary for VIP authors: publish few, meaningful hooks (pre/post-transmit, error-inject, filter), not a hook per line of the engine. Each hook is API surface you maintain forever — the Refactoring post's "Inappropriate Intimacy" smell, sold as a feature.
Common Mistakes
- Forgetting
`uvm_register_cb. Registration and invocation both quietly do nothing — the classic "my callback never fires" with no error anywhere. (Tools emit a warning at::addtime; regressions that don't grep warnings never see it.) - Signature drift in the override.
pre_drive(my_driver drv, my_seq_item tr)in the base, subtly different argument types in your extension — you've written a new method, not an override; the loop calls the empty base hook. Same disease as the missing-virtual bug: silent success. add(null, …)when you meant one instance. Type-wide registration hits every instance of the type, environment-wide, including inside other people's VIP.- Time-consuming task hooks on the fast path. A
pre_drivethat waits ten cycles just added ten cycles to every transaction — hooks execute inline in the driver's timeline. Observation belongs on analysis ports. - Order-dependent callbacks with unmanaged order. Two callbacks both modifying the transaction, correctness depending on who runs first, order determined by registration sequence scattered across tests. If order matters, control it (see Advanced) or merge the callbacks.
Interview Corner
Q: Callbacks vs factory overrides — when is each right?A: Factory overrides replace a type: every creation of X becomes Y, wholesale, at construction time. Callbacks augment an instance or type at published points: the component runs unchanged except where its author explicitly invited user code. Replace the algorithm → factory; inject at a point mid-algorithm → callback; and if the desire is passive observation, neither — analysis port.
Q: What does`uvm_register_cb actually do, and what happens without it?
A: It registers the (component-type, callback-type) pair with the callback pool's type system, licensing uvm_callbacks#(T,CB) to store and serve callbacks for that pair. Without it, ::add calls are rejected (with a warning, not an error) and `uvm_do_callbacks iterates an empty set — everything compiles, nothing fires.
A: Type-wide — ::add(null, cb) — one line instead of six. The follow-up an interviewer wants you to volunteer: type-wide means every instance of that driver type anywhere in the environment, including inside wrapped third-party IP, so the callback body must be safe to run in contexts you didn't anticipate — or you enumerate the six instances and register individually.
A: SystemVerilog has no function pointers; polymorphic objects are the language's mechanism for late-bound behavior. But the object form is also richer: a callback carries state (error rates, occupancy counters, coverage groups), multiple related hooks travel as one unit, and the object can be factory-created and configured like anything else in the methodology.
Beyond the Basics: Advanced → Expert
Level 1 — Production ergonomics: typedef the pool, iterate when you must
Two idioms separate production code from tutorial code. First, the pool typedef (drv_cb_pool above) — written once, it makes registration sites readable and gives the pair a name reviewers can grep. Second, when a component needs per-callback control the broadcast macro can't give — interrogate each callback, accumulate results, skip disabled ones — iterate explicitly with uvm_callback_iter: uvm_callback_iter#(my_driver, my_driver_callback) it = new(this); for (my_driver_callback cb = it.first(); cb != null; cb = it.next()) …. And when callbacks misbehave, uvm_callbacks#(T,CB)::display() dumps the pool's registrations — the first tool to reach for when "my callback never fires," before you re-read a line of your own code.
Level 2 — Registration semantics: ordering and inheritance
Two behaviors nobody reads the docs for until they're debugging them. Ordering: callbacks run in registration order by default; ::add takes an ordering argument (uvm_apprepend) to push a callback to the front — the sanctioned tool when your CRC-corruptor must run after everyone else's field randomizer. Use it sparingly and comment why; ordering dependencies between callbacks are a smell even when managed. Inheritance: extend my_driver into my_burst_driver and callbacks registered for the base type do not fire for the derived type — the pools are keyed by type, and the derived type is a different key. `uvm_set_super_type(my_burst_driver, my_driver) declares the relationship to the callback type system, re-uniting the pools. VIP authors who ship extendable drivers and forget this line strand every callback their users ever wrote.
Level 3 — Hooks with consequences: designing veto and transform chains
Once callbacks can modify and veto (the exit_on pattern), you're designing a pipeline, and pipeline rules apply. Give hooks precise contracts: what may be modified (tr fields yes, driver state no), what the return value means, whether later callbacks see earlier ones' modifications (they do — document it). Split observer hooks from transformer hooks into separate callback classes rather than one god-contract: observers can be freely multiplied, transformers need ordering discipline. And put the veto decision in the callback but the veto bookkeeping in the component — the driver logs and counts drops, so behavior stays auditable from one place even when the votes come from many.
Level 4 — The seams, unified: one environment, four extension mechanisms
The mark of a senior DV engineer is not knowing the four seams — it's composing them. A realistic error-injection campaign: a config object enables error mode and sets rates (data); the factory overrides the scoreboard with an error-tolerant variant that expects corruption (algorithm replacement); a callback on the driver does the actual per-packet corruption at the published hook (mid-algorithm injection); an analysis subscriber counts injected-vs-detected errors for the coverage report (passive observation). Each seam does the one thing the others can't, and the test class composes all four in twenty lines. When you find yourself forcing one mechanism to do another's job — a callback that replaces the whole algorithm, a factory override to tweak one delay — the environment's architecture is telling you which seam it's missing.
Level 5 — Full circle: UVM's own machinery runs on these callbacks
The framework eats its own cooking, and knowing where turns mysteries into mechanisms. uvm_report_catcher — the message-filtering tool with its own post on this blog — is a uvm_callback: registered via uvm_callbacks#(uvm_report_object, uvm_report_catcher), invoked through the same pool machinery as your driver hooks, which is exactly why catchers can be added per-component or globally (add(null,…) — type-wide registration, now recognizable). Objection draining, heartbeat monitoring, phase state changes — all expose uvm_callback-derived hook classes through the same pool. Two payoffs: debugging framework behavior becomes callback debugging (::display() works on UVM's pools too), and when you build infrastructure of your own — in UVM or in the bare-SV world this post opened with — you have a production-grade reference implementation to crib from, one typedef away.
Key Takeaways
- A callback system is a hook contract + a queue + invocation loops. UVM's framework is that machine with a central registry, type/instance registration, ordering, and debug tooling bolted on.
- The three-file pattern: contract (
uvm_callbackextension), publication (`uvm_register_cb+`uvm_do_callbacks), registration (::addin the test, per-instance or type-wide — know which you're doing). `uvm_do_callbacks_exit_onturns callbacks into voters — the veto pattern behind drop-lists and filters.- Callbacks are for injection at published mid-algorithm points. Observation → analysis ports; replacement → factory; parameters → config. Composing all four is the actual skill.
- UVM's report catcher, objection, and heartbeat machinery are callbacks — same pools, same debugging, same patterns.
Comments (0)
Leave a Comment