DPI-C tutorial · Part A · Step 03 · Spear & Tumbush §12.2–12.3
State and handles: C and C++ models that remember
A reference model is not a pure function: it has registers, memories and queues that survive between calls. This step is about where that state lives, who owns it, and how SystemVerilog points at it, with a chandle, without ever looking inside.
§0 · TutorialThe step, explained
Steps 01 and 02 called C functions that behaved like combinational logic: same inputs, same output, nothing remembered. Real models are sequential. A CPU model has a register file, a DMA model has descriptors in flight, a golden FIR has a delay line. The moment a C function keeps something between calls, three questions appear that the DPI standard does not answer for you: where does the state live, how many copies of it exist, and who frees it.
Spear's chapter 12 walks through the answer with a deliberately tiny model, a 7-bit counter with reset and load, written four ways: with a C static (Sample 12.7), with per-instance storage reached through a chandle (Sample 12.9), as a C++ class behind static wrappers (Samples 12.13–12.14) and as a transaction-level C++ model wrapped in an SV class (Samples 12.15–12.17). This guide follows the same path, runs every version in Verilator 5.052, and adds what the book leaves out: what really happens with two instances, how a null or dangling handle fails, how the generated prototype header catches a type mismatch, what leaks and why, and which thread calls your model when Verilator runs multithreaded.
Everything printed in this page comes from the lab, and lab/check.py asserts it (43 checks).
0.1Models have memory
From the simulator's point of view an imported DPI function is a plain C call: arguments go in, results come out, and the call returns before simulation time can advance (imported tasks can consume time only by calling exported SV tasks, see step 05). Nothing in that contract keeps data alive between two calls. If your model needs memory, the C side has to put it somewhere that outlives the call frame. There are only three such places in a C program:
- static storage (a
staticlocal or a global): one copy per process, created beforemain, never freed; - the heap (
malloc/new): as many copies as you allocate, alive until you free them; - someone else's storage: the simulator's, reached through a handle it gives you (the SV variables passed as
output/inout, or user data attached to a scope, step 05).
The counter is the smallest model that exercises all the problems: it has inputs (i, reset, load), an output, internal state, and in a real testbench you want more than one of it. It is 7 bits wide on purpose, so the C unsigned char that holds it is wider than the hardware and the code must mask (§12.2).
0.2State in a C static (Sample 12.7)
The first version is the one everybody writes before thinking about simulation: the count is a static local of the function.
// lab/static_counter.c (Sample 12.7, corrected)
#include "svdpi.h"
extern "C" void counter7(svBitVecVal* o, const svBitVecVal* i,
const svBit reset, const svBit load) {
static unsigned char count = 0; // one copy per process, shared by every caller
if (reset) count = 0;
else if (load) count = *i;
else count++;
count &= 0x7f; // 7-bit counter: mask the 8th bit of the char
*o = count;
}
Mapping, as in step 02: output bit [6:0] arrives as svBitVecVal* (one 32-bit word, C writes through it), input bit [6:0] as const svBitVecVal*, and the scalar input bit flags as svBit (an unsigned char holding 0 or 1) passed by value. The extern "C" is not in the book: Verilator compiles .c files with the C++ compiler (the lab's build log shows clang's treating 'c' input as 'c++' warning), so without it the symbol would be mangled. We come back to that in 0.8.
OCR and print aside, Sample 12.7 has two real defects for a 2026 reader: no extern "C" (fine for VCS 2012 compiling C, fatal in any flow that compiles it as C++), and the PLI call io_printf from veriuser.h in Sample 12.9, which Verilator does not provide. Use printf plus fflush(stdout), or vpi_printf if you link VPI.
With one caller this works perfectly. Sample 12.8 drives it from a single program and gets 0, 0 after reset, 126 after load, 127 after counting. The lab keeps that idea but turns the caller into a module, because that is how you would use it for real: one module wraps one model, and you instantiate the module wherever you need a counter.
// lab/static_counter.sv
import "DPI-C" function void counter7(output bit [6:0] out, input bit [6:0] in,
input bit reset, load);
module cnt_static #(parameter bit [6:0] INIT = 0) (input bit clk, reset, load);
bit [6:0] out;
always @(posedge clk) begin
counter7(out, INIT, reset, load); // every instance calls the SAME C function
$display("LOG static t=%0t inst=%m out=%0d", $time, out);
end
endmodule
module top;
bit clk, reset, load;
always #10 clk = ~clk;
cnt_static #(120) u1 (.*); // book values: i1 = 120 ...
cnt_static #(10) u2 (.*); // ... and i2 = 10
initial begin
@(negedge clk) load = 1; // both load at the next posedge
@(negedge clk) load = 0; // then both count
repeat (2) @(negedge clk);
$finish;
end
endmodule
0.3Instantiate it twice
Predict before reading on: two counters, u1 loads 120 and u2 loads 10 at t = 30, then both count. What should they print at t = 50 and 70? (121, 122 and 11, 12.) Here is what Verilator prints:
LOG static t=10 inst=top.u2 out=1
LOG static t=10 inst=top.u1 out=2
LOG static t=30 inst=top.u2 out=10
LOG static t=30 inst=top.u1 out=120
LOG static t=50 inst=top.u2 out=121
LOG static t=50 inst=top.u1 out=122
LOG static t=70 inst=top.u2 out=123
LOG static t=70 inst=top.u1 out=124
There is only one count in the process. Every call from every instance reads and writes it, so each clock it advances by two, and each instance sees whatever the other one left. At t = 10 u2 runs first and gets 1, then u1 gets 2. At t = 30 both load: u2 writes 10 (and reads 10 back), then u1 overwrites it with 120. From then on u2 sees u1's value + 1.
count; the second caller in each time step sees the first one's update. The bug is invisible on the load cycle (both values look right) and appears the first time both count.The load cycle hides the bug. At t = 30 both instances print exactly what you expect, because each one writes the shared variable and immediately reads its own value back. A test that only checks "load then read" passes. You need two instances doing read-modify-write in the same time step to see the corruption.
Verilator 5.052 ran u2's always before u1's, the reverse of the declaration order. IEEE 1800 leaves the order of processes woken by the same event unspecified, so another simulator may run u1 first and print a different (equally wrong) sequence. A model whose output depends on that order is broken by definition. Static state shared across callers turns an "unspecified order" into a real race.
When is a static fine? When there is genuinely one of the thing: a single golden model of the whole chip, a log file handle, a lookup table built once, a global statistic (Ex. 12.4 asks for a call counter across all instances: that one should be static). The rule is to make the sharing deliberate.
0.4The chandle type
To have one counter per instance, each caller must tell C which counter it means. The SV type designed for that is chandle (IEEE 1800-2023 §6.14, Spear §12.2.2): a variable wide enough to hold a C pointer on the host (64 bits on any machine you will use), whose content SV is not allowed to interpret. On the C side it is void*.
| Operation on a chandle (§6.14) | LRM | Verilator 5.052, --lint-only -Wall |
|---|---|---|
| Default value | null | null (lab: default_is_null=1) |
h == null, h != g, if (h), assign chandle to chandle, pass to/return from DPI | legal | accepted |
int x = h; (to another type) | illegal | only a WIDTHTRUNC warning (64 → 32 bits) |
g = h + 1; (arithmetic) | illegal | accepted silently |
assign g = h; (continuous assignment) | illegal | accepted silently |
always @(h) (event expression) | illegal | accepted silently |
| member of a packed struct/union | illegal | error |
port of a module, rand, untagged union | illegal | (not tested) |
Verilator models a chandle as a 64-bit integer (QData; the internal wrapper in the failed link of 0.8 is literally Vdpiimwrap_counter7_free_TOP____024unit(unsigned long long)). That is why it tolerates pointer arithmetic and continuous assignments. Code that relies on it will not compile on Questa, VCS or Xcelium. Treat a chandle as opaque: store it, compare it with null, pass it back to C. Nothing else.
0.5Per-instance state (Samples 12.9 and 12.10)
The pattern is the C version of a constructor: a _new function allocates one state object and returns its address as the chandle; every other function takes the chandle as its first argument. Add a _free and a live-object counter and you have a model you can test for leaks.
// lab/chandle_counter.c (Sample 12.9, corrected)
typedef struct { unsigned char cnt; } c7; // the state of ONE counter instance
static int live = 0; // how many c7 objects exist (leak detector)
extern "C" {
void* counter7_new(void) {
c7* c = (c7*)malloc(sizeof(c7)); // the cast is mandatory in C++
c->cnt = 0;
live++;
return c; // becomes an SV chandle
}
void counter7_free(void* inst) { if (inst) { free(inst); live--; } }
int counter7_live(void) { return live; }
int counter7(void* inst, svBitVecVal* count, const svBitVecVal* i,
const svBit reset, const svBit load) {
if (!inst) { printf("LOG cerr counter7 called with a null chandle\n"); return -1; }
c7* c = (c7*)inst;
if (reset) c->cnt = 0;
else if (load) c->cnt = *i;
else c->cnt++;
c->cnt &= 0x7f;
*count = c->cnt;
return 0;
}
}
On the SV side, each module instance owns one chandle. It constructs in an initial block and frees in a final block, the closest SV has to a constructor/destructor pair for a module:
// lab/chandle_counter.sv (excerpt)
import "DPI-C" function chandle counter7_new();
import "DPI-C" function void counter7_free(input chandle inst);
import "DPI-C" function int counter7_live();
import "DPI-C" function int counter7(input chandle inst, output bit [6:0] out,
input bit [6:0] in, input bit reset, load);
module cnt_inst #(parameter bit [6:0] INIT = 0) (input bit clk, reset, load);
chandle inst; // null until constructed
bit [6:0] out;
initial inst = counter7_new(); // one C object per module instance
final begin // SV has no destructor: free it yourself
counter7_free(inst);
$display("LOG final inst=%m live_after_free=%0d", counter7_live());
end
always @(posedge clk) begin
void'(counter7(inst, out, INIT, reset, load));
$display("LOG inst t=%0t inst=%m out=%0d", $time, out);
end
endmodule
Same stimulus as 0.3, now correct, and the final blocks return the live count to zero:
LOG inst t=10 inst=top.u2 out=1
LOG inst t=10 inst=top.u1 out=1
LOG inst t=30 inst=top.u2 out=10
LOG inst t=30 inst=top.u1 out=120
LOG inst t=50 inst=top.u2 out=11
LOG inst t=50 inst=top.u1 out=121
LOG inst t=70 inst=top.u2 out=12
LOG inst t=70 inst=top.u1 out=122
LOG live before_final=2
LOG live top_final=2
LOG final inst=top.u2 live_after_free=1
LOG final inst=top.u1 live_after_free=0
Two details from the log are worth noticing. First, LOG live top_final=2 printed before the instances freed their objects: Verilator ran top's final block first, then u2's, then u1's. The order of final blocks is unspecified, so never read "after cleanup" state from another final block. Second, the C and the C++ versions of this model (0.8) produce identical LOG lines with the same SV file, which is the point Spear makes in §12.3.2.
The chandle pattern is the C-ABI version of a class: _new is the constructor, the chandle is this, every function takes this first, _free is the destructor. Every C library you will ever wrap for a testbench (a codec, a crypto library, the Python interpreter in step 07) already has this shape.
0.6Null chandles
A chandle that was never assigned is null, and the call compiles and runs. What happens next depends only on the C code. The lab passes a never-constructed chandle on purpose (+null plusarg) to two versions of the C model:
| C model | Guard | Result with a null chandle |
|---|---|---|
chandle_counter.c | if (!inst) { printf(...); return -1; } | prints LOG cerr counter7 called with a null chandle, returns −1, simulation continues |
book_sig.c (Sample 12.9 as printed) | none | segmentation fault, the whole simulator exits with status 139 (RUN_RC=139), no SV error, no final blocks |
A crash in DPI code takes the simulator down with it: no $error, no coverage database, no waveform flush. That is the price of running in the same process. Guard every entry point that takes a handle; return an error code (or call $fatal through an exported function, step 05) and let SV decide. Where a handle comes from SV, a null check is input validation at a trust boundary.
0.7The prototype header catches type mismatches
Sample 12.9 declares the handle parameter as c7 *inst while SV declares chandle. Is that legal? It links and runs: the lab builds book_sig.c exactly like that and gets the correct counts. The reason is that a C-linkage symbol is just a name. The linker matches _counter7 with _counter7 and never sees parameter types. A mismatch that does matter (say SV passes int where C expects longint) links just as quietly and corrupts data at run time.
Verilator generates the antidote. For every import it writes a C prototype into V<top>__Dpi.h in the build directory, and since your C files are compiled from that directory with -I., a single #include "Vtop__Dpi.h" makes the compiler check your definition against the SV declaration (the lab's book_sig.c does it under -DCHECK_PROTO):
../../book_sig.c:17:7: error: conflicting types for 'counter7'
int counter7(c7* inst, svBitVecVal* count, const svBitVecVal* i, // <- c7*, not void*
^
./Vtop__Dpi.h:20:16: note: previous declaration is here
extern int counter7(void* inst, svBitVecVal* out, const svBitVecVal* in, svBit reset, svBit load);
Make #include "V<top>__Dpi.h" a rule for every DPI source file. It costs nothing in Verilator and turns a class of silent run-time corruptions into compile errors. Commercial simulators have the same feature under another name: vlog -dpiheader (Questa), xrun -dpiheader (Xcelium), and VCS writes vc_hdrs.h. Step 01 shows the header in detail.
0.8C++ behind a C ABI (Samples 12.13 and 12.14)
Most real models are C++: a class with private state and methods. The DPI cannot call a method directly. A method needs an object (this), and objects don't exist at link time, while DPI resolves imports by symbol name at link time (§12.3.2). The solution is the same as for C: one free function per method, with C linkage, taking the object pointer as the first argument.
// lab/counter7.cpp (Samples 12.13 + 12.14)
class Counter7 {
public:
static int live; // objects alive right now
Counter7() : cnt(0) { live++; }
~Counter7() { live--; }
void signal(svBitVecVal* count, const svBitVecVal* i, svBit reset, svBit load) {
if (reset) cnt = 0;
else if (load) cnt = *i;
else cnt++;
cnt &= 0x7f;
*count = cnt;
}
private:
unsigned char cnt;
};
int Counter7::live = 0;
extern "C" {
void* counter7_new(void) { return new Counter7; }
void counter7_free(void* inst) { delete static_cast<Counter7*>(inst); } // delete nullptr: no-op
int counter7_live(void) { return Counter7::live; }
int counter7(void* inst, svBitVecVal* count, const svBitVecVal* i, const svBit reset, const svBit load) {
if (!inst) { printf("LOG cerr counter7 called with a null chandle\n"); return -1; }
static_cast<Counter7*>(inst)->signal(count, i, reset, load);
return 0;
}
}
The SV side is chandle_counter.sv, unchanged. The lab builds it once against the C model and once against this C++ model, and check.py asserts that the two runs print identical LOG lines.
Forget extern "C" and the C++ compiler mangles the names (on macOS counter7_new() becomes __Z12counter7_newv), while the simulator calls the plain C names. The build fails at link time, and the error names exactly what the simulator was looking for (lab, nocxx.cpp):
"_counter7_new", referenced from:
Vtop___024root___eval_initial(Vtop___024root*) in Vtop__ALL.a[2](Vtop__ALL.o)
Vtop___024unit____Vdpiimwrap_counter7_new_TOP____024unit(unsigned long long&) in Vtop__ALL.a[2](Vtop__ALL.o)
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
Including Vtop__Dpi.h also fixes this one. The header wraps its prototypes in extern "C" { … }, and in C++ a later definition with the same signature inherits the linkage of the earlier declaration. The lab proves it: the same nocxx.cpp built with -DCHECK_PROTO (which includes the header) links and prints the correct counts. Keep both safety nets anyway: the include, and an explicit extern "C" block around your wrappers.
Exceptions stop at the wrapper. §35.5.1.6 allows C++ behind DPI "as long as C linkage conventions are observed at the language boundary", and adds that exceptions shall not propagate out of an imported subroutine: if one crosses into SystemVerilog the behavior is undefined. The simulator's caller is C-shaped and cannot catch anything. The lab (exc.sv, exc.cpp) throws std::runtime_error from a model method:
LOG exc before r=10
libc++abi: terminating due to uncaught exception of type std::runtime_error: model_step: bad input
RUN_RC=134 # abort: the rest of the test and every final block are gone
An uncaught exception is not an error message, it is abort(): no $error, no scoreboard summary, no final blocks, no flushed coverage. Every extern "C" wrapper around C++ (or around any library that throws, including the C++ STL: std::vector::at, std::stoi, new) catches everything and turns it into a return code. With try { … } catch (const std::exception& e) { …; return -1; } in the wrapper (-DSAFE), the same test prints C++ error: model_step: bad input, continues with LOG exc after r=-1 and runs its final block. Report the failure to SV with the return value or an exported error function (step 05). The same rule applies to setjmp/longjmp across an export (§35.5.3).
0.9Transaction-level models (Samples 12.15–12.17)
The signal-level counter is called on every clock edge, by every instance, whether or not anything changed. That is fine for a counter and slow for a CPU model. A transaction-level model exposes operations instead (reset, load, count, get), and the testbench calls them only when something happens. Each method gets a static wrapper as before. The new part is on the SV side: an SV class wraps the C++ object, so the chandle never appears in test code.
// lab/tlm.sv (excerpt; Sample 12.17 plus an explicit free())
class Counter7;
static int n_wrappers; // SV-side static: shared by every Counter7 object
local chandle inst; // per-object: which C++ object this wrapper owns
function new();
inst = counter7_new();
n_wrappers++;
endfunction
function void free(); // the "destructor" SV does not have
if (inst != null) counter7_delete(inst);
inst = null; // second free() / use after free becomes a no-op / error
endfunction
function void count(); counter7_count(inst); endfunction
function void load(bit [6:0] val); counter7_load(inst, val); endfunction
function void reset(); counter7_reset(inst); endfunction
function bit [6:0] get(); return 7'(counter7_get(inst)); endfunction
function bit alive(); return inst != null; endfunction
endclass
Note counter7_get returns int, not bit [6:0]. An import can only return small scalar types (void, byte, shortint, int, longint, real, shortreal, chandle, string, and scalar bit/logic). A packed vector would need a pointer, so it must come back through an output argument (step 02). The class converts with a cast. In the lab, reset; load(126); count; count gives get=0: 126 → 127 → 0 in 7 bits.
Signal-level vs transaction-level is the same trade-off as cycle-accurate vs functional reference models. Signal level costs one DPI call per instance per clock and is easy to align with the RTL cycle by cycle. Transaction level costs one call per transaction, and you compare at transaction boundaries in a scoreboard. Golden models for datapaths (step 06) are almost always transaction-level.
0.10Lifetime: SV garbage collection meets the C++ heap
SV class objects are garbage-collected: when no handle points to an object, the simulator may reclaim it. SV has no destructor, so nothing runs when that happens. The C++ object the chandle points to is invisible to the collector and stays allocated. The lab measures this with the C++ live-object counter (output of tlm.sv):
c2 = new c1; // shallow copy: copies the chandle, not the C++ object
c2.count();
// LOG shallow c1=1 c2=1 live=1 wrappers=1 <- one C++ object; new() did not run
for (int k = 0; k < 100; k++) begin tmp = new; tmp.count(); end
tmp = null;
// LOG leak live=101 wrappers=101 <- 100 C++ counters nobody can reach
for (int k = 0; k < 100; k++) begin tmp = new; tmp.count(); tmp.free(); end
// LOG noleak live=101 <- free() before dropping: no new leaks
c1.free(); // frees the object c2 ALSO points to
// LOG after_free c1_alive=0 c2_alive=1 live=100 <- c2 now holds a dangling chandle
new c1) creates a second owner of one C++ object; freeing through one leaves the other dangling. The lab does not call c2.count(): that is undefined behavior and may "work" for years.Three ownership policies work. Pick one per wrapper class and write it in the class header comment:
- Explicit free, as above: the test calls
free();free()nulls the chandle so a second call is harmless. Forbid shallow copies by convention. - Deep copy: a
copy()method that calls a C++ copy constructor through a_clonewrapper, so every SV wrapper owns exactly one C++ object (exercise T8). - Owned by a module: create in
initial, free infinal(0.5). Good for one model per instance that lives for the whole simulation. Leaks don't matter if the process ends, but a long regression that builds thousands of objects per test does run out of memory.
UVM objects are cloned with do_copy. If a uvm_object holds a chandle, its do_copy must either clone the C++ object or share it with reference counting. The default (copy the field) is the shallow copy of Fig. 0.5.
0.11Four homes for state
Combining 0.2–0.10, state for a DPI model can live in four places, with four sharing scopes. The lab exercises all four in tlm.sv and tlm.cpp: Counter7::live (C++ static), the cnt member (C++ heap object), Counter7::n_wrappers (SV class static) and inst (SV object member).
A detail the lab shows: n_wrappers stayed at 1 after c2 = new c1, because a shallow copy does not run new(). Any SV-side bookkeeping you do in the constructor (counting, registering, allocating a C++ object) is skipped by a shallow copy.
0.12pure and hidden state
Step 01 introduced pure: a promise that the result depends only on the inputs and the call has no side effects (IEEE 1800-2023 §35.5.2). The standard lets the simulator exploit the promise: it may eliminate a call whose result is unused, or merge two calls with the same arguments. A stateful model breaks the promise by definition. A counter's count() returns different values for the same (empty) input. Never import a function with state as pure.
How hard does Verilator 5.052 punish the lie? The lab imports a bump() that counts its calls as pure, calls it with duplicated arguments, discards a result with void'(), and overwrites a result in a loop:
import "DPI-C" pure function int bump(input int x); // a lie: bump() counts its calls
...
a = bump(1); // used result
b = bump(1); // same argument: may be merged with the first
void'(bump(5)); // result discarded: may be removed
for (int k = 0; k < 10; k++) c = bump(k); // only the last value of c is used
// LOG pure a=2 b=2 c=10 calls=13 (same with and without `pure`)
All 13 calls ran. Verilator 5.052 did not remove or merge anything here. That is a measurement of one tool version on one test, not a guarantee. The place where pure does change Verilator's behavior today is threading.
0.13Threads: who calls your model?
Commercial simulators evaluate SV processes on one thread, so DPI code runs on that thread. Verilator can partition the design across OS threads (--threads N) and has to decide whether a DPI import may run on any of them. The option --threads-dpi controls it. The manual says: all assumes every import is thread-safe ("Any DPI libraries need appropriate mutexes"); none "will serialize calls to DPI imports"; pure, the default, assumes pure imports are thread-safe and non-pure ones are not.
The lab measures it: four independent modules, two calling a pure import and two a non-pure one, 64 calls per clock each, 2000 clocks. Each call records std::this_thread::get_id() in a mutex-protected set.
| Build | OS threads seen by pure import | by non-pure import |
|---|---|---|
| (no --threads) | 1 | 1 |
| --threads 2 | 2 | 1 |
| --threads 2 --threads-dpi none | 1 | 1 |
| --threads 2 --threads-dpi all | 2 | 1 |
The default puts pure imports on two OS threads, so two pure calls can run at the same time. In this design all did not spread the non-pure calls, but the manual allows it. The simulation result was identical in all four builds (check.py compares the accumulators).
--threads 2, two pure calls can overlap in time. Any shared state they touch (a static counter, a global cache, a logging stream) needs a mutex or an atomic, or the import must not be pure."pure" now means two things: no side effects (LRM, optimizer) and thread-safe (Verilator scheduler). A model with any shared state, including a static call counter for statistics or a printf to a shared log, is neither. Default to non-pure. Mark pure only small math functions whose code you can read in one screen.
Questa, VCS and Xcelium evaluate SV on one thread, so DPI code runs serially. Their multicore modes (VCS FGP, Xcelium MCE) restrict or serialize DPI, and the exact rules are vendor-documented. A model that is correct only because calls are serialized is a latent bug the day someone turns on threads, and Part B makes it acute: the CPython interpreter of step 07 is not thread-safe without the GIL.
0.14Recipe: a stateful C/C++ model behind DPI
- C++ class with the model, no DPI types inside if you can help it (convert at the wrapper).
- One
extern "C"block with_new,_delete, and one wrapper per operation, each takingvoid*first and checking it against null. - Include
V<top>__Dpi.hin the wrapper file so the compiler checks every signature. - An SV class with a
local chandle, a constructor that calls_new, afree()that deletes and nulls, and methods with SV types. Test code never sees the chandle. - Decide copy semantics: forbid shallow copies, or provide
copy()via a C++ copy constructor. - Count live objects in C++ and assert zero at the end of the test (a one-line leak detector).
- Never
purefor anything stateful; mutex any shared state if you build with--threads. - Need the instance without passing a handle? Key a map by
%m(exercise T10), or use the standard scope user data,svPutUserData/svGetUserDatawith acontextimport (step 05).
§1 · ObjectivesWhat you can do after this step
- Explain why a C
staticin a DPI model is one copy per process, and predict the corrupted output of two instances sharing it. - Write the chandle pattern (
_new/ operations /_free) in C and in C++, with null guards and a live-object counter. - State what an SV
chandlemay and may not do per IEEE 1800-2023 §6.14, and where Verilator is more permissive. - Use the generated
V<top>__Dpi.hto turn signature mismatches and missingextern "C"into compile errors. - Wrap a transaction-level C++ model in an SV class, and choose an ownership policy (explicit free, deep copy, module-owned).
- Diagnose leaks, dangling chandles from shallow copies, and crashes from null handles.
- Decide when an import may be
pure, and what--threads/--threads-dpido to a stateful model.
Prerequisites: 01 First call (import, pure, build flow), 02 Data types (svBit, svBitVecVal, argument directions).
§2 · Exercises11 graded exercises, with solutions
★ basic · ★★ intermediate · ★★★ real design. Every code solution is in lab/ex/ and checked by check.py. "Ex. 12.N" = exercise from the book (which has no solutions). T1–T5 are the book's shifter, built up step by step.
T1 ★Ex. 12.2. Write shift_c(i, n, ld) in C: when ld is 1, shift i by n (left if n > 0, right if negative) and load it into an internal 32-bit register; when ld is 0, shift the register itself. Return the register. Test it from SV, including a shift by 40.
Show solution
// lab/ex/shift_static.c
static uint32_t shift(uint32_t v, int n) {
if (n >= 32 || n <= -32) return 0; // C: shifting by >= width is undefined behavior
return n >= 0 ? v << n : v >> -n;
}
extern "C" uint32_t shift_c(uint32_t i, int n, unsigned char ld) {
static uint32_t reg = 0; // the single, shared internal register
reg = shift(ld ? i : reg, n);
return reg;
}
import "DPI-C" function int unsigned shift_c(input int unsigned i, input int n, input bit ld);
// shift_c('hF0, 4, 1) = f00 shift_c(0, -8, 0) = f shift_c(0, 0, 0) = f
// shift_c('h8000_0001, 0, 1) = 80000001 shift_c(0, 1, 0) = 2 shift_c(0, 40, 0) = 0
The register is a static because the exercise asks for exactly one. The guard for |n| ≥ 32 is not optional: v << 40 on a 32-bit value is undefined in C, and x86 masks the count to 5 bits, so it would compute v << 8. SV defines the result as 0, and the golden model must match SV, not the CPU.
T2 ★★Ex. 12.3. Make the shifter multi-instance. Each instance needs a unique identifier: use the address of its storage, and print it with every call. Instantiate it twice and call each instance twice.
Show solution
// lab/ex/shift.c (excerpt)
typedef struct { uint32_t reg; int calls; } shifter;
extern "C" {
void* shift_new(uint32_t init) { shifter* s = (shifter*)calloc(1, sizeof *s); s->reg = init; return s; }
void shift_free(void* h) { free(h); }
uint32_t shift_c(void* h, uint32_t i, int n, unsigned char ld) {
shifter* s = (shifter*)h;
s->reg = shift(ld ? i : s->reg, n);
s->calls++; total_calls++;
printf("LOG ex3 addr=%p i=%08x n=%d ld=%d reg=%08x\n", h, i, n, ld, s->reg);
return s->reg;
}
}
Lab output (addresses vary per run; check.py asserts that the two instances have different addresses and that each address repeats):
LOG ex3 addr=0x6000028d4070 i=00000000 n=4 ld=0 reg=00000010
LOG ex3 addr=0x6000028d4080 i=00000000 n=-4 ld=0 reg=08000000
LOG ex3 addr=0x6000028d4070 i=00000000 n=1 ld=0 reg=00000020
LOG ex3 addr=0x6000028d4080 i=00000000 n=-1 ld=0 reg=04000000
T3 ★Ex. 12.4. Extend T2 to report the total number of shift_c calls across all instances, plus the per-instance count.
Show solution
Total = a static int total_calls (deliberately shared); per instance = a field of the struct. Both are incremented in shift_c; two getters expose them: int shift_total(void) and int shift_calls(void* h). Lab: calls_a=2 calls_b=2 total=4. This is the "static is fine when there really is one of it" case from 0.3. Under --threads the total would need std::atomic<int> if the import were pure (0.13).
T4 ★Ex. 12.5. Let the SV caller set the register's initial value when it creates an instance.
Show solution
Constructor arguments are ordinary DPI inputs: void* shift_new(uint32_t init), imported as function chandle shift_new(input int unsigned init). Lab: a = shift_new(1); b = shift_new('h8000_0000), then a shifted by 4 and 1 gives 20, b shifted by −4 and −1 gives 04000000.
T5 ★★Ex. 12.6. Encapsulate the shifter in an SV class so that the chandle never appears in test code.
Show solution
// lab/ex/shift_class.sv (excerpt)
class Shift;
local chandle h;
function new(int unsigned init = 0); h = shift_new(init); endfunction
function void free(); if (h != null) shift_free(h); h = null; endfunction
function int unsigned load(int unsigned i, int n = 0); return shift_c(h, i, n, 1); endfunction
function int unsigned shift(int n); return shift_c(h, 0, n, 0); endfunction
endclass
// s1 = new('hF); s2 = new; s2.load('hAB, 8); -> s1.shift(4) = f0, s2.shift(-4) = ab0, total = 3
local makes the handle private to the class, so test code cannot copy it out and free it twice.
T6 ★Predict. In static_counter.sv (0.2), change the stimulus so that reset is held at 1 for the whole simulation. What do u1 and u2 print? Does the shared static still cause a visible bug?
Show solution
Both print 0 every cycle (lab: static_reset.log, run with +hold_reset). Reset writes a constant, so the read-modify-write never depends on the other instance's update. The shared state is still there, it just isn't observable with this stimulus. Same lesson as the load cycle in 0.3: the corruption appears only when both callers do read-modify-write on the shared variable in the same time step. Tests that only reset and load prove nothing about instance isolation.
T7 ★★Find the bug. A colleague moved the counter to C++ and the build now fails with "_counter7_new", referenced from: … ld: symbol(s) not found. The function is right there in the .cpp. What is wrong, and what are two ways to fix it?
Show solution
The wrappers are compiled with C++ linkage, so the object file defines __Z12counter7_newv (lab: nm build/vl_nocxx/nocxx.o), while the simulator references the C name _counter7_new. Fix 1: wrap the wrappers in extern "C" { … }. Fix 2: #include "Vtop__Dpi.h" before the definitions; its extern "C" declarations give the definitions C linkage (lab build nocxxh). Do both.
T8 ★★Write. The Counter7 SV class of 0.9 shares its C++ object on new c1. Add a copy() method that returns an independent counter with the same value, and show that freeing the original leaves the copy intact.
Show solution
// lab/ex/clone.cpp (excerpt): C++ copy constructor + one more wrapper
Counter7(const Counter7& o) : cnt(o.cnt) { live++; }
extern "C" void* counter7_clone(void* inst) { return new Counter7(*obj(inst)); }
// lab/ex/clone.sv (excerpt)
class Counter7;
local chandle inst;
function new(chandle from = null); // null: fresh object; else adopt `from`
inst = (from == null) ? counter7_new() : from;
endfunction
function Counter7 copy(); copy = new(counter7_clone(inst)); endfunction
...
endclass
// a.load(5); b = a.copy(); b.count(); -> a=5 b=6 live=2
// a.free(); -> b_after_a_free=6 live=1
// b.free(); -> live_end=0
The constructor adopting an existing chandle is what lets copy() return a properly constructed wrapper. Calling new inside a method of the class constructs an object of that class.
T9 ★★★Design. Write a reference model of a bounded FIFO in C++ (std::deque) behind a chandle, with push returning 0 when full and pop returning 0 when empty (the value comes back through an output). Check two independent instances against SV queues for 2000 random operations.
Show solution
// lab/ex/fifo_model.cpp
struct Fifo { std::deque<uint32_t> q; int depth; };
static Fifo* F(void* p) { return static_cast<Fifo*>(p); }
extern "C" {
void* fifo_new(int depth) { return new Fifo{{}, depth}; }
void fifo_delete(void* f) { delete F(f); }
int fifo_push(void* f, uint32_t v) { if ((int)F(f)->q.size() >= F(f)->depth) return 0; F(f)->q.push_back(v); return 1; }
int fifo_pop(void* f, uint32_t* v) { if (F(f)->q.empty()) return 0; *v = F(f)->q.front(); F(f)->q.pop_front(); return 1; }
int fifo_size(void* f) { return (int)F(f)->q.size(); }
}
import "DPI-C" function int fifo_pop(input chandle f, output int unsigned v); // output -> uint32_t*
...
chandle m [2]; int unsigned q [2][$]; // two models, two SV reference queues
// each step: pick k, push or pop on both, compare accept/reject, popped value and size
Lab: errors=0 over 2000 operations, with both full and empty rejections exercised. An array of chandles is legal (an unpacked array of an unpacked type), which is how a testbench holds one model per channel.
T10 ★★★Design. Keep per-instance state without passing a chandle: each module instance identifies itself by its hierarchical name. Implement an accumulator acc_by_name(who, x) and instantiate three accumulators with steps 1, 10 and 100. What does this cost compared with a chandle?
Show solution
// lab/ex/registry.cpp
static std::map<std::string, int> state;
extern "C" int acc_by_name(const char* who, int x) { return state[who] += x; } // creates on first use
module accum #(parameter int STEP = 1) (input bit clk);
string me = $sformatf("%m"); // computed once, not per call
int sum;
always @(posedge clk) sum <= acc_by_name(me, STEP);
endmodule
// after 5 clocks: a=5 b=50 c=500, 3 entries, b.me = "top.b"
Costs: a string copy and a map lookup (O(log n) string compares) on every call instead of one pointer dereference; state is never freed (no destructor event); and the name is only unique per module instance, not per class object. The benefit: no constructor to forget, and the key is human-readable in C logs. The standard version of this idea is svGetScope() + svPutUserData in a context import (step 05), which keys by scope handle instead of by string.
T11 ★★Find the bug. A golden model has import "DPI-C" pure function int crc_step(input int crc, input byte b); and, for statistics, crc_step increments a global static long n_bytes. Simulation is built with verilator --threads 4. Name two independent problems.
Show solution
(1) The pure promise is false: the function has a side effect, so the LRM allows the simulator to drop calls whose result is unused or merge repeated calls. The statistic may be wrong on another tool (Verilator 5.052 happened to run all calls in the lab's test, 0.12). (2) Data race: under --threads 4 with the default --threads-dpi pure, Verilator assumes pure imports are thread-safe and calls them from several OS threads (lab: 2 threads seen with --threads 2), so n_bytes++ is an unsynchronized read-modify-write and loses counts. Fix: drop pure (the call gets serialized), or keep the CRC pure and move the statistic out of it (count in SV, or std::atomic<long>).
§3 · Quiz14 questions, self-grading
1. A C model keeps its state in a static local. The SV module that calls it is instantiated 4 times. How many copies of the state exist?
context lets C ask which scope is calling (step 05), but it does not duplicate C storage.2. In the lab, two instances share the static counter. On the cycle where both load, what do they print?
bit is 2-state and the calls are sequential.3. What is the C type of an SV chandle argument in the generated Vtop__Dpi.h?
void*, and the header says so: extern int counter7(void* inst, …). Verilator stores it internally as a 64-bit integer, but the C-visible type is void*. (svScope is the opaque handle type for scopes, a different thing.) You may declare c7* in your own definition and it links, but then the header include fails to compile.4. Sample 12.9 declares counter7(c7 *inst, …) while SV passes a chandle. Without including Vtop__Dpi.h, what happens in Verilator?
_counter7 by name, and c7* and void* have the same representation. The mismatch is caught only when the definition meets the generated prototype (answer a, with the include).5. Which of these did Verilator 5.052 reject under --lint-only -Wall?
int x = h got a width warning). Code that compiles in Verilator is not proof that it is legal SV.6. The SV side calls counter7(h, …) with a chandle that was never assigned, and the C function dereferences it without a check. Most likely outcome?
NULL, and the dereference is a segmentation fault in the simulator's own process (lab: exit status 139). SV has no chance to report anything. Guard handles at every C entry point.7. Why can't SV import a C++ member function like Counter7::count directly?
8. An SV wrapper class holds a chandle to a C++ object. The test does c = new; … c = null; and never calls free(). What happens to the C++ object?
live=101). The OS reclaims everything when the process exits, which is why the leak only bites in long regressions.9. c2 = new c1; where the class holds local chandle inst. Afterwards:
new c1 is a shallow copy: it copies every property, including the chandle, without calling new(). Lab: c1=1 c2=1 live=1 wrappers=1. After c1.free(), c2 holds a dangling pointer. local restricts access, not copying.10. Where does Ex. 12.4's "total calls across all instances" counter belong?
11. A stateful function is imported as pure. In the lab, how many of 13 calls did Verilator 5.052 execute, and what does that prove?
pure does change something real: it lets the call run on several threads (0.13).12. Built with verilator --threads 2 and default options, which imports may be called from more than one OS thread?
--threads-dpi pure treats pure imports as thread-safe. The lab saw 2 distinct thread ids for the pure import and 1 for the non-pure one. --threads-dpi none serializes everything; all allows every import on any thread.13. Why does the corrected Sample 12.9 need (c7*)malloc(...) with an explicit cast in this tutorial, when plain C does not?
treating 'c' input as 'c++'. Your "C" file is C++ in a Verilator flow: casts from void* and extern "C" are required. Commercial flows usually compile .c files as C, so portable DPI code is written to compile both ways.14. A module creates its model in initial and frees it in final. Another module's final block prints the live-object count. What will it print?
final blocks. In the lab Verilator ran top's first (it printed live=2), then u2's, then u1's. Do end-of-test leak checks somewhere that runs after all frees, e.g. in C++ at process exit, or explicitly after you call the frees yourself.§4 · LabStatic vs instance state, handles, C++ wrappers, leaks, threads
Run it
cd tutorial/03_state_and_handles/lab
python3 check.py # builds ~22 models with Verilator, runs them, asserts 50 facts (~15 s with ccache, ~1 min cold)
python3 check.py --no-run # re-check the logs in build/ without rebuilding
One Segmentation fault: 11 line on stderr during run.sh is expected: it is the booksig_null experiment (0.6). Logs are in build/<name>.log, build output in build/<name>.build, and each model's generated Vtop__Dpi.h in build/vl_<name>/.
Predict first
- Two instances sharing the static counter, load 120 and 10, then count twice: what does each print? (0.3)
- Same SV file, C model vs C++ model: same output?
- Book signature
c7*: builds? With the header included? - After 100
tmp = newwithoutfree(): how many C++ objects are alive? - With
--threads 2: on how many OS threads does a pure import run? A non-pure one?
Experiments and results (Verilator 5.052, macOS x86_64)
| Build / log | Files | What it shows | Result |
|---|---|---|---|
| static | static_counter.sv/.c | Sample 12.7 state shared by 2 instances | u2: 1, 10, 121, 123 · u1: 2, 120, 122, 124 |
| static_reset | same, +hold_reset | T6: sharing invisible under reset | all 0 |
| chandle | chandle_counter.sv/.c | per-instance state, initial/final ownership | u2: 1, 10, 11, 12 · u1: 1, 120, 121, 122 · live 2 → 0 |
| cpp | chandle_counter.sv + counter7.cpp | Samples 12.13/12.14 behind the same SV | identical LOG lines |
| chandle_null / booksig_null | +null | null chandle with / without guard | −1 and continues / SIGSEGV, exit 139 |
| booksig / booksigh | book_sig.c ± -DCHECK_PROTO | c7* vs void* | links and runs / conflicting types for 'counter7' |
| nocxx / nocxxh | nocxx.cpp ± header | missing extern "C" | link error "_counter7_new" / links (header gives C linkage) |
| exc_* | exc.sv/.cpp | C++ exception across the boundary (§35.5.1.6), and caught in the wrapper (-DSAFE) | abort, RUN_RC=134, no final · safe: r=-1, final runs |
| tlm | tlm.sv/.cpp | Samples 12.15–12.17, shallow copy, leak, dangling | get 0 · shallow live 1 · leak live 101 · after free c2 dangling |
| pure / honest | pure_lie.sv, pure_honest.sv | stateful function imported as pure | 13 calls in both |
| thr1 / thr2 / thr2none / thr2all | threads.sv/.cpp | OS threads per import | pure: 1 / 2 / 1 / 2 · non-pure: 1 / 1 / 1 / 1 |
| chandle_rule1–6 | neg/ | §6.14 rules under lint | only packed-struct is an error; int x = h warns |
| ex2, ex345, ex6, clone, fifo, registry | ex/ | exercise solutions | see §2 |
What to look at
build/vl_chandle/Vtop__Dpi.h: the C view of your imports. Notevoid* instand the parameter names taken from the SV formals.build/booksigh.build: the compiler comparing your definition with the header.build/nocxx.build, andnm build/vl_nocxx/nocxx.o | grep counter7: mangled__Z12counter7_newvvs the_counter7_newthat Verilator references.- Edit
tlm.svto callc2.count()afterc1.free()and run it a few times: undefined behavior that usually "works". Then run it under-CFLAGS -fsanitize=address -LDFLAGS -fsanitize=addressto see it caught (not part of check.py).
§5 · Cheat sheetState and handles on one page
| Construct | SV declaration | C / C++ side | Pitfall |
|---|---|---|---|
| Static state | function void f(...) | static T s; in the function | One copy per process; instances corrupt each other |
| Constructor | function chandle m_new(input ... init) | void* m_new(...) → new/malloc | Cast malloc (compiled as C++) |
| Operation | function int m_op(input chandle h, ...) | int m_op(void* h, ...), null check first | Null → SIGSEGV kills the simulator |
| Destructor | function void m_free(input chandle h) | delete static_cast<M*>(h) | SV never calls it for you |
| C++ method | as an operation | extern "C" wrapper → static_cast<M*>(h)->op() | No extern "C" → mangled name, link error |
| Return a vector | output bit [N-1:0] v | svBitVecVal* v | Imports return only scalars, chandle, string |
| SV wrapper | class M; local chandle h; ... endclass | — | new c1 copies the chandle (shared, then dangling) |
| Module owner | initial h = m_new(); final m_free(h); | — | final order unspecified |
| Prototype check | — | #include "Vtop__Dpi.h" | Without it, type mismatches link silently |
| Threads | pure or not | mutex/atomic for shared state | --threads N runs pure imports concurrently |
Rules
- State that belongs to an instance goes on the heap, reached through a chandle. Statics are only for things that are truly global.
- A chandle is opaque: store it, compare it with
null, pass it back. Verilator lets you do more; the LRM and other tools don't. - Every C entry point that takes a handle checks it for null.
- Every DPI source file includes the generated prototype header and wraps its entry points in
extern "C". - Every
_newhas a_free; a live-object counter that must reach 0 is the cheapest leak test. - Pick one copy policy per wrapper class: no copies, or deep
copy(). - Test instance isolation with two instances doing read-modify-write in the same time step; reset/load tests can't see sharing.
- Stateful ⇒ not
pure.purein Verilator also means "may run on any thread".
Spear & Tumbush, SystemVerilog for Verification, 3rd ed. (Springer, 2012), §12.2–12.3 and Ex. 12.2–12.6; IEEE 1800-2023 §6.14 (chandle), §35.5.1.4 and §35.5.1.6 (memory, C++), §35.5.2 (pure), H.6.6 and H.9.3; Verilator manual, --threads-dpi. Lab: Verilator 5.052, Apple clang, macOS x86_64. Every output in this page comes from 03_state_and_handles/lab and is asserted by check.py.