Appendix A showed the actor model at the block level on the UBUS protocol. This appendix extends the same approach to the SoC integration level, where a multi-master, multi-protocol architecture emerges in a single testbench.
UVM-based SoC verification accumulates particular complexities at integration scale: interrupt-driven sequencing tends to require locked uvm_sequencers, bridging dissimilar protocols requires layered virtual sequencers, and a complete RAL has substantial memory and runtime overhead.
This appendix builds a complete SystemVerilog Mini-SoC testbench from scratch as actors. Every listing below is taken from the runnable example at appB_mini_soc/, which compiles and runs under Verilator (make); the trace at the end of the appendix is its actual output.
Our Mini-SoC consists of two Masters (a CPU and a DMA) and three Slaves/Peripherals (a Timer, a Register Abstraction Layer dictionary, and a Protocol Bridge). Rather than wiring them together with complex TLM ports, we will simply instantiate them around a central UbusBfmActor acting as our interconnect.
Before writing components, we define the data structures that flow through the network — a minimal UBUS bus subset (carried over from Appendix A) plus the interrupt and APB cross-domain structs — in one mini_soc_pkg. The actors that follow all live in this package.
package mini_soc_pkg;
import actor_pkg::*;
// Bus contracts (a minimal UBUS subset)
typedef enum logic [1:0] { NOP=0, READ=1, WRITE=2 } ubus_dir_e;
typedef struct {
longint id;
int master_id;
logic [15:0] addr;
ubus_dir_e dir;
logic [7:0] data;
} UbusReq_s;
typedef struct {
longint id;
int master_id;
logic [7:0] data;
logic error;
} UbusRsp_s;
// Cross-domain contracts
typedef struct { int vector_id; int priority_level; } IrqMsg_s;
typedef struct { logic [31:0] paddr; logic [31:0] pwdata; logic pwrite; } ApbReq_s;
In standard UVM, suspending a CPU sequence to run an interrupt service routine requires grab()/ungrab() locks on the uvm_sequencer, and the locking discipline is a common source of deadlocks and missed arbitration cycles.
The actor framework has no sequencer to lock. The CpuActor consumes envelopes from its mailbox; if it sees an IrqMsg_s, it runs the ISR inline and then resumes draining the rest of the mailbox.
class CpuActor extends Actor;
Actor bfm_target;
int master_id = 0;
function new(string name = "CpuActor"); super.new(name); endfunction
virtual task act(MsgBase msg);
if (msg.getTypeName() == $typename(IrqMsg_s)) begin
IrqMsg_s irq = Msg#(IrqMsg_s)::unwrap(msg);
$display("[%0t] CpuActor: ASYNC IRQ! vector=%0d -- running ISR...",
$time, irq.vector_id);
#10; // ISR body
$display("[%0t] CpuActor: ISR complete.", $time);
end
else if (msg.getTypeName() == $typename(UbusRsp_s)) begin
UbusRsp_s rsp = Msg#(UbusRsp_s)::unwrap(msg);
if (rsp.master_id == master_id)
$display("[%0t] CpuActor: bus response data=0x%02h", $time, rsp.data);
end
endtask
task do_write(logic [15:0] addr, logic [7:0] data);
UbusReq_s req = '{default: '0, master_id: master_id, addr: addr,
dir: WRITE, data: data};
$display("[%0t] CpuActor: WRITE 0x%04h <= 0x%02h", $time, addr, data);
`PUBLISH_TO(bfm_target, req);
endtask
endclass
The DmaActor is the second master — a burst-write engine. It produces bus traffic but consumes none, so its act() is empty:
class DmaActor extends Actor;
Actor bfm_target;
int master_id = 1;
function new(string name = "DmaActor"); super.new(name); endfunction
virtual task act(MsgBase msg); endtask // pure producer
task burst_write(logic [15:0] base, logic [7:0] data, int len);
$display("[%0t] DmaActor: burst write len=%0d @ 0x%04h", $time, len, base);
for (int i = 0; i < len; i++) begin
UbusReq_s req = '{default: '0, master_id: master_id,
addr: base + 16'(i), dir: WRITE, data: data};
`PUBLISH_TO(bfm_target, req);
#5;
end
endtask
endclass
To trigger the interrupt, we create a TimerActor. It is wired for UbusReq_s writes via ‘WIRE; on a write to its base address (16’h1000) it begins a hardware counter and on expiration publishes an IrqMsg_s directly to the CpuActor.
class TimerActor extends Actor;
Actor cpu_target;
int timer_count;
bit timer_running = 0;
function new(Actor cpu, string name="TimerActor");
super.new(name);
this.cpu_target = cpu;
endfunction
// Inbound bus writes load the count.
virtual task act(MsgBase msg);
if (msg.getTypeName() == $typename(UbusReq_s)) begin
UbusReq_s req = Msg#(UbusReq_s)::unwrap(msg);
if (req.addr == 16'h1000 && req.dir == WRITE) begin
timer_count = int'(req.data);
timer_running = 1;
$display("[%0t] TimerActor: armed, count=%0d", $time, timer_count);
end
end
endtask
// Background counter loop, forked alongside the act() handler thread.
virtual task run_counter();
forever begin
#10;
if (timer_running) begin
timer_count--;
if (timer_count <= 0) begin
IrqMsg_s irq = '{vector_id: 8, priority_level: 1};
timer_running = 0;
$display("[%0t] TimerActor: expired -- firing IRQ to CPU", $time);
`PUBLISH_TO(cpu_target, irq);
end
end
end
endtask
// start() forks run(); we fork the countdown alongside the act() loop.
virtual task run();
fork run_counter(); join_none
super.run();
endtask
endclass
A full UVM RAL instantiates a uvm_reg object per register and a uvm_reg_field per field, plus the four-copy state model discussed in §5.14. For most testbench purposes, a register file is just an address-keyed dictionary, and that is what RalActor models: an associative array logic [31:0] memory_map [int] with a write handler that updates it and a backdoor read_reg accessor the testbench peeks directly, bypassing the bus. The dictionary substitutes for the testbench-side uvm_reg model; the hardware-side signoff of the register decoders, address-map correctness, and access-permission enforcement is a separate problem solved by a commercial formal register-map app, not by anything the testbench checks.
class RalActor extends Actor;
protected logic [31:0] memory_map [int];
function new(string name = "RalActor"); super.new(name); endfunction
function logic [31:0] read_reg(int addr); // backdoor peek
return memory_map.exists(addr) ? memory_map[addr] : 32'h0;
endfunction
virtual task act(MsgBase msg);
// Direct UBUS register writes land here; bridged APB writes arrive from the BridgeActor as ApbReq_s.
if (msg.getTypeName() == $typename(UbusReq_s)) begin
UbusReq_s req = Msg#(UbusReq_s)::unwrap(msg);
if (req.dir == WRITE && req.addr >= 16'h2000 && req.addr <= 16'h3FFF)
memory_map[int'(req.addr)] = 32'(req.data);
end
else if (msg.getTypeName() == $typename(ApbReq_s)) begin
ApbReq_s apb = Msg#(ApbReq_s)::unwrap(msg);
if (apb.pwrite) memory_map[apb.paddr] = apb.pwdata;
end
endtask
function void dump();
$display("--- RAL final state ---");
foreach (memory_map[a])
$display(" [0x%04h] = 0x%02h", a, memory_map[a]);
endfunction
endclass
An AHB-to-APB bridge in UVM is typically built with a layered virtual sequencer and TLM port routing between agents. An actor-based bridge is a single BridgeActor: it subscribes to UBUS writes, fills in an APB struct, and publishes it on the APB stream. The actor here performs the testbench-side domain translation; the hardware-side signoff of the real silicon bridge — that a write at AHB address \(X\) reaches the corresponding APB port with the right payload, every time — is the formal Connectivity Property Verification app (Chapter 3 §3.7), an orthogonal verification artifact.
class BridgeActor extends Actor;
Actor apb_target;
function new(string name = "BridgeActor"); super.new(name); endfunction
virtual task act(MsgBase msg);
if (msg.getTypeName() == $typename(UbusReq_s)) begin
UbusReq_s ubus_in = Msg#(UbusReq_s)::unwrap(msg);
if (ubus_in.dir == WRITE && ubus_in.addr >= 16'h4000
&& ubus_in.addr <= 16'h4FFF) begin
ApbReq_s apb_out;
apb_out.paddr = {16'h0000, ubus_in.addr};
apb_out.pwdata = {24'h000000, ubus_in.data};
apb_out.pwrite = 1;
`PUBLISH_TO(apb_target, apb_out);
end
end
endtask
endclass
The interconnect itself is one more actor. The masters ‘PUBLISH_TO it directly; it fans each request out to the wired slaves and returns a response to the originating master:
class UbusBfmActor extends Actor;
function new(string name = "UbusBfmActor"); super.new(name); endfunction
virtual task act(MsgBase msg);
if (msg.getTypeName() == $typename(UbusReq_s)) begin
UbusReq_s req = Msg#(UbusReq_s)::unwrap(msg);
UbusRsp_s rsp = '{default: '0, master_id: req.master_id,
data: req.data, error: 0};
publish(msg); // fan the request out to the wired slaves
`PUBLISH(rsp); // return a response to the originating master
end
endtask
endclass
We wire the components together in SocEnvActor. Each peripheral that watches bus traffic registers itself with the BFM via ‘WIRE, then discriminates by message type inside its own act() handler. Because ‘WIRE routes by message type, every UbusReq_s subscriber sees every bus write, so each slave address-decodes the range it owns inside act() (the TimerActor’s 16’h1000 check is the pattern; a production interconnect would decode centrally). The bridge’s apb_target is the RalActor, which sits behind it and consumes the translated ApbReq_s. There is no virtual sequencer.
class SocEnvActor extends Actor;
CpuActor cpu;
DmaActor dma;
TimerActor timer;
RalActor ral;
BridgeActor bridge;
UbusBfmActor bfm;
function new(string name = "SocEnvActor");
super.new(name);
bfm = new("UbusBfmActor");
cpu = new("CpuActor"); cpu.bfm_target = bfm;
dma = new("DmaActor"); dma.bfm_target = bfm;
timer = new(cpu, "TimerActor");
ral = new("RalActor");
bridge = new("BridgeActor"); bridge.apb_target = ral;
// `WIRE routes by message type: each slave wires for the request type, the CPU for the response type. The producer routes only those.
`WIRE(bfm, UbusReq_s, timer)
`WIRE(bfm, UbusReq_s, ral)
`WIRE(bfm, UbusReq_s, bridge)
`WIRE(bfm, UbusRsp_s, cpu)
endfunction
function void start_all();
bfm.start(); cpu.start(); dma.start();
timer.start(); ral.start(); bridge.start();
endfunction
endclass
endpackage
The top-level module builds the environment, starts every actor, and drives the scenario:
module tb_top;
import actor_pkg::*;
import mini_soc_pkg::*;
SocEnvActor env;
initial begin
env = new();
env.start_all();
env.cpu.do_write(16'h1000, 8'd30); // arm timer (30 ticks)
env.cpu.do_write(16'h2000, 8'hbb); // register write -> RAL
#150;
env.dma.burst_write(16'h4000, 8'haa, 2); // burst -> bridge -> APB -> RAL
#400; // let the timer expire + ISR run
env.ral.dump();
$finish;
end
endmodule
Running it (make) shows the asynchronous behavior of the actor framework: the CPU does its register writes while the timer counts down and fires an IRQ directly into the CPU’s queue. The trace below shows one interleaving of the CPU and Timer schedules; the actor topology admits many such schedules, and exhaustive reasoning about which orderings are reachable belongs at an abstract-specification level (TLA+/TLC over the same wiring), not at this case-study level. The trace is a single concrete run, not a coverage claim.
=== Mini-SoC: CPU + DMA masters, Timer/RAL/Bridge slaves === [0] CpuActor: WRITE 0x1000 <= 0x1e [0] CpuActor: WRITE 0x2000 <= 0xbb [0] CpuActor: bus response data=0x1e [0] CpuActor: bus response data=0xbb [0] TimerActor: armed, count=30 [150] DmaActor: burst write len=2 @ 0x4000 [300] TimerActor: expired -- firing IRQ to CPU [300] CpuActor: ASYNC IRQ! vector=8 -- running ISR... [310] CpuActor: ISR complete. --- RAL final state --- [0x2000] = 0xbb [0x4000] = 0xaa [0x4001] = 0xaa === Mini-SoC complete ===
The Mini-SoC case study illustrates a structural property of the actor framework: as verification requirements grow more complex, the OOP boilerplate that gives UVM its overhead grows faster than the testbench logic itself. Treating interrupts, bridges, and registers as decoupled, message-passing data structures keeps the testbench complexity proportional to the feature surface, not to the framework.