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.

SignalDriven byPurpose
PCLK, PRESETnSystemClock and active-low reset
PADDRRequesterAddress
PSELRequesterSelects one completer per transfer
PENABLERequesterLow in the SETUP cycle, high in the ACCESS cycle
PWRITERequester1 for write, 0 for read
PWDATA, PSTRBRequesterWrite data and byte strobes (APB4)
PPROTRequesterProtection type: privileged, secure, instruction (APB4)
PRDATACompleterRead data
PREADYCompleterLow to insert wait states, high to complete the transfer
PSLVERRCompleterError 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.

VersionSpecAdds
APB2AMBA 2 (IHI 0011)The basic two-cycle transfer, no PREADY, no error
APB3AMBA 3 (IHI 0024B)PREADY wait states and PSLVERR
APB4AMBA 4 (IHI 0024C)PSTRB byte strobes and PPROT protection
APB5AMBA 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.

ComponentRole in this VIP
TestBuilds the environment, sets configuration, starts sequences
EnvironmentInstantiates the three agents and the coverage subscriber
AgentOne per protocol role: driver, monitor and sequencer under a configuration object
DriverTurns an apb_xtn into pin wiggles through a clocking block
MonitorWatches the pins and publishes reconstructed apb_xtn objects
SequencerArbitrates between sequences and hands items to the driver
SubscriberSamples 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= -sv_seed random. 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:

  • PSEL is missing from the interface. With one completer it is implied, but every real APB fabric decodes PSEL, and a VIP that never drives it cannot be used against real RTL without edits. Add PSEL, drive it in SETUP, and make the completer monitor ignore cycles where it is low.
  • The driver used $random for data. The original drive() wrote PWDATA <= $random and PSTRB <= $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 using req.apb_wr_data and req.apb_strobe.
  • +define+UVM_NO_DPI in 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_scoreboard with 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 PENABLE in the same clocking-block cycle as the address, which relies on the completer tolerating that. A protocol-exact driver drives address with PENABLE low for one cycle, then raises PENABLE. 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 PREADY wait 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 $random instead 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).

Author
Mayur Kubavat
DV engineer working on SoC verification. Writes here about UVM, PCIe, SystemVerilog, and the everyday craft of getting designs to tape-out.

Comments (0)

Leave a Comment