DPI-C tutorial · Part A · Step 05 · Spear & Tumbush §12.7–12.8
Export and context: when C calls back into SystemVerilog
Up to now C was a leaf: SV called it, it computed, it returned. This step makes the call go both ways. C calls SV functions and tasks through export "DPI-C", and it needs to know which SV instance it is talking to. That is what context and the scope API are for. Verilator implements most of this faithfully. One big piece it rejects outright, and one small piece it gets silently wrong.
§0 · TutorialThe step in one idea
An imported C routine runs somewhere in the SV hierarchy: in the scope where its import declaration sits. As long as C only computes, that place doesn't matter. As soon as C wants to call back into SV (an exported function, a task, VPI), the simulator must know which module instance C stands in, because every instance has its own copy of the exported routine. context is the promise that makes the simulator record that place on every call. The scope API (svGetScope, svSetScope, …) lets C read and change it.
The step follows the book's §12.7–12.8 in order: export syntax, the three import flavors (pure, generic, context), the scope API, exported tasks, and the command-file memory model the book grows across Samples 12.37–12.45. Every sample was rebuilt in the lab on Verilator 5.052. Three results are worth knowing before you start:
- Verilator refuses timing controls inside exported tasks (
#,@, even nested in a called task). The book's timed memory model (Sample 12.40) does not compile. The portable alternative is the pull model: SV owns time, C is an iterator. - A non-
contextimport that calls an export fails fatally when no scope was ever set. When an earlier context call left a scope behind, it silently runs the wrong instance's export (0.6). puredid not save a single call in Verilator, and a DPI function in combinational logic ran 11 times for 5 input changes. Call counts are the simulator's business, never yours (0.4).
0.1Two directions across one boundary
Steps 01–04 used one direction: import "DPI-C" makes a C symbol callable from SV. export "DPI-C" is the mirror. It makes an SV function or task callable from C, under a C symbol the simulator generates. The two combine into a round trip. SV calls an imported C routine, which calls an exported SV routine, which may call another import, and so on. Each crossing is a plain C call on the same machine stack. No threads, no messages, no copies of the simulator state.
context import sets it on entry, and an export call reads it to pick the instance.0.2Your first export (Samples 12.34, 12.35, 12.46)
The export declaration is deliberately bare: export "DPI-C" function name;. There is no return type, no argument list, and not even (). The LRM forbids repeating the signature because it already lives in the SV function declaration, and two copies could drift apart. An optional C name renames it (export "DPI-C" c_name = function sv_name;), exactly like imports in step 01. The C side calls the symbol like any C function. Don't hand-write its prototype: include the generated Vtop__Dpi.h, which lists exports as well as imports.
// lab/scopes.sv: one C routine, two SV scopes (Samples 12.34 + 12.46)
module block;
import "DPI-C" context function void c_display();
export "DPI-C" function sv_display; // no return type, no args, no ()
function void sv_display();
$display("LOG sv where=%m");
endfunction
initial c_display();
endmodule
module top;
import "DPI-C" context function void c_display();
export "DPI-C" function sv_display; // a second, independent export
function void sv_display(); $display("LOG sv where=%m"); endfunction
block b1();
initial #1 c_display();
endmodule
// lab/scopes.c: printf replaces the book's io_printf (a PLI routine Verilator doesn't have)
#include <stdio.h>
#include "svdpi.h"
#include "Vtop__Dpi.h" // generated: prototypes for imports AND exports
void c_display(void) {
const char* file = "?"; int line = 0;
svGetCallerInfo(&file, &line); // SV file:line of the import call
printf("LOG c scope=%s caller=%s:%d\n", svGetNameFromScope(svGetScope()), file, line);
fflush(stdout);
sv_display(); // which one? the export of the CURRENT scope
}
LOG c scope=top.b1 caller=scopes.sv:9
LOG sv where=top.b1.sv_display
LOG c scope=top caller=scopes.sv:19
LOG sv where=top.sv_display
One C symbol sv_display stands for two SV functions, top.sv_display and top.b1.sv_display. The simulator dispatches on the scope that the context import recorded. That is the book's §12.8.5 "meaning of context" in four log lines. The generated header shows the C view:
// build/vl_scopes/Vtop__Dpi.h (excerpt)
// DPI EXPORTS
extern void sv_display();
// DPI IMPORTS
extern void c_display();
What can be exported: functions and tasks declared in a module, interface, program, package or $unit. Class methods cannot be exported (§12.8.4), because no object exists at elaboration time when the C symbol is bound. 0.12 shows the workaround. In one scope, one export per C name. The same name exported from several scopes is legal: that is exactly the two-instance case above.
§35.7 and §35.8: the export declaration must sit in the scope that defines the routine, one export per routine per scope, and the argument and result types obey the same restrictions as imports (so no packed-vector results, step 02). Two more rules are export-only: an export can't have an open-array formal (§35.5.6.1, H.8.2; open arrays exist only in import declarations), and a dynamic-array formal on an export is erroneous (§35.5.6). Shared C names across scopes are fine if the signatures match (§35.4): lab/xshared.sv exports hello from two different modules and C picks one with svSetScope: LOG xshared who=top.u1.hello, then who=top.u2.hello.
Verilator 5.052 does not diagnose the dynamic-array formal, it crashes: lab/xdyn.sv (export "DPI-C" function sum_dyn; with input int a[]) stops the build with %Error: Verilator internal fault, sorry. If you see an internal fault after adding an export, check its formals first.
Arguments when C calls an export. An export has exactly the C header an import with the same SV signature would have (H.8.2), so the type rules of step 02 apply in reverse: small inputs by value, everything else by pointer. The difference is who allocates: now C is the caller, so C provides the storage for every by-reference argument, in canonical layout (H.8.5). For output bit [39:0] v that is svBitVecVal v[SV_PACKED_DATA_NELEMS(40)]; on the C stack; for output int x, an int. For an output string SV writes a pointer into your const char*: the storage belongs to SV, so copy the text if you need it after the next call (H.8.10).
Verilator compiles .c files as C++ (clang warns "treating 'c' input as 'c++'"). The generated header wraps prototypes in extern "C", so including it is also what keeps C++ name mangling from breaking the link (step 01). svGetCallerInfo works and returns the path as given on the command line (ex/report.sv:12 in one exercise, scopes.sv:9 here).
0.3Three flavors of import: pure, generic, context
Every import makes a promise to the SV compiler about what the C code touches. The promise decides what the optimizer may assume and what bookkeeping the simulator must do on each call (IEEE 1800-2023 §35.5.2–35.5.3; book §12.7):
| Flavor | Declaration | C may… | C must not… | Simulator may… |
|---|---|---|---|---|
| pure | import "DPI-C" pure function | compute from its inputs only | touch globals/statics, do I/O, call exports or VPI, have output/inout args; can't be a task | drop a call whose result is unused, reuse a previous result for equal inputs, reorder |
| generic (default) | import "DPI-C" function | keep C state, do file I/O, allocate | call exports, use VPI/PLI, read SV objects other than its args | nothing special; call it exactly as written |
| context | import "DPI-C" context function / task | all of generic + call exports, VPI, svGetScope/svSetScope | (nothing extra) | must record the calling scope on every call; must assume SV state changed after the call |
The book's term "generic" comes from Sutherland (2004). The LRM has no name for the default flavor. Two consequences matter for golden models. First, a reference model that keeps state between calls (a CRC register, a FIR delay line, a scoreboard queue) is not pure, even if each call looks like a function of its inputs. Second, the only things that require context are calling back into SV and asking about scopes. A C model that just computes and logs with printf is generic.
0.4What pure buys in practice (measured)
The LRM allows the compiler to elide or merge pure calls. To see whether Verilator does, the lab declares the same C function twice, once pure and once generic. Each one secretly counts its calls, a side effect a real pure function must not have, so the counter is a probe. The SV code discards one result, computes f(a) + f(a), and calls f(a) in a loop with a loop-invariant argument:
// lab/pure.sv (core)
void'(sq_pure(a)); // result discarded: may be dropped?
r = sq_pure(a) + sq_pure(a); // common subexpression: may be merged?
for (int i = 0; i < 4; i++) r += sq_pure(a); // loop-invariant: may be hoisted?
// same three lines with sq_gen (no pure)
LOG pure calls=7 r=294
LOG gen calls=7 b=294
Seven SV calls, seven C calls, with or without pure, and also with -O3. Verilator does not use the license in procedural code. Combinational logic is a different story. With assign yp = sq_pure(x) and x changing 5 times, C ran 11 times for both flavors. Verilator evaluates combinational logic as often as its scheduling needs (initial settle, re-evaluation in the same time step), and the count is an implementation detail.
The number of times C runs is not part of the SV semantics, for either flavor. Procedural code: it is whatever the LRM allows (pure: possibly fewer). Continuous assignments and always_comb: whatever the simulator's scheduler does (11 for 5 here). A stateful C model, such as a CRC register or a counter, must be called from procedural code at well-defined events (a clock edge, a transaction), never from an assign.
Declaring something pure that isn't, for example a function that also logs to a file or bumps a counter, is harmless on Verilator 5.052 today and a heisenbug on a simulator that does use the license, or on the next Verilator. If you are not sure, leave it generic. Measured cost difference: none (0.5).
0.5What context costs (measured)
The book warns that context has "overhead" because the simulator must record the calling scope. The lab measures it with 5 M calls of a trivial C function (x ^ 0x5a), timed in C with clock_gettime, on the Verilator build of the lab (macOS x86_64, Verilator 5.052). The absolute values swing with machine load: a run with other builds in parallel gave 2.3 / 2.2 / 22.6 / 108 ns. The ordering and the context-vs-generic ratio held in every run, and those are what check.py asserts.
For a golden model called once per transaction, none of this matters: a DUT cycle in Verilator costs far more than 10 ns. It starts to matter when a model is called per bit or per sample in a tight loop. That is the "batching" topic of step 06 and step 08, where a call into Python costs far more than any of these (measured there).
0.6Forgetting context: one loud failure, one silent one
The book says that if a generic import calls an export, "the simulator could crash" (§12.7). On Verilator the outcome depends on what ran before, and the dangerous case doesn't crash. lab/noctx.sv has a non-context import call_export() in top that calls the export sv_hello. Two runs:
// lab/noctx.sv (core)
module other; // has its own sv_hello export
import "DPI-C" context function void touch(); // empty C function
export "DPI-C" function sv_hello;
function void sv_hello(); $display("LOG sv hello where=%m"); endfunction
`ifdef STALE initial touch(); `endif // a context call BEFORE ours
endmodule
module top;
import "DPI-C" function void call_export(); // BUG: should be context
export "DPI-C" function sv_hello;
function void sv_hello(); $display("LOG sv hello where=%m"); endfunction
other o();
initial #1 call_export();
endmodule
// run 1: no context call ever happened
%Warning: DPI C Function called by Verilog DPI import with missing 'context' keyword.
LOG c scope=NULL
%Error: unknown:0: Testbench C called 'sv_hello' but scope wasn't set, perhaps due to dpi import
call without 'context', or missing svSetScope. See IEEE 1800-2023 35.5.3.
Aborting...
// run 2 (-DSTALE): other.touch() ran at t=0
LOG c scope=top.o
LOG sv hello where=top.o.sv_hello // WRONG instance, no warning
The rule is mechanical: if the C body (or anything it calls) calls an export, uses VPI, or calls svGetScope, the import is context. A generic import that calls an export "works" in a small test because some earlier context call happened to leave the right scope. In the full testbench it calls the export of whichever instance ran last.
In run 1 the warning comes from Verilator's runtime when C asks for the scope from a non-context call. In run 2 there is no warning at all. The LRM makes the export call from a non-context import an error, but how loudly a given simulator diagnoses it is tool-dependent. Don't rely on getting a diagnostic.
§35.5.3 describes an import call chain: SV calls an import, which may call an export, which may call another import, and so on until control returns to the SV caller. The context property is checked per import call and is not inherited down the chain: a context import → export → generic import → export chain is an error at the second export, even though the chain started with a context call. Inside the chain, svSetScope changes the scope, and when an export returns the chain's scope is restored to its value before the export call. Leaving an export with setjmp/longjmp (or a C++ exception, step 03) skips that bookkeeping and is undefined. The scope functions themselves are undefined outside a context call chain (H.9.3).
0.7The scope API (Samples 12.48–12.50)
Six functions manage the scope register; their semantics are in H.9.3 (the prototypes are in svdpi.h, Annex I). Their behavior is undefined outside a DPI context call chain (H.9.3, §35.5.3):
| Function | Does | Measured on Verilator |
|---|---|---|
| svScope svGetScope(void) | the current scope (the import's instance) | NULL from a non-context first call |
| svScope svSetScope(svScope) | sets it, returns the previous one | yes; restore it when done |
| const char* svGetNameFromScope(svScope) | hierarchical name | top.b1, top.gen[0].u (no TOP. prefix) |
| svScope svGetScopeFromName(const char*) | lookup by full name, NULL if absent | "top.b2" ✓; "TOP.top.b2", "b2" ✗ |
| int svGetCallerInfo(const char**, int*) | SV file:line of the import call | works; path as on the command line |
| svPutUserData / svGetUserData | attach a void* to a scope, keyed by a pointer | works (0.8) |
Sample 12.48 saves a scope in one call and jumps back into it in a later one. The lab version has two instances of block, because it exposes a trap the book's single instance hides:
// lab/setscope.c (Sample 12.48, plus restoring the scope)
static svScope my_scope; // an opaque pointer: safe to keep
void save_my_scope(void) { my_scope = svGetScope(); }
void c_display(void) {
printf("LOG c called_from=%s\n", svGetNameFromScope(svGetScope()));
svScope prev = svSetScope(my_scope); // returns the previous scope
printf("LOG c now_in=%s prev=%s\n", svGetNameFromScope(svGetScope()), svGetNameFromScope(prev));
sv_display(); // block's export, not top's
svSetScope(prev); // restore
}
LOG c called_from=top.b2 // b2's initial ran first: save_my_scope() -> b2
LOG c now_in=top.b2 prev=top.b2
LOG sv where=top.b2.sv_display
LOG c called_from=top.b1 // then b1's initial: save_my_scope() -> b1 (overwrites!)
LOG c now_in=top.b1 prev=top.b1
LOG sv where=top.b1.sv_display
LOG c called_from=top // t=1: top calls c_display
LOG c now_in=top.b1 prev=top // lands in the LAST saved scope
LOG sv where=top.b1.sv_display
svScope can only remember one of them, and which one depends on initial-block order, which the LRM leaves unspecified. Per-instance data belongs on the scope (0.8), not in one global.Lookup by name is how C reaches an instance it was never called from. That is useful for broadcast-style models (exercise 7). Verilator accepts the name exactly as %m prints it, including generate blocks ("top.gen[0].u"), and rejects the internal TOP. prefix and relative names.
§12.8.6 says: "use the routine svGetScope to get a handle to the current scope, and then use that handle in a call to svGetScope to make the C code think it is inside another context". The second one is svSetScope. The book's code (Sample 12.48) is right. Also, the book never restores the previous scope. Inside one import call it doesn't matter, but helper code that sets a scope and returns should put the old one back.
0.8Per-scope user data: instance state without a chandle
Step 03 gave each SV instance its own C state by returning a chandle from a constructor and passing it back on every call. The scope gives a second way that needs no extra argument: svPutUserData(scope, key, ptr) hangs a pointer on the instance, and svGetUserData(scope, key) gets it back. The key is any unique pointer. The address of a private static is the idiom, so two C libraries never collide.
// lab/setscope.c: a counter per calling instance (import is context: it reads the scope)
static int key; // &key = "my" slot on any scope
int bump(void) {
svScope s = svGetScope();
int* n = (int*)svGetUserData(s, &key);
if (!n) { n = (int*)calloc(1, sizeof *n); svPutUserData(s, &key, n); }
return ++*n;
}
LOG bump where=top.b2 n=1
LOG bump where=top.b1 n=1
LOG bump where=top n=4 // top called it 4 times; b1 and b2 once each
Chandle or user data? Use the chandle when SV creates and destroys the C object explicitly: dynamic objects, classes, many models per instance. Use user data when the C state is naturally "one per module instance", such as a memory model or a per-port checker, and you want SV call sites to stay argument-free. Both leak if nobody frees them. Freeing user data at the end of simulation needs a final block that calls a context import.
A model that timestamps its log does not need an exported get_time(): svGetTime(scope, &t) fills an svTimeVal (= VPI's s_vpi_time) with simulation time (t.type = vpiSimTime, 64 bits in high/low, in simulation precision units) or scaled real time (vpiScaledRealTime, in the scope's time unit, in t.real). svGetTimeUnit and svGetTimePrecision return powers of ten (−9 = ns); a NULL scope means the global simulation unit. lab/svtime.sv (`timescale 1ns/1ps, plus a submodule with timeunit 1us) measures Verilator 5.052:
LOG svtime tag=t25 rc=000 sim=25000 scaled_low=25000 scaled_real=-1 ... unit=-9 prec=-12 gunit=-9
LOG svtime tag=sub30ns rc=000 sim=30000 scaled_low=30000 scaled_real=-1 ... unit=-9 prec=-12 gunit=-9
Simulation time is right (25 ns = 25000 ps). The rest deviates: the type field is ignored, so vpiScaledRealTime never writes t.real (still the −1 the C code put there), and the submodule's scope reports unit −9 instead of −6. Use vpiSimTime with svGetTimePrecision(NULL, …) and scale it yourself.
0.9Tasks across the boundary
Both directions accept tasks as well as functions: import "DPI-C" context task and export "DPI-C" task. The LRM gives both an int return on the C side (§35.8, §35.9, H.8.2). It is not a value but a disable flag. If SV disables a block while a task call chain is inside C, the exported task returns 1. C must then clean up, call svAckDisabledState() and return 1 from the import task, so the simulator can unwind. svIsDisabledState() tells C whether that is happening. §35.9 adds three rules: once disabled, the import must not call any more exports; the values of its output/inout arguments and its result are undefined; and the simulator shall stop with a fatal error if C breaks the protocol.
// lab/xtask.c: imported task looping over a zero-time exported task
int c_task(int n) { // import task: returns int (disable ack), 0 = normal
for (int i = 0; i < n; i++) {
int o;
sv_task(i, &o); // export task. LRM: returns int. Verilator: void
printf("LOG c i=%d o=%d\n", i, o);
}
printf("LOG c disabled_state=%d\n", svIsDisabledState());
return 0;
}
LOG c i=0 o=0
LOG c i=1 o=10
LOG c i=2 o=20
LOG c disabled_state=0
LOG sv calls=3 t=0
The generated header reveals the deviation: extern void sv_task(int i, int* o); for the export, and extern int c_task(int n); for the import. The book's footnote 2 says VCS also declared exported tasks as void. Portable C therefore never uses the return value of an exported task. In Verilator the disable protocol cannot trigger anyway, because an exported task can't block (0.10), so no SV disable can ever catch a call chain inside C.
An imported function that calls an exported task is illegal: a function can't call a task, on either side of the boundary. Verilator 5.052 accepts it as long as the task has no timing controls (lab/fn2task.sv prints LOG sv waited t=0). A stricter simulator is entitled to reject it, so the code isn't portable. If C calls an exported task, import the C routine as a context task.
0.10Exported tasks that wait: the book's model, Verilator's wall
§12.8.3 is the most ambitious use of DPI in the chapter. C reads a command file and calls exported SV tasks that consume simulation time (Sample 12.40: mem_write takes #10, mem_read takes #20). For this to work, the simulator must be able to suspend a C function mid-body while other SV processes run, and resume it later. Commercial simulators do it by giving each context task call its own stack, a coroutine. That is why the book insists that read_file be a context task "as the simulator needs to create a separate stack".
// lab/mem_task.sv: Sample 12.40 as printed (fixed OCR)
module top;
import "DPI-C" context task read_file(string fname);
export "DPI-C" task mem_read;
export "DPI-C" task mem_write;
export "DPI-C" function mem_build;
initial read_file("mem.dat");
int mem[];
function void mem_build(input int size); mem = new[size]; endfunction
task mem_read(input int addr, output int data); #20 data = mem[addr]; endtask
task mem_write(input int addr, input int data); #10 mem[addr] = data; endtask
endmodule
%Error-UNSUPPORTED: mem_task.sv:15:5: Unsupported: Timing controls inside DPI-exported tasks
%Error-UNSUPPORTED: mem_task.sv:18:5: Unsupported: Timing controls inside DPI-exported tasks
The lab checks the three ways to wait. #5, @(posedge clk), and a delay buried in a task that the exported task calls are all rejected at compile time with the same message (lab/xtime.sv, -DAT, -DNESTED). The exported task itself is fine as long as nothing under it can block (lab/xtask.sv).
Verilator's --timing implements SV processes as C++20 coroutines, but a DPI call is a plain C call. A C function in the middle of a call chain can't be suspended, so Verilator forbids anything below an export from waiting. Lifting that would need a separate stack per DPI call, which is what commercial simulators pay for. Design new C/SV code so C never needs to wait, and it runs everywhere.
On a simulator that does let an exported task wait, the C code of an imported task can be active in several SV threads at the same time: thread A's call is suspended inside an export at @(posedge clk), thread B calls the same import, and both C frames are live. The LRM tells the C programmer to write reentrant code: locals are per call, but static and global variables are shared by every suspended call, and only thread-safe C library calls may be used (no strtok, no shared static buffers). Four sequences driving one C test program are exactly this case. Verilator never gets there, because nothing below an export may wait, which is one more reason the pull model is the portable design.
0.11The command-file memory, zero-time version (Samples 12.37–12.39, 12.42)
The book's running example is a memory model that a C program drives from a text file. This is the realistic shape of "legacy C stimulus" or "a C test program pokes the model". The zero-time version, with exported functions, runs on every simulator. The lab grows Sample 12.38 to read and write so that it can execute the file of Sample 12.42 (M 100, W 12 34, W 99 8, R 12 34):
// lab/mem_func.sv
import "DPI-C" context function int read_file(string fname); // returns #errors
export "DPI-C" function mem_build;
export "DPI-C" function mem_read;
export "DPI-C" function mem_write;
int mem[];
function void mem_build(input int size); mem = new[size]; endfunction
function int mem_read(input int addr); return mem[addr]; endfunction
function void mem_write(input int addr, input int data); mem[addr] = data; endfunction
// lab/mem_func.c (core): the book's Sample 12.37 without its two bugs
FILE* f = fopen(fname, "r");
if (!f) { printf("LOG c error=cannot_open file=%s\n", fname); return 1; } // book: no check
while (fscanf(f, " %c", &cmd) == 1) { // book: while (!feof(file)) cmd = fgetc(file);
switch (cmd) {
case 'M': fscanf(f, "%d", &a); mem_build(a); break;
case 'W': fscanf(f, "%d %d", &a, &b); mem_write(a, b); break;
case 'R': fscanf(f, "%d %d", &a, &b);
if (mem_read(a) != b) errs++; break;
}
}
LOG c read addr=12 data=34 exp=34
LOG memf size=100 m12=34 m99=8 errs=0 t=0
Everything happens at t=0, which is the limitation the book fixes next with timed tasks (0.10), and which Verilator forces us to fix differently (0.13). The while (!feof()) idiom is a classic C bug. feof only becomes true after a read fails, so the loop body runs once more with cmd = EOF. Here the switch ignores it, which is why the book gets away with it. Testing the return of the read itself (fscanf(...) == 1) is the correct form. The leading space in " %c" also skips the newlines that the book's fgetc loop had to fall through.
0.12Objects behind an index (Samples 12.43–12.45)
Class methods can't be exported, and an SV class handle can't cross DPI: there is no C type for it, unlike chandle in the other direction. The book's answer is to export module-level wrapper functions and give C an integer index into a queue of handles. C says "memory 1, address 12". The wrapper does memq[1].read(12). This is the same trick as a file descriptor in Unix.
// lab/mem_oop.sv (core)
Memory memq[$];
function automatic int mem_build(input int size); // automatic: see trap below
Memory m = new(size);
memq.push_back(m);
return memq.size() - 1; // the "handle" C receives
endfunction
function int mem_read(input int idx, addr); return memq[idx].read(addr); endfunction
function void mem_write(input int idx, addr, data); memq[idx].write(addr, data); endfunction
// Sample 12.43 file: M0 1000, M1 2000, W0 12 34, W1 12 88, W0 99 18, R1 22 44, R0 12 34, R1 12 88
LOG c read mem=1 addr=22 data=0 exp=44 MISMATCH // the book's file reads an address never written
LOG c read mem=0 addr=12 data=34 exp=34 ok
LOG c read mem=1 addr=12 data=88 exp=88 ok
LOG memoop n=2 m0_12=34 m1_12=88 m0_99=18 errs=1
Location 12 holds 34 in memory 0 and 88 in memory 1: two objects, one C program, no handles crossing the boundary. The R1 22 44 line of Sample 12.43 reads a location that no command wrote, so it is a guaranteed mismatch. The lab keeps it as a built-in negative test.
The text of §12.8.4 says mem_build "returns the queue index, idx, to the C code". Sample 12.44 declares it function void, and Sample 12.45 ignores the return and trusts the number written in the file (M0, M1). If the file said M1 first, C and SV would disagree on every later access. The lab returns the index and C checks it against the file.
Functions in a module are static by default. Memory m = new(size); inside a static function is a static variable initializer, evaluated once at time 0 and not per call. Verilator rejects it (%Error-UNSUPPORTED: Static variable initializer), and other tools may silently share one object. function automatic, or a separate declaration and assignment, fixes it. The same trap in an initial block compiles silently. See exercise 5, a bug that really bit while this lab was being written.
0.13The pull model: the timed memory on every simulator
To give the memory real timing (writes 10 ns, reads 20 ns) without blocking inside C, invert the control flow (Fig. 0.6). SV owns the loop and time. C becomes a small iterator over the command file plus a checker. Each C call returns immediately, so the imports are plain generic functions: no context, no exports, nothing that can go stale.
// lab/mem_pull.sv (core)
import "DPI-C" function int cmd_next(output byte cmd, output int idx, addr, data);
import "DPI-C" function void cmd_result(int idx, addr, data, longint t);
class Memory;
int mem[];
function new(int size); mem = new[size]; endfunction
task read(int addr, output int data); #20 data = mem[addr]; endtask
task write(int addr, int data); #10 mem[addr] = data; endtask
endclass
initial begin
byte cmd; int idx, addr, data;
Memory m; // NOT "Memory m = new(..)": static initializer
void'(cmd_open("mem_oop.dat"));
while (cmd_next(cmd, idx, addr, data))
case (cmd)
"M": begin m = new(addr); memq.push_back(m); end
"W": memq[idx].write(addr, data);
"R": begin memq[idx].read(addr, data); cmd_result(idx, addr, data, $time); end
endcase
end
LOG c read mem=1 addr=22 data=0 exp=44 t=50 MISMATCH
LOG c read mem=0 addr=12 data=34 exp=34 t=70 ok
LOG c read mem=1 addr=12 data=88 exp=88 t=90 ok
LOG pull errs=1 t=90 ticks=89 // an always #1 counter kept running meanwhile
$time.Two details in mem_pull.c are worth stealing. First, cmd_next sets *addr = *data = 0 before parsing. An output argument that C doesn't write reaches SV as whatever garbage was in the temporary. Verilator calls skip_out(k__Vcvt, &o__Vcvt) with an uninitialized local and copies it back. lab/outarg.sv starts with o = 7 and reads back 32711 and 1114656704 (the values vary per run). Outputs are not "unchanged if untouched". See step 02 on argument directions. Second, the expected values stay in C (a small queue of exp), so the checker owns the file format and SV only reports what it saw. That split, with SV owning time and C owning data and verdicts, is the same one the golden-model scoreboard of step 06 uses.
The pull model is the default design for new C/SV (and Python/SV) code. Every call is a zero-time generic function, so it is portable to every simulator, cheap (no context), immune to stale scopes, and C never needs to block. Reach for exports only when C genuinely has to call into SV mid-computation: callbacks, error reporting with $error, reading SV configuration.
0.14Book errata and dated parts (§12.7–12.8)
io_printf(Samples 12.35, 12.41, 12.45, 12.48) is a PLI/VCS routine fromveriuser.h. Useprintfplusfflush(stdout), so C and SV output stay in order.- §12.8.6 text: "a call to svGetScope" should be
svSetScope(0.7). - Sample 12.44 vs text:
mem_buildisvoidbut the text says it returns the index (0.12). - Sample 12.43:
R1 22 44reads an unwritten location, a guaranteed mismatch (0.12). - Samples 12.37/12.41/12.45:
while (!feof(file))and an uncheckedfopen; the book admits "error checking has been removed" (0.11). - Sample 12.41: the book declares exported tasks
extern int"according to the LRM", with a footnote that VCS usedvoid. Verilator also usesvoid. Include the generated header and never use the return (0.9). - Sample 12.40 / §12.8.3: legal SV, but not portable to Verilator (0.10). The pull model (0.13) is the portable form.
0.15Rules of thumb
- Declare the innermost true flavor: pure ⊂ generic ⊂ context. Anything that reaches an export, VPI or the scope API is
context, with no exceptions. - Never let SV semantics depend on how many times C runs. Stateful C goes in procedural code at defined events, never in
assign/always_comb. - Include the generated
Vtop__Dpi.hin every C file; never hand-write export prototypes. - Per-instance C state: a chandle when SV manages lifetime,
svPutUserDatawhen it is "one per instance". Not one C global holding onesvScope. - If you call
svSetScope, restore the previous scope before returning. - C never waits. If a C model seems to need simulation time, turn it inside out: SV loops and waits, C is an iterator (pull model).
- Exported tasks: zero-time only (on Verilator), and called only from
context taskimports, never from imported functions. - C must write every
outputargument on every path.
§1Learning objectives
- Write an
export "DPI-C"function and task, call it from C through the generated header, and predict which instance's copy runs. - Classify any C routine as pure, generic or context from what its body touches, and state what each promise lets the simulator do.
- Explain the two failure modes of a missing
context(fatal NULL scope vs silent stale scope) and spot them in review. - Use
svGetScope,svSetScope(and restore),svGetNameFromScope,svGetScopeFromName,svGetCallerInfoand per-scope user data. - Know the Annex H task protocol (int disable flag,
svIsDisabledState/svAckDisabledState) and where Verilator deviates (void exported tasks, no timing in exports, function→task accepted). - Port the book's command-file memory to Verilator: zero-time exports, objects behind an index, and the timed version as a pull model.
- Choose between push and pull control flow for a C model, and explain why pull is the portable default.
Prerequisites: 01 (import, build, generated header), 02 (types, output arguments), 03 (chandle and instance state). 04 is not needed except for the string arguments in exercise 10.
§2Exercises
Solutions with code live in lab/ex/ and are built and checked by check.py. Try first, then open.
1 ★ Predict the output. Three scopes export who(), which prints %m. hop() calls who() after optionally switching to a pinned scope. pin() pins "top.m" by name.
module leaf; /* import hop (context), export who */ initial #2 hop(); endmodule
module mid; /* export who */ leaf l(); endmodule
module top; /* import hop, pin (context), export who */
mid m();
initial begin #1 hop(); #2 pin(); #1 hop(); #1 $finish; end
endmodule
static svScope pinned;
void pin(void) { pinned = svGetScopeFromName("top.m"); }
void hop(void) { if (pinned) svSetScope(pinned); who(); }
Show solution
t=1: called from top, nothing pinned, so it prints top.who. t=2: called from top.m.l, so top.m.l.who. t=3: pin(). t=4: from top, but now switched to top.m, so top.m.who. Lab: ex/predict.sv prints exactly top.who, top.m.l.who, top.m.who. Note that mid has no import at all. An export copy exists in every instance of a module that declares it, reachable only by setting the scope.
2 ★ Ex. 12.8. Write shift_c(i, n) in C, with a 32-bit unsigned i: shift left for n>0, right for n<0, unchanged for 0. C must not shift itself. It calls an exported SV void function shift_sv. Test n = 4, −4, 0, 40, −31 on 32'h8000_00F0.
Show solution
// ex/ex12_8.sv
import "DPI-C" context function int unsigned shift_c(int unsigned i, int n);
export "DPI-C" function shift_sv;
function void shift_sv(input int unsigned i, input int n, output int unsigned o);
o = (n > 0) ? i << n : (n < 0) ? i >> -n : i;
endfunction
unsigned int shift_c(unsigned int i, int n) { // context: it calls an export
unsigned int o;
shift_sv(i, n, &o); // SV output arg -> C pointer
return o;
}
n=4 o=00000f00 n=-4 o=0800000f n=0 o=800000f0 n=40 o=00000000 n=-31 o=00000001
The n=40 case shows one reason to do it in SV: SV defines a shift by ≥ width as 0, while in C x << 40 on a 32-bit type is undefined behavior. shift_c must be context, otherwise you get 0.6's failure.
3 ★ Find the bug. A colleague's test passes in a small TB and fails in the full chip TB, where the log says top.o.sv_hello instead of top.sv_hello. The code is lab/noctx.sv (0.6). What is wrong, why did the small TB pass, and what is the one-word fix?
Show solution
call_export is imported without context but calls the export sv_hello. A generic import does not update the scope register, so the export resolves in whatever scope the last context call left behind. In the small TB that was either nothing (fatal: "scope wasn't set") or, by luck, top itself. In the chip TB another instance (top.o) made a context call first. The fix is to add context to the import. Lab: noctx (fatal) vs stale (silent wrong instance).
4 ★ Classify. Pure, generic or context? (a) crc32_update(crc, byte) returning the new CRC; (b) crc32_feed(byte) keeping the CRC in a C static; (c) a function that writes each transaction to a log file; (d) a function that reads +seed= with vpi_get_vlog_info; (e) a function that calls $error through an export; (f) log2_ceil(n).
Show solution
(a) pure: the result depends only on the inputs. (b) generic: hidden state, the same input gives a different result. (c) generic: I/O is a side effect, and the compiler must not drop or merge calls. (d) context: VPI. (e) context: calls an export. (f) pure. Note (b): if it were wrongly declared pure, a compiler could legally merge two crc32_feed(8'h00) calls into one, and the golden CRC would be wrong. Verilator happens not to (0.4).
5 ★★ Find the bug (real one). This pull-model TB compiles cleanly on Verilator, but every read returns 0 (lab/ex/static_init_bug.sv). Predict errs, whether memq[0] == memq[1], and memq[0].mem.size(), then fix it.
initial begin
byte cmd; int idx, addr, data;
void'(cmd_open("mem_oop.dat"));
while (cmd_next(cmd, idx, addr, data))
case (cmd)
"M": begin Memory m = new(addr); memq.push_back(m); end
"W": memq[idx].write(addr, data);
"R": begin memq[idx].read(addr, data); cmd_result(idx, addr, data, $time); end
endcase
end
Show solution
Memory m = new(addr); is a declaration with an initializer inside a static block, so it runs once at time 0, before the loop, with addr = 0. Both M commands push the same size-0 object. Writes to mem[12] on an empty dynamic array are ignored, and reads return the default 0. Lab: LOG bug errs=3 same=1 size0=0. Fix: declare Memory m; at the top and assign m = new(addr); in the branch (as in mem_pull.sv), or make the block automatic. Not a DPI bug, but it hides perfectly behind one, because the first suspect is always the C side.
6 ★★ Port Sample 12.40 to Verilator. Keep the book's single memory, mem.dat (M 100, W 12 34, W 99 8, R 12 34), and the 10/20 ns timing. C parses the file, SV does the timing and the check. Predict the end time.
Show solution
// ex/pull1.sv (core)
import "DPI-C" function int cmd_next(output byte cmd, output int addr, data, exp);
while (cmd_next(cmd, addr, data, exp))
case (cmd)
"M": mem = new[addr];
"W": mem_write(addr, data); // #10
"R": begin mem_read(addr, got); // #20
if (got !== exp) begin errs++; $error("addr %0d: got %0d exp %0d", addr, got, exp); end
end
endcase
int cmd_next(char* cmd, int* addr, int* data, int* exp) {
*addr = *data = *exp = 0; // every output written on every path
if (!f || fscanf(f, " %c", cmd) != 1) return 0;
switch (*cmd) {
case 'M': fscanf(f, "%d", addr); break;
case 'W': fscanf(f, "%d %d", addr, data); break;
case 'R': fscanf(f, "%d %d", addr, exp); break;
}
return 1;
}
End time 10 + 10 + 20 = 40 ns. Lab: LOG pull1 errs=0 t=40. All imports are generic: no context, no exports.
7 ★★ Broadcast by name with per-instance state. A generate loop instantiates unit #(.ID(g)) u() for g = 0..2 under gen. Each unit exports int sv_ping(int k) returning ID*100 + k. Write a C broadcast(n), imported in top, that calls sv_ping in every unit with k = that unit's private call counter (starting at 1, stored with svPutUserData when each unit's initial calls hello()). Return the sum. What is it, and what name string finds unit 1?
Show solution
// ex/broadcast.c (core)
static int key;
void hello(void) { // context, called from each unit's initial
int* n = (int*)malloc(sizeof *n); *n = 0;
svPutUserData(svGetScope(), &key, n);
}
int broadcast(int n) {
svScope home = svGetScope(), s; int sum = 0; char name[64];
for (int i = 0; i < n; i++) {
snprintf(name, sizeof name, "top.gen[%d].u", i);
if (!(s = svGetScopeFromName(name))) continue;
svSetScope(s);
int* cnt = (int*)svGetUserData(s, &key);
sum += sv_ping(++*cnt);
}
svSetScope(home); // restore
return sum;
}
Sum = 1 + 101 + 201 = 303 (lab: LOG bcast sum=303). The name is "top.gen[1].u", exactly as %m prints it. broadcast must be called after time 0 (initial #1), so that every unit's hello() has run. Otherwise svGetUserData returns NULL. The lab saw the units' initials run in the order 2, 1, 0.
8 ★★ Review comment. In an RTL-ish golden model wrapper someone wrote assign exp_crc = c_crc_feed(in_valid, in_data);, where the C function keeps the running CRC in a static. The TB "works" on one simulator. Write the review comment.
Show solution
A stateful C function in a continuous assignment is called whenever the scheduler re-evaluates the expression, not once per accepted byte. The lab measured 11 C calls for 5 input changes on Verilator (comb), so the CRC would absorb bytes several times. It also runs when in_valid is low, and again whenever unrelated re-evaluation happens. Feed it from procedural code at the clock edge when the byte is accepted: always @(posedge clk) if (in_valid && !clear) exp = c_crc_feed(in_data);. Or make it pure, crc_next = f(crc, byte), with the state register in SV. Step 06 does exactly that with common/rtl/crc32_stream.sv.
9 ★★★ Ex. 12.9 + 12.10. Build a class Shift with shift_sv(i, n) that stores its result in a member, and shift_print(). Export shift_build (creates a Shift, returns its index) and module-level wrappers. From C, create three objects, shift 1 by 4, 0x100 by −8, and 0xdeadbeef by 0, then print all three.
Show solution
// ex/ex12_10.sv (core)
Shift q[$];
function automatic int shift_build();
Shift s = new(); q.push_back(s); return q.size() - 1;
endfunction
function void shift_sv(int idx, int unsigned i, int n); q[idx].shift_sv(i, n); endfunction
function void shift_print(int idx); q[idx].shift_print(idx); endfunction
void c_run(void) { // context
int a = shift_build(), b = shift_build(), c = shift_build();
shift_sv(a, 0x1, 4); shift_sv(b, 0x100, -8); shift_sv(c, 0xdeadbeef, 0);
shift_print(a); shift_print(b); shift_print(c);
}
LOG ex10 idx=0 result=00000010
LOG ex10 idx=1 result=00000001
LOG ex10 idx=2 result=deadbeef
Each object keeps its own result, which is the point of 12.10(d). Two details: shift_build must be automatic (0.12 trap), and an SV method name may equal the export name (shift_sv) because the class method is in the class scope, not the module scope.
10 ★★★ C reports through SV. A C checker finds mismatches. Make it report them with SV's $error, so they carry $time, count in the SV error total and obey the simulator's severity settings. Include the SV file:line of the call that triggered the check. On Verilator, what happens at the first $error by default?
Show solution
// ex/report.sv (core)
import "DPI-C" context function void c_check(int got, int exp);
export "DPI-C" function sv_report;
function void sv_report(string where, string msg);
$error("[C model @ %s] %s", where, msg);
endfunction
void c_check(int got, int exp) {
if (got == exp) return;
const char* file; int line; char where[256], msg[128];
svGetCallerInfo(&file, &line);
snprintf(where, sizeof where, "%s:%d", file, line);
snprintf(msg, sizeof msg, "got %d exp %d", got, exp);
sv_report(where, msg); // SV copies the strings
}
[10] %Error: report.sv:8: Assertion failed in top.sv_report: [C model @ ex/report.sv:12] got 4 exp 5
-Info: ex/report.sv:8: Verilog $stop, ignored due to +verilator+error+limit
[20] %Error: report.sv:8: Assertion failed in top.sv_report: [C model @ ex/report.sv:13] got 7 exp 9
By default Verilator turns the first $error into $stop and aborts. The lab runs with +verilator+error+limit+10 to keep going. The %Error location is the export's line (8). The useful location, that of the check, comes from svGetCallerInfo.
11 ★★★ Design: a legacy C test on Verilator. You inherit a C test written as straight-line code: bus_write(0x10, 5); x = bus_read(0x14); …, thousands of lines. Each access must take real bus cycles on the DUT. On a commercial simulator you would import run_test as a context task and export timed bus_write/bus_read tasks (the push model). Verilator rejects that. Sketch a design that runs the test unchanged.
Show solution
Give the legacy test its own stack, so that it can block, but outside the simulator. Run run_test() on a separate OS thread (or a ucontext/coroutine) started from a generic import. bus_write/bus_read become C stubs that post a command into a one-slot mailbox and block on a condition variable until the result arrives. On the SV side, a pull loop calls a generic import cmd_next() each time the bus is idle. That call blocks (in wall time, zero sim time) until the test thread posts a command, then returns it. SV drives the bus for as many cycles as needed and calls cmd_done(rdata), which wakes the test thread. Rules: only the simulator thread ever calls into SV or svdpi.h (the test thread never touches exports or scopes), and exactly one side runs at a time (strict hand-off). That keeps simulation deterministic. This is the push model's coroutine, built by hand in C, which is what commercial simulators do internally. When the test thread returns, cmd_next reports "done" and SV ends the test.
§3Quiz
1. Which export declaration is legal?
() (§12.8.1). The signature comes from the SV function declaration. Option a is the tempting one because it mirrors an import.2. top and its child b1 both export sv_display and both import the same context function c_display, which calls sv_display(). b1 calls c_display(). Which sv_display runs?
top.b1), and the export call dispatches to that instance's copy. One C symbol can stand for many SV copies, so there is no duplicate symbol. Lab scopes.3. A C function keeps a running CRC in a static and returns it. The best declaration is:
4. In the lab, how many times did C run for void'(f(a)); r = f(a) + f(a); for 4× r += f(a); with f declared pure?
pure is a license the LRM grants, not a behavior you can count on in either direction.5. assign y = f(x); and x changes 5 times. How many C calls should your design assume?
6. A generic (non-context) import calls an export. Earlier in the run, a context import in instance top.o was called. On Verilator 5.052:
top.o is used. The fatal error appears only when no scope was ever set (lab noctx vs stale). Verilator can't catch it at compile time because it can't see what C does.7. What does svSetScope(s) return?
svScope prev = svSetScope(s); … svSetScope(prev);. Lab: now_in=top.b1 prev=top.8. Which string does Verilator's svGetScopeFromName accept for instance u in generate block gen, iteration 1, under top?
%m prints. The internal TOP. prefix and relative names return NULL (lab setscope and ex/broadcast).9. On the C side, what does Verilator's generated header declare for export "DPI-C" task sv_task; with task sv_task(input int i, output int o)?
int return (the disable flag), but Verilator (like VCS per the book's footnote) generates void. Include the header and never use the return.10. Sample 12.40 (exported mem_write with #10, called from a C context task) on Verilator 5.052 with --timing:
11. Why does the pull model need no context at all?
12. C's cmd_next(char* cmd, int* idx, int* addr, int* data) doesn't write *data for an M command. Before the call, SV's data was 7. After it, SV sees:
13. Which of these need context? (select all)
14. Why can't you export a method of an SV class, and what is the book's workaround?
memq[idx].method() (Samples 12.44/12.45, lab mem_oop).§4Lab walkthrough
Run: cd tutorial/05_export_and_context/lab && python3 check.py. It builds 27 Verilator binaries in five parallel batches (about 15 s with a warm ccache, 1–2 min cold), runs them, and asserts 44 facts. Logs are in build/<name>.log, build output (including the expected compile errors) in build/<name>.build, and the generated prototypes in build/vl_<name>/Vtop__Dpi.h.
Predict first: before opening the logs, write down (1) which instance prints in scopes and setscope, (2) whether stale crashes, (3) how many C calls pure and comb make, (4) the end time of mem_pull.
| Experiment | Files | What it shows | Result (Verilator 5.052) |
|---|---|---|---|
| scopes | scopes.sv/.c | one C routine, two export copies (12.34, 12.46) | top.b1.sv_display, then top.sv_display; caller lines 9 and 19 |
| setscope | setscope.sv/.c | save/restore scope (12.48–12.50), lookup by name, user data | last saved = top.b1; only "top.b2" found; counters 1, 1, 4 |
| noctx / stale | noctx.sv/.c | export from a generic import | fatal "scope wasn't set" / silently runs top.o's export |
| xtask | xtask.sv/.c | zero-time exported task from an import task | works; header: export void, import int; svIsDisabledState()=0 |
| fn2task | fn2task.sv/.c | imported function → exported task (illegal) | accepted, runs |
| xt_delay / xt_at / xt_nested | xtime.sv/.c | #5, @(posedge), nested delay in an export | all three: compile error, UNSUPPORTED |
| pure / comb | pure.sv, comb.sv, pure.c | call counts | 7 = 7 (no elision); 11 calls for 5 changes |
| bench | bench.sv/.c | ns per call | generic ≈ pure ≈ 1.3–2.3; context ≈ 10–23; +export ≈ 18–108 (load-dependent) |
| outarg | outarg.sv/.c | unwritten output argument | garbage (e.g. 32636), not the old 7 |
| svtime | svtime.sv/.c | time API (H.13) | sim time in ps correct; real never written; submodule unit −9 (should be −6) |
| xdyn | xdyn.sv | dynamic-array formal on an export (erroneous, §35.5.6) | Verilator internal fault (crash), no diagnostic |
| xshared | xshared.sv/.c | one C name exported from two modules (§35.4) | top.u1.hello, then top.u2.hello |
| mem_func | mem_func.sv/.c, mem.dat | Samples 12.37–12.39, 12.42 | read 12 → 34, errs=0, t=0 |
| mem_task | mem_task.sv/.c | Sample 12.40 as printed | compile error at lines 15 and 18 |
| mem_oop | mem_oop.sv/.c, mem_oop.dat | Samples 12.43–12.45, index handles | 34 / 88 per object; book's R1 22 44 mismatches |
| mem_pull | mem_pull.sv/.c | timed memory, pull model | reads at 50/70/90 ns, end 90 ns, clock kept ticking |
| ex: predict, ex12_8, ex12_10, report, broadcast, pull1, sib | ex/ | exercise solutions | see §2; all asserted |
What to look at: open build/vl_xtask/Vtop__Dpi.h and compare it with H.8.2 (exported task = int). In build/vl_outarg/Vtop___024root__0.cpp, find the __Vdpiimwrap_ wrapper Verilator generates around every import: the temporaries k__Vcvt and o__Vcvt are where conversions, and the output-argument garbage, come from. Then break things on purpose: remove context from scopes.sv, or swap the svSetScope(prev) restore out of setscope.c, and rerun.
New Verilator behaviors found here are also recorded in tutorial/NOTES.md.
§5Cheat sheet
| Construct | SV declaration | C side | Pitfall |
|---|---|---|---|
| export function | export "DPI-C" function f; | include Vtop__Dpi.h; f(...) | no type/args/() in the export; one copy per instance |
| export, renamed | export "DPI-C" c_f = function f; | c_f(...) | C name must be unique per scope |
| export task | export "DPI-C" task t; | void t(...) (Verilator) | LRM: int disable flag; no timing in Verilator |
| pure import | import "DPI-C" pure function … | no state, no I/O, no outputs | call count not guaranteed; never a task |
| generic import | import "DPI-C" function … | state, files OK | must not call exports/VPI/scope API |
| context import | import "DPI-C" context function|task … | may call exports | ≈ 6–10× a generic call; forgetting it → NULL or stale scope |
| import task | import "DPI-C" context task t(…); | int t(...) → return 0 | needed to call exported tasks; C never blocks in Verilator |
| current scope | — | svScope s = svGetScope(); | only meaningful in a context call |
| switch scope | — | svScope p = svSetScope(s); … svSetScope(p); | restore it |
| names | — | svGetNameFromScope(s); svGetScopeFromName("top.u") | full %m name, no TOP. |
| caller location | — | svGetCallerInfo(&file, &line); | path as on the command line |
| per-scope data | — | svPutUserData(s, &key, p); svGetUserData(s, &key) | key = address of a private static; free in final |
| object from C | queue of handles + wrappers | int idx = build(); op(idx, …) | class methods can't be exported; wrapper must be automatic if it declares-with-init |
| timed C-driven model | SV loop + timed tasks | int next(output …) / void result(…) | pull, not push: portable everywhere |
Key rules
- pure ⊂ generic ⊂ context: declare the innermost true one.
- Calls exports, VPI or the scope API →
context, always. - The number of C calls is not SV semantics, so keep stateful C out of
assign/always_comb. - Always include the generated
Vtop__Dpi.h. - Per-instance state lives on the scope or behind a chandle, never in one global
svScope. - Restore the scope after
svSetScope. - C never waits: invert to a pull model.
- Exported tasks are called only from
context taskimports, and contain no timing in Verilator. - Write every
outputargument on every path. - Class handles never cross DPI: pass an index.
Spear & Tumbush, SystemVerilog for Verification, 3rd ed. (Springer, 2012), §12.7–12.8, Samples 12.32–12.50; IEEE Std 1800-2023 clause 35 and Annexes H, I. Lab: Verilator 5.052 on macOS x86_64. Every result in this guide is asserted by lab/check.py.