Skip to main content

RAL Worked Example: A PWM Register Block

The previous three pages built this piece by piece — the register model, the adapter and predictor wiring, and backdoor HDL paths. This page shows all of it together, for a small PWM (pulse-width modulator) peripheral, in the order it'd actually be designed.

Illustrative, not a compiled deliverable

Like the FIFO example in Example Walkthrough, this code is written to be syntactically correct and conceptually complete, but it hasn't been run through a simulator.

The register map​

OffsetRegisterFieldsAccess
0x00CTRLenable (bit 0), polarity (bit 1)RW
0x04PERIODperiod (bits 15:0)RW
0x08DUTYduty (bits 15:0)RW
0x0CSTATUSrunning (bit 0)RO

The register model, with backdoor paths included​

class pwm_ctrl_reg extends uvm_reg;
rand uvm_reg_field enable;
rand uvm_reg_field polarity;
`uvm_object_utils(pwm_ctrl_reg)

function new(string name = "pwm_ctrl_reg");
super.new(name, 32, UVM_NO_COVERAGE);
endfunction

virtual function void build();
enable = uvm_reg_field::type_id::create("enable");
enable.configure(this, 1, 0, "RW", 0, 1'b0, 1, 1, 0);
polarity = uvm_reg_field::type_id::create("polarity");
polarity.configure(this, 1, 1, "RW", 0, 1'b0, 1, 1, 0);
add_hdl_path_slice("ctrl_q", 0, 32);
endfunction
endclass

class pwm_period_reg extends uvm_reg;
rand uvm_reg_field period;
`uvm_object_utils(pwm_period_reg)

function new(string name = "pwm_period_reg");
super.new(name, 32, UVM_NO_COVERAGE);
endfunction

