UVM
Case Study — A Complete APB Agent
Build a complete, reusable UVM agent for AMBA APB end to end — the transaction item, the config, the driver that translates items into APB setup and access phases and waits for PREADY, the monitor that reconstructs transfers, and the active/passive agent that assembles them — with the sequencer-driver handshake, the testbench hierarchy, an APB waveform, and the wait-state bug every APB driver must get right.
Industry Case Studies · Module 30 · Page 30.1
The Engineering Problem
Every concept in the curriculum — components, phases, the factory, the config DB, the sequencer-driver handshake, TLM analysis — exists so you can build reusable verification IP. This case study assembles all of it into the smallest complete, real example: a UVM agent for AMBA APB, the protocol that wires up almost every register block on a chip. By the end you have an agent you could drop into a real environment.
An agent is a self-contained verification unit for one interface. For APB it must do four things: model an APB transfer as a transaction, drive that transaction onto the bus as the precise two-phase APB handshake, observe the bus and reconstruct the transaction independently, and assemble these so the same agent works active (driving) at block level and passive (observing only) at SoC level. The one detail that separates a toy APB driver from a correct one is PREADY: the slave can insert wait states, so the driver must hold the access phase until PREADY is asserted — get this wrong and reads return garbage against any real, wait-stated slave.
How do you build a complete, reusable UVM agent for APB — the transaction, the driver that produces the APB setup and access phases and waits for
PREADY, the monitor that reconstructs transfers, and the active/passive agent that assembles them — so the same agent drives at block level and observes at SoC level?
Motivation — why the APB agent is the canonical first VIP
APB is the right first agent to build, and building it whole is what makes the curriculum's pieces click together:
- APB is everywhere and simple enough to fully grasp. Almost every peripheral and register block hangs off an APB (or APB-like) bus, so an APB agent is immediately useful — and the protocol is small (a handful of signals, two phases) so you can build it completely without drowning in protocol detail.
- It exercises every core construct at once. The item is a
uvm_sequence_item; the driver uses the handshake and runs inrun_phase; the monitor uses an analysis port (TLM); the agent usesbuild_phase,connect_phase, the factory, and the config DB. The agent is where all of it has to work together, which is different from learning each in isolation. - Active/passive reuse is the whole point of UVM. The same agent class drives at block level and observes at SoC level, selected by one configuration field — the purest demonstration of why you build agents instead of writing directed bus tasks.
PREADYis the real-world detail. A driver that ignores wait states passes against a zero-wait slave and fails against a real one — the kind of bug that hides until integration. Building the agent correctly means building in the wait-state handling from the start.
The motivation, in one line: the APB agent is the smallest complete piece of reusable verification IP, so building it end to end is where the components, phases, factory, config DB, handshake, and TLM stop being separate lessons and become one working, reusable unit.
Mental Model
Hold the APB agent as a bilingual embassy between the abstract test and the physical bus:
A UVM agent is an embassy on the border between two worlds that do not speak the same language: the test, which thinks in abstract intentions ("write
0xABCDto register0x40"), and the bus, which speaks only in precise, timed pin wiggles. The embassy has three staff. The driver is the outbound translator: it takes an abstract instruction from the test and performs it as the exact diplomatic protocol the bus requires — for APB, the two-phase setup-then-access ceremony, and crucially it waits for the host's acknowledgement (PREADY) before considering the message delivered, never assuming. The monitor is the independent observer: it watches the same border crossing with its own eyes and writes down, in abstract terms, what actually happened — not what was intended, what occurred — so a third party (the scoreboard) can check the two against each other. And the embassy can run in two modes: a full active embassy that both sends and observes, or a passive listening post that only observes, used where the real traffic is generated by someone else and you just want to record it. The same embassy building serves both roles by staffing the translator only when it is active. The discipline that makes the embassy trustworthy is that the translator follows the protocol exactly — including waiting for acknowledgement — and the observer reports independently, so intention and reality are checked, never assumed equal. A UVM agent is an embassy on the border between the test's abstract intentions and the bus's timed pin wiggles. The driver is the outbound translator — it performs an abstract instruction as the exact APB ceremony (setup then access) and waits for the host's acknowledgement (PREADY) before the message is delivered. The monitor is the independent observer — it watches the same crossing and reports what actually happened, so a scoreboard can check intention against reality. And the embassy runs active (translate and observe) or passive (observe only), the same building staffing the translator only when active.
So building the agent is staffing the embassy: write the transaction (the abstract message), the driver (the protocol-following translator that waits for PREADY), the monitor (the independent observer with an analysis port), and the agent shell that hires the translator only when active and wires it to the sequencer. Intention flows out through the driver; reality flows back through the monitor; the two are checked, never assumed equal.
The APB Protocol in One Figure
Before the UVM, the protocol. An APB transfer is two phases: a setup phase that presents address and control, then an access phase that completes the transfer — and the access phase holds until the slave asserts PREADY, which is how wait states work.
The phases map one-to-one to driver code. SETUP is a single cycle where the master raises PSEL, keeps PENABLE low, and drives the address and control. ACCESS raises PENABLE and is the phase the master holds: on each rising edge it samples PREADY, and only when PREADY is high is the transfer complete — every cycle where PREADY is low is a slave-inserted wait state. On completion the master samples PRDATA (for a read) and PSLVERR (the error flag), then either returns to IDLE or, for back-to-back traffic, goes straight to SETUP for the next transfer. The driver you write below is a direct transcription of this: one setup cycle, then an access phase looped on PREADY.
The Agent Hierarchy
The agent is a uvm_agent containing three children — a sequencer, a driver, and a monitor — and it sits inside an env alongside a scoreboard. The driver and sequencer exist only when the agent is active.
The tree is the standard agent shape: test → env → agent → (sequencer / driver / monitor) with a scoreboard at env level. Two things are agent-specific. First, the monitor is unconditional — it is built whether the agent is active or passive, because observation is always wanted. Second, the sequencer and driver are conditional on get_is_active() — they form the stimulus-generating half that a passive instance does not need. Everything below is the code that fills this tree in.
The Transaction and the Config
The transaction models one APB transfer abstractly — address, data, direction — with no timing. The config carries what the agent needs to configure itself: the virtual interface and the active/passive choice.
class apb_seq_item extends uvm_sequence_item;
rand bit [31:0] addr;
rand bit [31:0] data;
rand bit write; // 1 = write, 0 = read
bit slverr; // response, captured from PSLVERR by driver/monitor
`uvm_object_utils_begin(apb_seq_item)
`uvm_field_int(addr, UVM_ALL_ON)
`uvm_field_int(data, UVM_ALL_ON)
`uvm_field_int(write, UVM_ALL_ON)
`uvm_object_utils_end
function new(string name = "apb_seq_item");
super.new(name);
endfunction
endclass
class apb_config extends uvm_object;
virtual apb_if vif; // the pins this agent drives/observes
uvm_active_passive_enum is_active = UVM_ACTIVE;
`uvm_object_utils(apb_config)
function new(string name = "apb_config");
super.new(name);
endfunction
endclassThe item is a uvm_sequence_item registered with the factory via `uvm_object_utils_begin/end, with addr, data, and write randomizable and slverr filled in after the transfer. It carries no timing — it is the abstract intent; turning it into pins is the driver's job. The apb_config is a uvm_object holding the virtual interface and the active/passive setting; passing one config object (rather than many separate config_db sets) is the scalable pattern. Note both are created through the factory, so a test can override the item (an error-injecting variant) or the config without editing the agent.
The Driver — translating items into APB phases
The driver is the heart of the agent: it loops on the sequencer-driver handshake, and for each item it produces the exact APB setup and access phases, holding access until PREADY.
class apb_driver extends uvm_driver #(apb_seq_item);
`uvm_component_utils(apb_driver)
virtual apb_if vif;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
if (!uvm_config_db#(virtual apb_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "virtual apb_if not set for apb_driver")
endfunction
task run_phase(uvm_phase phase);
vif.psel <= 1'b0; vif.penable <= 1'b0; // start in IDLE
forever begin
apb_seq_item tr;
seq_item_port.get_next_item(tr); // blocking — wait for stimulus
drive_transfer(tr);
seq_item_port.item_done(); // release the sequence
end
endtask
task drive_transfer(apb_seq_item tr);
@(posedge vif.clk);
// SETUP phase: PSEL=1, PENABLE=0, address + control valid
vif.psel <= 1'b1;
vif.penable <= 1'b0;
vif.paddr <= tr.addr;
vif.pwrite <= tr.write;
vif.pwdata <= tr.write ? tr.data : 32'd0;
@(posedge vif.clk);
// ACCESS phase: PENABLE=1, then HOLD until the slave asserts PREADY
vif.penable <= 1'b1;
do @(posedge vif.clk); while (!vif.pready); // every PREADY=0 cycle is a wait state
if (!tr.write) tr.data = vif.prdata; // capture read data
tr.slverr = vif.pslverr; // capture the response
vif.psel <= 1'b0; // back to IDLE
vif.penable <= 1'b0;
endtask
endclassThe run_phase is the canonical driver loop: get_next_item blocks until the sequencer hands over a transaction, drive_transfer performs it on the pins, and item_done releases the sequence to produce the next — the blocking handshake that paces stimulus. drive_transfer is the APB protocol verbatim: one cycle of SETUP (PSEL=1, PENABLE=0, address and control driven), then ACCESS (PENABLE=1) entered and held by the do … while (!vif.pready) loop, which is the single most important line — it makes the driver wait out any number of wait states. Only after PREADY does it capture PRDATA and PSLVERR. Phase placement: the vif is fetched in build_phase (guarded with `uvm_fatal), and all pin activity is in run_phase, because driving consumes time and build_phase is zero-time.
The Monitor — reconstructing transfers independently
The monitor never drives. It watches the pins, detects each completed transfer, rebuilds the transaction, and broadcasts it on an analysis port for the scoreboard and coverage.
class apb_monitor extends uvm_monitor;
`uvm_component_utils(apb_monitor)
virtual apb_if vif;
uvm_analysis_port #(apb_seq_item) ap; // one-to-many broadcast
function new(string name, uvm_component parent);
super.new(name, parent);
ap = new("ap", this);
endfunction
function void build_phase(uvm_phase phase);
if (!uvm_config_db#(virtual apb_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "virtual apb_if not set for apb_monitor")
endfunction
task run_phase(uvm_phase phase);
forever begin
apb_seq_item tr = apb_seq_item::type_id::create("tr");
// a transfer COMPLETES on the cycle where PSEL & PENABLE & PREADY all hold
do @(posedge vif.clk); while (!(vif.psel && vif.penable && vif.pready));
tr.addr = vif.paddr;
tr.write = vif.pwrite;
tr.data = vif.pwrite ? vif.pwdata : vif.prdata;
tr.slverr = vif.pslverr;
ap.write(tr); // broadcast to subscribers
end
endtask
endclassThe monitor's completion condition is the key: it samples on the cycle where PSEL, PENABLE, and PREADY are all high — the exact moment the access phase completes — because sampling earlier would capture data before the slave is ready (the same wait-state truth the driver respects). It rebuilds the transaction from the pins (PWDATA for writes, PRDATA for reads) and calls ap.write(tr) to broadcast on its uvm_analysis_port, which is non-blocking and one-to-many: zero or more subscribers (a scoreboard, a coverage collector) connect to it without the monitor knowing or caring. The monitor is built unconditionally, so a passive agent still observes and feeds checking.
The Agent — active/passive assembly
The agent assembles the three components and is where active/passive reuse lives: build the monitor always, the sequencer and driver only when active, and connect the driver to the sequencer in connect_phase.
typedef uvm_sequencer #(apb_seq_item) apb_sequencer; // a plain sequencer suffices
class apb_agent extends uvm_agent;
`uvm_component_utils(apb_agent)
apb_driver drv;
apb_monitor mon;
apb_sequencer seqr;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
mon = apb_monitor::type_id::create("mon", this); // ALWAYS built
if (get_is_active() == UVM_ACTIVE) begin // driving side only
seqr = apb_sequencer::type_id::create("seqr", this);
drv = apb_driver::type_id::create("drv", this);
end
endfunction
function void connect_phase(uvm_phase phase);
if (get_is_active() == UVM_ACTIVE)
drv.seq_item_port.connect(seqr.seq_item_export); // the handshake wire
endfunction
endclassbuild_phase creates the monitor unconditionally and the sequencer and driver only when get_is_active() returns UVM_ACTIVE — the one decision that makes the agent reusable across integration levels. connect_phase wires the driver's seq_item_port to the sequencer's seq_item_export, the connection that carries the handshake — and it is guarded by the same is_active check, because in a passive agent the driver and sequencer do not exist and connecting them would be a null-handle crash. (get_is_active() returns the value the base uvm_agent read from is_active in the config; passing the config object delivers it.) Phase placement is exact: construction in build_phase, connection in connect_phase, nothing time-consuming in either.
Sequences and the Test
A sequence produces transactions; the test raises an objection, starts the sequence on the agent's sequencer, and drops the objection. A compact env ties the agent and scoreboard together.
class apb_write_seq extends uvm_sequence #(apb_seq_item);
`uvm_object_utils(apb_write_seq)
rand bit [31:0] addr;
rand bit [31:0] data;
function new(string name = "apb_write_seq"); super.new(name); endfunction
task body();
apb_seq_item tr = apb_seq_item::type_id::create("tr");
start_item(tr); // request the sequencer
tr.write = 1'b1; tr.addr = addr; tr.data = data;
finish_item(tr); // blocks until driver item_done
endtask
endclass
class apb_env extends uvm_env;
`uvm_component_utils(apb_env)
apb_agent agent;
apb_scoreboard sb;
function new(string name, uvm_component parent); super.new(name, parent); endfunction
function void build_phase(uvm_phase phase);
agent = apb_agent::type_id::create("agent", this);
sb = apb_scoreboard::type_id::create("sb", this);
endfunction
function void connect_phase(uvm_phase phase);
agent.mon.ap.connect(sb.analysis_export); // monitor → scoreboard
endfunction
endclass
class apb_smoke_test extends uvm_test;
`uvm_component_utils(apb_smoke_test)
apb_env env;
function new(string name, uvm_component parent); super.new(name, parent); endfunction
function void build_phase(uvm_phase phase);
env = apb_env::type_id::create("env", this);
endfunction
task run_phase(uvm_phase phase);
apb_write_seq seq = apb_write_seq::type_id::create("seq");
phase.raise_objection(this); // keep run_phase alive
seq.addr = 32'h40; seq.data = 32'hABCD;
seq.start(env.agent.seqr); // run on the agent's sequencer
phase.drop_objection(this); // end of test
endtask
endclassThe apb_write_seq body is the producer side of the handshake: start_item requests the sequencer, the item is populated, and finish_item blocks until the driver's item_done — the mirror of the driver's loop. The apb_env builds the agent and scoreboard and, in connect_phase, wires the monitor's analysis port to the scoreboard's analysis export, completing the observation path. The apb_smoke_test shows the canonical objection bracket: raise the objection so run_phase stays alive, start the sequence on env.agent.seqr, and drop the objection when done — forget the raise and the test ends in zero time having driven nothing.
Waveform Perspective — an APB write with a wait state
Putting the driver against a slave that inserts one wait state shows why the PREADY loop matters: the access phase is held an extra cycle, and the transfer completes only when PREADY rises.
APB write to 0x40 = 0xABCD, with one slave wait state in the access phase
6 cyclesThe waveform is the protocol figure made concrete. PSEL rises in the SETUP cycle with PENABLE low and the address, direction, and write data driven; PENABLE rises for ACCESS; and the slave holds PREADY low for one cycle — the wait state — before asserting it. The transfer completes on the cycle where PENABLE and PREADY are both high, exactly the monitor's sampling condition and the driver's exit condition. A driver written as a fixed two-cycle sequence would have ended at cycle 2, sampled PRDATA while the slave was still busy, and produced a garbage read — which is precisely the DebugLab.
DebugLab — the driver that ignored PREADY
Reads return garbage against a real slave — the access phase didn't wait for PREADY
The agent passed every test against the simple model slave, then failed the moment it was connected to the real RTL register block: read transactions returned wrong data — sometimes the previous register's value, sometimes X — while writes appeared to work. The failures were intermittent and only happened against the real slave, never against the zero-wait model used in the unit test.
The driver's access phase ran a fixed two cycles and never sampled PREADY, so against a slave that inserts wait states it sampled PRDATA a cycle early — before the data was valid:
buggy: vif.penable <= 1; @(posedge clk); @(posedge clk); // fixed 2 cycles ✗
tr.data = vif.prdata; // sampled while slave still has PREADY=0 → garbage
real slave: holds PREADY low for 1+ wait-state cycles, drives PRDATA only when PREADY=1
result: read data sampled during the wait state → previous value or X
fix: vif.penable <= 1;
do @(posedge clk); while (!vif.pready); // hold ACCESS until PREADY ✓
tr.data = vif.prdata; // now sampled on the completion cycleThe model slave asserted PREADY immediately (zero wait states), so the fixed-latency driver happened to sample at the right cycle and the bug was invisible. The real slave inserted wait states, so the fixed driver sampled PRDATA during the wait — before the slave had driven valid read data — capturing stale data or X. Writes "worked" only because the slave latched PWDATA whenever it became ready, so an early-ending write still delivered data; reads, which must sample at the right instant, exposed the bug.
The tell is a transfer-timing bug that appears only against a wait-stated slave. Localize it at the access phase:
- Check that the access phase samples
PREADY. The driver (and monitor) must gate completion onPREADY, not on a fixed cycle count. A@(posedge clk)pair with noPREADYtest is the smoking gun. - Compare model-slave versus real-slave behavior. A bug that vanishes against a zero-wait model and appears against the real slave is almost always a missing wait-state handle — confirm by making the model insert a wait state and watching it fail too.
- Look at the completion cycle in the waveform. Confirm
PRDATAis sampled on the cycle wherePENABLE & PREADYare both high, not before. Sampling one cycle early is the signature.
Build wait-state handling in from the start, on both the driver and the monitor:
- Gate the access phase on
PREADY, always. Write the access phase asdo @(posedge clk); while (!vif.pready);so it holds for any number of wait states — never a fixed cycle count. - Sample data and response on the completion cycle only. Capture
PRDATAandPSLVERRafter thePREADYloop exits, the one cycle they are guaranteed valid. - Test against a wait-stated slave. Include a model slave that inserts random wait states in the unit test, so a fixed-latency driver fails immediately at block level instead of surviving to integration.
The one-sentence lesson: an APB access phase completes when the slave asserts PREADY, not after a fixed number of cycles — a driver or monitor that doesn't wait for PREADY reads data during the wait state and returns garbage against any real, wait-stated slave.
Common Mistakes
- Ending the access phase on a fixed cycle count instead of
PREADY. The slave can insert wait states; the access phase must loop onPREADY. A fixed-latency driver passes against a zero-wait model and fails against real RTL — the DebugLab bug. - Connecting the driver to the sequencer unconditionally. The
drv.seq_item_port.connect(seqr.seq_item_export)must be guarded by theis_activecheck; in a passive agent the driver and sequencer don't exist and the connect is a null-handle crash. - Building the sequencer and driver in a passive agent. Only the monitor is unconditional; the stimulus half is built only when
UVM_ACTIVE. Building them passive wastes resources and may drive a bus the real block owns. - Creating components with
newinstead oftype_id::create. Bypasses the factory, so a test can't override the item or any component. Every component and the item are created via the factory. - Sampling the monitor before the transfer completes. The monitor must sample on the
PSEL & PENABLE & PREADYcycle; sampling earlier captures data before the slave is ready, the same wait-state error in the observe path. - Forgetting the objection in the test. Without
raise_objection,run_phaseends in zero time before the sequence drives anything — a green pass that tested nothing.
Senior Design Review Notes
Interview Insights
The driver runs a forever loop on the sequencer-driver handshake, and for each item it produces the two APB phases, holding the access phase until PREADY. In run_phase it starts the bus in IDLE (PSEL=0, PENABLE=0), then loops: seq_item_port.get_next_item blocks until the sequencer hands over a transaction; the driver performs it on the pins; then seq_item_port.item_done releases the sequence. Performing it is the protocol: on a clock edge it enters SETUP — PSEL=1, PENABLE=0, and it drives PADDR, PWRITE, and for a write PWDATA. On the next edge it enters ACCESS — PENABLE=1 — and this is the phase it holds: it loops on the rising edge sampling PREADY, staying in the access phase for every cycle PREADY is low, which is how it absorbs slave wait states. Only when PREADY is high is the transfer complete, at which point it captures PRDATA for a read and PSLVERR for the response, then returns the bus to IDLE. The two details that matter are the handshake pacing (the driver only takes a new item when it has finished the current one, via get_next_item/item_done) and the PREADY wait (the access phase is held until the slave is ready, never a fixed number of cycles). Phase-wise, the virtual interface is fetched in build_phase and all the pin driving is in run_phase, because driving consumes time. The understanding to convey is the handshake loop wrapping a faithful setup-then-access protocol with a PREADY-gated completion, which is the whole job of an APB driver.
If the driver ends the access phase after a fixed number of cycles instead of waiting for PREADY, it works against a slave that never inserts wait states but returns garbage against any slave that does — and reads expose it. A real APB slave can hold PREADY low to insert wait states, meaning the transfer is not complete and, for a read, PRDATA is not yet valid. A driver that assumes a fixed two-cycle access samples PRDATA during the wait state, before the slave has driven the data, so the read captures stale data or X. The insidious part is that it passes in unit test: a simple model slave that asserts PREADY immediately gives zero wait states, so the fixed-latency driver happens to sample at the right cycle and looks correct. The bug only appears at integration against the real RTL register block that inserts wait states — exactly when it is most expensive to find. Writes often seem to work even with the bug, because the slave latches PWDATA whenever it becomes ready, so an early-ending write still delivers its data; reads break because they must sample at the precise completion cycle. The fix is to gate the access phase on PREADY with a do-while loop, and the prevention is to test against a model slave that inserts random wait states so the bug fails immediately at block level. The understanding to convey is that PREADY is the completion signal, ignoring it is a latent integration bug invisible against a zero-wait model, and reads are where it surfaces — which shows you have actually integrated an agent against real RTL.
The agent builds the monitor unconditionally and builds the sequencer and driver only when get_is_active returns UVM_ACTIVE, and it guards the driver-to-sequencer connection on the same check — so one class is a full driving agent at block level and a monitor-only observer at SoC level, selected by one configuration field. In build_phase, the monitor is always created because observation is always wanted; the sequencer and driver are created inside an if on get_is_active being UVM_ACTIVE, so a passive agent simply does not build them. In connect_phase, the connection drv.seq_item_port.connect(seqr.seq_item_export) is wrapped in the same is_active check, because in the passive case those components do not exist and connecting them would be a null-handle crash. The is_active value comes from the agent's configuration — typically the apb_config object the parent set — and the base uvm_agent exposes it through get_is_active. Why this matters: at block level the testbench drives the APB, so the agent is active and generates stimulus; at SoC level the real on-chip master drives that APB, so the agent is passive and only observes and feeds coverage and checking, without driving signals the RTL now owns. The same agent, reconfigured, serves both — which is the reuse that is the entire reason to build an agent rather than write directed bus tasks. The understanding to convey is the conditional build plus guarded connect on is_active, and that it serves reuse across integration levels, which is the defining property of a reusable agent.
The monitor observes the bus independently and rebuilds the transaction from what actually happened on the pins, rather than taking the driver's item, precisely so that the check compares intention against reality rather than reality against itself. If the scoreboard checked the driver's transaction against the DUT, and the monitor just forwarded the driver's item, a driver bug would be invisible — the testbench would be comparing the driver's intent to a result derived from that same intent. By having the monitor reconstruct independently from the pins — sampling PADDR, PWRITE, PWDATA or PRDATA, and PSLVERR on the completion cycle — the observed transaction reflects what the bus actually did, so the scoreboard comparing expected (predicted from stimulus) against observed (from the monitor) catches a driver that drove the wrong thing, a DUT that responded wrong, or a protocol error. This independence is why the monitor exists as a separate component rather than being folded into the driver, and it is also what lets the monitor work in a passive agent where there is no driver at all — it just watches. The monitor broadcasts the reconstructed item on a uvm_analysis_port, which is non-blocking and one-to-many, so the scoreboard and a coverage collector both subscribe without the monitor knowing. The completion-cycle sampling matters: it samples when PSEL, PENABLE, and PREADY are all high, the same wait-state-respecting instant the driver uses. The understanding to convey is that independent reconstruction is what makes checking meaningful — observe reality, do not echo intent — which is a core principle of why agents separate the monitor from the driver.
Construction and configuration-reading happen in build_phase, connections happen in connect_phase, and all the timed bus activity — driving and monitoring — happens in run_phase, which is the only phase that consumes simulation time. In build_phase: the agent creates the monitor always and the sequencer and driver when active; the driver and monitor each get their virtual interface from the config DB; everything is created through the factory. build_phase runs top-down and is zero-time, so it only constructs and fetches config — no waiting, no driving. In connect_phase, which runs bottom-up after the whole tree is built, the driver's seq_item_port is connected to the sequencer's seq_item_export (guarded on is_active), and at env level the monitor's analysis port is connected to the scoreboard — connections are made here because every component and its ports exist by now, after build completed. Then in run_phase, the time-consuming work runs: the driver loops getting items and driving the APB phases, the monitor loops sampling completed transfers and broadcasting them, and the test raises an objection, starts a sequence, and drops the objection. run_phase is where simulation time advances and the pins move. There are other phases — end_of_elaboration for checking the assembled topology, report for end-of-test reporting — but the agent's core lives in build (construct + get config), connect (wire), and run (drive + observe). The understanding to convey is the build-construct, connect-wire, run-drive mapping, with build and connect being zero-time setup and run being where all bus behavior happens, which shows you know not just what the components do but when each does it.
Exercises
- Add a read sequence. Write an
apb_read_seqmirroring the write sequence, and explain where in the driver the read data is captured and why it must be after thePREADYloop. - Make the slave insert wait states. Sketch a model slave that holds
PREADYlow for a random one-to-three cycles, and explain why the fixed-latency driver passes without it and fails with it. - Go passive. Describe exactly what changes in
build_phaseandconnect_phasewhen the agent is configuredUVM_PASSIVE, and what would crash if theconnect_phaseguard were missing. - Wire the scoreboard. Outline an
apb_scoreboardwith an analysis export that receives the monitor's transactions, and how it would check a read against an expected register model. - Override the item. Show how a test would register a factory override to replace
apb_seq_itemwith an error-injecting variant, and why the agent needs no change for it to take effect.
Summary
- A UVM APB agent is four pieces from the curriculum's primitives: the transaction (
apb_seq_item, abstract, no timing), the driver (handshake loop producing APB setup + access, holding access untilPREADY), the monitor (independent pin observation, analysis-port broadcast), and the agent (active/passive assembly). - The driver runs
get_next_item→ drive setup, then access held by ado … while (!PREADY)loop →item_done; thePREADYwait is the one detail that separates a correct agent from one that fails against real, wait-stated slaves. - The monitor samples on the
PSEL & PENABLE & PREADYcompletion cycle and reconstructs the transaction from the pins (not the driver's item), so the scoreboard checks intention against reality. - The agent builds the monitor always and the sequencer + driver only when
UVM_ACTIVE, guarding the driver-to-sequencer connect on the sameis_activecheck — so one class is active block-level VIP and passive SoC-level observer. - Phase placement: build constructs and fetches config (zero-time), connect wires ports, run does all the timed driving and observing; everything is created through the factory and configured by one
apb_configobject. - The durable rule of thumb: build the APB agent as transaction-driver-monitor-agent, drive the setup-then-access protocol with the access phase held until
PREADY, observe independently on the completion cycle, and make the sequencer and driver conditional on active — then the same agent drives at block level and observes at SoC level, and thePREADYdiscipline is what keeps it correct against real silicon, not just a zero-wait model.
Next — AXI Agent: the APB agent established the pattern — item, driver, monitor, active/passive assembly — on the simplest protocol. The next case study scales it to AXI, where independent read and write channels, outstanding transactions, and out-of-order responses turn the single-channel driver into multiple coordinated channel drivers and demand a far richer transaction and scoreboard.