25. SystemC Tutorial - The UVM-SystemC Agent: Driver, Sequencer & Monitor via TLM

Why this matters

Written 2026-07-12 for the concept-first series.

If you verify silicon for a living, you already know what an agent is. Sequence item, sequencer, driver, monitor, an is_active switch — the SystemVerilog UVM agent is the most-instantiated organizational unit in the industry, stamped out thousands of times across every VIP catalog and every in-house bench. What this post teaches is the same structure rebuilt in pure C++ on top of the SystemC kernel, using Accellera's UVM-SystemC library — the standard way to bring UVM discipline to virtual-platform verification, where the DUT is a TLM-2.0 model, the whole stack compiles with g++, and no simulator license is anywhere in sight. Firmware bring-up teams, architecture groups shipping reference models, and anyone validating a virtual platform before RTL exists runs testbenches shaped exactly like the one you will build here.

The agent is the hardest post in this section, and it is deliberately the first one after the introduction. Three things you take for granted in SystemVerilog change under your feet, and each one silently breaks a testbench if you port by muscle memory. First, sequence items move through the sequencer by value — the driver receives a copy, so mutating it to return read data does nothing, and the response must travel back on an explicit channel. Second, there are no field macros — every transaction class hand-writes do_copy, do_print, and do_compare, and the do_compare signature itself differs from SV. Third, there is no virtual interface — a UVM-SystemC component is an sc_module, so the driver owns a TLM-2.0 initiator socket directly and the initiator loop you wrote in Part 2 of the TLM section becomes the BFM. Get these three shifts into your fingers and the rest of UVM-SystemC feels like coming home; miss them and you will chase phantom bugs for days. By the end of this post you will have compiled and run a complete active/passive agent — sequencer, TLM driver, passthrough monitor, analysis subscriber — against a TLM memory DUT, watched the library's own guard rails fire when the handshake is misused, and mapped every piece back to the SystemVerilog construct it replaces. That is the working skeleton every remaining post in this section builds on.

One honesty note before we start, carried over from the section introduction: UVM-SystemC 1.0-beta6 is a beta whose API is modeled on UVM 1.2, and its own release notes call it highly experimental. Everything in this post is compile-verified against that exact release; where the beta has sharp edges, they are named, not papered over.

Prerequisites

  • Part 1 — Why UVM-SystemC: UVM Discipline for Virtual Platforms. You need the landscape from Part 1: where UVM-SystemC fits, how to build and install uvm-systemc-1.0-beta6 against SystemC 3.0.x (including the Apple-Silicon configure notes), why uvm::run_test() replaces both sc_start() and +UVM_TESTNAME, and the library-maturity framing this section is honest about.
  • TLM Part 2 — The Generic Payload & Blocking Transport. The driver in this post fills a tlm_generic_payload field by field and calls b_transport. Every rule from that post — who sets which field, response-status discipline, paying the annotated delay — applies verbatim inside the driver's run_phase.
  • TLM Part 3 — Initiator & Target Sockets. The driver owns a tlm_utils::simple_initiator_socket, the monitor owns both a target and an initiator socket, and the DUT registers b_transport on a simple_target_socket. Socket binding rules from that post are assumed fluent.
  • SystemVerilog UVM fluency. This post is written for DV engineers who already build SV UVM agents. Every concept is introduced by mapping from the SV construct you know; if get_next_item, item_done, and is_active are not second nature yet, work through a SystemVerilog UVM agent first.
  • Toolchain. SystemC 3.0.x (the installed kernel banners as 3.0.1-Accellera) plus uvm-systemc-1.0-beta6, compiled with g++ in C++17 mode. Every example gives its exact build line; both libraries' include and lib directories go on the compile line, and both lib directories go on the runtime library path.

Mental model (first principles)

Strip the agent to its load-bearing idea and it is this: the UVM-SystemC agent is the SystemVerilog UVM agent with the mechanics swapped from class handles to SystemC plumbing. Nothing about the division of labor changes. A sequence produces transactions and knows nothing about pins or sockets. A sequencer arbitrates and buffers them. A driver consumes them one at a time and translates each into DUT activity. A monitor observes DUT activity and broadcasts reconstructed transactions to whoever subscribes. An agent bundles all four behind one is_active switch. If you can draw the SV UVM agent from memory, you have already drawn the UVM-SystemC one.

What changes is the transport between the boxes, and the change has teeth. In SystemVerilog, get_next_item(req) hands the driver a class handle — the very object the sequence built. Driver and sequence stare at the same transaction through two names; the driver can scribble read data into it and the sequence sees the scribbles the moment finish_item returns. UVM-SystemC severs that link. The sequencer's request channel is a tlm_fifo<REQ> — a FIFO of objects, not pointers. When the sequence's finish_item pushes the item, the FIFO stores a copy. When the driver's get_next_item(req) peeks it, req receives another copy by plain C++ assignment. Three objects now exist: the sequence's original, the FIFO's element, and the driver's stack variable. Mutating the driver's copy is shouting into a pillow — the sequence will never hear it. Results travel back on an explicit response channel (item_done(rsp)), tagged with set_id_info so the sequencer can route them to the requesting sequence.

Here is an analogy that keeps the two data-passing disciplines in this testbench straight. The generic payload from the TLM section was one work-order form passed by reference — initiator and target write on the same piece of paper, which is why stale-field reuse bugs exist. The sequencer handshake is the opposite: a fax machine. The sequence faxes its order to the sequencer; the driver receives a printout. Annotate your printout all you like — the sender's original is untouched, and if the shop floor needs to send results back, it faxes a separate reply referencing the order number (set_id_info). One testbench, two disciplines: payloads shared by reference between driver and DUT, items copied by value between sequence and driver. Confusing the two is the number-one porting bug, and the Beginner and Intermediate examples below make each discipline visible in real output.

The second structural shift: a uvm_component is an sc_module. That single inheritance fact eliminates the virtual interface. In SystemVerilog, class-based components live outside the module hierarchy, so a virtual interface handle must be smuggled in through the config db to let the driver touch pins. In UVM-SystemC there is no divide to bridge: the driver is a module, so it owns a tlm_utils::simple_initiator_socket as a member and calls socket->b_transport(gp, delay) straight from run_phase, which runs in a thread context where wait() is legal. The config db still exists — for configuration values like is_active — but interface handles have simply ceased to be a thing that needs passing.

Third: the sequencer's seq_item_export is genuine SystemC plumbing, not a lookalike. uvm_sequencer<REQ,RSP> implements the pull interface uvm_sqr_if_base<REQ,RSP> and exposes it through an imp-style export bound to itself; the driver's seq_item_port is the matching pull port. connect_phase binds them exactly the way you bound sc_port to sc_export in the Foundations section — in fact drv->seq_item_port.connect(sqr->seq_item_export) and the SystemC-native drv->seq_item_port(sqr->seq_item_export) are the same operation, and an unbound seq_item_port is caught by SystemC elaboration itself as a hard error, not by a UVM check. The monitor's uvm_analysis_port<T> is likewise a thin alias of tlm::tlm_analysis_port<T>: connect() is bind(), write() fans out to every subscriber, zero-or-many bindings allowed.

