Contents

DPI-C tutorial · Step 06 · End of Part A

The golden-model pattern: a C reference model behind a self-checking testbench

Steps 01–05 taught the mechanics of DPI: types, handles, arrays, exports. This step puts them to work on the job DPI exists for in verification: an independent C model of the DUT's spec, called from a scoreboard, compared bit-exactly, and proven to fire. The SV half of the contract is built so step 08 can replace the C with Python without touching a line of SystemVerilog.

Prereqs: 01–05Book: Spear & Tumbush ch. 12 (§12.2–12.4), ch. 1 (layered TB)Lab: lab/check.py, Verilator 5.052

§0 · TutorialThe step in one idea

A testbench without a reference model can only tell you that the DUT did something. A scoreboard with a reference model tells you whether it did the right thing, on every transaction, for stimulus nobody wrote by hand. The reference model is usually written in a software language: the spec is already there (a standard, a MATLAB or Python model from the algorithm team), software is faster to write than RTL, and an independent implementation does not share the RTL's bugs. DPI-C is the standard way to call that model from SystemVerilog.

This step builds the whole pattern on two small DUTs from common/rtl: a byte-streaming CRC-32 and an 8-tap fixed-point FIR. Along the way it answers the questions you hit on a real block. How should the DPI functions be shaped? Should the model be called per transaction or per block? When should the comparison happen? How do you know the checker can fail at all? What does a DPI call cost, and what dominates the run time instead? Every number on this page comes from the lab and is asserted by check.py.

0.1What a golden model is (and what it is not)

A golden model (also called a reference model or predictor) is an implementation of the DUT's specification that runs next to the simulation and predicts what the DUT should output for the inputs it actually received. Spear's chapter 1 places it inside the layered testbench. The generator creates transactions, the driver turns them into pin activity, the monitor turns pin activity back into transactions, and the scoreboard compares observed results against the model's predictions (§1.10). Chapter 12 supplies the mechanism: the model lives in C and the scoreboard calls it through import "DPI-C".

SYSTEMVERILOG (simulator) C / C++ generator driver DUTRTL monitor scoreboard goldenmodelgolden.c txn pins pins in & out txns x, h y_exp DPI the DUT never sees the model:only the scoreboard calls it
Fig. 1The layered testbench of Spear ch. 1 with the reference model moved across the DPI boundary. Monitors observe pins, never TB variables; the scoreboard owns the model's handle (h), sends it the inputs the monitor saw, and compares the returned prediction against the observed output.

Three properties separate a useful golden model from a second copy of the RTL:

