Contents

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.

Prereqs: 01 (import, the generated Vtop__Dpi.h), 03 (chandle, per-instance state)Book pages: PDF = book + 36 in ch. 12Every number below comes from lab/check.py

§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:

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.

ONE THREAD, ONE STACK SV: initial in top.b1c_display(); C: c_display()import "DPI-C" context function SV: top.b1.sv_display()export "DPI-C" function import call export call returnsunwind scope register= top.b1 context:set on entry The export that runs is the copy in the scope register's instance, not "the" sv_display.
Fig. 0.1A round trip is nested ordinary calls. The scope register is a simulator-side "current instance" pointer. A 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):

FlavorDeclarationC may…C must not…Simulator may…
pureimport "DPI-C" pure functioncompute from its inputs onlytouch globals/statics, do I/O, call exports or VPI, have output/inout args; can't be a taskdrop a call whose result is unused, reuse a previous result for equal inputs, reorder
generic (default)import "DPI-C" functionkeep C state, do file I/O, allocatecall exports, use VPI/PLI, read SV objects other than its argsnothing special; call it exactly as written
contextimport "DPI-C" context function / taskall 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
CONTEXT · + call exports, VPI, svGetScope/svSetScope, read SV state GENERIC (default) · + static/global C state, files, malloc PUREresult = f(inputs) only cost per call:pure ≈ generic ≈ 1.3–1.8 ns context ≈ 10 ns(measured, 0.5)
Fig. 0.2The flavors nest: each one may do everything the inner one may, plus more. Declare the innermost flavor that is true. Lying outward (calling an export from a generic import) is the bug, not the overhead.

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.

NS PER CALL (5 M CALLS, ONE RUN OF check.py) generic1.3 pure1.5 context9.7 context + export18.5 scale: 17.5 px/ns · ratios are what check.py asserts (context > 2.5× generic)
Fig. 0.3A context call costs about 6–10× a generic call on Verilator (runs: 10.9/1.8, 9.7/1.3, 22.6/2.3). An export call from C adds about the same again, because Verilator re-establishes the scope on the way back into SV. In absolute terms both are tiny: 10 ns per call is 100 M calls/s.

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
SCOPE REGISTER OVER TIME (-DSTALE RUN) time t=0 t=1 NULL top.o (never changed again) top.o.touch()context: SETS scope top.call_export()generic: leaves it sv_hello() → top.o.sv_hello (the author meant top.sv_hello)
Fig. 0.4A generic import does not touch the scope register, so an export call inside it uses whatever the last context call left there. Verilator only diagnoses the case where nothing was ever set.

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):

FunctionDoesMeasured 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 oneyes; restore it when done
const char* svGetNameFromScope(svScope)hierarchical nametop.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 callworks; path as on the command line
svPutUserData / svGetUserDataattach a void* to a scope, keyed by a pointerworks (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
HIERARCHY, EXPORT COPIES, AND ONE C GLOBAL topsv_display · c_display top.b1sv_display · save_my_scope top.b2sv_display · save_my_scope C: my_scopelast writer wins (b2: overwritten) Initial-block order between instances is unspecified (Verilator: b2 before b1). One global = one instance.
Fig. 0.5Each instance has its own export copies. A C global holding one 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).

PUSH (BOOK, §12.8.3)PULL (PORTABLE, 0.13) C: while (cmd = read()) … SV export task #10 call C frame must be SUSPENDEDfor 10 ns of sim time commercial sims: own stack per callVerilator 5.052: compile error SV: while (cmd_next(…)) C: next cmd SV task #10 C returns at once (zero time)SV blocks in its own process every simulator, every flavorVerilator: runs (90 ns, 0.13)
Fig. 0.6Who owns the loop decides who has to block. In the push model C blocks inside SV time, which needs simulator coroutines. In the pull model C is an iterator and every wait happens in an SV process, which every simulator can schedule.

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
mem_pull: 3 WRITES × 10 ns + 3 READS × 20 ns = 90 ns (scale 7 px/ns) 0102030507090 W0 12 W1 12 W0 99 R1 22 → 0 ✗ R0 12 → 34 ✓ R1 12 → 88 ✓ M0, M1 at t=0 (zero time) dots: cmd_result() calls into C, each in zero time
Fig. 0.7The pull model's real timeline from the lab log. Time advances only inside SV tasks. C sees each read result together with its $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)

0.15Rules of thumb

  1. Declare the innermost true flavor: pure ⊂ generic ⊂ context. Anything that reaches an export, VPI or the scope API is context, with no exceptions.
  2. 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.
  3. Include the generated Vtop__Dpi.h in every C file; never hand-write export prototypes.
  4. Per-instance C state: a chandle when SV manages lifetime, svPutUserData when it is "one per instance". Not one C global holding one svScope.
  5. If you call svSetScope, restore the previous scope before returning.
  6. 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).
  7. Exported tasks: zero-time only (on Verilator), and called only from context task imports, never from imported functions.
  8. C must write every output argument on every path.

§1Learning objectives

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?

