Emergent Hardware Verification

Chapter A From UVM to Actors: The UBUS Example

The Universal Verification Methodology (UVM) reference implementation has long included the “UBUS” example as the gold standard for teaching layered testbench architecture. The standard UBUS example features a Master Agent, a Slave Agent, a Bus Monitor, a Scoreboard, RTL bus arbitration, and a layered sequence library. While it serves as an excellent tutorial for OOP class inheritance and phase execution, it also exposes the heavy boilerplate and tight coupling inherent to UVM.

(-tikz- diagram)

Figure A.1: The actor-based UBUS topology.

In this appendix we rebuild the UBUS architecture using the actor framework. Heavy OOP classes are replaced by immutable structs, and monolithic components by reactive actors. The result preserves the verification functionality in significantly less code and scales horizontally without locking. The implementation faithfully mirrors the same dut_dummy.v RTL arbiter interface that the original UBUS example uses, so the actor framework integrates with the same hardware handshake.

Step 1: Defining the Data Contracts

In the legacy UVM example, the ubus_transfer class contains both the data payload and numerous utility functions. In our actor framework, behavior is strictly separated from data. We define the pure data contracts as immutable SystemVerilog structs.

package ubus_pkg;
  typedef enum logic [1:0] { NOP=0, READ=1, WRITE=2 } ubus_dir_e;


 // The request envelope sent by the Test Thread to the Master Actor
  typedef struct {
    longint         id;
    int             master_id;
    logic [15:0] addr;
    ubus_dir_e      dir;
    logic     [7:0] data;
    int             size;
    int             transmit_delay;
 } UbusReq_s;


 // Response a master actor publishes back to its requester
  typedef struct {
    longint         id;
    int             master_id;
    logic     [7:0] data;
    logic           error;
 } UbusRsp_s;


 // Scoreboard notification published by the bus monitor
  typedef struct {
    logic [15:0] addr;
    logic     [7:0] data;
    ubus_dir_e      dir;
    int             master_id;
 } UbusMonPkt_s;
endpackage
Step 2: The Master Actor and Physical Arbitration

A standard UVM multi-master testbench needs a uvm_sequencer to queue and arbitrate transactions before they reach the uvm_driver. In the actor framework, the queue role is filled by the actor’s mailbox: the UbusMasterActor pops a request from its mailbox, raises sig_request on the RTL arbiter, waits for grant, drives the pin-level transaction, and publishes a response.