What did not change is at least as important: the handshake protocol. An item becomes "current" when get_next_item (or peek) returns it and stays current until item_done (or get). Calling get_next_item twice without an intervening item_done is a reported error; calling item_done with nothing outstanding is fatal. Blocking behavior, one-item-at-a-time discipline, sequence blocked in finish_item until the driver completes the item — all identical to SystemVerilog, line for line. The protocol survived the language crossing; only the freight changed from handles to copies.

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#dbeafe', 'primaryTextColor': '#1e293b', 'primaryBorderColor': '#3b82f6', 'lineColor': '#64748b', 'secondaryColor': '#f1f5f9'}}}%%
flowchart LR
    SEQ["rw_seq
(uvm_sequence)"] -- "finish_item:
copy INTO tlm_fifo" --> SQR subgraph AGENT ["BusAgent (uvm_agent)"] SQR["uvm_sequencer<bus_trans>
seq_item_export"] -- "get_next_item(req):
copy OUT of fifo" --> DRV DRV["BusDriver
simple_initiator_socket"] MON["BusMonitor
passthrough tap + ap"] end DRV -- "b_transport(gp, delay)" --> MON MON -- "b_transport (forwarded)" --> DUT["MemoryDut
(plain TLM target)"] MON -. "ap.write(tr)" .-> SUB["TxPrinter
(uvm_subscriber)"]

Read the diagram left to right: transactions are copied down the sequence-to-driver path (fax machine), then carried by reference in a generic payload from driver through monitor to DUT (shared form), then copied again into the analysis broadcast. Every arrow is a mechanism you have already met in this series; the agent just arranges them into the standard verification shape.

Because this is the section pilot, here is the mapping table every Section 4 post will carry in this format — concept, the SystemVerilog UVM construct you know, the UVM-SystemC 1.0-beta6 equivalent, and the practical difference that bites.

Concept SystemVerilog UVM UVM-SystemC 1.0-beta6 Watch out
Component base uvm_component, a class outside the module hierarchy uvm::uvm_component derives from sc_core::sc_module Components own sockets and processes directly; the class/module divide is gone
Transaction base uvm_sequence_item + uvm_object_utils_begin `/field macros uvm::uvm_sequence_item + UVM_OBJECT_UTILS (factory + type name only) No field macros exist. Hand-write do_copy, do_compare, do_print, convert2string
do_compare signature do_compare(uvm_object rhs, uvm_comparer comparer) do_compare(const uvm_object&, const uvm_comparer*) const Comparer is a pointer defaulting to nullptr, method is const; ignoring the comparer is normal
Driver uvm_driver #(REQ, RSP) with seq_item_port uvm::uvm_driver<REQ,RSP> with seq_item_port and rsp_port Same two members, C++ templates instead of parameterized classes
Item hand-off get_next_item(req) returns the sequence's handle get_next_item(req) copy-assigns out of a tlm_fifo<REQ> Driver mutations never reach the sequence; send responses explicitly via item_done(rsp)
Handshake rules item current from get_next_item to item_done identical — double get_next_item = error, orphan item_done = fatal Protocol unchanged; only the transport changed
Reaching the DUT virtual interface via uvm_config_db TLM-2.0 initiator socket owned by the driver No vif concept at all; config db carries values, not interfaces
Monitor output uvm_analysis_port #(T) uvm::uvm_analysis_port<T> = alias of tlm::tlm_analysis_port<T> connect() is literally SystemC bind(); subscribers implement write(const T&)
Agent switch uvm_agent with is_active same, plus get_is_active(); ACTPASS warning if unset Guard both creation (build_phase) and binding (connect_phase)
Test selection run_test() + +UVM_TESTNAME=my_test uvm::run_test("MyTest") — string argument only No plusargs exist in beta6; the test instance is named after the test type, not uvm_test_top
Run control run_test() from an initial block; phases; starting_phase uvm::run_test() calls sc_start() itself; objections in the test's run_phase Never call sc_start(); the public starting_phase member is gone
Randomization req.randomize() with { ... } none in beta6 Use std::mt19937 for stimulus variety until CRAVE arrives later in this section

Keep the table within reach for the rest of the post; every example below is one or more of its rows made executable.

Beginner: First Principles

The smallest interesting UVM-SystemC program that deserves the name "agent-shaped" has four parts: a sequence item, a sequence that sends a few of them, a driver that consumes them, and a test that builds and connects the two sides. No DUT yet, no monitor, no agent wrapper — just the item pipeline, so every mechanism is visible in isolation. We add the rest in the Intermediate section.

One reading note before the code: UVM-SystemC programs print the SystemC and UVM-SystemC version banners at startup. Every expected-output block in this post elides those banners and starts at the [RNTST] line; everything from there on is pasted verbatim from a real run. The UVM_INFO file(line) prefixes refer to line numbers in the listing exactly as printed here — the file you compile is the file you read.

// file: agent_hello.cpp
// Build: g++ -std=c++17 -I$SYSTEMC_HOME/include -I$UVM_SYSTEMC_HOME/include \
//            agent_hello.cpp -o agent_hello \
//            -L$SYSTEMC_HOME/lib -L$UVM_SYSTEMC_HOME/lib -lsystemc -luvm-systemc
// Run:   LD_LIBRARY_PATH=$SYSTEMC_HOME/lib:$UVM_SYSTEMC_HOME/lib ./agent_hello

#include <systemc>
#include <uvm>
#include <iomanip>
#include <sstream>

enum bus_op_t { BUS_READ = 0, BUS_WRITE = 1 };

// The sequence item. There are no field macros in UVM-SystemC --
// do_copy / do_compare / do_print / convert2string are written by hand.
class bus_trans : public uvm::uvm_sequence_item {
public:
  unsigned int addr{0};
  unsigned int data{0};
  bus_op_t     op{BUS_READ};

  bus_trans(const std::string& name = "bus_trans")
    : uvm::uvm_sequence_item(name) {}

  UVM_OBJECT_UTILS(bus_trans);

  virtual void do_copy(const uvm::uvm_object& rhs) {
    const bus_trans* rhs_ = dynamic_cast<const bus_trans*>(&rhs);
    if (rhs_ == nullptr)
      UVM_FATAL("do_copy", "cast failed: rhs is not a bus_trans");
    uvm_sequence_item::do_copy(rhs);
    addr = rhs_->addr;
    data = rhs_->data;
    op   = rhs_->op;
  }

  virtual bool do_compare(const uvm::uvm_object& rhs,
                          const uvm::uvm_comparer* comparer = nullptr) const {
    const bus_trans* rhs_ = dynamic_cast<const bus_trans*>(&rhs);
    if (rhs_ == nullptr) return false;
    return (addr == rhs_->addr) && (data == rhs_->data) && (op == rhs_->op);
  }

  virtual void do_print(const uvm::uvm_printer& printer) const {
    printer.print_string("op", (op == BUS_WRITE) ? "BUS_WRITE" : "BUS_READ");
    printer.print_field_int("addr", addr);
    printer.print_field_int("data", data);
  }

  virtual std::string convert2string() const {
    std::ostringstream str;
    str << ((op == BUS_WRITE) ? "WRITE" : "READ ")
        << " addr=0x" << std::hex << std::setw(2) << std::setfill('0') << addr
        << " data=0x" << std::hex << std::setw(8) << std::setfill('0') << data;
    return str.str();
  }
};

// The sequence: three writes. Full sequence anatomy is the next post's topic.
class three_writes_seq : public uvm::uvm_sequence<bus_trans> {
public:
  three_writes_seq(const std::string& name = "three_writes_seq")
    : uvm::uvm_sequence<bus_trans>(name) {}

  UVM_OBJECT_UTILS(three_writes_seq);

  virtual void body() {
    for (unsigned int i = 0; i < 3; i++) {
      bus_trans* req = new bus_trans("req");
      req->op   = BUS_WRITE;
      req->addr = 0x10 + 4 * i;
      req->data = 0xA0 + i;
      start_item(req);
      finish_item(req);  // blocks until the driver calls item_done()
      delete req;        // the driver received a copy; this object is ours to free
    }
  }
};

// The driver: pulls items from the sequencer and "executes" them by printing.
class BusDriver : public uvm::uvm_driver<bus_trans> {
public:
  BusDriver(uvm::uvm_component_name name)
    : uvm::uvm_driver<bus_trans>(name) {}

  UVM_COMPONENT_UTILS(BusDriver);

  virtual void run_phase(uvm::uvm_phase& phase) {
    bus_trans req;  // stack object: receives a COPY of each sequence item
    for (;;) {
      seq_item_port->get_next_item(req);
      UVM_INFO(get_name(), "executing " + req.convert2string(), uvm::UVM_MEDIUM);
      seq_item_port->item_done();
    }
  }
};

// The test: builds sequencer + driver, connects them, runs the sequence.
class AgentHelloTest : public uvm::uvm_test {
public:
  uvm::uvm_sequencer<bus_trans>* sequencer{nullptr};
  BusDriver*                     driver{nullptr};

  AgentHelloTest(uvm::uvm_component_name name) : uvm::uvm_test(name) {}

  UVM_COMPONENT_UTILS(AgentHelloTest);

  virtual void build_phase(uvm::uvm_phase& phase) {
    uvm::uvm_test::build_phase(phase);
    // uvm_sequencer<REQ> is used as-is -- no subclass needed. Anything
    // constructed inside build_phase becomes a child of this component.
    sequencer = new uvm::uvm_sequencer<bus_trans>("sequencer");
    driver    = BusDriver::type_id::create("driver", this);
  }

  virtual void connect_phase(uvm::uvm_phase& phase) {
    driver->seq_item_port.connect(sequencer->seq_item_export);
  }

  virtual void run_phase(uvm::uvm_phase& phase) {
    phase.raise_objection(this);    // hold the run phase open
    three_writes_seq seq("seq");
    seq.start(sequencer, nullptr);  // blocks until body() returns
    phase.drop_objection(this);     // let the run phase end
  }
};

int sc_main(int, char*[]) {
  uvm::run_test("AgentHelloTest");  // factory creates the test by type name
  return 0;                         // never call sc_start(): run_test() does
}

Expected output (banners elided):

UVM_INFO @ 0 s: reporter [RNTST] Running test AgentHelloTest...
UVM_INFO agent_hello.cpp(92) @ 0 s: AgentHelloTest.driver [driver] executing WRITE addr=0x10 data=0x000000a0
UVM_INFO agent_hello.cpp(92) @ 0 s: AgentHelloTest.driver [driver] executing WRITE addr=0x14 data=0x000000a1
UVM_INFO agent_hello.cpp(92) @ 0 s: AgentHelloTest.driver [driver] executing WRITE addr=0x18 data=0x000000a2
UVM_INFO ../../../src/uvmsc/report/uvm_default_report_server.cpp(667) @ 0 s: reporter [UVM/REPORT/SERVER] 
--- UVM Report Summary ---

** Report counts by severity
UVM_INFO      :   4
UVM_WARNING   :   0
UVM_ERROR     :   0
UVM_FATAL     :   0
** Report counts by id
[RNTST]                 1
[driver]                3

UVM_INFO @ 0 s: reporter [FINISH] UVM-SystemC phasing completed; simulation finished

Walk through it from the bottom up, because the bottom is where SystemVerilog habits break first.

sc_main contains one call. uvm::run_test("AgentHelloTest") asks the factory to create a component of the registered type name AgentHelloTest, then runs the full UVM phase machine, then starts and stops the SystemC kernel itself — run_test() calls sc_start() internally and arranges sc_stop() when phasing completes. You never call sc_start() in a UVM-SystemC program. There is also no +UVM_TESTNAME: beta6 has no command-line processing at all, so the string argument is the only test-selection mechanism. Note the hierarchy names in the output — AgentHelloTest.driver — the top-level instance is named after the test type. There is no uvm_test_top in this library, and any scoreboard or config path that assumes one will silently match nothing.

The item is a class with hand-written plumbing. UVM_OBJECT_UTILS(bus_trans) does exactly two jobs: it registers the type with the factory (type_id) and provides get_type_name. That is all. Nothing generates copy, compare, or print — there are no uvm_field_* macros anywhere in the library — so the four methods that SV field macros would have synthesized are written by hand. Look at their shapes carefully, because they differ from SV in small, compiler-enforced ways. do_copy takes a const uvm::uvm_object&, dynamic_casts it, and calls the base do_copy before copying members. do_compare is a const method taking a const uvm::uvm_comparer* — a pointer, defaulting to nullptr, and the shipped Accellera examples simply ignore it, comparing members directly. do_print drives the supplied printer. convert2string is your logging workhorse. This is the porting tax, paid once per transaction class; the Advanced section explains why you must also keep the class safely copyable by plain C++ assignment.

The sequence allocates with new and frees with delete. start_item(req) and finish_item(req) take raw pointers, and no reference counting exists behind them. The pattern to internalize: the sequence owns the object it newed, finish_item copies it into the sequencer's FIFO, and once finish_item returns the sequence is free to delete it — the driver is working from its own copy and never touches this object again. Forgetting the delete is a leak multiplied by every transaction in a soak run. (Also note what is missing: no randomize(). Beta6 has no native randomization; when stimulus needs variety we reach for std::mt19937, and constrained-random proper arrives with CRAVE later in this section.)

The driver's run_phase is the loop you already know. get_next_item(req) blocks until a sequence has an item ready, fills the stack object req with a copy, and marks the item "current". The driver executes it — here, a print — and item_done() retires it, unblocking the sequence's finish_item. Because run_phase runs in a SystemC thread process, this driver could legally wait() mid-loop; the Intermediate driver does exactly that to pay bus latency. The for(;;) never exits, and does not need to: when objections drop, the phase machinery kills the phase processes cleanly, exactly as phase.raise_objection/drop_objection ending a SV UVM run phase does.

The test builds, connects, runs — and holds the objection. build_phase creates the driver through the factory (type_id::create("driver", this)) so tests can override it later, and creates the sequencer with plain new. That works because uvm::uvm_sequencer<bus_trans> is usable as-is — no subclass required — and because of a pleasant consequence of components being modules: during a component's build_phase the library pushes that component onto SystemC's hierarchy stack, so anything you construct there, factory-made or newed, becomes its child. connect_phase binds the pull port to the pull export; this is real SystemC binding, and forgetting it is an elaboration error (E109: complete binding failed), caught before the first delta cycle — arguably earlier and louder than SV UVM's runtime connect errors. Finally, objections: the sequence's starting_phase member from UVM 1.1 does not exist in beta6, so the durable idiom is the one shown — raise in the test's run_phase, start the sequence (which blocks until body() finishes), drop.

Note Common confusion: the entire run happens at 0 s. Nothing in this program consumes simulation time — the driver never waits, the item pipeline is untimed synchronization, and blocking here means process blocking, not time passing. Simulation time in a UVM-SystemC bench advances exactly when it did in the TLM section: when someone wait()s on a delay, usually the driver paying an annotated b_transport latency. The Intermediate full-agent example shows timestamps moving for precisely that reason.

Intermediate: How It Really Works

Two things stand between the hello-world pipeline and a production-shaped agent: you must see the by-value semantics with your own eyes (because it is the single most bug-prone difference from SystemVerilog), and you must assemble the full four-box agent against a real TLM DUT. One example each.

Proving the item moves by value — and using the response channel

The claim from the mental model: get_next_item hands the driver a copy, driver-side mutations vanish, and results return on an explicit response channel routed by set_id_info. This program makes every step observable. The driver deliberately vandalizes its copy of the request; the sequence then prints its own object (untouched) and the response it received (carrying the driver's actual answer).

// file: agent_by_value.cpp
// Build: g++ -std=c++17 -I$SYSTEMC_HOME/include -I$UVM_SYSTEMC_HOME/include \
//            agent_by_value.cpp -o agent_by_value \
//            -L$SYSTEMC_HOME/lib -L$UVM_SYSTEMC_HOME/lib -lsystemc -luvm-systemc
// Run:   LD_LIBRARY_PATH=$SYSTEMC_HOME/lib:$UVM_SYSTEMC_HOME/lib ./agent_by_value

#include <systemc>
#include <uvm>
#include <iomanip>
#include <sstream>

enum bus_op_t { BUS_READ = 0, BUS_WRITE = 1 };

class bus_trans : public uvm::uvm_sequence_item {
public:
  unsigned int addr{0};
  unsigned int data{0};
  bus_op_t     op{BUS_READ};

  bus_trans(const std::string& name = "bus_trans")
    : uvm::uvm_sequence_item(name) {}

  UVM_OBJECT_UTILS(bus_trans);

  virtual void do_copy(const uvm::uvm_object& rhs) {
    const bus_trans* rhs_ = dynamic_cast<const bus_trans*>(&rhs);
    if (rhs_ == nullptr)
      UVM_FATAL("do_copy", "cast failed: rhs is not a bus_trans");
    uvm_sequence_item::do_copy(rhs);
    addr = rhs_->addr;
    data = rhs_->data;
    op   = rhs_->op;
  }

  virtual bool do_compare(const uvm::uvm_object& rhs,
                          const uvm::uvm_comparer* comparer = nullptr) const {
    const bus_trans* rhs_ = dynamic_cast<const bus_trans*>(&rhs);
    if (rhs_ == nullptr) return false;
    return (addr == rhs_->addr) && (data == rhs_->data) && (op == rhs_->op);
  }

  virtual void do_print(const uvm::uvm_printer& printer) const {
    printer.print_string("op", (op == BUS_WRITE) ? "BUS_WRITE" : "BUS_READ");
    printer.print_field_int("addr", addr);
    printer.print_field_int("data", data);
  }

  virtual std::string convert2string() const {
    std::ostringstream str;
    str << ((op == BUS_WRITE) ? "WRITE" : "READ ")
        << " addr=0x" << std::hex << std::setw(2) << std::setfill('0') << addr
        << " data=0x" << std::hex << std::setw(8) << std::setfill('0') << data;
    return str.str();
  }
};

// The sequence sends one item, then proves the driver's mutation of its
// copy never reached this object -- and picks up the response instead.
class one_item_seq : public uvm::uvm_sequence<bus_trans> {
public:
  one_item_seq(const std::string& name = "one_item_seq")
    : uvm::uvm_sequence<bus_trans>(name) {}

  UVM_OBJECT_UTILS(one_item_seq);

  virtual void body() {
    bus_trans* req = new bus_trans("req");
    req->op   = BUS_WRITE;
    req->addr = 0x20;
    req->data = 0x11111111;
    start_item(req);
    finish_item(req);

    // The driver overwrote data in ITS copy. Ours is untouched:
    UVM_INFO(get_name(), "after finish_item, my item: " + req->convert2string(),
             uvm::UVM_MEDIUM);

    // Results come back on the explicit response channel, not through req:
    bus_trans* rsp = new bus_trans("rsp");
    get_response(rsp);
    UVM_INFO(get_name(), "response received:        " + rsp->convert2string(),
             uvm::UVM_MEDIUM);

    delete req;
    delete rsp;
  }
};

class BusDriver : public uvm::uvm_driver<bus_trans> {
public:
  BusDriver(uvm::uvm_component_name name)
    : uvm::uvm_driver<bus_trans>(name) {}

  UVM_COMPONENT_UTILS(BusDriver);

  virtual void run_phase(uvm::uvm_phase& phase) {
    bus_trans req, rsp;
    for (;;) {
      seq_item_port->get_next_item(req);
      UVM_INFO(get_name(), "copy received:  " + req.convert2string(),
               uvm::UVM_MEDIUM);

      req.data = 0xDEADDEAD;  // mutate the COPY: the sequence never sees this
      UVM_INFO(get_name(), "copy mutated:   " + req.convert2string(),
               uvm::UVM_MEDIUM);

      rsp.set_id_info(req);   // route the response to the requesting sequence
      rsp.op   = req.op;
      rsp.addr = req.addr;
      rsp.data = 0x600D600D;  // "the DUT's answer"
      seq_item_port->item_done(rsp);
    }
  }
};

class ByValueTest : public uvm::uvm_test {
public:
  uvm::uvm_sequencer<bus_trans>* sequencer{nullptr};
  BusDriver*                     driver{nullptr};

  ByValueTest(uvm::uvm_component_name name) : uvm::uvm_test(name) {}

  UVM_COMPONENT_UTILS(ByValueTest);

  virtual void build_phase(uvm::uvm_phase& phase) {
    uvm::uvm_test::build_phase(phase);
    sequencer = new uvm::uvm_sequencer<bus_trans>("sequencer");
    driver    = BusDriver::type_id::create("driver", this);
  }

  virtual void connect_phase(uvm::uvm_phase& phase) {
    driver->seq_item_port.connect(sequencer->seq_item_export);
  }

  virtual void run_phase(uvm::uvm_phase& phase) {
    phase.raise_objection(this);
    one_item_seq seq("seq");
    seq.start(sequencer, nullptr);
    phase.drop_objection(this);
  }
};

int sc_main(int, char*[]) {
  uvm::run_test("ByValueTest");
  return 0;
}

Expected output (banners elided):

UVM_INFO @ 0 s: reporter [RNTST] Running test ByValueTest...
UVM_INFO agent_by_value.cpp(101) @ 0 s: ByValueTest.driver [driver] copy received:  WRITE addr=0x20 data=0x11111111
UVM_INFO agent_by_value.cpp(105) @ 0 s: ByValueTest.driver [driver] copy mutated:   WRITE addr=0x20 data=0xdeaddead
UVM_INFO agent_by_value.cpp(76) @ 0 s: ByValueTest.sequencer@@seq [seq] after finish_item, my item: WRITE addr=0x20 data=0x11111111
UVM_INFO agent_by_value.cpp(82) @ 0 s: ByValueTest.sequencer@@seq [seq] response received:        WRITE addr=0x20 data=0x600d600d
UVM_INFO ../../../src/uvmsc/report/uvm_default_report_server.cpp(667) @ 0 s: reporter [UVM/REPORT/SERVER] 
--- UVM Report Summary ---

** Report counts by severity
UVM_INFO      :   5
UVM_WARNING   :   0
UVM_ERROR     :   0
UVM_FATAL     :   0
** Report counts by id
[RNTST]                 1
[driver]                2
[seq]                   2

UVM_INFO @ 0 s: reporter [FINISH] UVM-SystemC phasing completed; simulation finished

Four lines tell the whole story. The driver receives data=0x11111111 — the copy. It mutates its copy to 0xdeaddead. Then the sequence, after finish_item has returned, prints its own object: still 0x11111111. In SystemVerilog UVM those would be the same object and the sequence would print 0xdeaddead; here the mutation evaporated, exactly as the fax-machine model predicts. Finally get_response delivers 0x600d600d — the value the driver explicitly placed in rsp and attached to this request via rsp.set_id_info(req). That set_id_info call is load-bearing: it stamps the response with the request's sequence and transaction IDs, which is how the sequencer knows which sequence's response queue to deliver it to. Omit it and the response is either misrouted or dropped, and get_response blocks a sequence forever — a hang, not an error message.

Notice also the driver reuses two stack objects, req and rsp, for every iteration of its loop. That is safe because of by-value transport: nothing downstream holds a pointer into the driver's stack. The corresponding SV habit — allocating a fresh response object per transaction because the sequence will keep the handle — is unnecessary here; the sequencer's response path copies too. Between the sequence's new/delete pair and the driver's reusable stack objects, you have now seen both ownership disciplines that UVM-SystemC demands, and the Advanced section distills them into rules.

Tip When a read must return data to the sequence, the response channel is not optional decoration — it is the only road home. Decide per protocol whether item_done(rsp) (response coupled to completion, shown here) or a separate rsp_port.write() (streaming responses) fits better, and use one consistently across an agent. The Advanced section compares the options, including the one the header itself deprecates.

The full agent: TLM driver, passthrough monitor, active/passive switch

Now the real thing. One file, seven classes, and every box from the mental-model diagram: a sequence doing write-then-readback with a checking compare(), a uvm_sequencer used as-is, a driver that is a genuine TLM-2.0 initiator, a monitor that observes traffic and broadcasts on an analysis port, a subscriber that consumes the broadcast, an agent that assembles them under is_active, an env that adds the DUT, and a test that configures and runs it. The DUT is deliberately a plain sc_module — the 256-byte TLM memory from the TLM section, with 10 ns of annotated latency — because that is the honest topology of virtual-platform verification: the DUT knows nothing about UVM.

The one genuinely new design decision is the monitor. There are no pins to snoop and no vif to peek through, so where does a monitor tap transactions? The cleanest answer at this stage: put the monitor in the transaction path as a forwarding passthrough. The driver's initiator socket binds to the monitor's target socket; the monitor forwards every payload untouched to the DUT and, on the way back, publishes a reconstructed bus_trans on its analysis port. The observed traffic is therefore the actual traffic — including the DUT's read data and response status — not a parallel guess.

// file: agent_full.cpp
// Build: g++ -std=c++17 -I$SYSTEMC_HOME/include -I$UVM_SYSTEMC_HOME/include \
//            agent_full.cpp -o agent_full \
//            -L$SYSTEMC_HOME/lib -L$UVM_SYSTEMC_HOME/lib -lsystemc -luvm-systemc
// Run:   LD_LIBRARY_PATH=$SYSTEMC_HOME/lib:$UVM_SYSTEMC_HOME/lib ./agent_full

#include <systemc>
#include <tlm.h>
#include <tlm_utils/simple_initiator_socket.h>
#include <tlm_utils/simple_target_socket.h>
#include <uvm>
#include <cstring>
#include <iomanip>
#include <sstream>

enum bus_op_t { BUS_READ = 0, BUS_WRITE = 1 };

// ---------------------------------------------------------------- item ----
class bus_trans : public uvm::uvm_sequence_item {
public:
  unsigned int addr{0};
  unsigned int data{0};
  bus_op_t     op{BUS_READ};

  bus_trans(const std::string& name = "bus_trans")
    : uvm::uvm_sequence_item(name) {}

  UVM_OBJECT_UTILS(bus_trans);

  virtual void do_copy(const uvm::uvm_object& rhs) {
    const bus_trans* rhs_ = dynamic_cast<const bus_trans*>(&rhs);
    if (rhs_ == nullptr)
      UVM_FATAL("do_copy", "cast failed: rhs is not a bus_trans");
    uvm_sequence_item::do_copy(rhs);
    addr = rhs_->addr;
    data = rhs_->data;
    op   = rhs_->op;
  }

  virtual bool do_compare(const uvm::uvm_object& rhs,
                          const uvm::uvm_comparer* comparer = nullptr) const {
    const bus_trans* rhs_ = dynamic_cast<const bus_trans*>(&rhs);
    if (rhs_ == nullptr) return false;
    return (addr == rhs_->addr) && (data == rhs_->data) && (op == rhs_->op);
  }

  virtual void do_print(const uvm::uvm_printer& printer) const {
    printer.print_string("op", (op == BUS_WRITE) ? "BUS_WRITE" : "BUS_READ");
    printer.print_field_int("addr", addr);
    printer.print_field_int("data", data);
  }

  virtual std::string convert2string() const {
    std::ostringstream str;
    str << ((op == BUS_WRITE) ? "WRITE" : "READ ")
        << " addr=0x" << std::hex << std::setw(2) << std::setfill('0') << addr
        << " data=0x" << std::hex << std::setw(8) << std::setfill('0') << data;
    return str.str();
  }
};

// ------------------------------------------------------------ sequence ----
// Write a word, read it back, check the read data against expectation.
class rw_seq : public uvm::uvm_sequence<bus_trans> {
public:
  rw_seq(const std::string& name = "rw_seq")
    : uvm::uvm_sequence<bus_trans>(name) {}

  UVM_OBJECT_UTILS(rw_seq);

  virtual void body() {
    // WRITE 0xCAFEF00D to 0x40
    bus_trans* wr = new bus_trans("wr");
    wr->op = BUS_WRITE; wr->addr = 0x40; wr->data = 0xCAFEF00D;
    start_item(wr);
    finish_item(wr);
    bus_trans* wr_rsp = new bus_trans("wr_rsp");
    get_response(wr_rsp);
    delete wr; delete wr_rsp;

    // READ it back
    bus_trans* rd = new bus_trans("rd");
    rd->op = BUS_READ; rd->addr = 0x40; rd->data = 0;
    start_item(rd);
    finish_item(rd);
    bus_trans* rd_rsp = new bus_trans("rd_rsp");
    get_response(rd_rsp);

    // Check with the hand-written do_compare (via uvm_object::compare).
    bus_trans expected("expected");
    expected.op = BUS_READ; expected.addr = 0x40; expected.data = 0xCAFEF00D;
    if (rd_rsp->compare(expected)) {
      UVM_INFO(get_name(), "read-back MATCH: " + rd_rsp->convert2string(),
               uvm::UVM_MEDIUM);
    } else {
      UVM_ERROR(get_name(), "read-back MISMATCH: " + rd_rsp->convert2string());
    }
    delete rd; delete rd_rsp;
  }
};

// -------------------------------------------------------------- driver ----
// TLM-2.0 initiator: translates each bus_trans into a generic payload.
// No virtual interface anywhere -- a uvm_component IS an sc_module, so the
// driver owns the initiator socket directly.
class BusDriver : public uvm::uvm_driver<bus_trans> {
public:
  tlm_utils::simple_initiator_socket<BusDriver> socket;

  BusDriver(uvm::uvm_component_name name)
    : uvm::uvm_driver<bus_trans>(name), socket("socket") {}

  UVM_COMPONENT_UTILS(BusDriver);

  virtual void run_phase(uvm::uvm_phase& phase) {
    bus_trans req, rsp;
    for (;;) {
      seq_item_port->get_next_item(req);

      // Section 3's initiator loop IS the BFM.
      tlm::tlm_generic_payload gp;
      sc_core::sc_time delay = sc_core::SC_ZERO_TIME;
      unsigned int data = req.data;
      gp.set_command(req.op == BUS_WRITE ? tlm::TLM_WRITE_COMMAND
                                         : tlm::TLM_READ_COMMAND);
      gp.set_address(req.addr);
      gp.set_data_ptr(reinterpret_cast<unsigned char*>(&data));
      gp.set_data_length(4);
      gp.set_streaming_width(4);
      gp.set_byte_enable_ptr(nullptr);
      gp.set_dmi_allowed(false);
      gp.set_response_status(tlm::TLM_INCOMPLETE_RESPONSE);

      socket->b_transport(gp, delay);
      wait(delay);  // run_phase is a thread: paying latency here is legal

      if (!gp.is_response_ok())
        UVM_ERROR(get_name(), "b_transport failed: " +
                  std::string(gp.get_response_string()));

      rsp.set_id_info(req);  // route the response to the requesting sequence
      rsp.op   = req.op;
      rsp.addr = req.addr;
      rsp.data = data;       // for a read, the target wrote into `data`
      seq_item_port->item_done(rsp);
    }
  }
};

// ------------------------------------------------------------- monitor ----
// There are no pins to snoop, so the monitor sits in the transaction path
// as a passthrough: driver -> monitor -> DUT. It forwards every payload
// untouched and publishes a bus_trans copy on its analysis port.
class BusMonitor : public uvm::uvm_monitor {
public:
  tlm_utils::simple_target_socket<BusMonitor>    in_socket;   // from driver
  tlm_utils::simple_initiator_socket<BusMonitor> out_socket;  // to DUT
  uvm::uvm_analysis_port<bus_trans>              ap;

  BusMonitor(uvm::uvm_component_name name)
    : uvm::uvm_monitor(name),
      in_socket("in_socket"), out_socket("out_socket"), ap("ap") {
    in_socket.register_b_transport(this, &BusMonitor::b_transport);
  }

  UVM_COMPONENT_UTILS(BusMonitor);

  void b_transport(tlm::tlm_generic_payload& gp, sc_core::sc_time& delay) {
    out_socket->b_transport(gp, delay);  // forward to the DUT untouched
    if (gp.is_response_ok()) {
      bus_trans tr("observed");
      tr.op   = (gp.get_command() == tlm::TLM_WRITE_COMMAND) ? BUS_WRITE
                                                             : BUS_READ;
      tr.addr = static_cast<unsigned int>(gp.get_address());
      std::memcpy(&tr.data, gp.get_data_ptr(), 4);
      ap.write(tr);  // broadcast to every connected subscriber
    }
  }
};

// ---------------------------------------------------------- subscriber ----
class TxPrinter : public uvm::uvm_subscriber<bus_trans> {
public:
  int count{0};

  TxPrinter(uvm::uvm_component_name name)
    : uvm::uvm_subscriber<bus_trans>(name) {}

  UVM_COMPONENT_UTILS(TxPrinter);

  virtual void write(const bus_trans& t) {
    count++;
    UVM_INFO(get_name(), "observed " + t.convert2string(), uvm::UVM_MEDIUM);
  }
};

// --------------------------------------------------------------- agent ----
class BusAgent : public uvm::uvm_agent {
public:
  uvm::uvm_sequencer<bus_trans>* sequencer{nullptr};
  BusDriver*                     driver{nullptr};
  BusMonitor*                    monitor{nullptr};

  BusAgent(uvm::uvm_component_name name) : uvm::uvm_agent(name) {}

  UVM_COMPONENT_UTILS(BusAgent);

  virtual void build_phase(uvm::uvm_phase& phase) {
    uvm::uvm_agent::build_phase(phase);  // reads "is_active" from the config db
    monitor = BusMonitor::type_id::create("monitor", this);  // always built
    if (get_is_active() == uvm::UVM_ACTIVE) {
      sequencer = new uvm::uvm_sequencer<bus_trans>("sequencer");
      driver    = BusDriver::type_id::create("driver", this);
    }
  }

  virtual void connect_phase(uvm::uvm_phase& phase) {
    if (get_is_active() == uvm::UVM_ACTIVE) {
      driver->seq_item_port.connect(sequencer->seq_item_export);
      driver->socket.bind(monitor->in_socket);  // driver drives THROUGH the tap
    }
  }
};

// ----------------------------------------------------------------- DUT ----
// A plain SystemC TLM target, exactly like Section 3's memory. Not a UVM
// component -- the DUT knows nothing about the testbench methodology.
struct MemoryDut : sc_core::sc_module {
  tlm_utils::simple_target_socket<MemoryDut> socket;
  unsigned char mem[256];

  MemoryDut(sc_core::sc_module_name name) : sc_module(name), socket("socket") {
    for (unsigned i = 0; i < 256; i++) mem[i] = 0;
    socket.register_b_transport(this, &MemoryDut::b_transport);
  }

  void b_transport(tlm::tlm_generic_payload& gp, sc_core::sc_time& delay) {
    sc_dt::uint64 addr = gp.get_address();
    unsigned int  len  = gp.get_data_length();
    if (addr + len > 256) {
      gp.set_response_status(tlm::TLM_ADDRESS_ERROR_RESPONSE);
      return;
    }
    unsigned char* ptr = gp.get_data_ptr();
    if (gp.get_command() == tlm::TLM_WRITE_COMMAND)
      for (unsigned i = 0; i < len; i++) mem[addr + i] = ptr[i];
    else
      for (unsigned i = 0; i < len; i++) ptr[i] = mem[addr + i];
    delay += sc_core::sc_time(10, sc_core::SC_NS);  // annotate 10 ns latency
    gp.set_response_status(tlm::TLM_OK_RESPONSE);
  }
};

// ----------------------------------------------------------------- env ----
class AgentEnv : public uvm::uvm_env {
public:
  BusAgent*  agent0{nullptr};
  TxPrinter* printer{nullptr};
  MemoryDut* dut{nullptr};

  AgentEnv(uvm::uvm_component_name name) : uvm::uvm_env(name) {}

  UVM_COMPONENT_UTILS(AgentEnv);

  virtual void build_phase(uvm::uvm_phase& phase) {
    uvm::uvm_env::build_phase(phase);
    agent0  = BusAgent::type_id::create("agent0", this);
    printer = TxPrinter::type_id::create("printer", this);
    dut     = new MemoryDut("dut");  // plain sc_module child: legal here
  }

  virtual void connect_phase(uvm::uvm_phase& phase) {
    agent0->monitor->out_socket.bind(dut->socket);
    agent0->monitor->ap.connect(printer->analysis_export);
  }
};

// ---------------------------------------------------------------- test ----
class AgentFullTest : public uvm::uvm_test {
public:
  AgentEnv* env{nullptr};

  AgentFullTest(uvm::uvm_component_name name) : uvm::uvm_test(name) {}

  UVM_COMPONENT_UTILS(AgentFullTest);

  virtual void build_phase(uvm::uvm_phase& phase) {
    uvm::uvm_test::build_phase(phase);
    // Explicit is better than the ACTPASS warning + default:
    uvm::uvm_config_db<int>::set(this, "env.agent0", "is_active",
                                 uvm::UVM_ACTIVE);
    env = AgentEnv::type_id::create("env", this);
  }

  virtual void run_phase(uvm::uvm_phase& phase) {
    phase.raise_objection(this);
    rw_seq seq("seq");
    seq.start(env->agent0->sequencer, nullptr);
    std::ostringstream str;
    str << "monitor broadcast " << env->printer->count << " transactions";
    UVM_INFO(get_name(), str.str(), uvm::UVM_MEDIUM);
    phase.drop_objection(this);
  }
};

int sc_main(int, char*[]) {
  uvm::run_test("AgentFullTest");
  return 0;
}

Expected output (banners elided):

UVM_INFO @ 0 s: reporter [RNTST] Running test AgentFullTest...
UVM_INFO agent_full.cpp(193) @ 0 s: AgentFullTest.env.printer [printer] observed WRITE addr=0x40 data=0xcafef00d
UVM_INFO agent_full.cpp(193) @ 10 ns: AgentFullTest.env.printer [printer] observed READ  addr=0x40 data=0xcafef00d
UVM_INFO agent_full.cpp(94) @ 20 ns: AgentFullTest.env.agent0.sequencer@@seq [seq] read-back MATCH: READ  addr=0x40 data=0xcafef00d
UVM_INFO agent_full.cpp(301) @ 20 ns: AgentFullTest [AgentFullTest] monitor broadcast 2 transactions
UVM_INFO ../../../src/uvmsc/report/uvm_default_report_server.cpp(667) @ 20 ns: reporter [UVM/REPORT/SERVER] 
--- UVM Report Summary ---

** Report counts by severity
UVM_INFO      :   5
UVM_WARNING   :   0
UVM_ERROR     :   0
UVM_FATAL     :   0
** Report counts by id
[AgentFullTest]         1
[RNTST]                 1
[printer]               2
[seq]                   1

UVM_INFO @ 20 ns: reporter [FINISH] UVM-SystemC phasing completed; simulation finished

Read the timestamps first, because they prove the whole timing story. The write is observed at 0 s — the printer's write() runs inside the monitor's b_transport, before the driver has paid the DUT's annotated 10 ns. The read is observed at 10 ns (after the driver paid the write's latency), the sequence's check lands at 20 ns (after the read's latency too), and the simulation finishes at 20 ns. Two transactions, 10 ns each, all time paid by the driver's wait(delay) — the DUT annotates, the initiator pays, exactly the discipline from the TLM section, now living inside a UVM phase.

Now trace one transaction end to end, naming each mechanism as it fires. The sequence news wr, fills it, finish_item copies it into the sequencer FIFO. The driver's get_next_item(req) copies it out. The driver builds a stack tlm_generic_payload — every field set per the generic-payload rules, TLM_INCOMPLETE_RESPONSE sentinel included — pointing the data pointer at a local data variable. socket->b_transport(gp, delay) enters the monitor (the driver is bound to the tap, not the DUT); the monitor forwards to the DUT, which does the byte copy, annotates 10 ns, stamps TLM_OK_RESPONSE, and returns through the monitor, which reconstructs a bus_trans from the payload and ap.write()s it into the printer. Back in the driver: pay the delay, check the status, assemble rsp (for the read, data now holds the DUT's bytes), set_id_info, item_done(rsp). The sequence's get_response picks it up, and the hand-written do_compare — invoked through the public compare() — declares the readback a match. Every arrow in the mental-model diagram just executed, and you can point to the line of code implementing each one.

Three structural details deserve a second look:

  • The agent guards both creation and connection. uvm::uvm_agent::build_phase(phase) must be called first — that base call is what resolves is_active for this instance from the config db (it accepts the enum or a plain int, which is why uvm_config_db<int>::set works). The monitor is created unconditionally; sequencer and driver only under UVM_ACTIVE, and the same guard must wrap connect_phase, because a passive agent has no driver whose port could be bound — and an unbound sc_port is an elaboration error, not a warning. The Advanced section runs the passive configuration for real.
  • The DUT is newed, not factory-created. MemoryDut is a plain sc_module with no type_id, and it does not need one: constructed during the env's build_phase it lands in the hierarchy as env.dut, and nobody will ever factory-override a DUT. Keeping the DUT methodology-free is not laziness — it is the entire point of virtual-platform verification, where the model under test is delivered by another team and must not depend on your testbench library.
  • The analysis path is ordinary TLM-1 broadcast. ap.connect(printer->analysis_export) binds a tlm_analysis_port to a uvm_subscriber's export; write() fans out synchronously to zero or more subscribers, each receiving const bus_trans& — and anything that stores it must copy it. Post 27 hangs a scoreboard off this exact port; the printer is a placeholder with a counter.
Key takeaway The driver's DUT-facing half is identical to the TLM-section initiator — same payload fields, same status check, same pay-the-delay idiom. UVM-SystemC did not add a transaction API to reach the DUT; it added sequencing discipline behind the initiator you already know how to write. If you can write a TLM initiator and an SV UVM driver, the UVM-SystemC driver is their literal intersection.

Advanced: Edge Cases & LRM Corners

The Beginner and Intermediate sections cover the agent working correctly. This section is about the edges: what the library does when the handshake is abused, which response idiom will survive the beta, who owns which object, how is_active really resolves, and where beta6 diverges from the UVM 1.2 documentation it models.

Corner 1: the handshake guard rails — and what they actually print

The sequencer enforces the current-item protocol at runtime with two tripwires, one recoverable and one fatal. Knowing their exact messages turns a confusing log into a thirty-second diagnosis. This program violates the protocol both ways on purpose:

// file: agent_handshake_errors.cpp
// Build: g++ -std=c++17 -I$SYSTEMC_HOME/include -I$UVM_SYSTEMC_HOME/include \
//            agent_handshake_errors.cpp -o agent_handshake_errors \
//            -L$SYSTEMC_HOME/lib -L$UVM_SYSTEMC_HOME/lib -lsystemc -luvm-systemc
// Run:   LD_LIBRARY_PATH=$SYSTEMC_HOME/lib:$UVM_SYSTEMC_HOME/lib ./agent_handshake_errors

#include <systemc>
#include <uvm>
#include <sstream>

// Minimal item: this program is about the handshake, not the payload.
class tiny_tx : public uvm::uvm_sequence_item {
public:
  int value{0};
  tiny_tx(const std::string& name = "tiny_tx")
    : uvm::uvm_sequence_item(name) {}
  UVM_OBJECT_UTILS(tiny_tx);
};

class one_item_seq : public uvm::uvm_sequence<tiny_tx> {
public:
  one_item_seq(const std::string& name = "one_item_seq")
    : uvm::uvm_sequence<tiny_tx>(name) {}
  UVM_OBJECT_UTILS(one_item_seq);
  virtual void body() {
    tiny_tx* req = new tiny_tx("req");
    req->value = 42;
    start_item(req);
    finish_item(req);
    delete req;
  }
};

// A deliberately broken driver that violates the handshake both ways.
class BrokenDriver : public uvm::uvm_driver<tiny_tx> {
public:
  BrokenDriver(uvm::uvm_component_name name)
    : uvm::uvm_driver<tiny_tx>(name) {}

  UVM_COMPONENT_UTILS(BrokenDriver);

  virtual void run_phase(uvm::uvm_phase& phase) {
    tiny_tx req;
    seq_item_port->get_next_item(req);   // legal: item becomes "current"
    seq_item_port->get_next_item(req);   // MISUSE 1: second get -> UVM_ERROR
    seq_item_port->item_done();          // legal: completes the item
    seq_item_port->item_done();          // MISUSE 2: nothing outstanding -> UVM_FATAL
  }
};

class HandshakeErrorTest : public uvm::uvm_test {
public:
  uvm::uvm_sequencer<tiny_tx>* sequencer{nullptr};
  BrokenDriver*                driver{nullptr};

  HandshakeErrorTest(uvm::uvm_component_name name) : uvm::uvm_test(name) {}

  UVM_COMPONENT_UTILS(HandshakeErrorTest);

  virtual void build_phase(uvm::uvm_phase& phase) {
    uvm::uvm_test::build_phase(phase);
    sequencer = new uvm::uvm_sequencer<tiny_tx>("sequencer");
    driver    = BrokenDriver::type_id::create("driver", this);
  }

  virtual void connect_phase(uvm::uvm_phase& phase) {
    driver->seq_item_port.connect(sequencer->seq_item_export);
  }

  virtual void run_phase(uvm::uvm_phase& phase) {
    phase.raise_objection(this);
    one_item_seq seq("seq");
    seq.start(sequencer, nullptr);
    phase.drop_objection(this);
  }
};

int sc_main(int, char*[]) {
  uvm::run_test("HandshakeErrorTest");
  return 0;
}

Expected output (banners elided; the process exits with a nonzero status):

UVM_INFO @ 0 s: reporter [RNTST] Running test HandshakeErrorTest...
UVM_ERROR @ 0 s: reporter [HandshakeErrorTest.sequencer] get_next_item() called twice without item_done or get in between
UVM_FATAL @ 0 s: reporter [uvm::uvm_sequencer] Item_done() called with no outstanding requests.
Each call to item_done() must be paired with a previous call to get_next_item().
UVM_INFO ../../../src/uvmsc/report/uvm_default_report_server.cpp(667) @ 0 s: reporter [UVM/REPORT/SERVER] 
--- UVM Report Summary ---

** Report counts by severity
UVM_INFO      :   1
UVM_WARNING   :   0
UVM_ERROR     :   1
UVM_FATAL     :   1
** Report counts by id
[HandshakeErrorTest.sequencer]                                      1
[RNTST]                 1
[uvm::uvm_sequencer]    1

Error: (E549) uncaught exception: Simulation terminated by uvm_root::die()
In file: ../../../src/sysc/kernel/sc_except.cpp:101
In process: HandshakeErrorTest.driver.exec_proc_run_HandshakeErrorTest_driver_0 @ 0 s

The double get_next_item is a UVM_ERROR and the call then returns the same current item again — the FIFO peek is repeated, so the simulation limps on with the driver holding a second copy of the same transaction. That leniency is a trap: an accidental double-get in a refactored driver does not stop the run, it silently re-executes transactions. Grep your logs for called twice without item_done. The orphan item_done, by contrast, is UVM_FATAL — the FIFO pop finds nothing, the library calls uvm_root::die(), and the SystemC kernel surfaces it as the E549 uncaught exception you see, naming the exact driver process. One more protocol note while we are here: these tripwires are per-sequencer state, which is one of several reasons a driver must be the only process calling the pull port — the current-item protocol has no notion of two concurrent consumers.

Corner 2: three response paths, one of them already deprecated

Beta6 gives a driver three ways to return data, and they are not equally future-proof.

  • item_done(rsp) — couples the response to completion; the sequencer routes it (via the IDs stamped by set_id_info) to the requesting sequence's response queue, where get_response collects it. This is the idiom used throughout this post, and the most portable: it matches SV UVM behavior exactly.
  • seq_item_port->put(rsp) (or the sequencer's put) — decouples response delivery from item_done, for protocols where responses stream back later or out of order. Same routing rules, same set_id_info obligation.
  • rsp_port.write(rsp) — the driver's second built-in member, an analysis broadcast of responses. Anything may subscribe, but nothing routes back to the sequence; use it for observation, not for the request/response contract.

There is a fourth name you will meet in Accellera's own basic_read_write_sequence example: put_response(rsp). Do not adopt it — in the beta6 header it is annotated with a TODO questioning whether it belongs in the standard at all ("not in standard anymore?"), which is as close as a library comes to telling you a method is on death row. The shipped example predates that doubt. Teach your fingers item_done(rsp) and put(); they are the two the UVM-SystemC LRM stands behind.

Corner 3: ownership — two copy systems, four rules

A UVM-SystemC agent runs two copy systems side by side, and keeping them straight is what "who owns this object" means here.

The C++ copy system (copy constructor, operator=) is what the machinery itself uses: finish_item's push into the tlm_fifo, get_next_item's copy-assignment into the driver's stack object, the response queue's internal *item = *crsp copy, and the analysis port's fan-out all go through plain C++ copying — not through do_copy. The UVM copy system (copy()/clone() calling your do_copy) is what user code calls — scoreboards cloning observed transactions, sequences duplicating template items. Both must work, which yields four rules:

  1. Keep transaction classes trivially copyable in the C++ sense. Flat value members (integers, enums, small arrays, std::string) copy correctly with the compiler-generated members. The moment you add an owning raw pointer, the FIFO transfer breaks silently — two objects sharing one buffer. If a member needs deep-copy semantics, implement the C++ copy constructor and operator= in addition to do_copy.
  2. The sequence owns what it news. start_item/finish_item take raw pointers and never take ownership; delete the item after finish_item (or reuse it for the next iteration and delete at the end of body()). Nothing in the library frees sequence items — a soak test leaks one item per transaction if you forget.
  3. The driver owns nothing. Stack req/rsp objects, reused every iteration, are the correct pattern precisely because everything downstream copies.
  4. Subscribers copy what they keep. write(const bus_trans& t) hands you a reference to an object that may not outlive the call (the monitor in this post passes a stack temporary). Store t by value, never by address.

Corner 4: how is_active really resolves — and the passive agent in action

uvm::uvm_agent::build_phase looks up "is_active" in the resource pool by the agent's full instance name, accepting a genuine uvm_active_passive_enum, a plain int, or an integral type — a forgiving lookup chain that exists precisely so uvm_config_db<int>::set(...) works. If nothing matches, it warns (id ACTPASS:, colon included — a beta6 quirk worth knowing when you grep) and defaults to active. Two agents, one unset and one flipped passive, make the whole mechanism visible:

// file: agent_active_passive.cpp
// Build: g++ -std=c++17 -I$SYSTEMC_HOME/include -I$UVM_SYSTEMC_HOME/include \
//            agent_active_passive.cpp -o agent_active_passive \
//            -L$SYSTEMC_HOME/lib -L$UVM_SYSTEMC_HOME/lib -lsystemc -luvm-systemc
// Run:   LD_LIBRARY_PATH=$SYSTEMC_HOME/lib:$UVM_SYSTEMC_HOME/lib ./agent_active_passive

#include <systemc>
#include <uvm>
#include <sstream>

class tiny_tx : public uvm::uvm_sequence_item {
public:
  int value{0};
  tiny_tx(const std::string& name = "tiny_tx")
    : uvm::uvm_sequence_item(name) {}
  UVM_OBJECT_UTILS(tiny_tx);
};

class TinyDriver : public uvm::uvm_driver<tiny_tx> {
public:
  TinyDriver(uvm::uvm_component_name name)
    : uvm::uvm_driver<tiny_tx>(name) {}
  UVM_COMPONENT_UTILS(TinyDriver);
};

class TinyMonitor : public uvm::uvm_monitor {
public:
  uvm::uvm_analysis_port<tiny_tx> ap;
  TinyMonitor(uvm::uvm_component_name name)
    : uvm::uvm_monitor(name), ap("ap") {}
  UVM_COMPONENT_UTILS(TinyMonitor);
};

class TinyAgent : public uvm::uvm_agent {
public:
  uvm::uvm_sequencer<tiny_tx>* sequencer{nullptr};
  TinyDriver*                  driver{nullptr};
  TinyMonitor*                 monitor{nullptr};

  TinyAgent(uvm::uvm_component_name name) : uvm::uvm_agent(name) {}

  UVM_COMPONENT_UTILS(TinyAgent);

  virtual void build_phase(uvm::uvm_phase& phase) {
    uvm::uvm_agent::build_phase(phase);  // resolves is_active for THIS instance
    monitor = TinyMonitor::type_id::create("monitor", this);  // always
    if (get_is_active() == uvm::UVM_ACTIVE) {
      sequencer = new uvm::uvm_sequencer<tiny_tx>("sequencer");
      driver    = TinyDriver::type_id::create("driver", this);
    }
  }

  virtual void connect_phase(uvm::uvm_phase& phase) {
    // Guard the binding too: a passive agent has no driver to connect.
    // Leaving an sc_port unbound is an elaboration ERROR, not a warning.
    if (get_is_active() == uvm::UVM_ACTIVE)
      driver->seq_item_port.connect(sequencer->seq_item_export);
  }
};

class ActivePassiveTest : public uvm::uvm_test {
public:
  TinyAgent* agent0{nullptr};  // is_active never set: warns, defaults ACTIVE
  TinyAgent* agent1{nullptr};  // flipped to PASSIVE via the config db

  ActivePassiveTest(uvm::uvm_component_name name) : uvm::uvm_test(name) {}

  UVM_COMPONENT_UTILS(ActivePassiveTest);

  virtual void build_phase(uvm::uvm_phase& phase) {
    uvm::uvm_test::build_phase(phase);
    uvm::uvm_config_db<int>::set(this, "agent1", "is_active",
                                 uvm::UVM_PASSIVE);
    agent0 = TinyAgent::type_id::create("agent0", this);
    agent1 = TinyAgent::type_id::create("agent1", this);
  }

  virtual void end_of_elaboration_phase(uvm::uvm_phase& phase) {
    for (TinyAgent* a : { agent0, agent1 }) {
      std::ostringstream str;
      str << a->get_name() << " is "
          << (a->get_is_active() == uvm::UVM_ACTIVE ? "ACTIVE " : "PASSIVE")
          << "  driver=" << (a->driver    ? "built" : "absent")
          << "  sequencer=" << (a->sequencer ? "built" : "absent")
          << "  monitor=" << (a->monitor   ? "built" : "absent");
      UVM_INFO(get_name(), str.str(), uvm::UVM_MEDIUM);
    }
  }
};

int sc_main(int, char*[]) {
  uvm::run_test("ActivePassiveTest");
  return 0;
}

Expected output (banners elided):

UVM_INFO @ 0 s: reporter [RNTST] Running test ActivePassiveTest...
UVM_WARNING @ 0 s: ActivePassiveTest.agent0 [ACTPASS:] Active or passive mode for agent 'ActivePassiveTest.agent0' has not been defined. Default behavior is active.
UVM_INFO agent_active_passive.cpp(86) @ 0 s: ActivePassiveTest [ActivePassiveTest] agent0 is ACTIVE   driver=built  sequencer=built  monitor=built
UVM_INFO agent_active_passive.cpp(86) @ 0 s: ActivePassiveTest [ActivePassiveTest] agent1 is PASSIVE  driver=absent  sequencer=absent  monitor=built
UVM_INFO ../../../src/uvmsc/report/uvm_default_report_server.cpp(667) @ 0 s: reporter [UVM/REPORT/SERVER] 
--- UVM Report Summary ---

** Report counts by severity
UVM_INFO      :   3
UVM_WARNING   :   1
UVM_ERROR     :   0
UVM_FATAL     :   0
** Report counts by id
[ACTPASS:]              1
[ActivePassiveTest]     2
[RNTST]                 1

UVM_INFO @ 0 s: reporter [FINISH] UVM-SystemC phasing completed; simulation finished

agent0, with nothing configured, triggers the ACTPASS: warning and comes up active with all three children built. agent1, flipped by one uvm_config_db<int>::set call before the agents are created, comes up passive: monitor built, driver and sequencer never constructed. Note the config-db call happens in the test's build_phase before type_id::create runs for the agents — top-down build order is what makes parent-configures-child work, same as SV UVM. And a subtlety that costs people an afternoon: the base uvm::uvm_agent::build_phase(phase) call is not a politeness — it is the is_active resolution. Skip it in your override and get_is_active() returns the constructor default forever, warnings and config settings notwithstanding.

Version differences

Everything in this post is verified against uvm-systemc-1.0-beta6 (2024-07-01, the current release and identical to the project's main branch) running on SystemC 3.0.x — note the 3.0.0-labeled installer builds a kernel whose banner self-identifies as 3.0.1-Accellera. The API is modeled on UVM 1.2, but the implementation descends from the UVM 1.1d reference code, and the release notes describe the library as in beta and highly experimental. Concretely, relative to the SV UVM 1.2 you know, beta6 has: no field macros (permanent design choice, not a gap), no command-line processor at all (uvm_cmdline_processor exists only inside commented-out TODO blocks — hence no +UVM_TESTNAME, no +uvm_set_config_*), no UVM TLM-2.0 wrapper classes (you use SystemC's native TLM-2.0 directly, as this post does — arguably better), no uvm_heartbeat, uvm_barrier, or uvm_sequence_library, and no native randomization or coverage (CRAVE and FC4SC, later in this section). The sequence API is UVM 1.2-shaped: the public starting_phase member is gone in favor of get_starting_phase()/set_starting_phase() and set_automatic_phase_objection() — even Accellera's ubus example ships its old starting_phase objection code commented out, which is why this series standardizes on objections in the test's run_phase. Counterintuitively, the register layer (RAL) is fully ported and among the better-tested corners of the library; it appears later in the section. Finally, two call syntaxes work on the driver's pull port — seq_item_port.get_next_item(req) and seq_item_port->get_next_item(req) — both compile and run against beta6; this series uses the arrow form to match the shipped examples.

Hands-on exercise

Turn the Intermediate example's printer into a real checker, and prove it catches a real bug.

Starting from agent_full.cpp:

  1. Build a MirrorChecker — a uvm_subscriber<bus_trans> holding a std::map<unsigned int, unsigned int> mirror of the memory. On an observed BUS_WRITE, update the mirror. On an observed BUS_READ, compare the observed data against the mirror (use a bus_trans you fill with the expected value and your hand-written do_compare via compare()), and report UVM_ERROR on mismatch. Count checks performed; report the total and pass/fail in report_phase.
  2. Drive it with a longer sequence — extend rw_seq to write-then-readback eight different addresses with distinct data patterns (a std::mt19937 seeded constant makes the patterns varied and reproducible — remember, no randomize() exists here).
  3. Inject a DUT bug — make MemoryDut corrupt bit 0 on writes to one specific address (mem[addr] |= 1; behind an if). Rerun and confirm the checker flags exactly the readbacks from that address, with the mismatching data printed via convert2string.
  4. Flip the agent passive — change the test's config-db call to UVM_PASSIVE and rerun. Predict, before you run: what happens, and when? (Hint: seq.start(env->agent0->sequencer, ...) — what is sequencer now? This failure mode is why production tests guard stimulus with get_is_active().)

Hints

  • The subscriber's write(const bus_trans& t) receives a reference to a temporary — copy anything you keep into the map by value.
  • report_phase(uvm::uvm_phase&) runs after run_phase ends; it is the natural home for the final tally, and it works exactly like its SV UVM counterpart.
  • For step 3, the monitor sits after the corruption point (it forwards to the DUT and observes the result), so the checker sees the corrupted read data — that is precisely why the passthrough tap topology observes the truth.
  • For step 4, expect a crash, not a message: dereferencing the null sequencer pointer is undefined behavior. The fix — guarding stimulus on get_is_active() — is one if in the test.

No solution is provided. The understanding lives in watching your own checker catch the bug you planted.

Common mistakes

  • Mutating the driver's req and expecting the sequence to see it. In SV UVM, driver and sequence share a handle, so writing read data into req works. Here req is a copy; the write vanishes and the sequence's get_response (or worse, its inspection of the original item) sees stale zeros. Fix: send results back explicitly — rsp.set_id_info(req) then item_done(rsp) (or put) — and have the sequence call get_response. If get_response hangs forever, the missing set_id_info is the first thing to check.
  • Waiting for field macros to generate copy/compare/print. UVM_OBJECT_UTILS registers the type with the factory and provides get_type_name — nothing else. There are no uvm_field_* macros in the library, so an item without hand-written do_copy/do_compare/do_print/convert2string compiles fine and then prints nothing useful, compares as unequal (base do_compare), and copies only what the C++ default members copy. Fix: hand-write all four for every transaction class; it is the per-class porting tax, and the bus_trans in this post is the reusable pattern.
  • Porting the SV do_compare signature verbatim. SV's do_compare(uvm_object rhs, uvm_comparer comparer) becomes bool do_compare(const uvm::uvm_object& rhs, const uvm::uvm_comparer* comparer = nullptr) const — reference-to-const object, pointer comparer defaulting to nullptr, const method. Get any of the three wrong and you have silently declared a new overload instead of overriding; compare() then falls through to the base implementation and everything "matches". Fix: copy the signature from this post's bus_trans exactly, and feel free to ignore the comparer — Accellera's own examples do.
  • Raising objections through the sequence's starting_phase. The UVM 1.1-era public member does not exist in beta6; code that compiles against memories of if (starting_phase != null) starting_phase.raise_objection(this) has no direct equivalent, and the UVM 1.2-style accessors plus set_automatic_phase_objection are beta-fresh territory. Fix: the durable idiom is the one every example in this post uses — phase.raise_objection(this) / drop_objection(this) around stimulus in the test's run_phase (with phase.get_objection()->set_drain_time(this, t) when trailing activity needs to drain).
  • Hunting for the virtual interface. There is no vif, no interface handle in the config db, and no BFM-behind-an-interface pattern. The driver is an sc_module; it owns a tlm_utils::simple_initiator_socket and calls b_transport from run_phase. Fix: reframe the port map — DUT-facing sockets are members of the driver and monitor, bound in connect_phase like any SystemC port, and the config db carries configuration values (like is_active), not connectivity.
  • Assuming the handshake protocol itself changed with the language. It did not — and both over-trusting and under-trusting it cause bugs. The rules are SV UVM's rules verbatim: item current from get_next_item until item_done; double get_next_item is a reported error that hands you the same item again (silent re-execution if unnoticed); orphan item_done is fatal (uvm_root::die()); one consumer process per sequencer. Fix: keep the canonical loop — get_next_item(req), execute, item_done([rsp]) — in exactly one driver thread, and treat the two guard-rail messages from the Advanced section as instant diagnoses, not mysteries.

Recap

After working through this post you can now:

  • Map every box of the SystemVerilog UVM agent onto its UVM-SystemC equivalent — and state precisely what changed (transport, copies, sockets) and what did not (roles, phases, handshake protocol).
  • Write a transaction class with hand-written do_copy, do_compare (pointer comparer, const method), do_print, and convert2string, and explain why it must also stay safely C++-copyable for the FIFO and analysis paths.
  • Explain the by-value item flow — copy into the tlm_fifo at finish_item, copy out at get_next_item — and predict, before running, that driver-side mutations never reach the sequence.
  • Return read data the right way: rsp.set_id_info(req), item_done(rsp), get_response — and name the alternatives (put, rsp_port) plus the one to avoid (put_response).
  • Build a driver that is a genuine TLM-2.0 initiator — payload filled per the generic-payload rules, latency paid with wait(delay) inside run_phase — with no virtual interface anywhere.
  • Tap traffic with a passthrough monitor and broadcast reconstructed transactions on a uvm_analysis_port to uvm_subscriber components.
  • Assemble a uvm_agent that guards both creation and binding on get_is_active(), configure it through uvm_config_db<int>, and read the ACTPASS: warning correctly.
  • Scaffold and run the whole thing — factory-registered test, uvm::run_test("Name") in sc_main with no sc_start() and no +UVM_TESTNAME, objections in the test's run_phase.
  • Diagnose the two handshake guard rails from their exact log messages, and manage item ownership (new/delete in sequences, stack objects in drivers, copy-by-value in subscribers) without leaks.

Further reading

Standards and reference manuals

  • Accellera, UVM-SystemC Language Reference Manual (ships in the uvm-systemc distribution under docs/) — §7.2 (uvm_driver), §7.3 (uvm_monitor), §8.2–8.3 (sequencer, uvm_sqr_if_base, handshake semantics and the current-item protocol), §9.3.5 (starting-phase accessors), §13 (UTILS and report macros).
  • Accellera, UVM 1.2 Class Reference — the API surface beta6 models; useful for spotting where the C++ mirrors or omits the SV original.
  • IEEE Std 1800.2 — the SystemVerilog UVM standard, for the SV side of every mapping-table row.
  • IEEE Std 1666-2011, §10 — generic payload and b_transport rules the driver in this post obeys.

Library source and examples (ground truth for beta6)

  • uvm-systemc-1.0-beta6 source: src/uvmsc/comps/uvm_driver.h, uvm_monitor.h, uvm_agent.h/.cpp, src/uvmsc/seq/uvm_sequencer.h, uvm_sequencer_ifs.h, uvm_sequence_item.h, src/uvmsc/macros/uvm_object_defines.h, src/uvmsc/tlm1/uvm_analysis_port.h — every signature quoted in this post reads directly from these headers.
  • Shipped examples: examples/uvmsc/simple/sequence/basic_read_write_sequence/ (item + driver + sequencer skeleton) and examples/uvmsc/integrated/ubus/ (production-shaped agent build/connect pattern, monitor write(), objection/drain-time usage).

Consortium and community

  • systemc.org UVM-SystemC FAQ and the SystemC Verification Working Group pages — status, roadmap, and the standardization outlook.
  • "Advancing System Level Verification Using UVM in SystemC" (DVCon US 2014) — the paper that framed the library's goals; still the best short rationale for UVM discipline in virtual platforms.

Next in this section

→ Part 3: Sequences & the Sequence Library — the producer side of the fax machine: sequence anatomy beyond body(), layering and virtual sequences, arbitration, default_sequence wiring, response handling patterns at scale, and honest notes on what beta6's sequence machinery does and does not port from SV UVM. Read it here: 26. SystemC Tutorial — Sequences & the Sequence Library.

Author
Mayur Kubavat
DV engineer working on SoC verification. Writes here about UVM, PCIe, SystemVerilog, and the everyday craft of getting designs to tape-out.

Comments (0)

Leave a Comment