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.
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
| Offset | Register | Fields | Access |
|---|---|---|---|
0x00 | CTRL | enable (bit 0), polarity (bit 1) | RW |
0x04 | PERIOD | period (bits 15:0) | RW |
0x08 | DUTY | duty (bits 15:0) | RW |
0x0C | STATUS | running (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.
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.