A block testbench can try every corner of one IP. An SoC testbench cannot; it runs slower, has more state and costs more per bug. Scaling verification means deciding which question each level answers, and moving checks between levels instead of rewriting them.
Level | Finds best | Weak at | Typical speed |
|---|---|---|---|
IP (block) simulation | Functional corners, protocol rules, error paths | Integration: wiring, clocks, address maps, shared resources | Fast; many seeds per night |
Subsystem simulation | Interaction of a few IPs: arbitration, ordering across blocks | Full-chip boot and software flows | Medium |
SoC simulation | Connectivity, address decode, interrupts, reset and clock sequencing, power states | Deep corners of any one IP | Slow; few long tests |
Emulation / FPGA prototype | Software bring-up, long runs, performance, rare events that need billions of cycles | Visibility and debug turnaround | Orders of magnitude faster than simulation |
Gate-level simulation | Reset and initialisation, X issues, synthesis and DFT netlist changes | Functional coverage; very slow | Slowest |
The common mistake is to repeat IP-level tests at SoC level. That spends the slowest cycles on questions the block bench already answered. SoC tests should target what only the SoC has: connections, shared resources and system flows.
Stimulus rarely transfers from IP to SoC, because at SoC level the IP is driven by real neighbours. Checks do transfer. An IP-level protocol checker, bound to every instance of the IP, keeps working at subsystem and SoC level and catches integration bugs that the IP bench could never create.
The bench below is a small illustration. An IP raises req and holds it until gnt. Its checker, written once, says "req holds until gnt, and gnt comes within 4 cycles". One bind line attaches it to every requester instance, in the IP bench and in the SoC. At SoC level two requesters share a round-robin arbiter, with port 0 requesting every cycle.
package chk_pkg;
int violations = 0;
int grants_seen = 0;
endpackage
// IP-level rule, written once: req holds until gnt, and gnt comes within MAXW cycles.
module req_chk #(parameter int MAXW = 4) (
input logic clk,
input logic rst_n,
input logic req,
input logic gnt
);
int waited = 0;
logic req_q = 0, gnt_q = 0;
always @(posedge clk) begin
if (!rst_n) begin
waited = 0; req_q = 0; gnt_q = 0;
end else begin
if (req_q && !gnt_q && !req) begin
chk_pkg::violations++;
$display("%m: req dropped before gnt at %0t", $time);
end
if (req && gnt) chk_pkg::grants_seen++;
waited = (req && !gnt) ? waited + 1 : 0;
if (waited > MAXW) begin
chk_pkg::violations++;
$display("%m: no gnt after %0d cycles at %0t", MAXW, $time);
waited = 0;
end
req_q = req; gnt_q = gnt;
end
end
endmodule
// The IP: raises req when it wants the bus, holds it until gnt.
module requester (
input logic clk,
input logic rst_n,
input logic want,
input logic gnt,
output logic req
);
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) req <= 1'b0;
else if (!req || gnt) req <= want; // next request may follow a grant back to back
endmodule
// SoC integration: two requesters share one round-robin arbiter.
module soc (
input logic clk,
input logic rst_n,
input logic [1:0] want
);
logic [1:0] req, gnt;
logic last; // port granted most recently
requester u_ip0 (.clk, .rst_n, .want(want[0]), .gnt(gnt[0]), .req(req[0]));
requester u_ip1 (.clk, .rst_n, .want(want[1]), .gnt(gnt[1]), .req(req[1]));
always_comb begin
gnt = 2'b00;
if (req == 2'b11) gnt[!last] = 1'b1; // both asking: the other one's turn
else gnt = req;
end
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) last <= 1'b1;
else if (gnt[1]) last <= 1'b1;
else if (gnt[0]) last <= 1'b0;
endmodule
// The same bind line runs in the IP bench and here: every requester instance gets the checker.
bind requester req_chk #(.MAXW(4)) u_chk (.clk(clk), .rst_n(rst_n), .req(req), .gnt(gnt));
module tb_soc_reuse;
timeunit 1ns; timeprecision 1ps;
logic clk = 0, rst_n = 0;
logic [1:0] want = 0;
always #5 clk = ~clk;
soc dut (.*);
initial begin
std::process p = std::process::self();
p.srandom(7);
repeat (2) @(negedge clk);
rst_n = 1;
repeat (300) begin
@(negedge clk);
want[0] = 1'b1; // port 0 always busy: the stress case
want[1] = ($urandom_range(0, 1) == 1);
end
@(negedge clk); want = 0;
repeat (4) @(negedge clk);
if (chk_pkg::violations == 0 && chk_pkg::grants_seen > 100)
$display("PASS: IP checker bound at SoC level saw %0d grants, no starvation or dropped req", chk_pkg::grants_seen);
else $fatal(1, "FAIL: %0d checker violations, %0d grants", chk_pkg::violations, chk_pkg::grants_seen);
$finish;
end
endmoduleWith the round-robin arbiter the bench passes. Replace the round-robin line with fixed priority (if (req[0]) gnt[0] = 1'b1;) and the checker in dut.u_ip1.u_chk reports "no gnt after 4 cycles" again and again; the bench ends FAIL: 60 checker violations. The IP bench, with one requester and an always-granting model, could never show this starvation. The check came for free at SoC level because it was bound, not copied. See bind Basics.
In UVM, the same idea is a passive agent: the IP environment's monitor, checkers and coverage reused at SoC level with the driver turned off.
Emulation is worth its setup cost when a question needs far more cycles than simulation can give: booting software, long traffic soak tests, performance counters over real workloads, and rare events such as counter wrap after 2^32 events. Before handing over:
Make the checks synthesisable or move them to the transaction level. Many class-based scoreboards do not run inside an emulator; plan which checks run on the host and how fast transactions stream out.
Add end-of-run audits that need no waveform: credit and tag counts, error counters, signatures of memory regions.
Decide in advance how a failure comes back to simulation: a checkpoint, a trace of transactions, or a seed and cycle number.
Without a way back, an emulation failure becomes a week of lab-style debug.
RTL simulation is X-optimistic. In an if statement an X condition is treated as false and takes the else branch (IEEE 1800-2017, 12.4). A case with an X selector matches no item and takes the default. Real gates propagate the unknown, so RTL can pass where silicon has an uninitialised flop driving logic.
// snippet: X-optimism in RTL
logic en; // no reset: X after power-up
always_comb
if (en) y = a; // en is X: RTL takes the else branch, y = b
else y = b; // gates: y is X wherever a and b differMost commercial simulators offer an X-propagation mode that makes RTL more pessimistic; the option names and exact semantics are vendor-specific, so read your simulator's manual. Verilator is 2-state and cannot show X at all, which is why the snippet is not run here.
Run | Earns its cost when | Keep it small by |
|---|---|---|
RTL X-propagation | Blocks with non-reset flops, power gating, retention, or reset domain crossings | Running reset and power-sequence tests only |
Zero-delay gate-level | Netlist changes after synthesis or DFT insertion, reset and init checks, sign-off of X behaviour | A short list of boot, reset and mode-change tests |
Timing-annotated gate-level | Asynchronous paths, clock-domain and reset sequencing that static timing cannot model, final confidence on critical interfaces | A few tests on the paths static timing does not cover |
Functional coverage is not the goal of gate-level runs. Their job is to confirm that what was verified at RTL is what was built, and that the chip comes out of reset in a known state.
The IP bench had one requester and a grant model that always answered. Starvation needs a second requester sharing an arbiter, which exists only at SoC level. The IP checker caught it because it was bound to every instance.
A flop with no reset feeding logic that matters after reset. RTL is X-optimistic: an X condition in an if takes the else branch, so RTL picked a value; the gates propagated the X.