The interviewer has your CV open at "designed fault injection for a DMA VIP". "Your driver has callbacks. A test adds a truncation callback and a beat counter. In which order do they run? What does the counter see? What does the callback site cost when nobody adds anything? And how would you know if a fault callback never fired?" Four questions, and each one has a precise answer you can show in a log. This lesson builds the callback machinery on the DMA bench and answers all four.
A callback is an object you attach to a running component so that, at a fixed point in its code, the component calls a method on your object. The component's author decides where the hook is and what it may change; a test decides what happens there and when. That split is the whole value: the VIP stays one file that nobody forks, and every project plugs its own behaviour into the same sockets.
Think of a socket built into a workbench. The bench's maker chose where the socket goes and what voltage it carries (the hook's signature). A virtual hook with a factory override is replacing the whole bench with one that has a different tool bolted on. A callback is plugging a power strip into the socket: you can plug in two tools, in an order, and unplug one halfway through the job. Three pieces make it work, and each has a different owner: the callback class (VIP author), the registration and call site (VIP author), and the add/delete (test writer).
Two details in that picture explain most surprises. First, the instance queues live in one global pool keyed by the object, shared by every uvm_callbacks#(T, CB) specialisation; typewide callbacks are kept per host type T. Second, the macro calls the method directly on each callback, so every callback must cope with what an earlier one left behind, including a beat set to null.
The hook's signature is the contract. This one receives the driver (so a callback can read its config), the beat index within the packet, and the beat by inout, so a callback can replace it with a modified copy or drop it with null. The host registers the pair with uvm_register_cb and calls the list with uvm_do_callbacks inside the bench's existing pre_drive hook. In a real VIP both lines go into axis_driver itself; the course bench is never edited, so the scratch run puts them in a subclass that one type override swaps in.
// snippet: scratch run alongside the DMA bench
typedef class axis_cb_driver;
class axis_beat_cb extends uvm_callback;
`uvm_object_utils(axis_beat_cb)
function new(string name = "axis_beat_cb");
super.new(name);
endfunction
// Called once per beat before it is driven: replace beat, or set it to null to drop it.
virtual function void pre_beat(axis_cb_driver drv, int unsigned idx, inout axis_item beat);
endfunction
endclass
class axis_cb_driver extends axis_driver;
`uvm_component_utils(axis_cb_driver)
`uvm_register_cb(axis_cb_driver, axis_beat_cb)
int unsigned beat_no;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function axis_item pre_drive(axis_item it);
axis_item beat = it;
`uvm_do_callbacks(axis_cb_driver, axis_beat_cb, pre_beat(this, beat_no, beat))
beat_no = it.last ? 0 : beat_no + 1;
return beat;
endfunction
endclassDecide function or task per hook when you design it: overrides must match, so a function hook can never wait. Timing faults (extra idle cycles) need a task hook where waiting is legal.
The truncation callback is the bench's axis_truncating_driver moved into a callback, clone included: the sequence's own item is never edited. The counter is an observer that records how many beats it saw and how many were still alive when it ran.
// snippet: scratch run alongside the DMA bench
class axis_trunc_cb extends axis_beat_cb;
`uvm_object_utils(axis_trunc_cb)
int unsigned cut = 3;
function new(string name = "axis_trunc_cb");
super.new(name);
endfunction
function void pre_beat(axis_cb_driver drv, int unsigned idx, inout axis_item beat);
axis_item b;
if (beat == null || idx < cut) return; // already dropped, or before the cut
if (idx > cut) begin beat = null; return; end
$cast(b, beat.clone()); // never edit the sequence's item
b.last = 1'b1;
beat = b;
`uvm_info_context("TRUNC_CB", $sformatf("%s: TLAST forced on beat %0d", get_name(), idx), UVM_LOW, drv)
endfunction
endclass
class axis_count_cb extends axis_beat_cb;
`uvm_object_utils(axis_count_cb)
int unsigned seen, driven;
function new(string name = "axis_count_cb");
super.new(name);
endfunction
function void pre_beat(axis_cb_driver drv, int unsigned idx, inout axis_item beat);
seen++;
if (beat != null) driven++;
endfunction
endclassuvm_info_context with the driver puts the driver's path on the line. A plain uvm_info inside a callback reports as reporter, because a callback is an object, not a component.
A callback attaches to an object, so the object must exist: add it in connect_phase or end_of_elaboration_phase, never in the test's build_phase, where the agent's children have not been built yet. display() prints the queue, and is the first thing to call when a callback seems not to fire.
// snippet: scratch run alongside the DMA bench (drv is env.in_agt.drv cast to axis_cb_driver)
function void end_of_elaboration_phase(uvm_phase phase);
trunc = axis_trunc_cb::type_id::create("trunc");
cnt = axis_count_cb::type_id::create("cnt");
uvm_callbacks#(axis_cb_driver, axis_beat_cb)::add(drv, trunc); // UVM_APPEND
uvm_callbacks#(axis_cb_driver, axis_beat_cb)::add(drv, cnt); // runs after trunc
// uvm_callbacks#(axis_cb_driver, axis_beat_cb)::add(drv, cnt, UVM_PREPEND); // runs before trunc
uvm_callbacks#(axis_cb_driver, axis_beat_cb)::display(drv);
endfunction
// During run_phase: void'(trunc.callback_mode(0)) switches it off,
// uvm_callbacks#(axis_cb_driver, axis_beat_cb)::delete(drv, trunc) removes it.Call | What it does |
|---|---|
| Attach to one instance, at the back (the default) or the front with |
| Typewide: every instance of |
| Attach to every component under |
| Detach. Legal at run time, between callback sites. |
| Switch one callback off or on without removing it; returns the previous mode. |
| Print what is attached where, with the on/off state. |
| Call the list of another object, for example an item's callbacks from inside its driver. |
| Stop at the first callback whose bit-returning method returns |
| Let a derived host inherit typewide callbacks registered on its base type. |
The scratch run first appends the counter after the truncation callback. The display table shows the queue, and the counter's numbers show it ran second:
UVM_INFO uvm_callback.svh(511) @ 0: reporter [UVM/CB/DISPLAY] Registered callbacks for instance uvm_test_top.env.in_agt.drv of axis_cb_driver
trunc uvm_test_top.env.in_agt.drv on ON
cnt uvm_test_top.env.in_agt.drv on ON
UVM_INFO adv_tb.sv(53) @ 275000: uvm_test_top.env.in_agt.drv [TRUNC_CB] trunc: TLAST forced on beat 3
UVM_INFO adv_tb.sv(129) @ 385000: uvm_test_top [TRUNC] DMA reported ERR for the short packet, as expected
UVM_INFO adv_tb.sv(130) @ 385000: uvm_test_top [CB] count_cb (after trunc): saw 8 beats, 4 driven
UVM_INFO dma_tb.sv(411) @ 485000: uvm_test_top.env.sb [SB] 4 words matched, 0 mismatched275000, 385000, 485000 are the same times as the bench's dma_truncate_test, which does the same cut with a factory override. The behaviour moved; it did not change.
saw 8, 4 driven: the counter ran after the cut, so beats 4 to 7 reached it as null. With UVM_PREPEND the same run prints saw 8 beats, 8 driven: the counter now runs before the cut.
In a second scratch test the callback is switched off, on, then deleted across three 6-word jobs: STATUS reads 00000002 (DONE), 00000004 (ERR), 00000002, and the scoreboard ends at 16 words matched (6 + 4 + 6). One test turned a fault on and off, which no override can do.
The bench already has the other two mechanisms. axis_driver declares a virtual pre_drive hook that does nothing; axis_truncating_driver overrides it, and dma_truncate_test swaps it in by type override:
// snippet: from the course DMA bench
// Fault injection without touching the sequence: cuts every packet after beat
// `cut` by setting TLAST there and dropping the rest of the packet.
class axis_truncating_driver extends axis_driver;
`uvm_component_utils(axis_truncating_driver)
int unsigned cut = 3; // index of the beat that gets TLAST
int unsigned beat_no;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function axis_item pre_drive(axis_item it);
axis_item b;
int unsigned i = beat_no;
beat_no = it.last ? 0 : beat_no + 1; // count beats per packet
if (i > cut) return null; // after the cut: drop the beat
if (i < cut) return it;
$cast(b, it.clone()); // never edit the sequence's own item
b.last = 1'b1;
`uvm_info("TRUNC", $sformatf("TLAST forced on beat %0d", i), UVM_MEDIUM)
return b;
endfunction
endclassQuestion | Virtual hook + override | Callback |
|---|---|---|
Who must prepare the code | A virtual method in the component | A registered callback class and a call site |
When the choice is made | Before the component is created | Any time after it exists |
Two behaviours at once | Needs a class that has both | Two callbacks, run in queue order |
Scope | All instances (type) or one path (instance) | One object, or typewide with |
Turn off mid-test | No |
|
Can change the driver's state or flow | Yes: it is the driver | Only through what the hook passes in |
Use an override when a whole component changes for a whole test (the bench's reset-aware drivers), a callback when small behaviours must combine, target one instance or switch mid-test, and a plain virtual hook inside your own code. Error injection specifically, and the scoreboard side of an expected fault, is in Callbacks versus factory overrides and Checking expected faults.
The macro creates a uvm_callback_iter object and walks the queue every time it runs, even when the queue is empty. Scratch measurements of one million calls of an empty site took 0.15 to 0.18 s on Verilator across runs (about 0.15 µs per call, on an Apple M4 Pro; the performance lesson table has the 0.18 s run). That is nothing once per transaction and real money once per clock in a 200-lane monitor. Put sites at transaction boundaries, not in per-cycle loops.
Count firing. Every fault callback counts how often it fired, and the test checks the count; a fault test that checks only for "no errors" passes when the fault never happens.
Print the queue. uvm_callbacks#(T, CB)::display() shows typewide entries as (*) and each instance's queue.
Trace the library. Compile with +define+UVM_CB_TRACE_ON and every add, delete and execution is reported (the macro bodies include uvm_cb_trace_noobj calls that are empty otherwise).
Report catchers are callbacks too. uvm_report_catcher extends uvm_callback; so do uvm_reg_cbs and uvm_objection_callback. The same add, order and display rules apply. Catchers are covered in Synchronisation and reporting.
The fault that never fired. A test attached the truncation callback typewide, but named the base class as the host: uvm_callbacks#(axis_driver, axis_beat_cb)::add(null, trunc). The driver that calls the list is axis_cb_driver, and typewide queues are kept per host type. In the scratch run, the only trace was one warning, and the job finished cleanly:
UVM_WARNING @ 0: reporter [CBUNREG] Callback trunc cannot be registered with object (*) because callback type axis_trunc_cb is not registered with object type uvm_object
UVM_INFO adv_tb.sv(228) @ 605000: uvm_test_top [CB] STATUS=00000002
UVM_INFO dma_tb.sv(411) @ 705000: uvm_test_top.env.sb [SB] 8 words matched, 0 mismatchedSTATUS=2 is DONE: the packet went through whole. The same wrong type with an instance add behaves differently, which makes the warning hard to read: it prints the same CBUNREG warning, yet the callback still fires (STATUS=4, ERR), because instance queues are keyed by the object, not by T. Treat CBUNREG as an error (uvm_root::get().set_report_severity_id_override(UVM_WARNING, "CBUNREG", UVM_ERROR) in the base test) and make every fault test check its firing count.
The corruption nobody saw. A second callback flipped bit 0 of beat 2's data, to prove the scoreboard catches corrupted words. The scratch run reported beat 2: data cbccc8de -> cbccc8df and then 8 words matched, 0 mismatched. The scoreboard was right. The input monitor watches the pins, so it saw the corrupted word, and the scoreboard predicted the DMA's output from it. A fault injected before the observation point is just different stimulus. To test the DUT, inject faults the DUT must detect (the early TLAST gives STATUS.ERR); to test the checker, corrupt what the checker compares, or have the DUT path produce the wrong word, as the bench's DMA_BUG_WORD6 build does.
axis_cb_driver instances. A test intends to truncate packets on dma1 only, and writes uvm_callbacks#(axis_cb_driver, axis_beat_cb)::add(drv1, trunc) in its build_phase, right after super.build_phase(). What happens?drv1 cannot have been assigned yet: the test's build_phase creates the env, but the agents and drivers are built later, top-down, so the handle is still null. add with a null object is a typewide add, so the callback lands on both drivers and both DMAs see short packets. Move the add to connect_phase or end_of_elaboration_phase, when the handle is real.
Give the hook a bit return value, for example virtual function bit allow(axis_cb_driver drv, axis_item it), and call it with uvm_do_callbacks_exit_on(axis_cb_driver, axis_beat_cb, allow(this, it), 0) as the last statement of a function returning bit. The macro returns 0 at the first callback that returns 0, and 1 if none does.
axis_count_cb the first callback with UVM_PREPEND "so the count is right". When is that the wrong choice?It depends on what the count means. Prepended, it counts the beats the sequence produced (8 of 8). Appended, it counts the beats actually driven (4 of 8). A coverage or firing check usually wants what reached the pins, so it should run last. Write down which one you mean, because the order is part of the result.
Next: a callback is chosen in code; most run-to-run variation comes from the command line. The command line in full shows every built-in plusarg, and the typos that the library silently ignores.