A KEY write that the DMA ignored has to be found in a 40 µs waveform with 300 signals. You add PSEL, PENABLE, PADDR and PWDATA, zoom, scroll, and count transfers by eye. A colleague opens the same run, expands a stream called apb_bus under the APB monitor, and clicks the fourth bar: addr=0c data=ffff0000 write=1, at the exact time, aligned with the wires below it.
That stream is transaction recording. UVM can write every transaction a component sees into a database that the waveform tool shows as bars with fields. This lesson shows how to turn it on, how the library records items automatically and where you add calls yourself, what it costs, and why it belongs in debug reruns rather than regressions.
Waveforms are raw sensor traces: every wire, every clock. A transaction stream is the flight recorder's event log: "write to KEY started here, ended there, with these values". Both are recorded against the same clock, so the viewer can show one above the other. You read the log to find the moment, then the traces to see why.
In UVM terms, a component calls begin_tr() when a transaction starts and end_tr() when it ends. Between them the library opens a recorder on a stream, a named lane of transactions belonging to that component, inside a transaction database. At end_tr() it calls the transaction's do_record(), which writes the fields.
A component records only when its recording_detail is not UVM_NONE, and the default is UVM_NONE. The 1800.2 reference library reads recording_detail from the configuration in the component's constructor, so the setting must be made before the component is created:
// snippet: in the test, before the env and its agents are created
function void build_phase(uvm_phase phase);
if ($test$plusargs("DMA_RECORD"))
uvm_config_db#(int)::set(this, "env.*", "recording_detail", UVM_FULL);
super.build_phase(phase); // dma_base_test creates env here
endfunctionMaking it a plusarg is the point: the same compiled bench records only when someone asks. IEEE 1800.2 also offers set_recording_enabled(1) and set_recording_enabled_hier(1) on a component, which take effect at once. Most simulators additionally have their own switch to make their database the default one and to record UVM activity automatically; it differs per tool, so check your simulator's documentation for it.
A sequencer with recording on records items automatically: finish_item() calls begin_tr() on the sequencer and item_done() ends it, so every item a driver executes appears on a stream named after its root sequence, with parent links to the sequences that sent it. disable_auto_item_recording() on the sequencer turns that off when a driver wants to record its own timing. Monitors record nothing unless they call the API themselves, which is where the most useful streams come from, because they show what really happened on the bus:
// snippet: verified in a scratch run (sync_tb.sv)
task run_phase(uvm_phase phase);
for (int i = 0; i < 2; i++) begin
bus_item t = bus_item::type_id::create("apb_tr");
#10ns;
void'(begin_tr(t, "apb_bus")); // transaction starts on the bus
t.addr = 8'h0C; t.data = 32'hCAFE_F000 + i; t.write = 1;
#20ns;
end_tr(t); // transaction completes: do_record runs now
end
endtaskIn a real monitor, begin_tr() goes where the transfer starts (APB: the setup phase) and end_tr() where it completes (PREADY in the access phase). The item says what to record in do_record(); explicit record_field() calls give each field a size and a radix:
// snippet
function void do_record(uvm_recorder recorder);
super.do_record(recorder);
recorder.record_field("addr", addr, $bits(addr), UVM_HEX);
recorder.record_field("data", data, $bits(data), UVM_HEX);
recorder.record_field("write", write, $bits(write), UVM_BIN);
endfunctionbegin_tr() also takes a label, a description, a begin time and a parent handle, which links transactions into a tree: a burst as the parent, its beats as children. And the UVM_RM_RECORD action records report messages into the same database, on a MESSAGES stream per reporting component, so errors can be viewed on the same time axis as the transactions.
The open-source library's default database is uvm_text_tr_database, which writes tr_db.log. It is not meant for viewing, but it shows exactly what a viewer receives:
scratch run: +UVM_TESTNAME=rec_test (tr_db.log, first transaction)
CREATE_STREAM @10000 {NAME:apb_bus T:TVM SCOPE:uvm_test_top.mon STREAM:329}
OPEN_RECORDER @10000 {TXH:331 STREAM:329 NAME:apb_tr TIME:10000 TYPE="Begin_No_Parent, Link"}
SET_ATTR @30000 {TXH:331 NAME:inst_id VALUE:245 RADIX:UVM_DEC BITS=32}
SET_ATTR @30000 {TXH:331 NAME:addr VALUE:c RADIX:UVM_HEX BITS=8}
SET_ATTR @30000 {TXH:331 NAME:data VALUE:cafef000 RADIX:UVM_HEX BITS=32}
SET_ATTR @30000 {TXH:331 NAME:write VALUE:1 RADIX:UVM_BIN BITS=1}
CLOSE_RECORDER @30000 {TXH:331 TIME=30000}
FREE_RECORDER @30000 {TXH:331}One stream per component and stream name: apb_bus under uvm_test_top.mon. In a viewer this is the row under the monitor's scope.
The recorder opens at 10 ns and closes at 30 ns: that is the bar's extent.
The attributes are written at 30 ns, at end_tr(). Fields filled in during the transfer, such as read data, are recorded correctly because do_record() runs at the end.
In a commercial simulator's waveform viewer, recorded streams appear in the design hierarchy under the component that recorded them. Each transaction is a bar spanning begin to end, labelled with its name; selecting it shows the recorded fields, and parent links let you expand a sequence into its items. Signals and streams share one time axis, so a cursor on a bar lands on the matching wires.
Every recorded transaction opens a recorder, formats each field, writes to a database and frees the recorder; at UVM_FULL across a whole SoC env, that is per beat, on every interface. Recording can easily become one of the largest costs in a run, and the database can grow larger than the waveform. Measure it on your own bench with the simulator's profiler before deciding; the order of magnitude depends on the tool and the traffic. The working rule:
Situation | Recording |
|---|---|
Nightly regression, thousands of seeds | Off. Nobody opens those databases, and the farm pays for every byte. |
Debug rerun of one failing seed | On, with the plusarg, usually only on the env subtree around the failure. |
Bring-up of a new agent or VIP | On for that agent, to check begin and end points against the waveform. |
Performance analysis of a protocol (latency, outstanding transactions) | On for the monitors involved; the transaction database is the data source. |
An engineer added the recording_detail setting to the test's end_of_elaboration_phase, "after everything is built, so the paths exist". The run produced no streams at all. The setting was correct but too late: the components had read recording_detail in their constructors, during build, and nothing reads it again. Moving the config_db::set before super.build_phase() in the test fixed it. The same rule applies to every setting a component reads at construction: set it before the component exists.
A second, quieter failure: a monitor that calls begin_tr() without a matching end_tr() on an error path (a reset in the middle of a transfer) leaves an open recorder per aborted transaction. The viewer shows bars that never end, and memory grows over a long test. End or abandon the transaction on every path, including reset.
data field shows the right value in the recorded stream although the monitor fills it in only at the end of the access phase. Why?The fields are written by do_record(), which the library calls from end_tr(). By then the monitor has filled in the read data, so the recorded value is the final one.
Automatic item recording spans from finish_item() (the item handed to the driver) to item_done(), which includes the time the driver waits before driving. Bus-accurate bars come from a monitor that calls begin_tr() and end_tr() at the protocol's real start and end.
So any failing test can be rerun with recording on, with the same seed and the same compiled bench, without a code change or a new test name. A separate test class would only record that one scenario.
Next: this topic is complete. Testbenches at Scale moves from one run to many: code quality, stimulus for many configurations, and VIP shared across projects.