Learn UVM by Building an AMBA APB VIP: Testbench Architecture, Agents, Sequences and Tests
The fastest way to understand a UVM testbench is to read a small, complete one. AMBA APB is the right protocol for that: it has a dozen signals, a two-phase transfer, and no pipelining, so the verification IP stays small enough to hold in your head while still using every UVM building block you will meet on a real project. This post walks through the APB UVM VIP on GitHub, file by file, and explains why each piece exists. It was first published in 2016 as a short overview; this rewrite adds the protocol summary, the component walkthrough with code, and an honest list of what the code gets wrong ten years later.
APB in one page
APB is the low-bandwidth peripheral bus of the AMBA family. A requester (the old term is master) talks to one or more completers (slaves) through a simple address and data interface with no bursts, no split transactions and no arbitration.
| Signal | Driven by | Purpose |
|---|---|---|
PCLK, PRESETn | System | Clock and active-low reset |
PADDR | Requester | Address |
PSEL | Requester | Selects one completer per transfer |
PENABLE | Requester | Low in the SETUP cycle, high in the ACCESS cycle |
PWRITE | Requester | 1 for write, 0 for read |
PWDATA, PSTRB | Requester | Write data and byte strobes (APB4) |
PPROT | Requester | Protection type: privileged, secure, instruction (APB4) |
PRDATA | Completer | Read data |
PREADY | Completer | Low to insert wait states, high to complete the transfer |
PSLVERR | Completer | Error response, valid in the last ACCESS cycle |
Every transfer takes at least two cycles. In the SETUP cycle the requester drives PSEL, PADDR, PWRITE and PWDATA with PENABLE low. In the ACCESS cycle it raises PENABLE and holds everything else stable until the completer answers with PREADY high. A completer that needs more time holds PREADY low and the ACCESS cycle stretches.
{"signal": [
{"name": "PCLK", "wave": "p......"},
{"name": "PSEL", "wave": "0.1..0."},
{"name": "PENABLE", "wave": "0..1.0."},
{"name": "PWRITE", "wave": "0.1..0."},
{"name": "PADDR", "wave": "x.3..x.", "data": ["ADDR"]},
{"name": "PWDATA", "wave": "x.4..x.", "data": ["DATA"]},
{"name": "PREADY", "wave": "0...10."},
{"name": "phase", "wave": "x.56.x.", "data": ["SETUP", "ACCESS + 1 wait"]}
], "config": {"hscale": 1.5}}
The protocol has grown in small steps. Knowing which version a VIP targets tells you which signals to expect on the interface.
| Version | Spec | Adds |
|---|---|---|
| APB2 | AMBA 2 (IHI 0011) | The basic two-cycle transfer, no PREADY, no error |
| APB3 | AMBA 3 (IHI 0024B) | PREADY wait states and PSLVERR |
| APB4 | AMBA 4 (IHI 0024C) | PSTRB byte strobes and PPROT protection |
| APB5 | AMBA 5 (IHI 0024E) | PWAKEUP, user signals, parity and check signals |
The interface in this VIP carries PSTRB and PPROT, so despite the "APB v2.0" label in the repository it is really an APB4 signal set. It also omits PSEL, which is one of the simplifications discussed at the end.
Testbench architecture
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#dbeafe', 'primaryTextColor': '#1e293b', 'primaryBorderColor': '#3b82f6', 'lineColor': '#64748b', 'secondaryColor': '#f1f5f9', 'tertiaryColor': '#ffffff'}}}%%
flowchart TB
subgraph TEST["apb_test"]
subgraph ENV["apb_env"]
subgraph REQ["apb_requester (agent)"]
MS([seqr]) --> MD[driver]
MM[monitor]
end
subgraph CMP["apb_completer (agent)"]
SS([seqr]) --> SD[driver]
SM[monitor]
end
subgraph RST["reset_agent"]
RS([seqr]) --> RD[driver]
end
COV[apb_subscriber coverage]
end
end
MM & SM --> COV
MD & SD <==> IF{{apb_if}}
RD -.->|PRESETn| IF
classDef agent fill:#dbeafe,stroke:#3b82f6
classDef interface fill:#d1fae5,stroke:#10b981
classDef seqr fill:#fef3c7,stroke:#f59e0b
class REQ,CMP,RST agent
class IF interface
class MS,SS,RS seqr
Three agents share one interface. The requester agent generates and drives transfers, the completer agent responds to them, and a tiny reset agent owns PRESETn so that reset can be sequenced like any other stimulus. A subscriber collects functional coverage from both monitors. There is no DUT in the classic sense: the VIP verifies itself, requester against completer, which is how you bring up a VIP before pointing it at real RTL.
| Component | Role in this VIP |
|---|---|
| Test | Builds the environment, sets configuration, starts sequences |
| Environment | Instantiates the three agents and the coverage subscriber |
| Agent | One per protocol role: driver, monitor and sequencer under a configuration object |
| Driver | Turns an apb_xtn into pin wiggles through a clocking block |
| Monitor | Watches the pins and publishes reconstructed apb_xtn objects |
| Sequencer | Arbitrates between sequences and hands items to the driver |
| Subscriber | Samples coverage on every monitored transaction |
apb2_uvm_tb/
├── top/ apb_if.sv, top.sv
├── apb_env/ apb_xtn.svh, apb_env.svh, apb_env_config.svh, apb_seqr.svh,
│ apb_subscriber.svh, apb_base_seq.svh, apb_env_pkg.sv
├── apb_requester/ agent, config, driver, monitor, sequence library
├── apb_completer/ agent, config, driver, monitor
├── reset_agent/ agent, driver, sequencer, sequence, transaction
├── apb_test/ apb_base_test, apb_init_test, apb_reset_test, apb_test_pkg.sv
└── sim/ apb.f, apb_inc.f, run_apb.py, apb_wave.do
The interface: four clocking blocks
The interface declares the APB signals once and then declares four clocking blocks: mdrv_cb and mmon_cb for the requester's driver and monitor, sdrv_cb and smon_cb for the completer's. Each clocking block lists the same signals with different directions, and a modport bundles each clocking block with PRESETn.
interface apb_if(input PCLK);
logic PRESETn;
logic [31:0] PADDR, PWDATA, PRDATA;
logic [2:0] PPROT;
logic [3:0] PSTRB;
logic PWRITE, PENABLE, PREADY, PSLVERR;
clocking mdrv_cb @(posedge PCLK);
default input #1 output #0;
output PADDR, PWDATA, PSTRB, PPROT, PWRITE;
inout PENABLE;
input PREADY, PRDATA, PSLVERR;
endclocking
clocking mmon_cb @(posedge PCLK);
default input #1 output #0;
input PADDR, PWDATA, PSTRB, PPROT, PWRITE, PENABLE, PREADY, PRDATA, PSLVERR;
endclocking
// sdrv_cb and smon_cb mirror these for the completer side
modport MDRV_MP(clocking mdrv_cb, input PRESETn);
modport MMON_MP(clocking mmon_cb, input PRESETn);
modport SDRV_MP(clocking sdrv_cb, input PRESETn);
modport SMON_MP(clocking smon_cb, input PRESETn);
endinterface
Why four? Because a clocking block is a sampling and driving contract, not just a list of pins. A driver's block says which signals it may drive and when they are sampled; a monitor's block is all inputs. Giving each component its own block, with default input #1 output #0, means every component samples one time unit after the edge and drives on the edge, so there are no races between the driver writing PADDR and the monitor reading it. Modports then restrict each component to its own block, so the compiler stops a monitor from accidentally driving a signal.
The transaction
apb_xtn is the unit of currency in the VIP. Everything the requester can choose is rand; everything the completer reports is plain.
class apb_xtn extends uvm_sequence_item;
rand bit [31:0] apb_address;
rand bit [31:0] apb_wr_data;
rand bit [31:0] apb_rd_data;
bit [3:0] apb_strobe;
bit apb_enable;
int unsigned apb_en_delay;
bit apb_ready;
bit apb_completer_err;
typedef enum {APB_READ, APB_WRITE} apb_rd_wr_e;
rand apb_rd_wr_e apb_rd_wr;
rand bit [2:0] apb_prot;
`uvm_object_utils_begin(apb_xtn)
`uvm_field_int(apb_address, UVM_DEFAULT)
`uvm_field_int(apb_wr_data, UVM_DEFAULT)
`uvm_field_int(apb_rd_data, UVM_DEFAULT)
`uvm_field_int(apb_ready, UVM_DEFAULT)
`uvm_field_int(apb_completer_err, UVM_DEFAULT)
`uvm_field_enum(apb_rd_wr_e, apb_rd_wr, UVM_DEFAULT)
`uvm_object_utils_end
function new(string name = "apb_xtn");
super.new(name);
endfunction
endclass
The uvm_field_* macros give you print, copy, compare and pack for free, which is why the driver can call req.print() and get a formatted table. On large projects many teams replace the macros with hand-written do_print and do_compare for speed and control; for a learning VIP the macros are the right call.
The requester driver
The driver does two jobs at once, and the fork makes that explicit. One branch watches PRESETn and, when reset asserts, kills the in-flight transfer with disable driver and parks the bus. The other branch is the classic sequencer handshake: get_next_item, drive, item_done.
task run_phase(uvm_phase phase);
fork
forever begin
wait(!apb_intf.PRESETn);
`uvm_info("APB/DRV", "RESET assertion detected", UVM_MEDIUM)
disable driver;
apb_intf.PADDR <= 0; apb_intf.PENABLE <= 0;
apb_intf.PWDATA <= 0; apb_intf.PSTRB <= 0; apb_intf.PPROT <= 0;
wait(apb_intf.PRESETn);
end
forever begin
seq_item_port.get_next_item(req);
begin: driver
wait(apb_intf.PRESETn);
drive();
end
seq_item_port.item_done(req);
end
join
endtask
task apb_requester_driver::drive();
if (req.apb_rd_wr == apb_xtn::APB_READ)
apb_intf.mdrv_cb.PWRITE <= 0;
else begin
apb_intf.mdrv_cb.PWRITE <= 1;
apb_intf.mdrv_cb.PWDATA <= req.apb_wr_data;
apb_intf.mdrv_cb.PSTRB <= req.apb_strobe;
end
apb_intf.mdrv_cb.PADDR <= req.apb_address;
apb_intf.mdrv_cb.PPROT <= req.apb_prot;
apb_intf.mdrv_cb.PENABLE <= 1;
wait(apb_intf.mdrv_cb.PENABLE && apb_intf.mdrv_cb.PREADY);
apb_intf.mdrv_cb.PENABLE <= 0;
endtask
Two details are worth copying into your own drivers. First, the driver gets its virtual interface from a configuration object (apb_requester_config) pulled out of uvm_config_db in build_phase, not from a global. That is what makes the agent reusable: instantiate it twice with two configs and it drives two buses. Second, the reset branch is inside the driver, so a test can assert reset mid-transfer through the reset agent and the driver recovers on its own. Drivers that ignore reset are the most common source of "the testbench hangs after the second reset".
Sequences and tests
The sequence library is deliberately thin. A base sequence exists so every APB sequence shares a type, and one concrete sequence randomizes a single transfer:
class apb_rd_wr_seq extends apb_base_seq;
`uvm_object_utils(apb_rd_wr_seq)
task body();
req = apb_xtn::type_id::create("req");
start_item(req);
assert(req.randomize());
finish_item(req);
endtask
endclass
Tests layer on top. apb_base_test builds the environment and its configuration objects; apb_init_test runs the read/write sequence after reset; apb_reset_test asserts reset through the reset agent while traffic is flowing, which exercises the driver's reset branch above. Selecting one is a plusarg, never a recompile:
cd sim
python run_apb.py -t apb_init_test # batch
python run_apb.py -t apb_reset_test --gui # waveform
The script does vlib, vlog -f apb_inc.f and vsim +UVM_TESTNAME=. The random seed is printed in the log, and re-running with -sv_seed reproduces a failure exactly. If you are new to this flow, the UVM compile and run quick start covers the simulator switches in detail.
What I would change today
Reading your own code from a decade ago is instructive. These are the things a reviewer would flag now, in the order I would fix them:
PSELis missing from the interface. With one completer it is implied, but every real APB fabric decodesPSEL, and a VIP that never drives it cannot be used against real RTL without edits. AddPSEL, drive it in SETUP, and make the completer monitor ignore cycles where it is low.- The driver used
$randomfor data. The originaldrive()wrotePWDATA <= $randomandPSTRB <= $random, ignoring the randomized fields in the transaction. That silently breaks any scoreboard, because the monitor sees different data than the sequence generated. The listing above shows the corrected version usingreq.apb_wr_dataandreq.apb_strobe. +define+UVM_NO_DPIin the compile line. In 2016 this avoided a DPI link problem on one machine. It also disables regular-expression factory overrides and command-line+uvm_set_*plusargs. Drop it and use the simulator's bundled UVM, as the quick start post explains.- No scoreboard. The completer is a responder, so the natural check is "what the requester monitor saw equals what the completer monitor saw". A
uvm_scoreboardwith two analysis exports and an in-order compare would make the VIP self-checking. - The two-cycle SETUP/ACCESS split is not explicit. The driver raises
PENABLEin the same clocking-block cycle as the address, which relies on the completer tolerating that. A protocol-exact driver drives address withPENABLElow for one cycle, then raisesPENABLE. Add an assertion in the interface to catch either side cheating:assert property (@(posedge PCLK) $rose(PENABLE) |-> $stable(PADDR));
Key takeaways
- APB's two-cycle SETUP/ACCESS transfer with
PREADYwait states is the whole protocol. Know which version's signals your interface carries. - One interface, several clocking blocks: each driver and monitor gets its own sampling and driving contract, and modports enforce it.
- Agents get their virtual interface from a configuration object in
uvm_config_db, which is what makes them instantiable more than once. - Handle reset inside the driver with a parallel branch that disables the in-flight transfer.
- Tests are selected by
+UVM_TESTNAME; sequences and configuration do the rest without recompiling. - A VIP that drives
$randominstead of the transaction's fields cannot be scoreboarded. Check this first when a scoreboard "never matches".
Source: apb2_uvm_tb on GitHub. Protocol references: AMBA APB Protocol Specification (Arm IHI 0024).
Comments (0)
Leave a Comment