Advanced UVM

Chapter 1 of 6

Meet AXI4-Lite and axil_regfile

Start the Advanced UVM track with a new DUT: the AXI4-Lite protocol's five channels and VALID/READY handshake rules, a small register-mapped peripheral with an interrupt output, and a complete hand-driven testbench that proves it all works.

Every chapter of systemverilog-basics and uvm so far — 26 chapters in total — used the same mux2: a single combinational output, driven and checked through one interface. That simplicity was the point: it let those tracks teach syntax and then UVM mechanics without a complicated DUT getting in the way. But chapter 10 of uvm ended by naming exactly what mux2 can't support — coordinating more than one agent, and a real register map to build a register model against. This track needs a new DUT, on purpose.

AXI4-Lite in one page

AXI4-Lite is a real, widely-used protocol (part of ARM's AMBA AXI specification) for simple, register-style peripherals — small enough to actually learn in one page, unlike the full AXI4 protocol it's a subset of. Everything below is taken from the spec itself (AMBA AXI and ACE Protocol Specification, ARM IHI 0022D, chapter B1), not recalled from memory.

Five independent channels, each with its own VALID/READY handshake:

ChannelDirectionCarries
Write address (AW)master → slaveaddress + protection bits for a write
Write data (W)master → slavedata + byte strobes for a write
Write response (B)slave → masterdid the write succeed?
Read address (AR)master → slaveaddress + protection bits for a read
Read data (R)slave → masterthe read data + did it succeed?

The handshake rule, stated precisely by the spec: a transfer happens on the clock edge where both VALID and READY are high. The side asserting VALID must not wait for READY first — it has to be willing to hold its information stable and assert VALID on its own schedule. The side asserting READY is allowed to wait for VALID before asserting READY (or it can assert READY early, before VALID even shows up). This asymmetry is what makes the protocol deadlock-free: if both sides were allowed to wait for the other, neither would ever move first.

No bursts. Unlike full AXI4, every AXI4-Lite transaction is exactly one beat — there's no AWLEN/ARLEN (burst length), no AWBURST/ARBURST (burst type). This is most of why AXI4-Lite is "lite": no burst state machine to build or verify.

AW and W are independent. The spec's write dependency rules say a slave "can wait for AWVALID or WVALID, or both, before asserting AWREADY" and "can wait for AWVALID or WVALID, or both, before asserting WREADY" — meaning a master is free to send the address and the data in either order, or at the same time, and a slave must be ready to latch whichever arrives first while it waits for the other. The slave must wait for both AW and W to complete before asserting BVALID. This is a genuinely harder piece of handshake timing than anything mux2 ever required.

Responses: each transaction gets a 2-bit response code — OKAY (0), SLVERR (2, a real but failing access — e.g. writing a read-only register), or DECERR (3, no such address at all). AXI4-Lite drops the fourth AXI4 response, EXOKAY, since it doesn't support exclusive accesses.

Meet axil_regfile

A small AXI4-Lite peripheral, 32-bit data, word-addressed, with four mapped registers and one output that none of the AXI channels carry: irq.

AddrNameR/WBehavior
0x00CTRLRWbit0 ENABLE; bit1 IRQ_CLR (write 1 to clear COUNT and deassert irq — this bit is never stored, it's a one-shot pulse)
0x04STATUSRObit0 mirrors the current irq output
0x08DATARWwriting (while ENABLE=1) increments an internal counter; reading returns the last value written
0x0CCOUNTROthe internal counter; reaching a fixed threshold asserts irq

Any other address returns DECERR — a small, honest address decoder, not a placeholder. Writing to STATUS or COUNT (mapped, but read-only) returns SLVERR — the address exists, the access doesn't.

One detail worth stating explicitly since the DUT relies on it: CTRL writes are not read-modify-write. Whatever 32 bits arrive become the new value of both ENABLE and (transiently) IRQ_CLR — there's no hidden logic preserving ENABLE across a write that only means to pulse IRQ_CLR. To clear the interrupt and keep counting enabled, software has to write both bits at once (0x3, not 0x2). Real memory-mapped registers work exactly this way; it's mux2-vs-axil_regfile levels of "this now requires reading the docs," on purpose.

The counter's threshold is a SystemVerilog parameter, not a fifth register — a fixed value set at build time is realistic for plenty of real peripherals, and it keeps the register count small.

The DUT

The interface, DUT, and testbench below are meant to live in one file — `timescale at the very top, compiler-directive style (SV ch3), applies to every module compiled after it, so it only needs to be stated once even though three separate modules follow it:

`timescale 1ns/1ps
 
module axil_regfile #(
  parameter int IRQ_THRESHOLD = 4
) (
  input  logic        s_axi_aclk,
  input  logic        s_axi_aresetn,
 
  input  logic [7:0]  s_axi_awaddr,
  input  logic [2:0]  s_axi_awprot,
  input  logic        s_axi_awvalid,
  output logic        s_axi_awready,
 
  input  logic [31:0] s_axi_wdata,
  input  logic [3:0]  s_axi_wstrb,
  input  logic        s_axi_wvalid,
  output logic        s_axi_wready,
 
  output logic [1:0]  s_axi_bresp,
  output logic        s_axi_bvalid,
  input  logic        s_axi_bready,
 
  input  logic [7:0]  s_axi_araddr,
  input  logic [2:0]  s_axi_arprot,
  input  logic        s_axi_arvalid,
  output logic        s_axi_arready,
 
  output logic [31:0] s_axi_rdata,
  output logic [1:0]  s_axi_rresp,
  output logic        s_axi_rvalid,
  input  logic        s_axi_rready,
 
  output logic         irq
);
 
  localparam logic [7:0] ADDR_CTRL   = 8'h00;
  localparam logic [7:0] ADDR_STATUS = 8'h04;
  localparam logic [7:0] ADDR_DATA   = 8'h08;
  localparam logic [7:0] ADDR_COUNT  = 8'h0C;
 
  localparam logic [1:0] RESP_OKAY   = 2'b00;
  localparam logic [1:0] RESP_SLVERR = 2'b10;
  localparam logic [1:0] RESP_DECERR = 2'b11;
 
  logic        ctrl_enable;
  logic [31:0] data_reg;
  logic [31:0] count_reg;
 
  assign irq = (count_reg >= IRQ_THRESHOLD);
 
  // ---- Write channel: AW and W are latched independently, since the
  // spec permits either to arrive first, then combined into one register
  // write once both have arrived.
  logic        aw_have, w_have;
  logic [7:0]  aw_addr_q;
  logic [31:0] w_data_q;
 
  logic aw_fire, w_fire, write_fire;
  assign aw_fire = s_axi_awvalid && s_axi_awready;
  assign w_fire  = s_axi_wvalid  && s_axi_wready;
  assign write_fire = (aw_have || aw_fire) && (w_have || w_fire) && !s_axi_bvalid;
 
  // Single-outstanding-transaction slave: don't accept a new AW/W until
  // the current one's response has been consumed (s_axi_bvalid stays high
  // until BREADY). The spec explicitly permits this ("a slave can
  // restrict [outstanding transactions] by the appropriate use of the
  // handshake signals"). Deliberately depends only on registered state
  // (aw_have/w_have/bvalid), never on write_fire itself -- gating on
  // write_fire here would make awready depend combinationally on awvalid
  // through write_fire/aw_fire, exactly the kind of input-to-output
  // combinational path the spec (A3.2) says an AXI interface must not have.
  assign s_axi_awready = !aw_have && !s_axi_bvalid;
  assign s_axi_wready  = !w_have  && !s_axi_bvalid;
 
  logic [7:0]  aw_addr_eff;
  logic [31:0] w_data_eff;
  assign aw_addr_eff = aw_have ? aw_addr_q : s_axi_awaddr;
  assign w_data_eff  = w_have  ? w_data_q  : s_axi_wdata;
 
  always_ff @(posedge s_axi_aclk or negedge s_axi_aresetn) begin
    if (!s_axi_aresetn) begin
      aw_have      <= 1'b0;
      w_have       <= 1'b0;
      s_axi_bvalid <= 1'b0;
      s_axi_bresp  <= RESP_OKAY;
      ctrl_enable  <= 1'b0;
      data_reg     <= '0;
      count_reg    <= '0;
    end else begin
      if (aw_fire && !write_fire) begin
        aw_addr_q <= s_axi_awaddr;
        aw_have   <= 1'b1;
      end
      if (w_fire && !write_fire) begin
        w_data_q <= s_axi_wdata;
        w_have   <= 1'b1;
      end
 
      if (write_fire) begin
        aw_have      <= 1'b0;
        w_have       <= 1'b0;
        s_axi_bvalid <= 1'b1;
        unique case (aw_addr_eff)
          ADDR_CTRL: begin
            ctrl_enable <= w_data_eff[0];
            if (w_data_eff[1]) count_reg <= '0;
            s_axi_bresp <= RESP_OKAY;
          end
          ADDR_DATA: begin
            data_reg <= w_data_eff;
            if (ctrl_enable) count_reg <= count_reg + 1;
            s_axi_bresp <= RESP_OKAY;
          end
          ADDR_STATUS, ADDR_COUNT: s_axi_bresp <= RESP_SLVERR;
          default:                 s_axi_bresp <= RESP_DECERR;
        endcase
      end else if (s_axi_bvalid && s_axi_bready) begin
        s_axi_bvalid <= 1'b0;
      end
    end
  end
 
  // ---- Read channel: a single address channel, so no AW/W-style
  // independence problem -- one cycle of latency from AR to RVALID.
  assign s_axi_arready = !s_axi_rvalid;
 
  always_ff @(posedge s_axi_aclk or negedge s_axi_aresetn) begin
    if (!s_axi_aresetn) begin
      s_axi_rvalid <= 1'b0;
      s_axi_rresp  <= RESP_OKAY;
      s_axi_rdata  <= '0;
    end else begin
      if (s_axi_arvalid && s_axi_arready) begin
        s_axi_rvalid <= 1'b1;
        unique case (s_axi_araddr)
          ADDR_CTRL:   begin s_axi_rdata <= {31'b0, ctrl_enable}; s_axi_rresp <= RESP_OKAY; end
          ADDR_STATUS: begin s_axi_rdata <= {31'b0, irq};         s_axi_rresp <= RESP_OKAY; end
          ADDR_DATA:   begin s_axi_rdata <= data_reg;             s_axi_rresp <= RESP_OKAY; end
          ADDR_COUNT:  begin s_axi_rdata <= count_reg;            s_axi_rresp <= RESP_OKAY; end
          default:     begin s_axi_rdata <= '0;                   s_axi_rresp <= RESP_DECERR; end
        endcase
      end else if (s_axi_rvalid && s_axi_rready) begin
        s_axi_rvalid <= 1'b0;
      end
    end
  end
 
endmodule

Two things worth pointing at directly: write_fire only goes high once (aw_have || aw_fire) and (w_have || w_fire) are both true — that's the AW/W independence rule from the spec, implemented. And every response code (RESP_OKAY/RESP_SLVERR/RESP_DECERR) is decided by a real address comparison, not hardcoded — DECERR genuinely means "no register lives here."

A real bug this exact design hit, worth knowing about: an earlier version of s_axi_awready/s_axi_wready also gated on !write_fire, meaning "don't accept a new AW/W the instant the current one fires." That reads as reasonable — but write_fire depends on aw_fire, which depends on s_axi_awready itself. Simulating it (not just reading it) turned up the problem immediately: it's a genuine combinational loop, awready → write_fire → awready, with no stable solution — exactly the "no combinatorial paths between input and output signals" rule the spec states outright (A3.2). The fix above depends only on registered state (aw_have/w_have/s_axi_bvalid), which turns out to already be enough to prevent overlap. The lesson generalizes past this one signal: a handshake-ready signal that feels like it should react to "did a transfer just happen" is a natural place to accidentally close a combinational loop, because "did a transfer just happen" is usually computed from that same ready signal.

axi4lite_if: extending the clocking-block pattern

SV ch13 built a clocking block (simple_bus_if) to drive a sequential interface without race conditions — default input #1step output #1, driving through cb.<signal> <= ... and sampling cb.<signal>. AXI4-Lite just has more signals to put through the same pattern:

interface axi4lite_if (input logic aclk);
  logic        aresetn;
 
  logic [7:0]  awaddr;
  logic [2:0]  awprot;
  logic        awvalid;
  logic        awready;
 
  logic [31:0] wdata;
  logic [3:0]  wstrb;
  logic        wvalid;
  logic        wready;
 
  logic [1:0]  bresp;
  logic        bvalid;
  logic        bready;
 
  logic [7:0]  araddr;
  logic [2:0]  arprot;
  logic        arvalid;
  logic        arready;
 
  logic [31:0] rdata;
  logic [1:0]  rresp;
  logic        rvalid;
  logic        rready;
 
  clocking cb @(posedge aclk);
    default input #1step output #1;
    output awaddr, awprot, awvalid, wdata, wstrb, wvalid, bready,
           araddr, arprot, arvalid, rready;
    input  awready, wready, bresp, bvalid, arready, rdata, rresp, rvalid;
  endclocking
 
  modport dut_mp (
    input  aclk, aresetn, awaddr, awprot, awvalid, wdata, wstrb, wvalid,
           bready, araddr, arprot, arvalid, rready,
    output awready, wready, bresp, bvalid, arready, rdata, rresp, rvalid
  );
 
  modport tb_mp (clocking cb);
endinterface

aclk and aresetn stay outside the clocking block, same reason clk did in ch13: they're not signals a driver reads/writes through skewed sampling, they're generated directly by the testbench. dut_mp connects the DUT at instantiation, exactly like simple_bus_if's dut_mp did; tb_mp is the minimal view — just the clocking block — that a driver class will eventually get as a virtual axi4lite_if.tb_mp handle, the same handoff pattern uvm ch3/ch5 already taught for mux2_if.

A complete, runnable example

This chapter doesn't use the UVM class library at all yet — it's plain SystemVerilog, so it doesn't need a UVM-capable simulator. It does use axi4lite_if's clocking block, though, which rules out Icarus Verilog specifically (confirmed directly — Icarus doesn't implement clocking blocks at all, the same limitation SV ch13 already flags). On EDA Playground, pick a commercial-grade simulator instead — Aldec Riviera-PRO is free to use there with no license of your own required, and implements the full SystemVerilog feature set. Paste the interface and DUT above into the same file as this testbench and run it:

module tb;
  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;
    axi_if.awvalid = 1'b0;
    axi_if.wvalid  = 1'b0;
    axi_if.bready  = 1'b1;
    axi_if.arvalid = 1'b0;
    axi_if.rready  = 1'b1;
    repeat (3) @(posedge aclk);
    axi_if.aresetn = 1'b1;
  end
 
  task automatic axi_write(input logic [7:0] addr, input logic [31:0] data);
    axi_if.cb.awaddr  <= addr;
    axi_if.cb.awvalid <= 1'b1;
    axi_if.cb.wdata   <= data;
    axi_if.cb.wstrb   <= 4'hF;
    axi_if.cb.wvalid  <= 1'b1;
    @(axi_if.cb);
    while (!(axi_if.cb.awready && axi_if.cb.wready)) @(axi_if.cb);
    axi_if.cb.awvalid <= 1'b0;
    axi_if.cb.wvalid  <= 1'b0;
    while (!axi_if.cb.bvalid) @(axi_if.cb);
    $display("t=%0t WRITE addr=0x%0h data=0x%0h bresp=%0d", $time, addr, data, axi_if.cb.bresp);
  endtask
 
  task automatic axi_read(input logic [7:0] addr);
    axi_if.cb.araddr  <= addr;
    axi_if.cb.arvalid <= 1'b1;
    @(axi_if.cb);
    while (!axi_if.cb.arready) @(axi_if.cb);
    axi_if.cb.arvalid <= 1'b0;
    while (!axi_if.cb.rvalid) @(axi_if.cb);
    $display("t=%0t READ  addr=0x%0h data=0x%0h rresp=%0d", $time, addr, axi_if.cb.rdata, axi_if.cb.rresp);
  endtask
 
  initial begin
    $dumpfile("waves.vcd");
    $dumpvars(0, tb);
 
    wait (axi_if.aresetn === 1'b1);
    @(axi_if.cb);
 
    axi_write(8'h00, 32'h1);   // CTRL: ENABLE=1
    axi_write(8'h08, 32'hAA);  // DATA write #1 -> COUNT=1
    axi_write(8'h08, 32'hBB);  // DATA write #2 -> COUNT=2
    axi_write(8'h08, 32'hCC);  // DATA write #3 -> COUNT=3
    axi_write(8'h08, 32'hDD);  // DATA write #4 -> COUNT=4 == IRQ_THRESHOLD
    axi_read(8'h0C);           // COUNT
    axi_read(8'h04);           // STATUS -- expect bit0=1
    $display("t=%0t irq=%0b", $time, irq);
 
    axi_write(8'h00, 32'h3);   // CTRL: ENABLE=1, IRQ_CLR=1 (write both bits, not just IRQ_CLR)
    axi_read(8'h04);           // STATUS -- expect bit0=0 again
    $display("t=%0t irq=%0b", $time, irq);
 
    axi_read(8'h10);           // unmapped -> DECERR
    $finish;
  end
endmodule

Running this prints eight lines: four writes, a COUNT/STATUS read pair showing irq asserted after the fourth write, the clearing write, a second STATUS read showing irq deasserted, and a final DECERR on an address nothing is mapped to. The waveform shows exactly what the register map promised: irq stays low through the first three DATA writes, rises after the fourth, and falls the instant the IRQ_CLR write's BVALID is issued.

Summary

  • AXI4-Lite has five independent VALID/READY-handshaked channels, no bursts, and three response codes (OKAY/SLVERR/DECERR) — small enough to hold in your head, unlike full AXI4.
  • The handshake's core rule: VALID can't wait for READY, but READY can wait for VALID — this is what keeps the protocol deadlock-free.
  • AW and W are independent — a slave must be ready to latch either one first, and only respond once both have arrived.
  • axil_regfile is a 4-register peripheral (CTRL/STATUS/DATA/COUNT) with an irq output — a second interface mux2 never had, which is exactly what motivates the rest of this track.
  • Register writes are whole-word, not read-modify-write — clearing the interrupt while leaving counting enabled means writing both bits at once.
  • axi4lite_if extends SV ch13's clocking-block pattern to a wider interface; nothing about the pattern itself changed, just the signal count.

Which statement correctly describes the AXI4-Lite VALID/READY handshake rule?

A master writes to axil_regfile's STATUS register (a mapped, read-only address). What response should it get, and why?

What word offset address (hex, e.g. 0x08) is axil_regfile's DATA register mapped to?