virtual function void build();
period = uvm_reg_field::type_id::create("period");
period.configure(this, 16, 0, "RW", 0, 16'd0, 1, 1, 0);
add_hdl_path_slice("period_q", 0, 32);
endfunction
endclass

class pwm_duty_reg extends uvm_reg;
rand uvm_reg_field duty;
`uvm_object_utils(pwm_duty_reg)

function new(string name = "pwm_duty_reg");
super.new(name, 32, UVM_NO_COVERAGE);
endfunction

virtual function void build();
duty = uvm_reg_field::type_id::create("duty");
duty.configure(this, 16, 0, "RW", 0, 16'd0, 1, 1, 0);
add_hdl_path_slice("duty_q", 0, 32);
endfunction
endclass

class pwm_status_reg extends uvm_reg;
uvm_reg_field running;
`uvm_object_utils(pwm_status_reg)

function new(string name = "pwm_status_reg");
super.new(name, 32, UVM_NO_COVERAGE);
endfunction

virtual function void build();
running = uvm_reg_field::type_id::create("running");
running.configure(this, 1, 0, "RO", 1, 1'b0, 1, 0, 0);
add_hdl_path_slice("status_q", 0, 32);
endfunction
endclass

class pwm_reg_block extends uvm_reg_block;
rand pwm_ctrl_reg ctrl;
rand pwm_period_reg period;
rand pwm_duty_reg duty;
pwm_status_reg status;
`uvm_object_utils(pwm_reg_block)

function new(string name = "pwm_reg_block");
super.new(name, UVM_NO_COVERAGE);
endfunction

virtual function void build();
ctrl = pwm_ctrl_reg::type_id::create("ctrl");
period = pwm_period_reg::type_id::create("period");
duty = pwm_duty_reg::type_id::create("duty");
status = pwm_status_reg::type_id::create("status");

ctrl.configure(this); ctrl.build();
period.configure(this); period.build();
duty.configure(this); duty.build();
status.configure(this); status.build();

default_map = create_map("default_map", 0, 4, UVM_LITTLE_ENDIAN);
default_map.add_reg(ctrl, 'h00, "RW");
default_map.add_reg(period, 'h04, "RW");
default_map.add_reg(duty, 'h08, "RW");
default_map.add_reg(status, 'h0C, "RO");

set_hdl_path_root("tb_top.dut");
endfunction
endclass

create_map()'s fourth argument, UVM_LITTLE_ENDIAN, is a piece of this register model no earlier page named: it tells RAL how to pack a register's bytes onto the bus when a field's width doesn't cleanly match a single bus access — the same little-endian-vs-big-endian byte ordering that shows up anywhere data crosses a bus boundary. For this block every register is exactly one 32-bit bus access wide, so the choice is invisible in practice; it matters the moment a register model is reused against a design or bus protocol that expects the opposite ordering, which is exactly why RAL exposes it as an explicit argument rather than hard-coding an assumption. UVM_BIG_ENDIAN is the alternative value for the same argument, chosen at create_map() time based on the real bus protocol's convention, not the field's own layout.

STATUS.running's configure() call differs from the other three fields in two ways worth noticing: volatile is 1 (its value can change on its own, driven by DUT logic, not just by a bus write) and is_rand is 0 — consistent with The Register Abstraction Layer's point that a read-only field has nothing meaningful to randomize for a write.

The bus and the adapter​

class reg_bus_txn extends uvm_sequence_item;
rand bit write;
rand bit [7:0] addr;
rand bit [31:0] data;
`uvm_object_utils(reg_bus_txn)

function new(string name = "reg_bus_txn");
super.new(name);
endfunction
endclass

class pwm_reg_adapter extends uvm_reg_adapter;
`uvm_object_utils(pwm_reg_adapter)

function new(string name = "pwm_reg_adapter");
super.new(name);
endfunction

virtual function uvm_sequence_item reg2bus(const ref uvm_reg_bus_op rw);
reg_bus_txn txn = reg_bus_txn::type_id::create("txn");
txn.write = (rw.kind == UVM_WRITE);
txn.addr = rw.addr;
txn.data = rw.data;
return txn;
endfunction

virtual function void bus2reg(uvm_sequence_item bus_item, ref uvm_reg_bus_op rw);
reg_bus_txn txn;
if (!$cast(txn, bus_item))
`uvm_fatal("ADAPTER", "Bus item is not a reg_bus_txn")
rw.kind = txn.write ? UVM_WRITE : UVM_READ;
rw.addr = txn.addr;
rw.data = txn.data;
rw.status = UVM_IS_OK;
endfunction
endclass

The reg_bus_agent that drives and monitors reg_bus_txn on real pins isn't repeated here — it's built exactly as The Driver and The Monitor & Subscriber taught, just for this bus instead of the FIFO's.

The environment: register model wired to the bus​

class pwm_env extends uvm_env;
`uvm_component_utils(pwm_env)

pwm_reg_block regmodel;
reg_bus_agent bus_agent;
pwm_reg_adapter adapter;
uvm_reg_predictor #(reg_bus_txn) predictor;

function new(string name, uvm_component parent);
super.new(name, parent);
endfunction

function void build_phase(uvm_phase phase);
super.build_phase(phase);
bus_agent = reg_bus_agent::type_id::create("bus_agent", this);

regmodel = pwm_reg_block::type_id::create("regmodel");
regmodel.build();
regmodel.lock_model();

adapter = pwm_reg_adapter::type_id::create("adapter");
predictor = uvm_reg_predictor#(reg_bus_txn)::type_id::create("predictor", this);
endfunction

function void connect_phase(uvm_phase phase);
super.connect_phase(phase);
regmodel.default_map.set_sequencer(bus_agent.sequencer, adapter);
predictor.map = regmodel.default_map;
predictor.adapter = adapter;
bus_agent.monitor.ap.connect(predictor.bus_in);
endfunction
endclass

The test and sequence: frontdoor writes, then a backdoor-checked read​

class pwm_smoke_seq extends uvm_sequence;
`uvm_object_utils(pwm_smoke_seq)
pwm_reg_block regmodel;

function new(string name = "pwm_smoke_seq");
super.new(name);
endfunction

task body();
uvm_status_e status;
uvm_reg_data_t rdata;

// Frontdoor: configure the peripheral over the real bus
regmodel.period.write(status, 32'd1000, .parent(this));
regmodel.duty.write(status, 32'd250, .parent(this));
regmodel.ctrl.enable.write(status, 1'b1, .parent(this));

// Frontdoor read of a read-only status field
regmodel.status.read(status, rdata, .parent(this));
if (rdata[0] !== 1'b1)
`uvm_error("SEQ", "PWM did not report running after enable")

// Backdoor: cross-check the same field directly against DUT storage
regmodel.status.peek(status, rdata);
if (rdata[0] !== 1'b1)
`uvm_error("SEQ", "Backdoor peek disagrees with frontdoor read on STATUS.running")
endtask
endclass

class pwm_smoke_test extends uvm_test;
`uvm_component_utils(pwm_smoke_test)
pwm_env env;

function new(string name, uvm_component parent);
super.new(name, parent);
endfunction

function void build_phase(uvm_phase phase);
super.build_phase(phase);
env = pwm_env::type_id::create("env", this);
endfunction

task run_phase(uvm_phase phase);
pwm_smoke_seq seq = pwm_smoke_seq::type_id::create("seq");
seq.regmodel = env.regmodel;
phase.raise_objection(this);
seq.start(env.bus_agent.sequencer);
phase.drop_objection(this);
endtask
endclass

How it all connects​

pwm_smoke_test.run_phase starts pwm_smoke_seq on the bus agent's sequencer, with regmodel handed in directly from the environment. Each frontdoor call inside the sequence — regmodel.period.write(...), regmodel.ctrl.enable.write(...) — asks the register map for its sequencer and adapter (both set in pwm_env::connect_phase), which turns the call into a reg_bus_txn through reg2bus(), sends it down exactly the driver/sequencer path The Sequencer & Sequence described, and — because the predictor is watching the same bus through the monitor's analysis port — the mirror updates the moment the transaction completes, independent of whichever path issued it. The final peek() call skips all of that entirely, reaching tb_top.dut.status_q directly through the HDL path each register configured in its build(), and cross-checks it against what the frontdoor read() just reported two lines earlier — the same value, reached two structurally different ways.

Beyond this section

RAL — model, frontdoor, and backdoor — is now covered end to end for a register-driven DUT, the same depth Sections B–G reached for the FIFO's pin-driven one. What's left, each substantial enough to be its own section: the raw HDL backdoor access routines RAL's peek()/poke() sit on top of, and the singleton pattern, as advanced/specialized topics; and a set of guidelines for writing genuinely reusable verification components — the discipline that turns a working testbench like either of this curriculum's two examples into one other engineers can safely build on top of.