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
endclassHand-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
endclassget_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
endclassfork ... 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
endclassThis 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
endclassstart_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
endmoduleThis 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_txncarries 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 shapeuvmch8 used formux_transaction'syfield.- The driver's
drive()task is chapter 1's hand-written tasks, unchanged in substance, now called fromget_next_item/item_doneinstead of a bareinitialblock. - The real new material is backpressure:
axil_regfilecan holdREADYlow for as long as it needs, and the driver has to be written to wait for it —mux2never 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_agentis the exactuvm_agentpattern fromuvmch8 —get_is_active()gates whether the sequencer/driver exist at all, setting up chapter 3's passiveirq_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?