The export names the routine and nothing else: no return type, no argument list, not even () (§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?

The context import records the caller's scope (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:

Hidden state means the same inputs can give different results, so it is not pure. It doesn't call exports, VPI or the scope API, so it doesn't need context. State alone never requires context. Imported tasks can't be pure anyway.

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?

Verilator 5.052 elides nothing in procedural code, even with -O3. 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?

Continuous assignments are re-evaluated whenever the simulator decides to. The count is not SV semantics, so a design must not depend on it. Stateful C belongs in procedural code at defined events.

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:

A generic import doesn't touch the scope register, so the stale 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?

It returns the previously current scope, so you can restore it: 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?

The name is exactly what %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)?

Outputs are pointers. §35.8 and H.8.2 give exported tasks an 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:

Verilator can't suspend a C frame, so it rejects any timing control reachable from an exported task, including one nested in a called task. The portable fix is the pull model.

11. Why does the pull model need no context at all?

Context is needed only to call exports, use VPI or use the scope API. In the pull model all traffic is SV→C calls with arguments and returns, so every import is generic, and cheaper (0.5).

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:

Verilator passes outputs through a temporary that C is expected to fill, and an unwritten one copies back whatever it held. Write every output on every path (step 02). There is no X: Verilator is 2-state, and the C side has no idea of X anyway.

13. Which of these need context? (select all)

The scope API and exports need context. File I/O and persistent heap state are generic: allowed without context, just not pure.

14. Why can't you export a method of an SV class, and what is the book's workaround?

A class handle can't cross DPI and the export needs a fixed address at elaboration, so C names objects by integer index and module wrappers do 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.

ExperimentFilesWhat it showsResult (Verilator 5.052)
scopesscopes.sv/.cone C routine, two export copies (12.34, 12.46)top.b1.sv_display, then top.sv_display; caller lines 9 and 19
setscopesetscope.sv/.csave/restore scope (12.48–12.50), lookup by name, user datalast saved = top.b1; only "top.b2" found; counters 1, 1, 4
noctx / stalenoctx.sv/.cexport from a generic importfatal "scope wasn't set" / silently runs top.o's export
xtaskxtask.sv/.czero-time exported task from an import taskworks; header: export void, import int; svIsDisabledState()=0
fn2taskfn2task.sv/.cimported function → exported task (illegal)accepted, runs
xt_delay / xt_at / xt_nestedxtime.sv/.c#5, @(posedge), nested delay in an exportall three: compile error, UNSUPPORTED
pure / combpure.sv, comb.sv, pure.ccall counts7 = 7 (no elision); 11 calls for 5 changes
benchbench.sv/.cns per callgeneric ≈ pure ≈ 1.3–2.3; context ≈ 10–23; +export ≈ 18–108 (load-dependent)
outargoutarg.sv/.cunwritten output argumentgarbage (e.g. 32636), not the old 7
svtimesvtime.sv/.ctime API (H.13)sim time in ps correct; real never written; submodule unit −9 (should be −6)
xdynxdyn.svdynamic-array formal on an export (erroneous, §35.5.6)Verilator internal fault (crash), no diagnostic
xsharedxshared.sv/.cone C name exported from two modules (§35.4)top.u1.hello, then top.u2.hello
mem_funcmem_func.sv/.c, mem.datSamples 12.37–12.39, 12.42read 12 → 34, errs=0, t=0
mem_taskmem_task.sv/.cSample 12.40 as printedcompile error at lines 15 and 18
mem_oopmem_oop.sv/.c, mem_oop.datSamples 12.43–12.45, index handles34 / 88 per object; book's R1 22 44 mismatches
mem_pullmem_pull.sv/.ctimed memory, pull modelreads at 50/70/90 ns, end 90 ns, clock kept ticking
ex: predict, ex12_8, ex12_10, report, broadcast, pull1, sibex/exercise solutionssee §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

ConstructSV declarationC sidePitfall
export functionexport "DPI-C" function f;include Vtop__Dpi.h; f(...)no type/args/() in the export; one copy per instance
export, renamedexport "DPI-C" c_f = function f;c_f(...)C name must be unique per scope
export taskexport "DPI-C" task t;void t(...) (Verilator)LRM: int disable flag; no timing in Verilator
pure importimport "DPI-C" pure function …no state, no I/O, no outputscall count not guaranteed; never a task
generic importimport "DPI-C" function …state, files OKmust not call exports/VPI/scope API
context importimport "DPI-C" context function|task …may call exports≈ 6–10× a generic call; forgetting it → NULL or stale scope
import taskimport "DPI-C" context task t(…);int t(...) → return 0needed 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 Cqueue of handles + wrappersint idx = build(); op(idx, …)class methods can't be exported; wrapper must be automatic if it declares-with-init
timed C-driven modelSV loop + timed tasksint next(output …) / void result(…)pull, not push: portable everywhere

Key rules

  1. pure ⊂ generic ⊂ context: declare the innermost true one.
  2. Calls exports, VPI or the scope API → context, always.
  3. The number of C calls is not SV semantics, so keep stateful C out of assign/always_comb.
  4. Always include the generated Vtop__Dpi.h.
  5. Per-instance state lives on the scope or behind a chandle, never in one global svScope.
  6. Restore the scope after svSetScope.
  7. C never waits: invert to a pull model.
  8. Exported tasks are called only from context task imports, and contain no timing in Verilator.
  9. Write every output argument on every path.
  10. 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.