class UbusMasterActor extends Actor;
  virtual ubus_if vif;
 int master_id;


  virtual task act(MsgBase msg);
    if (msg.getTypeName() == $typename(UbusReq_s)) begin
      UbusReq_s req = Msg#(UbusReq_s)::unwrap(msg);
      UbusRsp_s rsp;


      // Optional pre-transfer gap
      if (req.transmit_delay > 0)
           repeat(req.transmit_delay) @(vif.cb);


      // ---- ARBITRATION PHASE ----
      @(vif.cb);
      vif.cb.sig_request[master_id] <= 1;
      do begin
           @(vif.cb);
      end while (vif.sig_grant[master_id] !== 1'b1);
      vif.cb.sig_request[master_id] <= 0;


      // ---- ADDRESS PHASE ----
      vif.cb.sig_addr     <= req.addr;
      // ... drive read/write/size
      @(vif.cb);


      // ---- DATA PHASE ----
      // ... drive data out
      @(vif.cb); // Mandatory 1-cycle wait
      while (vif.sig_wait !== 0) @(vif.cb);


      `PUBLISH(rsp); // Publish response back to topology
    end
  endtask
endclass
Step 3: Address-Mapped Slave Actors

The UVM Slave Agent requires its own sequencer and driver. Our UbusSlaveActor is simply a reactive node that maintains an internal memory array. It autonomously monitors the shared bus, responding only to transactions within its assigned address range, and injecting randomized wait states to throttle the bus.

class UbusSlaveActor extends Actor;
  virtual ubus_if vif;
 int                    slave_id;
  logic [15:0]          min_addr, max_addr;
  logic [7:0]           mem[logic[15:0]];


  virtual task run();
    forever begin
       do begin
           @(vif.cb);
       end while (vif.sig_grant[0] !== 1'b1 && vif.sig_grant[1] !== 1'b1);


       begin
           logic [15:0] captured_addr;
           @(vif.cb); // Capture address
           captured_addr = vif.cb.sig_addr;


           if (captured_addr >= min_addr && captured_addr <= max_addr) begin
             // Inject random temporal delay
             int wait_cycles = $urandom_range(0, 4);
             if (wait_cycles > 0) begin
                 vif.sig_wait <= 1; // Direct NBA removes 1-cycle delay
                 repeat(wait_cycles) @(vif.cb);
             end
             vif.sig_wait <= 0;


             if (vif.cb.sig_write) begin
                 @(vif.cb); // Wait for Data Phase before sampling
                 mem[captured_addr] = vif.cb.sig_data;
             end
             // ... complete READ data phase
             vif.sig_wait <= 'hz;
           end
       end
    end
  endtask
endclass
Step 4: Passive Observability (Monitors and Scoreboard)

Because data is immutable and flows via typed message structs, passive observability is trivial. The UbusProtocolMonitorActor sniffs the bus and PUBLISHes structs. The UbusScoreboardActor subscribes to these streams, maintaining a shadow memory to verify data integrity without ever blocking the active execution threads.

class UbusScoreboardActor extends Actor;
  logic [7:0] shadow_mem[logic[15:0]];


  virtual task act(MsgBase msg);
    if (msg.getTypeName() == $typename(UbusMonPkt_s)) begin
      UbusMonPkt_s pkt = Msg#(UbusMonPkt_s)::unwrap(msg);


      case (pkt.dir)
           WRITE: shadow_mem[pkt.addr] = pkt.data;
           READ: begin
             if (shadow_mem.exists(pkt.addr)) begin
                 if (shadow_mem[pkt.addr] === pkt.data)
                   $display("Scoreboard: READ PASS [0x%04h]", pkt.addr);
                 else
                   $error("Scoreboard: READ FAIL [0x%04h]", pkt.addr);
             end
           end
      endcase
    end
  endtask
endclass
Step 5: Wiring the Topology

In the legacy UVM UBUS environment, wiring two masters and four slaves involves uvm_config_db entries for each agent, plus the connect_phase that hooks each agent’s analysis port up to the scoreboard. In the actor framework, the env is itself an actor that constructs the sub-actors in its own constructor and connects edges with ‘WIRE.

class UbusEnvActor extends Actor;
  UbusMasterActor            masters[2];
  UbusSlaveActor             slaves[4];
  UbusProtocolMonitorActor bus_monitor;
  UbusScoreboardActor        scoreboard;


  function new(virtual ubus_if vif, string name="UbusEnvActor");
    masters[0] = new(vif, 0, "Master[0]");
    masters[1] = new(vif, 1, "Master[1]");


    slaves[0] = new(vif, 0, 16'h0000, 16'h3FFF, "Slave[0]");
    slaves[1] = new(vif, 1, 16'h4000, 16'h7FFF, "Slave[1]");
    slaves[2] = new(vif, 2, 16'h8000, 16'hBFFF, "Slave[2]");
    slaves[3] = new(vif, 3, 16'hC000, 16'hFFFF, "Slave[3]");


    bus_monitor = new(vif, "BusMonitor");
    scoreboard     = new("Scoreboard");


    // Bus monitor publishes UbusMonPkt_s to Scoreboard
    `WIRE(bus_monitor, UbusMonPkt_s, scoreboard)
  endfunction
 // ... start() method
endclass
Step 6: The test_2m_4s Test Scenario

We replace the monolithic uvm_test base class. We execute the classic 2 Master / 4 Slave test by firing randomized structs concurrently via two test threads, matching the UVM sequence’s behavior without virtual sequencers.

class Ubus2M4STest;
  UbusEnvActor env;


  task run();
    env.start(); // Boot the topology non-blockingly


    fork
      begin // Master 0 Thread
           for (int i = 0; i < 6; i++) begin
             logic [15:0] addr = $urandom_range(16'h0000, 16'h7FFF);
             `PUBLISH_TO(env.masters[0], UbusReq_s'{/* READ */});
             `PUBLISH_TO(env.masters[0], UbusReq_s'{/* WRITE (modify) */});
             `PUBLISH_TO(env.masters[0], UbusReq_s'{/* READ (verify) */});
           end
      end
      begin // Master 1 Thread
           for (int i = 0; i < 8; i++) begin
             logic [15:0] addr = $urandom_range(16'h8000, 16'hFFFF);
             `PUBLISH_TO(env.masters[1], UbusReq_s'{/* READ */});
             `PUBLISH_TO(env.masters[1], UbusReq_s'{/* WRITE (modify) */});
             `PUBLISH_TO(env.masters[1], UbusReq_s'{/* READ (verify) */});
           end
      end
    join
  endtask
endclass

The shipped example (ubus_2m_4s_test.sv) factors this stimulus into a first-class Ubus2M4SMasterStimulus actor and makes Ubus2M4STest a thin orchestrator that instantiates one stimulus actor per master; the inline fork form is shown here for brevity.

Step 7: The Final Line Count Comparison

The previous steps cover the full feature set of the legacy UVM UBUS integrated example: bus arbitration through the RTL arbiter, randomized slave wait-states, passive protocol monitoring, scoreboarding, and the multi-master / multi-slave 2M/4S test scenario.

Table A.1 compares line counts between the legacy UVM components and their actor-framework equivalents. Both columns are reproducible by running wc -l: the UVM side on the in-tree ch5_legacy_uvm_ubus/ tree, the actor side on appA_actor_ubus/.

Table A.1: UBUS line counts: UVM vs. actors.

UVM Component UVM Lines Actor Equivalent Actor Lines
ubus_transfer.sv 84 ubus_pkg.sv (structs) 43
master agent + driver + monitor 447 ubus_master_actor.sv 99
master sequencer + sequence lib 377 ubus_stimulus_actor.sv 72
slave agent + driver + monitor 418 ubus_slave_actor.sv 84
slave sequencer + responder lib 186 (subsumed in slave actor) 0
ubus_bus_monitor.sv 367 ubus_protocol_monitor_actor.sv 49
ubus_example_scoreboard.sv 120 ubus_scoreboard_actor.sv 61
coverage (embedded in monitors / env) ubus_coverage_actor.sv 48
ubus_env.sv 126 ubus_env_actor.sv 127
test_lib (2m_4s + variants) 442 ubus_2m_4s_test.sv 55
package + interface + tb wrappers 352 tb_top.sv + ubus_if.sv 107
Total 2,919 Total actor 745

The four-to-one reduction is the least interesting result here. Line count is a poor proxy for anything that matters — terse code can be unreadable and verbose code can be correct — so the headline ratio is better read as a side effect than as the point. The advantages that actually carry weight are architectural:

  • Concurrency without shared state. Actors interact only through asynchronous messages; each keeps its state private and every payload is immutable or passed by value, so there is no shared execution space to guard. The fork-join over shared configuration objects, sequencer state, and global variables that a UVM environment leans on — with the mutex and semaphore discipline it requires, the threads left idling on locks, and the scheduling-dependent races that reproduce on one run and vanish on the next — has nothing to operate on here. That same absence of contention is why the topology scales out rather than degrading past a thread count.

  • Data-oriented design. Messages are plain SystemVerilog structs and behavior lives in the actors, so data and behavior are kept apart instead of fused into one class. Each actor owns its own copy of a payload, which is what makes the network data-race-free; and because the structs are contiguous values rather than pointer-chased heap objects, the layout is cache-friendly — the property that lets a future emulator schedule actors across cores without lock contention. Constraints and coverage attach to the behavior, not to the data.

  • Declarative, fully visible structure. A producer never names its consumers and a consumer never names its producers; the entire topology lives in the parent’s ‘WIRE statements, with no hidden edges and no run-time uvm_config_db string lookups to chase. There is no build/connect/run phase machine to keep in lock-step either — an actor is alive and listening the moment it is wired — so the phase-ordering and configuration-timing bugs that dominate UVM bring-up cannot arise.

  • The same structural shape as the silicon. The testbench is a concurrent network of state machines exchanging events on mailboxes — the shape of the DUT itself, rather than a sequential class hierarchy laid over it. That structural match is why the actor core has a synthesizable form (Appendix E) that carries onto FPGA emulation and silicon, where a dynamic-heap-heavy UVM testbench cannot follow.

  • Observability built in. Every message carries its own trace_id and causal ancestry, so cross-actor tracing, recording, and deterministic replay are intrinsic to the substrate rather than bolted on afterwards.

Compactness still earns a place on the list, but for a humbler reason than the ratio suggests: comprehensibility. With the ceremony stripped away, far less stands between a reader and the verification intent, which makes the actor version easier to follow — and to change — than the verbose original. That is a real benefit; it is simply a secondary one.

Conclusion

Converting the UBUS example to the actor framework removes the UVM class-inheritance overhead, the uvm_config_db indirection, the phase-synchronization logic, and most of the boilerplate macros. What remains is an asynchronous reactive network of actors driving the same dut_dummy RTL arbiter the legacy testbench drives — and, as a by-product, in roughly one-quarter the lines of code.