A model that is cycle-accurate (it reproduces the DUT's pipeline, latency and stalls) is a different tool. It is useful for performance work, but it tends to become a second RTL with the same bugs. Keep timing in the testbench (monitor and scoreboard) and function in the model.

0.2The two DUTs and their specs

Both DUTs live in tutorial/common/rtl and are shared with steps 07–08. What matters here is the spec each one implements, because that is what the model encodes.

DUTInterfaceSpec (what the model must reproduce)
crc32_streamclear, in_valid, in_data[7:0] → crc[31:0]CRC-32/IEEE 802.3 (Ethernet, zip, Python's zlib.crc32): reflected, polynomial 0xEDB88320, init 0xFFFFFFFF, final XOR 0xFFFFFFFF. One byte per clock when in_valid. clear has priority over in_valid and restarts the message. crc is combinational from the register: it is the CRC of every byte accepted so far.
fir8coef[8], in_valid, in_x → out_valid, out_y (all Q1.15 signed 16-bit)$y[n] = \mathrm{sat}_{16}\big(\lfloor (\textstyle\sum_{k=0}^{7} c_k\,x[n-k] + 2^{14}) / 2^{15} \rfloor\big)$. Lossless 35-bit accumulator, round half-up, saturate to $[-32768, 32767]$. Latency 1 clock. The delay line starts at 0 and only shifts on in_valid.
FIR8 OUTPUT PATH (BIT WIDTHS) 8 × (16×16)c[k]·x[n−k]32 b each Σ35 b (3 guard) + 2¹⁴round >>> 15floor, 20 b sat16[−32768, 32767] mutant 4: keep only 32 bits here mutant 1: drop the +2¹⁴ mutant 2: wrap
Fig. 2Where the FIR's spec decisions live, and the three deliberate model bugs (mutants) the lab uses in 0.8 to prove the checker fires. Eight 32-bit products need $\lceil\log_2 8\rceil = 3$ guard bits, hence 35.

Ambiguity in the spec is the most common source of "model bugs" that are really spec bugs. Before writing a line of C, settle every one of these: rounding mode (half-up, half-even, truncate), what an arithmetic shift does to negatives (floor), saturation versus wrap, reset state, what happens when two controls are active at once (clear with in_valid), and the bit order of a CRC. The lab drives clear together with a stray byte on purpose (clear_with_valid) so that the priority rule is exercised, not assumed.

0.3Designing the DPI contract

The contract is the set of import "DPI-C" declarations. Put them in a package of their own, lab/golden_pkg.sv. Everything on the SV side depends on that file and nothing else, so anything that defines these C symbols can serve as the model. It can be golden.c, a C++ class (exercise 9), or the embedded-Python bridge of step 08.

package golden_pkg;
  // CRC-32: incremental, state behind a chandle (one call per byte)
  import "DPI-C" function chandle      crc32_new();
  import "DPI-C" function void         crc32_free  (input chandle h);
  import "DPI-C" function void         crc32_clear (input chandle h);
  import "DPI-C" function int unsigned crc32_update(input chandle h, input byte unsigned b);
  // CRC-32: whole message, stateless. msg must be a FIXED-size array (Verilator)
  import "DPI-C" pure function int unsigned crc32_msg(input byte unsigned msg[], input int len);
  // FIR: stateful, per sample or per block
  import "DPI-C" function chandle  fir8_new  (input shortint coef[8]);
  import "DPI-C" function void     fir8_free (input chandle h);
  import "DPI-C" function shortint fir8_step (input chandle h, input shortint x);
  import "DPI-C" function void     fir8_block(input chandle h, input shortint x[],
                                              output shortint y[], input int n);
endpackage

Each choice in that file is a decision you will make again on a real block:

Passing a dynamic array (or a queue) to an open-array DPI formal fails at elaboration, pointing at the import line:

%Error-UNSUPPORTED: golden_pkg.sv:10:75: Unsupported: DPI argument of type DYNARRAYDTYPE
%Error: Internal Error: ... ../V3Task.cpp:759: No non-inline task associated with this task call?

A fixed-size unpacked array as the actual works, both as input and output, and svSize, svLow, svGetArrayPtr and svGetArrElemPtr1 behave as H.12.2 and H.12.4 says. Recorded in tutorial/NOTES.md.

Design the contract as if the implementation will change, because it will. A model often starts in C, is replaced by the algorithm team's Python or MATLAB, and ends up as a compiled library from another group. If the SV side only depends on golden_pkg.sv, each swap is a link-line change. Step 08 makes that literal: same golden_pkg.sv, same testbenches, a different object behind the symbols.

fir_tb.sv crc_tb.sv bench.sv golden_pkg.sv9 imports= the contract link time golden.cC, this step ex/golden_cpp.cppC++ classes, exercise 9 Python bridge + zlib/numpystep 08
Fig. 3The seam. Testbenches depend on the package; the package names C symbols; exactly one object file defines them. The lab proves the C++ swap: fir_cpp and fir_c_ref run the same seed and print the same digest and PASS.

0.4The C models

lab/golden.c has two parts. The first is the CRC. A bitwise byte update builds the 256-entry table once, and the model itself uses the table. The code is short, and its structure is the point:

#include "svdpi.h"
#include "Vtop__Dpi.h"   /* generated prototypes: a signature mismatch is a compile error */

static uint32_t crc_tab[256];
static inline uint32_t crc_step(uint32_t s, uint8_t b) { return (s >> 8) ^ crc_tab[(s ^ b) & 0xFF]; }

typedef struct { uint32_t state; } crc_t;    /* register value, before the final inversion */

void* crc32_new(void) { crc_tab_init(); crc_t* c = malloc(sizeof *c); c->state = 0xFFFFFFFFu; return c; }
void  crc32_clear(void* h) { ((crc_t*)h)->state = 0xFFFFFFFFu; }
unsigned int crc32_update(void* h, unsigned char b) {
  crc_t* c = (crc_t*)h;
  c->state = crc_step(c->state, b);
  return ~c->state;                            /* = the DUT's crc output after this byte */
}

Three details carry over to every model you write:

The FIR model is just as small. The accumulator is 64-bit, so the 35-bit sum can never overflow, and C's >> on a negative signed value is an arithmetic shift on every compiler you will meet. That shift is implementation-defined in C++17 and only guaranteed in C++20, but it matches SV's >>>, which is floor division by a power of two.

typedef struct { int16_t coef[8]; int16_t dly[8]; } fir_t;   /* dly[0] = x[n-1] */

static int16_t fir_q15(const fir_t* f, int16_t x) {
  int64_t acc = (int64_t)f->coef[0] * x;
  for (int k = 1; k < 8; k++) acc += (int64_t)f->coef[k] * f->dly[k - 1];
  int64_t r = (acc + (1 << 14)) >> 15;                    /* round half-up, floor */
  return (int16_t)(r > 32767 ? 32767 : r < -32768 ? -32768 : r);
}
short fir8_step(void* h, short x) {                     /* predict, then shift */
  fir_t* f = (fir_t*)h;  int16_t y = fir_q15(f, x);  fir_push(f, x);  return y;
}
void fir8_block(void* h, const svOpenArrayHandle x, const svOpenArrayHandle y, int n) {
  const short* xp = svGetArrayPtr(x);  short* yp = svGetArrayPtr(y);  /* NULL if not contiguous */
  for (int i = 0; i < n; i++) yp[i] = fir8_step(h, xp[i]);
}

fir8_block is written as a loop over fir8_step on purpose. If the block path were a separate implementation, stream mode and batch mode could disagree because of the model, and you would chase a DUT bug that does not exist. One arithmetic core with two entry points is the rule.

0.5The layered testbench

Each DUT gets a small layered TB in one file (lab/fir_tb.sv, lab/crc_tb.sv): an interface with a clocking block, and a package with a transaction class, a driver, a monitor and a scoreboard. The structure is Spear's chapter 1 minus the generator/agent ceremony; UVM would split each class into a uvm_sequence_item, uvm_driver, uvm_monitor and uvm_scoreboard connected by analysis ports, but the DPI part is identical.

The clocking block is what makes the monitor/scoreboard pairing trivial. Its inputs are sampled just before the clock edge (#1step), so at edge $k$ the monitor sees the input the DUT is about to capture and the output the DUT registered at edge $k-1$:

clocking cb @(posedge clk);                  // sample just before the edge, drive just after it
  output in_valid, in_x;
  input  in_valid_s = in_valid, in_x_s = in_x, out_valid, out_y;
endclocking

task run();                                     // fir_monitor
  forever begin
    @(vif.cb);
    if (vif.rst_n !== 1'b1) continue;
    if (vif.cb.out_valid)  sb.write_out(vif.cb.out_y);   // result of the input of edge k-1
    if (vif.cb.in_valid_s) sb.write_in(vif.cb.in_x_s);   // input captured at edge k
  end
endtask

The scoreboard keeps a FIFO of predictions. write_in calls the model immediately and pushes the expected output; write_out pops the oldest prediction and compares. Because the model is untimed, the scoreboard never needs to know that the latency is one cycle; it only needs outputs to come out in input order, which is the spec.

function void write_in(shortint x);
  xs.push_back(x);
  if (!batch) exp_q.push_back(fir8_step(h, x));    // DPI call: one per accepted input
endfunction
function void write_out(shortint y);
  ys.push_back(y);
  if (!batch) begin
    if (exp_q.size() == 0) begin error(ys.size() - 1, 0, y, "output with no input"); return; end
    compare(ys.size() - 1, exp_q.pop_front(), y);
  end
endfunction
WHAT THE MONITOR SAMPLES (#1step BEFORE EACH EDGE; DOTS = SAMPLED HIGH) clk in_valid in_x x0 x1 x2 out_valid out_y y0 y1 y2 k: x0k+1: x1, y0k+2: y1k+3: x2k+4: y2
Fig. 4Pairing through a FIFO, not through timing. At each edge the monitor reports what it sampled: write_in(x) calls fir8_step and pushes the prediction; write_out(y) pops the oldest prediction. A bubble on in_valid (between x1 and x2) shifts both streams equally, so the pairing holds with any gap pattern.

The CRC needs a different scoreboard because its output is a state, valid on every cycle, not a stream of results. Its monitor calls one method per edge, edge_seen(cyc, clear, valid, byte, crc). In stream mode the scoreboard first checks the sampled crc against its current prediction (the CRC of everything accepted so far) and then applies the edge's action to the model: crc32_clear on clear (which wins over valid, as in the RTL), otherwise crc32_update. The model replays the DUT's control decisions, which is exactly why those decisions have to be in the spec.

Pass the monitor's sampled values to the model, never the driver's intent. If the driver's transaction says valid=1 but a protocol bug keeps in_valid low, a model fed from the driver predicts an output that never existed and the scoreboard blames the wrong side. In the lab the driver and the scoreboard never talk.

0.6Where to compare: per transaction or per block

Both scoreboards take +MODE=stream (default) or +MODE=batch. In stream mode the model is called as the monitor reports, and each output is checked when it appears. In batch mode the scoreboard only records. For the FIR it calls fir8_block once per 256 samples at the end of the test. For the CRC it calls crc32_msg once per frame, when clear closes the frame, and compares the final CRC only.

Run (lab)ModeComparedErrorsFirstResult
fir_s1stream5000—PASS
fir_b1batch5000—PASS, same digest 99b218a1
fir_flipstream, +FLIP=10020001idx 100FAIL (reported at the cycle it happened)
fir_flip_bbatch, +FLIP=10020001idx 100FAIL (reported at end of test)
crc_s1stream (every cycle)17690—PASS
crc_b1batch (per frame)120—PASS
crc_flipstream, +FLIP=50017691cycle 500FAIL
crc_flip_bbatch, +FLIP=500120—PASS: blind

+FLIP=k is a fake DUT bug injected in the monitor. For the FIR it flips the LSB of output #k; for the CRC it flips bit 31 of the sampled crc on checked cycle #k, a one-cycle glitch that the next edge "repairs". For a stream-of-results DUT like the FIR, both modes find the bug and name the same index; the difference is when you hear about it (at simulated time of the failure, with waveforms around it, versus after the run). For a state-output DUT like the CRC, the per-frame check is strictly weaker: the glitch on cycle 500 lands mid-frame (frame 2, byte 151) and batch mode never looks at that cycle.

ONE FRAME OF THE CRC RUN (crc_flip, frame 2) cycles bytes accepted (gaps allowed) clear stream batch glitch @ cycle 500 batch: one check per frame → misses it
Fig. 5Check density. Stream mode compares the CRC on every one of 1769 edges; batch mode compares 12 times, once per frame, so a transient error between frame ends is invisible to it.

Compare at monitor time unless you have a measured reason not to. You get the failure at the simulated time it happens (the waveform window you will open is right there), you catch transient and mid-transaction errors, and you can stop the run at the first mismatch. Batch comparison is for when the per-call cost dominates. That is not the case for a C model (0.9), but it can be the case for Python or a remote model.

Two smaller decisions also show up in the code. The error report prints the first five mismatches in full (model, kind, index or cycle, input, expected, got) and then only counts, because after the first error a stateful DUT usually diverges for the rest of the transaction. In the CRC mutant run below, one bad table entry produces 73 mismatches, all consequences of the first. The second decision: after every comparison the final report also checks that the number of outputs equals the number of inputs, because a DUT that drops or duplicates an output can otherwise pass every comparison it was given.

0.7Stimulus, corners and seeds

A golden model makes random stimulus worth running: no one has to write the expected values. It does not make random stimulus good. Uniform 16-bit samples almost never hit the cases a fixed-point DUT gets wrong: full-scale inputs, the most negative code, long runs that drive the accumulator to its extremes. So the transaction class weights the corners explicitly:

class fir_txn;
  rand bit      valid;
  rand shortint x;
  constraint c_valid { valid dist {1 := 8, 0 := 2}; }                 // random bubbles
  constraint c_x { x dist {32767 := 5, -32768 := 5, 0 := 5,               // saturation corners
                           [-100:100] :/ 15, [-32767:32766] :/ 70}; }
endclass

The coefficients are random too, with a 1-in-4 chance per tap of being exactly 32767 or -32768. The result is that 142 of the 500 outputs of seed 1 saturate (248 of 500 for seed 2). Without that, the saturation logic, which is half of the spec, would be checked by luck. The CRC frames get the same treatment: length 0 (an empty frame), 1–8, up to 256; bytes weighted toward 0x00 and 0xFF; random gaps; and a clear that sometimes arrives together with a stray byte.

Write negative dist values as plain decimals. In Verilator 5.052, x dist {-16'sh8000 := 5} on a shortint is unsatisfiable (%Warning-UNSATCONSTR, randomize() returns 0). The likely cause is that the negation is evaluated at a wider width, so the solver looks for $+32768$, which a shortint cannot hold. -32768 works.

Reproducibility is one plusarg. The TB reads +SEED=n and calls process::self().srandom(seed) before creating any object, so every random value derives from it, including those of the class instances, which inherit the seed of the thread that constructs them (IEEE 1800-2023 §18.14). It prints a digest of the coefficients and every input sample. Seed 1 gives 99b218a1 in both stream and batch mode, and seed 2 gives 949caf20. The equality across modes is not an accident. The scoreboard never draws a random number, so changing how you check cannot change what you drive. Keep it that way: a checker that consumes randomness makes a failing seed stop failing when you add a debug print.

randomize() in Verilator calls an external SMT solver (z3 here), once per call. In the lab, the 500-sample FIR run with randomize() takes 5.6 s of wall time with the CPU mostly idle, about 11 ms per accepted sample. The same TB with +FAST (the identical distribution built from $urandom_range in fir_txn::fast_randomize) runs 200,000 samples in 1.6 s, about 8 µs per sample. Parallel runs contend for the solver. Commercial simulators have in-process solvers and much lower per-call costs, but the lesson holds everywhere: measure before you blame the model.

0.8Proving the checker fires

A scoreboard that never fails is either checking a perfect DUT or checking nothing. You find out which by breaking something on purpose and watching it fail. The lab does this in two places:

MutantBugErrors / comparedFirstWhat the pattern says
1FIR truncates (no +2¹⁴)5337 / 20000idx 549.5 % of the 10,779 non-saturated outputs: rounding differs exactly when the discarded 15 bits are ≥ ½ LSB, which for random data is half the time. Saturated outputs are unaffected.
2FIR wraps instead of saturating9221 / 20000idx 4Exactly the 9221 saturated outputs, and no others. Without corner weighting, this count could be 0.
4FIR accumulates in 32 bits2611 / 20000idx 37Only sums beyond $\pm 2^{31}$ differ; it needs several full-scale products in the delay line at once. Example: exp=-32768 got=32767, a sign flip.
3CRC table entry 0x5A wrong73 / 1769 (stream)
2 / 12 (batch)
cycle 67
cycle 111
One lookup in 256 is wrong; once hit, every later CRC of that frame differs (cycles 67, 68, 69…). Batch mode finds it later and only at the frame level.
FIR MUTANTS: MISMATCHES PER 20,000 SAMPLES (SEED 3, +FAST) 0 %25 %50 %75 % 1 · truncate5337 (26.7 %) 2 · wrap9221 (46.1 %) = all saturated 4 · 32-bit acc2611 (13.1 %)
Fig. 6Each mutant is caught, but by a different slice of the stimulus. If mutant 2 or 4 survives a run, your stimulus does not reach saturation or full-scale accumulation, and the DUT's versions of those paths are unverified too.

Mutation is a cheap coverage metric for the checker itself. Functional coverage tells you which inputs you drove; a surviving mutant tells you that a whole class of wrong answers would have passed. Keep two or three mutants in the regression as "must fail" tests: they catch a scoreboard that a refactor quietly disconnected.

0.9What a DPI call costs

lab/bench.sv times $10^7$ calls of each kind with a wall-clock DPI helper (wall_s(), clock_gettime) and prints nanoseconds per item. Numbers from the run used for this page; on a busy machine they move by up to 2×, so check.py asserts orders of magnitude and orderings, not exact values.

ItemWhat is timedns / item
sv_nopempty SV function call (baseline)0.5
dpi_nopempty DPI call into C, int → int2.3
crc32_updateone byte, chandle state3.6
crc32_msg256per byte, 256-byte buffer per call2.0
sv_crc_bytethe bitwise CRC in plain SV7.0
fir8_stepone sample6.1
fir8_block256per sample, 256 per call5.6
sv_fir_stepthe same FIR as an SV class19.8
TB, +FASTone FIR sample through driver, DUT, monitor, scoreboard≈ 8,000
randomize()one FIR sample with the solver≈ 11,000,000
COST PER ITEM, LOG SCALE (VERILATOR 5.052, THIS MACHINE) 0.1 ns1101001 µs10 µs100 µs1 ms sv_nop dpi_nop crc32_update fir8_step sv_fir_step TB per sample randomize()≈ 11 ms (off the chart)
Fig. 7Seven orders of magnitude between the cheapest and the most expensive thing in the run. The DPI call into the golden model sits in the nanoseconds. The simulated testbench (class methods, clocking blocks, the --timing scheduler) costs microseconds per sample. The constraint solver costs milliseconds.

What the numbers say, in order of importance:

  1. In Verilator a DPI import is an ordinary C function call. The generated wrapper converts arguments (here, none of the types need conversion) and calls your function: about 2 ns of overhead. The 200,000-sample run calls fir8_step 200,000 times, about 1.2 ms of a 1.6 s run, under 0.1 %. check.py asserts that the DPI share is below 5 %.
  2. Batching buys nothing for a C model. fir8_block256 is within noise of fir8_step: the arithmetic dominates the call. The full FIR TB in batch mode was 1.31 s versus 1.64 s in stream mode for 200,000 samples, a difference made by SV queue handling and machine noise, not DPI. With C, choose stream or batch for debuggability (0.6), not speed.
  3. The model in C beats the model in SV. The same FIR as an SV class costs about 3× more per sample in Verilator. This is not an argument against SV models, but it dispels the idea that crossing into C is expensive.
  4. This changes with Python. A call into an embedded CPython interpreter costs on the order of a microsecond or more: the arguments become Python objects and the interpreter runs bytecode. Then 200,000 calls cost a noticeable fraction of the run, and the block API (one call, one array) becomes the tool. Step 08 measures exactly that, through these same imports, on both backends (its tb_perf.sv; step 08).

Not measured here. In event-driven simulators the import wrapper does more work: argument marshaling for 4-state and packed types, context and scope bookkeeping for context imports, and sometimes a call through a dynamically loaded .so. Expect tens of nanoseconds per call rather than single digits, still far below the per-cycle cost of a UVM testbench. Measure with the same bench.sv before you restructure a scoreboard for speed.

0.10Swapping the implementation

The contract in 0.3 pays off here. lab/ex/golden_cpp.cpp implements the same nine symbols with two C++ classes, Crc32 and Fir8. Each chandle is a new'd object; each import is a one-line wrapper that casts the handle and calls a method. This is Spear's §12.3 static-wrapper pattern (Samples 12.14 and 12.16), minus the hand-written extern "C" block: including Vtop__Dpi.h gives every definition C linkage, because a function first declared extern "C" keeps that linkage.

void* fir8_new(const short* coef) { return new Fir8(coef); }
void  fir8_free(void* h) { delete static_cast<Fir8*>(h); }
short fir8_step(void* h, short x) { return static_cast<Fir8*>(h)->step(x); }

The run script builds the FIR testbench twice from the same SV, once with golden.c and once with ex/golden_cpp.cpp, and runs both with +SEED=3 +FAST +N=20000. Both print digest ca87667f and PASS fir8. Nothing on the SV side knew which one it got.

Step 08 replaces the object again, this time with a thin C layer that forwards each call into an embedded Python interpreter running zlib.crc32 and a numpy FIR. Three things in this step's contract were chosen for that:

0.11Recipe: a C golden model for your block

  1. Pin down the spec to the bit: rounding, saturation, signedness, reset state, control priorities, bit/byte order. Find an external check value (a standard's test vector, the algorithm team's output).
  2. Write the contract as a package of imports: new/free plus a per-item call for stateful models, a block call for throughput, pure only for stateless functions, plain C types, fixed-size buffers plus length.
  3. Write the model against the spec, with a different algorithm from the RTL where possible. Include the generated __Dpi.h. Abort on contract violations, never on mismatches.
  4. Feed it from the monitor, never from the driver. Pair outputs with predictions through a FIFO (streams) or by replaying the control decisions every cycle (state outputs).
  5. Compare at monitor time, bit-exactly, with a report that names the model, the index or cycle, the input, the expected and the observed value. Print the first few mismatches, count the rest, and check the transaction counts at the end.
  6. Weight the corners in the stimulus, take the seed from a plusarg, and keep the checker free of random calls.
  7. Break it on purpose: one DUT-side injection, two or three model mutants, all expected to fail, all in the regression.
  8. Measure before optimizing. With a C model the DPI call is noise; the solver and the TB dominate.

§1 · ObjectivesWhat you should be able to do

Prerequisites: import declarations and the generated header (01), type mapping (02), chandle state (03), open arrays (04), pure/context (05).

§2 · Exercises12 graded exercises, with solutions

★ basic · ★★ intermediate · ★★★ real design. Every solution that contains code or a number was run in the lab and is checked by check.py; code lives in lab/ex/. "Ex. 12.N" = exercise from Spear & Tumbush ch. 12 (the book gives no solutions).

E1 ★Predict. The FIR TB is run with +SEED=1 +FAST +N=2000 +FLIP=100, once in stream mode and once with +MODE=batch. For each: how many errors, at which index, and at what point of the run is the mismatch printed?

Show solution

Both report errors=1 first=100, with the line LOG mismatch model=fir8 kind=value idx=100 x=28789 exp=-26047 got=-26048 (the LSB flip turns −26047 into −26048). In stream mode the line is printed during the run, at the clock edge where output #100 is sampled. In batch mode it is printed after the driver finishes, inside check_batch(), so the log shows it after the fir_run line. Same detection, different debug experience: in stream mode you can stop the simulation and look at the waveform at that time.

E2 ★Predict. Mutant 1 makes the C model truncate instead of rounding (it drops the +2¹⁴). In a 20,000-sample run where 9221 outputs saturate, roughly how many mismatches do you expect, and why are saturated outputs safe?

Show solution

Rounding and truncation differ exactly when bit 14 of the accumulator (the first discarded bit) is 1, i.e. when the discarded fraction is ≥ ½ LSB. For random data that is half the time, so about ½ × (20000 − 9221) ≈ 5390. The lab measures 5337 (49.5 %). Saturated outputs are safe because their sum is so far beyond ±32767 that a ½-LSB difference does not change the clamped result. The first mismatch is at idx 5 (exp=32532 got=32533): the model is one LSB low, as truncation always is.

E3 ★★Ex. 12.1 + 12.2, as golden models. Write shift_c(i, n): shift a 32-bit unsigned value left by n if positive, right by -n if negative, unchanged if 0. Then shift_reg(i, n, ld) with an internal register: if ld, load i shifted by n, otherwise shift the register. Check both against SV's own shift operators for n ∈ {0, 1, 4, 31, 32, 40, −1, −4, −31, −32, −40}.

Show solution
/* ex/shift.c (excerpt). C makes x << 32 undefined behavior; SV defines it as 0. */
static uint32_t sh(uint32_t i, int n) {
  if (n >= 32 || n <= -32) return 0;
  return n > 0 ? i << n : n < 0 ? i >> -n : i;
}
unsigned int shift_c(unsigned int i, int n) { return sh(i, n); }
static uint32_t reg;                                  /* one register for every caller */
unsigned int shift_reg(unsigned int i, int n, unsigned char ld) {
  reg = ld ? sh(i, n) : sh(reg, n);
  return reg;
}
import "DPI-C" pure function int unsigned shift_c(input int unsigned i, input int n);
import "DPI-C" function int unsigned shift_reg(input int unsigned i, input int n, input bit ld);

The interesting part is the guard. The spec ("shift by n") is clear in SV, where shifting by 32 or more gives 0, but in C x << 32 is undefined behavior. In practice, x86 shift instructions mask the count to 5 bits, so a variable shift by 32 typically returns x unchanged. Without the guard, the model and the RTL disagree at n = 32 and 40, and the model is the one that is wrong. shift_c is pure; shift_reg is not, because it has state. The lab checks all 11 shift amounts plus the register sequence (load 0x0F<<4 = 0xF0, then shift right 2 = 0x3C): LOG ex12_2 reg=0000003c, PASS shift.

E4 ★★Ex. 12.3–12.5. Make the shift register per-instance: shift_new(init) returns a handle with the register initialized to init, and shift_inst(h, i, n, ld) prints the instance address and a global count of calls over all instances. Create two instances and call each twice.

Show solution
static unsigned long calls;                           /* Ex. 12.4: across instances */
void* shift_new(unsigned int init) {                  /* Ex. 12.5: initial value */
  uint32_t* r = malloc(sizeof *r);  *r = init;  return r;
}
unsigned int shift_inst(void* h, unsigned int i, int n, unsigned char ld) {
  uint32_t* r = h;
  *r = ld ? sh(i, n) : sh(*r, n);
  printf("LOG shift_inst inst=%p ... reg=%08x calls=%lu\n", h, *r, ++calls);
  return *r;
}

Lab output (addresses vary per run):

LOG shift_inst inst=0x600003508030 i=00000000 n=1 ld=0 reg=00000002 calls=1
LOG shift_inst inst=0x600003508040 i=00000000 n=-4 ld=0 reg=00000010 calls=2
LOG shift_inst inst=0x600003508030 i=00000000 n=1 ld=0 reg=00000004 calls=3
LOG shift_inst inst=0x600003508040 i=00000000 n=-4 ld=0 reg=00000001 calls=4

Instance A starts at 1 and shifts left (2, 4); B starts at 0x100 and shifts right by 4 (0x10, 0x1). Two addresses, one counter. This is exactly what the FIR scoreboard does with fir8_new: one model per scoreboard, so a TB with two FIR instances needs two scoreboards and two handles. A static model would interleave both channels' samples into one delay line. The address doubles as an instance ID in logs, as Ex. 12.3 suggests, but it is not stable across runs, so never use it in a golden log you diff.

E5 ★★Find the bug. A colleague "simplifies" the FIR TB by letting the driver feed the scoreboard directly:

task run(int n);
  fir_txn t = new();
  for (int sent = 0; sent < n; ) begin
    void'(t.randomize());
    @(vif.cb);
    vif.cb.in_valid <= t.valid;
    vif.cb.in_x     <= t.x;
    sb.write_in(t.x);                    // "the monitor is redundant for inputs"
    sent += t.valid;
  end
endtask

The DUT is correct. What does the scoreboard report, and when?

Show solution

The driver calls write_in for every transaction, including bubbles (valid=0), which the DUT ignores. Each bubble pushes an extra prediction and advances the model's delay line with a sample the DUT never saw. From the first bubble on, predictions are misaligned and the model's state is wrong, so almost every comparison fails. The first mismatch is reported at the first output after the first bubble, and the final count check fails too (more inputs than outputs). The fix is the rule from 0.5: the model is fed from what the monitor sampled (in_valid_s/in_x_s at the edge), which is by definition what the DUT captured. A related subtle variant: feeding from the driver with a if (t.valid) guard works here, but breaks the day a protocol bug (or a backpressure signal) makes the DUT not capture a sample the driver offered.

E6 ★★Find the bug. To "help the optimizer", someone declares the per-sample FIR call as import "DPI-C" pure function shortint fir8_step(input chandle h, input shortint x);. Which IEEE 1800 rule does this break, and what can a simulator legally do as a result?

Show solution

IEEE 1800-2023 §35.5.2: a pure function's result depends only on its inputs and it has no side effects. fir8_step has both a hidden input (the delay line behind h) and a side effect (it shifts the delay line). Given pure, a simulator may skip a call whose result is unused, evaluate two calls with equal arguments once, or reorder calls. Any of these desynchronizes the model's delay line from the DUT's, and the run fails in a way that depends on the compiler's optimization choices: the worst kind of failure. The same rule says crc32_msg may be pure (no state, result depends only on msg and len). Note that a chandle argument is not what disqualifies a function; the state behind it is. Step 05 covers pure/context in detail.

E7 ★★Predict, then fix. The CRC TB runs with +FLIP=500 (bit 31 of the sampled crc flipped on checked cycle 500, mid-frame) in batch mode. Pass or fail? If batch mode has to stay (say the model is remote and expensive), what cheap check would catch a one-cycle glitch?

Show solution

Pass. The lab's crc_flip_b reports compared=12 errors=0: the per-frame check only looks at the cycle where clear closes each frame, and cycle 500 is byte 151 of frame 2. Stream mode (crc_flip) reports errors=1 first=500. Cheap fixes that do not need the model on every cycle:

  • A stability property (a sketch, not in the lab): crc may change only on an edge where in_valid or clear was sampled high (assert property (@(posedge clk) !$past(in_valid || clear) |-> $stable(crc))), which catches any glitch without a model.
  • Check more points per frame with the block model: call crc32_msg on a prefix at a few random cycles per frame. It is still cheap and catches persistent errors earlier, though not a single-cycle glitch, unless you happen to sample that cycle.

The general lesson: a per-transaction check of a state output only covers the states at transaction boundaries.

E8 ★★★A model-free check. CRC-32 has a residue property: append the CRC of a message to it, little-endian, and the CRC of the extended message is the constant 0x2144DF1C, whatever the message. Using only crc32_msg, write a test that checks this for 1000 random messages of 0–60 bytes. Why is such a check valuable next to a golden model?

Show solution
// ex/residue_tb.sv
module top;
  import golden_pkg::*;
  initial begin
    byte unsigned b[64];
    int bad = 0;
    for (int t = 0; t < 1000; t++) begin
      int len = $urandom_range(60);
      int unsigned c;
      for (int i = 0; i < len; i++) b[i] = $urandom;
      c = crc32_msg(b, len);
      for (int k = 0; k < 4; k++) b[len + k] = c[8*k +: 8];      // little-endian
      if (crc32_msg(b, len + 4) != 32'h2144DF1C) bad++;
    end
    $display("LOG residue trials=1000 bad=%0d", bad);
    $finish;
  end
endmodule

Lab: LOG residue trials=1000 bad=0. The value of the property is that it is independent of any implementation. It checks the model (here) or the DUT (feed the DUT a frame plus its own CRC and look at crc) with no reference at all. It also cannot be fooled by a "consistent" bug shared by the RTL and a model written from the same misunderstanding, for example a wrong final XOR or reflected-vs-normal bit order. This is the same idea as the check value 0xCBF43926 (0.1), taken further: anchor your model to facts that do not come from your own code. The constant can be confirmed outside the simulator: zlib.crc32(m + zlib.crc32(m).to_bytes(4, 'little')) gives 0x2144df1c for any m.

E9 ★★★Swap the implementation. Implement the full golden_pkg contract with C++ classes (Spear §12.3), build the FIR testbench against it instead of golden.c, and show that the two runs are indistinguishable.

Show solution

lab/ex/golden_cpp.cpp (about 60 lines): class Crc32 with a static inline const std::array table and a push(byte) method; class Fir8 with step(x); then one-line wrappers per import:

#include "Vtop__Dpi.h"   // declares the imports extern "C"; the definitions below inherit it
void* crc32_new() { return new Crc32; }
void  crc32_free(void* h) { delete static_cast<Crc32*>(h); }
unsigned int crc32_update(void* h, unsigned char b) { return static_cast<Crc32*>(h)->push(b); }
void* fir8_new(const short* coef) { return new Fir8(coef); }
short fir8_step(void* h, short x) { return static_cast<Fir8*>(h)->step(x); }
// ... crc32_clear, crc32_msg, fir8_free, fir8_block likewise

Build line: vlb fir_cpp golden_pkg.sv fir8.sv fir_tb.sv wall.c ex/golden_cpp.cpp. Running +SEED=3 +FAST +N=20000 against both objects prints the same digest=ca87667f, compared=20000 errors=0 and PASS fir8. Note what the C++ version needs that the book's Sample 12.14 spells out by hand: C linkage. Here it comes for free from including the generated header, because a function first declared extern "C" keeps C linkage at its definition. Without the include (and without an explicit extern "C"), the definitions get C++-mangled names and the link fails; the lab's nohdr build (the same file with the include line removed) stops with Undefined symbols for architecture x86_64.

E10 ★★Estimate. A nightly regression drives 10 million FIR samples through this TB. Using the lab's numbers (≈ 11 ms per sample with randomize() under contention, ≈ 8 µs per sample with $urandom, ≈ 6 ns per fir8_step), how long does it take each way, and how much of it is the golden model?

Show solution

With randomize(): $10^7 \times 11\,\text{ms} \approx 1.1 \times 10^5\,\text{s} \approx 30$ hours. With $urandom: $10^7 \times 8\,\mu\text{s} = 80\,\text{s}$. Golden model: $10^7 \times 6\,\text{ns} = 0.06\,\text{s}$ in either case, which is 0.07 % of the fast run and nothing in the slow one. Moving the model to batch mode would save nothing. The fixes that matter are on the stimulus side: randomize a whole block per call (a rand shortint x[64] with foreach constraints amortizes the solver call), or generate the per-sample distribution with $urandom and keep randomize() for the rare, structured decisions (frame lengths, configurations). On a commercial simulator with an in-process solver the gap is smaller, but the method is the same: measure each layer before you optimize one.

E11 ★Find the bug (build line). To compare implementations in one run, someone builds verilator --binary ... golden_pkg.sv fir8.sv fir_tb.sv wall.c golden.c ex/golden_cpp.cpp. What happens?

Show solution

The link fails, because both objects define the same nine C symbols. The lab's dup build shows it:

duplicate symbol '_crc32_clear' in:
    .../build/vl_dup/golden_cpp.o
    .../build/vl_dup/golden.o
duplicate symbol '_fir8_step' in:
...

The leading underscore is the macOS symbol convention. A DPI import names a global C symbol, and there is exactly one per program. To run two models side by side, give the second implementation different C names and bind them to different SV names with the c_identifier = form of step 01 (import "DPI-C" ref_fir8_step = function shortint ref_step(...)), or put each in its own shared library loaded explicitly. That is also why step 08 replaces golden.c on the link line rather than adding to it.

E12 ★Predict. byte unsigned b[256]; and then $display("%h", crc32_msg(b, 300));. What does the simulation print, and how does it end?

Show solution

The model checks its contract: len outside [0, svSize(msg, 1)]. It prints crc32_msg: len=300 outside buffer of 256 on stderr and calls abort(). The $display never completes, and the process dies with SIGABRT (exit code 134; the lab records RUN_RC=134). Without the guard, the C loop would read 44 bytes past the end of the SV array. That is undefined behavior: usually a plausible-looking wrong CRC, which the scoreboard would report as a DUT bug. A loud abort with the reason is the right response to a testbench bug; reserve mismatch reports for DUT-versus-model disagreements.

§3 · QuizCheck your reasoning

Pick an answer, press Check. Each explanation covers the right answer and the tempting wrong one.

1. The RTL CRC does eight bitwise shift/XOR steps per byte. Which reference model gives the most independent check?

(c) uses a different algorithm and an external anchor, so a shared misunderstanding (bit order, init, final XOR) shows up as a disagreement or as a wrong check value. (a) and (b) transliterate the RTL and tend to share its bugs; (d) records today's behavior, bugs included.

2. fir8_step(h, x) updates a delay line behind h. Which import qualifier is correct?

It has hidden state and a side effect, so it is not pure. A simulator may legally drop or merge "redundant" pure calls, desynchronizing the model. context is about calling exported SV routines or accessing SV scope from C, not about C-side state; this function needs neither.

3. In Verilator 5.052, what happens if the TB passes a dynamic array to input byte unsigned msg[]?

Measured: Verilator rejects it at the import, followed by an internal error. A fixed-size array actual works. Commercial simulators accept dynamic arrays here, which is exactly why the contract carries an explicit length and uses fixed buffers: it works everywhere.

4. The FIR monitor samples through a clocking block. At edge $k$ it sees out_valid=1. The output belongs to the input captured at…

out_y is registered (latency 1), and #1step sampling sees the value from before edge $k$, i.e. what was registered at $k-1$. The scoreboard's FIFO pairs outputs with predictions in order, so the latency never appears in the checker; that is what keeps the model untimed.

5. Why does the scoreboard call the model from write_in (monitor) instead of from the driver?

Bubbles, backpressure and protocol bugs make "offered" and "captured" differ. A model fed from the driver predicts outputs that never existed (exercise 5). DPI calls are allowed from any procedural context.

6. The CRC TB injects a one-cycle glitch mid-frame (+FLIP=500). Stream mode reports 1 error. Batch mode (one crc32_msg per frame) reports…

Measured: compared=12 errors=0. The per-frame check only samples the CRC at the frame boundary; the glitch is repaired by the next edge. For a state output, per-transaction checking only covers the states at transaction boundaries.

7. Seed 1 gives digest 99b218a1 in stream mode. In batch mode with the same seed the digest is…

Random stability (IEEE 1800-2023 §18.14) makes each thread's and object's sequence depend on its seed and on the order of calls in that thread/object. The checker consumes none, so changing it cannot perturb stimulus. A checker that called $urandom in the stream path only would break this.

8. Mutant 2 (wrap instead of saturate) produced exactly 9221 mismatches in a run with 9221 saturated outputs. If a run of that mutant produced 0 mismatches, the most likely conclusion is…

Wrap and saturate only differ when the result is out of range. Zero kills means the run never got there. (a) and (b) are possible but you check them second; (d) is irrelevant for a stream-of-results DUT (both modes compare every output).

9. Measured in Verilator: an empty DPI call costs about…

dpi_nop: 1.6–2.3 ns per call across runs, versus 0.5 ns for an empty SV function. Verilator compiles the SV to C++ and the import becomes a direct call. Nothing crosses a process boundary; that only happens with sockets or remote models.

10. The FIR TB with a C model is slow. Which change gives the biggest speedup?

Per sample: about 6 ns for the model, about 8 µs for the TB, about 11 ms for randomize() in Verilator (external solver). (a) saves nanoseconds; (b) is a correctness bug; (c) is slower (the SV FIR costs about 3× the C one).

11. Which properties of the contract were chosen so that step 08 can replace the C with Python? (select all)

Handles map to model instances; block calls amortize the much larger per-call cost of the interpreter. None of the imports needs context, and the arguments are deliberately 2-state plain integers. 4-state values would need svLogicVecVal handling on both sides, and Verilator is 2-state anyway.

12. golden.c and ex/golden_cpp.cpp are both put on the same Verilator command line. Result?

An import names one global C symbol; two definitions are a link error (duplicate symbol '_crc32_clear', measured). There is no priority rule. To compare two models in one run, give them different C names.

13. The model detects len > svSize(msg, 1). What should it do?

(a) hides a TB bug. (b) turns a TB bug into a "DUT mismatch" and sends the designer hunting. A contract violation should stop the run loudly (the lab shows crc32_msg: len=300 outside buffer of 256, exit 134).

14. A model hits a bad CRC table entry once in a frame. The stream scoreboard prints 5 mismatches and counts 73. How many distinct bugs is that, most likely?

Stateful outputs propagate the first error until the state is reset (here, by clear). That is why the report leads with the first mismatch (first=67) and only counts the rest.

§4 · LabRun it, read it, break it

Files in 06_golden_model_c/lab/:

FileWhat it is
golden_pkg.svthe contract: 9 imports (reused unchanged by step 08)
golden.cC models for CRC-32 and FIR8; -DMUTANT=1..4 injects model bugs
fir_tb.sv, crc_tb.svlayered self-checking TBs; plusargs +SEED +N/+MSGS +MODE=stream|batch +FLIP +FAST
bench.sv, bench.c, wall.ccost per call, with a wall-clock DPI helper
ex/exercise solutions: shift.c/shift_tb.sv (E3, E4), residue_tb.sv (E8), golden_cpp.cpp (E9), len_tb.sv (E12)
run.sh, check.pybuild and run everything (about 1–2 min); assert every claim on this page (48 checks)

Before running, predict: (1) Will stream and batch FIR runs with the same seed print the same digest? (2) Which mutant produces exactly as many errors as there are saturated outputs? (3) Will batch-mode CRC catch +FLIP=500? (4) Is fir8_block256 much faster per sample than fir8_step?

cd tutorial/06_golden_model_c/lab
python3 check.py              # runs run.sh, then asserts; prints the bench numbers
python3 check.py --no-run     # re-check existing logs
./build/vl_fir/Vtop +SEED=7 +FAST +N=100000 +MODE=batch   # try your own

What to look at: build/*.log, one per run, with LOG lines (fir_run, fir_sb, crc_sb, mismatch, bench) and PASS/FAIL. build/results.txt counts PASS/FAIL per TB: 12 PASS and 8 FAIL, and the 8 FAILs are the intended ones (flips and mutants).

ExperimentCommand (run.sh)Result (this run)
FIR, seed 1, stream / batch+SEED=1 +N=500 [+MODE=batch]PASS, 500 compared, digest 99b218a1 both, 142 saturated
FIR, seed 2+SEED=2 +N=500PASS, digest 949caf20, 248 saturated
FIR, fake DUT bug+FAST +N=2000 +FLIP=100FAIL, errors=1, first=100 (stream and batch)
CRC, stream / batch+SEED=1 +MSGS=12PASS, 1769 / 12 compared, check value cbf43926
CRC, glitch+FLIP=500stream FAIL first=500; batch PASS (blind)
Mutants 1 / 2 / 4 (FIR)-DMUTANT=n, +SEED=3 +FAST +N=200005337 / 9221 / 2611 errors; first idx 5 / 4 / 37
Mutant 3 (CRC)-DMUTANT=3stream 73 errors first cycle 67; batch 2 of 12 frames, first cycle 111
C++ swapex/golden_cpp.cppsame digest ca87667f, PASS
Both models linked / no headerdup / nohdr buildslink errors: duplicate symbol / Undefined symbols
Throughput, FIR TB+FAST +N=200000stream 1.64 s, batch 1.31 s (≈ 8 µs/sample)
randomize() cost+N=500 (no +FAST)5.6 s, ≈ 11 ms/sample, 4 runs in parallel

Things to try: in crc_scoreboard::edge_seen, apply the byte when clear and valid arrive together (the opposite of the RTL's priority) and watch which cycle fails first; change BLK in the FIR scoreboard to 1 and re-time batch mode; add a fifth mutant (round half-even) and predict its kill rate before running (hint: it only differs on exact ties).

§5 · Cheat sheetFor the next block you verify

NeedSV declarationC prototypePitfall
model instancefunction chandle m_new(...)void* m_new(...)pair with m_free; one per scoreboard, never static
per-item predictionfunction shortint m_step(input chandle h, input shortint x)short m_step(void* h, short x)not pure (state)
stateless whole messagepure function int unsigned m_msg(input byte unsigned a[], input int len)unsigned int m_msg(const svOpenArrayHandle a, int len)Verilator: fixed-size actual only; check len ≤ svSize
block, in and outfunction void m_block(input chandle h, input shortint x[], output shortint y[], input int n)void m_block(void* h, const svOpenArrayHandle x, const svOpenArrayHandle y, int n)loop over the per-item core; svGetArrayPtr may be NULL
fixed config arrayinput shortint coef[8]const short* coefcopy it: the pointer is only valid during the call
prototype check—#include "Vtop__Dpi.h"also gives extern "C"; other sims: -dpiheader

Rules

  1. The model implements the spec, not the RTL; anchor it to an external value.
  2. Untimed model, timed testbench: latency and gaps live in the monitor/scoreboard.
  3. Feed the model from the monitor's sampled values.
  4. Compare at monitor time, bit-exactly; report first mismatch in full, count the rest, check the counts.
  5. Per-frame checks of a state output are blind between frames.
  6. pure only for stateless functions.
  7. Abort on contract violations; report mismatches.
  8. Weight the corners; one seed plusarg; no randomness in the checker.
  9. Keep mutants in the regression as must-fail tests.
  10. In Verilator, DPI into C costs nanoseconds: measure the solver and the TB first. Batch for Python, not for C.

Spear & Tumbush, SystemVerilog for Verification, 3rd ed. (Springer, 2012), ch. 1 (§1.9–1.11) and ch. 12 (§12.2–12.4, Ex. 12.1–12.5); IEEE Std 1800-2023 §35.5.6.1, §35.6.1, H.12. DUTs: tutorial/common/rtl. Every number on this page comes from 06_golden_model_c/lab/check.py (Verilator 5.052, macOS x86_64); timings vary with machine load.