Advanced UVM

Chapter 2 of 6

AXI4-Lite Driver, Monitor, and Agent

Wrap axil_regfile in real UVM: an axi_txn sequence item, a driver that finally has to wait for READY instead of driving combinationally, a monitor that reconstructs transactions from two independent channels, and both packaged into axi_agent -- reusing UVM Basics' seq_item_port/analysis_port machinery directly, since the handshake is the only genuinely new thing here.

Chapter 1 drove axil_regfile by hand, with two plain tasks (axi_write/axi_read) called directly from an initial block. This chapter wraps the exact same driving logic in UVM: a sequence item, a driver, a monitor, and a uvm_agent — all built from machinery uvm ch8 already taught (seq_item_port, analysis_port, a virtual interface handle). Nothing about the UVM plumbing is new. What's new is what's on the other end of it: a real handshake that can make the driver wait, for the first time in this site's history — mux2 was combinational, so uvm ch8's driver never once had to sit still and wait for a ready signal.

axi_txn: one item, both directions

A single transaction type covers reads and writes, discriminated by is_write — and, following the same precedent uvm ch8 set when it added a y field to mux_transaction so one transaction could carry both what was driven and what the DUT produced, axi_txn carries both the fields a sequence fills in (addr, wdata) and the fields the driver fills in once the transaction completes (rdata, resp):

class axi_txn extends uvm_sequence_item;
  rand bit        is_write;
  rand bit [7:0]  addr;
  rand bit [31:0] wdata;
       bit [31:0] rdata;
       bit [1:0]  resp;
 
  `uvm_object_utils(axi_txn)
 
  function new(string name = "axi_txn");
    super.new(name);
  endfunction
 
  function void do_copy(uvm_object rhs);
    axi_txn rhs_;
    if (!$cast(rhs_, rhs)) `uvm_fatal("AXI_TXN", "do_copy: cast failed")
    super.do_copy(rhs);
    is_write = rhs_.is_write;
    addr     = rhs_.addr;
    wdata    = rhs_.wdata;
    rdata    = rhs_.rdata;
    resp     = rhs_.resp;
  endfunction
 
  function bit do_compare(uvm_object rhs, uvm_comparer comparer);
    axi_txn rhs_;
    if (!$cast(rhs_, rhs)) return 0;
    return (is_write == rhs_.is_write) && (addr == rhs_.addr) &&
           (wdata == rhs_.wdata) && (rdata == rhs_.rdata) && (resp == rhs_.resp);
  endfunction
 
  function string convert2string();
    return is_write ?
      $sformatf("WRITE addr=0x%0h wdata=0x%0h resp=%0d", addr, wdata, resp) :
      $sformatf("READ  addr=0x%0h rdata=0x%0h resp=%0d", addr, rdata, resp);
  endfunction
endclass

Hand-written do_copy/do_compare/convert2string, uvm_object_utils — exactly uvm ch4's pattern, nothing new here either.

The driver: backpressure for the first time

The driver's drive() task is a direct port of chapter 1's axi_write/axi_read tasks — same handshake, same clocking-block access, just living inside a class method now instead of a bare initial block:

class axi_driver extends uvm_driver #(axi_txn);
  `uvm_component_utils(axi_driver)
 
  virtual axi4lite_if.tb_mp vif;
 
  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction
 
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    if (!uvm_config_db#(virtual axi4lite_if.tb_mp)::get(this, "", "vif", vif))
      `uvm_fatal("AXI_DRV", "virtual interface not set")
  endfunction
 
  task run_phase(uvm_phase phase);
    // axi4lite_if.tb_mp only exposes the clocking block (ch1), not aresetn
    // directly -- the driver has no way to sense reset itself, so it just
    // waits past the fixed reset window the top-level testbench uses. The
    // same simplification chapter 1's own hand-driven testbench relied on,
    // just stated explicitly here since it's now hidden inside a class.
    repeat (4) @(vif.cb);
    vif.cb.awvalid <= 1'b0;
    vif.cb.wvalid  <= 1'b0;
    vif.cb.bready  <= 1'b1;
    vif.cb.arvalid <= 1'b0;
    vif.cb.rready  <= 1'b1;
 
    forever begin
      seq_item_port.get_next_item(req);
      drive(req);
      seq_item_port.item_done();
    end
  endtask
 
  task drive(axi_txn txn);
    if (txn.is_write) begin
      vif.cb.awaddr  <= txn.addr;
      vif.cb.awvalid <= 1'b1;
      vif.cb.wdata   <= txn.wdata;
      vif.cb.wstrb   <= 4'hF;
      vif.cb.wvalid  <= 1'b1;
      @(vif.cb);
      while (!(vif.cb.awready && vif.cb.wready)) @(vif.cb);
      vif.cb.awvalid <= 1'b0;
      vif.cb.wvalid  <= 1'b0;
      while (!vif.cb.bvalid) @(vif.cb);
      txn.resp = vif.cb.bresp;
    end else begin
      vif.cb.araddr  <= txn.addr;
      vif.cb.arvalid <= 1'b1;
      @(vif.cb);
      while (!vif.cb.arready) @(vif.cb);
      vif.cb.arvalid <= 1'b0;
      while (!vif.cb.rvalid) @(vif.cb);
      txn.rdata = vif.cb.rdata;
      txn.resp  = vif.cb.rresp;
    end
  endtask
endclass

get_next_item/item_done is exactly the two-sided sequencer/driver protocol uvm ch7 previewed and ch8 gave full treatment — req is uvm_driver #(axi_txn)'s built-in handle, filled in by get_next_item. The genuinely new part is the two while loops inside drive(): mux2 was combinational, so uvm ch8's driver never had a cycle where it had already asserted a signal and still had to sit there waiting for the DUT to catch up. Here, axil_regfile can (and, per spec, is allowed to) hold awready/wready/arready low for as long as it needs — the driver has to be written to tolerate that, not just assume one cycle is always enough.

The monitor: reconstructing transactions from two independent channels

The monitor doesn't drive anything — it watches the same vif.cb the driver uses (reusing the driver's tb_mp view rather than getting a dedicated read-only one, the same simplification uvm ch8 flagged for mux2_if's monitor) and reports completed transactions over an analysis_port:

class axi_monitor extends uvm_monitor;
  `uvm_component_utils(axi_monitor)
 
  virtual axi4lite_if.tb_mp vif;
  uvm_analysis_port #(axi_txn) ap;
 
  function new(string name, uvm_component parent);
    super.new(name, parent);
    ap = new("ap", this);
  endfunction
 
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    if (!uvm_config_db#(virtual axi4lite_if.tb_mp)::get(this, "", "vif", vif))
      `uvm_fatal("AXI_MON", "virtual interface not set")
  endfunction
 
  task run_phase(uvm_phase phase);
    fork
      watch_write();
      watch_read();
    join
  endtask
 
  task watch_write();
    bit [7:0]  addr_q;
    bit [31:0] data_q;
    forever begin
      @(vif.cb);
      if (vif.cb.awvalid && vif.cb.awready) addr_q = vif.cb.awaddr;
      if (vif.cb.wvalid  && vif.cb.wready)  data_q = vif.cb.wdata;
      if (vif.cb.bvalid  && vif.cb.bready) begin
        axi_txn txn = axi_txn::type_id::create("txn");
        txn.is_write = 1'b1;
        txn.addr     = addr_q;
        txn.wdata    = data_q;
        txn.resp     = vif.cb.bresp;
        ap.write(txn);
      end
    end
  endtask
 
  task watch_read();
    bit [7:0] addr_q;
    forever begin
      @(vif.cb);
      if (vif.cb.arvalid && vif.cb.arready) addr_q = vif.cb.araddr;
      if (vif.cb.rvalid  && vif.cb.rready) begin
        axi_txn txn = axi_txn::type_id::create("txn");
        txn.is_write = 1'b0;
        txn.addr     = addr_q;
        txn.rdata    = vif.cb.rdata;
        txn.resp     = vif.cb.rresp;
        ap.write(txn);
      end
    end
  endtask
endclass

fork ... join runs watch_write() and watch_read() as two independent, concurrent processes — the same construct systemverilog-basics ch14 taught, now doing real work: the write and read channels genuinely need independent watchers, since they can be mid-transaction on both at once. Notice watch_write() is simpler than axil_regfile's own write-acceptance logic (chapter 1) — the DUT has to decide whether it's ready to accept a new AW/W (that's what aw_have/w_have and the readiness signals are for), but the monitor only has to remember the latest address and data it saw and pair them with the next BVALID — with one outstanding transaction at a time (chapter 1's DUT design), the most recently captured addr_q/data_q are always the right ones by the time BVALID fires.

Packaging into axi_agent

class axi_sequencer extends uvm_sequencer #(axi_txn);
  `uvm_component_utils(axi_sequencer)
  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction
endclass
 
class axi_agent extends uvm_agent;
  `uvm_component_utils(axi_agent)
 
  axi_sequencer sqr;
  axi_driver    drv;
  axi_monitor   mon;
 
  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction
 
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    mon = axi_monitor::type_id::create("mon", this);
    if (get_is_active() == UVM_ACTIVE) begin
      sqr = axi_sequencer::type_id::create("sqr", this);
      drv = axi_driver::type_id::create("drv", this);
    end
  endfunction
 
  function void connect_phase(uvm_phase phase);
    super.connect_phase(phase);
    if (get_is_active() == UVM_ACTIVE) drv.seq_item_port.connect(sqr.seq_item_export);
  endfunction
endclass

This is the exact uvm_agent pattern uvm ch8 introduced: get_is_active() == UVM_ACTIVE gates whether the sequencer/driver get built at all, so an agent can be reused as a passive, monitor-only observer just by configuring it differently — a distinction chapter 3 puts to real use for irq_agent.

A sequence and a test: the same scenario, now UVM-driven

class axi_basic_seq extends uvm_sequence #(axi_txn);
  `uvm_object_utils(axi_basic_seq)
 
  function new(string name = "axi_basic_seq");
    super.new(name);
  endfunction
 
  task write(bit [7:0] addr, bit [31:0] data);
    axi_txn req = axi_txn::type_id::create("req");
    start_item(req);
    req.is_write = 1'b1;
    req.addr     = addr;
    req.wdata    = data;
    finish_item(req);
  endtask
 
  task read(bit [7:0] addr);
    axi_txn req = axi_txn::type_id::create("req");
    start_item(req);
    req.is_write = 1'b0;
    req.addr     = addr;
    finish_item(req);
  endtask
 
  task body();
    write(8'h00, 32'h1);   // CTRL: ENABLE=1
    write(8'h08, 32'hAA);  // DATA write #1
    write(8'h08, 32'hBB);  // DATA write #2
    write(8'h08, 32'hCC);  // DATA write #3
    write(8'h08, 32'hDD);  // DATA write #4 -> COUNT=4 == IRQ_THRESHOLD
    read(8'h0C);            // COUNT
    read(8'h04);            // STATUS -- expect bit0=1
    write(8'h00, 32'h3);   // CTRL: ENABLE=1, IRQ_CLR=1
    read(8'h04);            // STATUS -- expect bit0=0 again
    read(8'h10);            // unmapped -> DECERR
  endtask
endclass
 
class axi_smoke_test extends uvm_test;
  `uvm_component_utils(axi_smoke_test)
 
  axi_agent agt;
 
  function new(string name, uvm_component parent);
    super.new(name, parent);
  endfunction
 
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    agt = axi_agent::type_id::create("agt", this);
  endfunction
 
  task run_phase(uvm_phase phase);
    axi_basic_seq seq = axi_basic_seq::type_id::create("seq");
    phase.raise_objection(this);
    seq.start(agt.sqr);
    phase.drop_objection(this);
  endtask
endclass

start_item/finish_item (uvm ch7), the factory's type_id::create() (uvm ch6), and the objection wrapping the test's blocking seq.start(sqr) call (uvm ch7's own "objection moves from driver to test" decision, once run_phase stops being a hand-written loop) — this sequence and test are assembled entirely from material the reader already has. Reusing ch1's axil_regfile and axi4lite_if unchanged, the top-level module follows uvm ch3's exact config_db handoff pattern:

`include "uvm_macros.svh"
import uvm_pkg::*;
 
module tb_top;
  logic aclk;
  logic irq;
 
  axi4lite_if axi_if (.aclk(aclk));
 
  axil_regfile #(.IRQ_THRESHOLD(4)) dut (
    .s_axi_aclk    (axi_if.aclk),
    .s_axi_aresetn (axi_if.aresetn),
    .s_axi_awaddr  (axi_if.awaddr),
    .s_axi_awprot  (axi_if.awprot),
    .s_axi_awvalid (axi_if.awvalid),
    .s_axi_awready (axi_if.awready),
    .s_axi_wdata   (axi_if.wdata),
    .s_axi_wstrb   (axi_if.wstrb),
    .s_axi_wvalid  (axi_if.wvalid),
    .s_axi_wready  (axi_if.wready),
    .s_axi_bresp   (axi_if.bresp),
    .s_axi_bvalid  (axi_if.bvalid),
    .s_axi_bready  (axi_if.bready),
    .s_axi_araddr  (axi_if.araddr),
    .s_axi_arprot  (axi_if.arprot),
    .s_axi_arvalid (axi_if.arvalid),
    .s_axi_arready (axi_if.arready),
    .s_axi_rdata   (axi_if.rdata),
    .s_axi_rresp   (axi_if.rresp),
    .s_axi_rvalid  (axi_if.rvalid),
    .s_axi_rready  (axi_if.rready),
    .irq           (irq)
  );
 
  initial aclk = 1'b0;
  always #5 aclk = ~aclk;
 
  initial begin
    axi_if.aresetn = 1'b0;
    repeat (3) @(posedge aclk);
    axi_if.aresetn = 1'b1;
  end
 
  initial begin
    uvm_config_db#(virtual axi4lite_if.tb_mp)::set(null, "*", "vif", axi_if);
    run_test("axi_smoke_test");
  end
endmodule

This chapter needs a simulator that's both UVM-capable and implements clocking blocks — Icarus Verilog satisfies neither. On EDA Playground, pick Aldec Riviera-PRO (free, no license of your own required — the same alternative chapter 1 and SV ch13 already point to); it covers both requirements at once.

Summary

  • axi_txn carries both what a sequence requests (addr/wdata) and what the driver observes once a transaction completes (rdata/resp) — the same one-transaction-carries-both-directions shape uvm ch8 used for mux_transaction's y field.
  • The driver's drive() task is chapter 1's hand-written tasks, unchanged in substance, now called from get_next_item/item_done instead of a bare initial block.
  • The real new material is backpressure: axil_regfile can hold READY low for as long as it needs, and the driver has to be written to wait for it — mux2 never required this.
  • The monitor reconstructs completed transactions with less bookkeeping than the DUT needs to accept them — it just remembers the latest address/data and pairs them with the next response, since only one transaction is outstanding at a time.
  • axi_agent is the exact uvm_agent pattern from uvm ch8 — get_is_active() gates whether the sequencer/driver exist at all, setting up chapter 3's passive irq_agent.
  • This chapter's example needs both a UVM-capable simulator and one with full clocking-block support — Icarus Verilog has neither; use Aldec Riviera-PRO on EDA Playground.

Why does axi_driver's drive() task need a while loop waiting for awready/wready, when uvm ch8's mux2 driver never needed anything like it?

Why is axi_monitor's watch_write() simpler than axil_regfile's own write-acceptance logic from chapter 1, even though both are reconstructing the same AW/W independence?

Which field of axi_txn distinguishes a read transaction from a write transaction?