Contents

DPI-C from scratch · Part A · Step 01

The first call: import, link, and what can go wrong

An import "DPI-C" declaration is a promise that a C function with a matching signature will be there at link time. The simulator cannot read C, so nothing checks that promise unless you make the compiler do it. This step makes the first call work, then breaks it on purpose in every way it usually breaks.

Book: Spear & Tumbush 3rd ed., §12.1 (pp. 415–419; PDF page = book page + 36)Simulator: Verilator 5.052, macOS x86_64Answers are hidden: think first

§0 · TutorialThe step in one idea

DPI is not a protocol, a socket or an API you program against. It is a calling convention plus a type mapping. When SystemVerilog calls an imported function, the simulator calls a plain C function in the same process, on the same thread, with arguments laid out the way the C ABI expects. When that function returns, SystemVerilog continues. No simulation time passes, nothing is copied that does not have to be, and nobody checks that the two sides agree on types except the C compiler, and only if you show it both declarations.

That last sentence sets the agenda for this step. The SV side and the C side are compiled separately and meet only at the linker, by symbol name. Most first-week DPI bugs come from that: a name the linker cannot find (C++ name mangling, a typo, a static), or worse, a name it finds whose types differ from what SV assumed. The second kind links fine and returns garbage. Here you build the smallest working example, then see each failure mode with its exact error message, measured on Verilator 5.052. Every output in this page comes from the lab and is asserted by check.py.

0.1Why DPI exists

Verilog always had a way to call C: the PLI (Programming Language Interface). It came in three generations (§12 intro): TF (task/function routines, 1980s), ACC (access routines) and VPI (Verilog Procedural Interface, IEEE 1364-2001, now clause 36–38 of IEEE 1800). PLI/VPI is a reflection API: your C code gets handles to simulator objects, walks the design hierarchy, reads and writes values through vpi_get_value/vpi_put_value with format conversion, registers callbacks on value changes and time steps. That power is why cocotb is built on VPI. It is also why calling a 5-line C function through VPI takes 50 lines: a systf registration table, a calltf routine that fetches each argument by iterating handles, a compiletf that checks them, and a copy of every value in and out.

DPI (IEEE 1800 clause 35, Annex H for the C layer, Annex I for svdpi.h) takes the opposite approach. It gives up reflection: C sees only the arguments, as native C values or pointers. In exchange the call is just a function call. The simulator generates the glue at compile time, because it knows the SV prototype. It knows nothing about the C side.

PLI / VPI (reflection) SV: $my_task(a, b) simulator dispatch → registered calltf vpi_handle / vpi_scan / vpi_get_value ×N your C code · vpi_put_value DPI-C (calling convention) SV: y = factorial(i) generated wrapper (compile time)converts types, then a plain C call int factorial(int i)
Fig. 0.1 VPI hands C a view of the simulator; DPI hands C its arguments. DPI wins whenever C only needs to compute something from values, which covers every golden model in this tutorial. VPI stays the right tool for tools that inspect the design (waveform dumpers, cocotb, coverage collectors).

The book's 2012 claim still holds: "SystemVerilog only supports an interface to the C language. C++ code has to be wrapped to look like C." The "DPI-C" string literally means C calling convention and C symbol names. Any language reachable from C works through it, including C++ (step 03) and Python (step 07), but each must present C-linkage functions.

0.2The first import

Sample 12.1/12.2 of the book, rewritten as a module (the book uses program, which nothing here needs) and with the const of Sample 12.5:

// hello.sv (excerpt)
module top;
  import "DPI-C" pure function int factorial(input int i);
  initial begin
    for (int i = -1; i <= 13; i++) $display("LOG fact i=%0d f=%0d", i, factorial(i));
    $finish;
  end
endmodule
/* hello.c (excerpt): the C side. Why extern "C" is needed: 0.5 */
extern "C" int factorial(const int i) {   /* const: the compiler rejects writes to an input */
  return i <= 1 ? 1 : i * factorial(i - 1);
}
$ verilator --binary --top-module top hello.sv hello.c   # .c file on the same command line
$ ./obj_dir/Vtop
LOG fact i=10 f=3628800
LOG fact i=12 f=479001600
LOG fact i=13 f=1932053504          # 13! = 6227020800 does not fit in 32 bits (0.13)

The import declaration has four parts. Learn them once:

import declaration (IEEE 1800-2023 §35.5.4) import "DPI-C" pure \expect = function int fexpect(); keyword spec string:"DPI-C" only propertypure | context(optional) c_name = (optional):the LINKER symbol an ordinary SV prototype: SV name, SV types,directions, no body. The simulator derivesthe C prototype from this line alone.
Fig. 0.2 The c_name defaults to the SV name. Either way, it is a global C symbol: the only thing the linker uses to find the function.

0.3What a call really does

Verilator turns your SV into C++. For each import it generates a small wrapper function named after the scope (Vtop___024root____Vdpiimwrap_top__DOT__addmul_TOP). Here is the real one for Sample 12.4's addmul, taken from build/vl_hello/Vtop___024root__0.cpp:

extern "C" int addmul(int a, int b, int* sum);     // generated from the SV import

void Vtop___024root____Vdpiimwrap_top__DOT__addmul_TOP(IData a, IData b, IData &sum, IData &addmul__Vfuncrtn) {
    sum = 0U;
    int a__Vcvt;  a__Vcvt = a;              // SV value -> C type
    int b__Vcvt;  b__Vcvt = b;
    int sum__Vcvt;                           // a TEMPORARY for the output (uninitialized!)
    int addmul__Vfuncrtn__Vcvt;
    addmul__Vfuncrtn__Vcvt = addmul(a__Vcvt, b__Vcvt, &sum__Vcvt);   // the plain C call
    sum = (sum__Vcvt);                       // copy back after return
    addmul__Vfuncrtn = (addmul__Vfuncrtn__Vcvt);
}
SV processp = addmul(6, 7, s) wrapper (generated) a__Vcvt = 6b__Vcvt = 7int sum__Vcvt; // temp s = sum__Vcvt (13)p = ret (42) addmul (your C)*sum = a + b;return a * b; call return same OS thread, same time slot: $time does not move during the call
Fig. 0.3 Inputs are passed by value; outputs go through a pointer to a temporary that is copied back on return. In Verilator, C never holds a pointer into the simulator's own storage for a plain output int; the standard allows either.

Three consequences you will use for the rest of the tutorial:

Imports in combinational logic. An import can be called anywhere a function can: initial, always, a class method, an always_comb, a continuous assign. In the last two the simulator decides when to call it: whenever it re-evaluates the expression. lab/comb.sv drives one counter x through two call sites, always_comb y = ping_raw(x) and assign z = ping_raw(x + 1), and lets x change 10 times:

LOG comb t=1 calls=2                        # both sites evaluated once at time 0
LOG comb t=101 calls=22 x=10 y=10 z=11      # + 10 changes x 2 sites

Verilator is economical here: one call per input change per site. An event-driven simulator may evaluate a combinational process more than once per time step (glitches through delta cycles), and nothing in the LRM promises "exactly once per change". A stateless (truly pure) function does not care. A stateful C model called from always_comb advances its state an unpredictable number of times. Call stateful models only from clocked or procedural code, where you control the count.

0.4The Verilator build pipeline

With --binary, Verilator does three things in one command: it translates SV to C++, writes a makefile, and runs make. Every non-SV file on the command line (.c, .cpp, .o, .a) becomes part of that build. The key line of the generated Vtop.mk:

hello.o: hello.c
	$(OBJCACHE) $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(OPT_FAST)  -c -o $@ $<

$(CXX), the C++ compiler, even for a .c file. On a fresh compile clang even says so: clang: warning: treating 'c' input as 'c++' when in C++ mode, this behavior is deprecated. So in Verilator your "C" is always C++. That has one big consequence, in the next section.

hello.sv hello.c verilatorSV → C++ Vtop*.cpp → .a Vtop__Dpi.h $(CXX) -c → hello.o link→ Vtop #include (optional, recommended) names meet only at the link: "_factorial" must exist in some .o
Fig. 0.4 Two independent compilations, joined by the linker. Including Vtop__Dpi.h is the only way to let the C++ compiler compare both sides (0.6).

For larger projects the same pieces work without --binary: verilator --cc --exe plus your own main, or compile the C into a library and pass -LDFLAGS. Passing compiler and linker flags looks like -CFLAGS -I/some/include -LDFLAGS -lm. Step 07 uses exactly that to link Python.

C++ supports overloading, so a C++ compiler encodes the parameter types into the symbol name (name mangling). int factorial(int) compiled as C++ becomes __Z9factoriali on macOS (_Z9factoriali on Linux ELF). The generated SV side declares extern "C" int factorial(int), so it asks the linker for _factorial. The lab's noextern.c drops the extern "C":

$ nm build/vl_noextern/noextern.o | grep fact
0000000000000000 T __Z9factoriali
# link step:
Undefined symbols for architecture x86_64:
  "_addmul", referenced from:
      Vtop___024root___eval_initial(Vtop___024root*) in Vtop__ALL.a[2](Vtop__ALL.o)
      Vtop___024root____Vdpiimwrap_top__DOT__addmul_TOP(unsigned int, unsigned int, unsigned int&, unsigned int&) in ...
  "_expect", referenced from: ...
  "_factorial", referenced from: ...
ld: symbol(s) not found for architecture x86_64
Vtop__ALL.oU _factorial hello.o (extern "C")T _factorial noextern.o (C++)T __Z9factoriali match: links no match: Undefined symbols
Fig. 0.5 The linker compares strings. U = undefined (needed), T = defined in the text section.

Two portable fixes, both in the lab:

/* fix 1 (hello.c): C linkage when compiled as C++, a no-op when compiled as C (commercial flows) */
#ifdef __cplusplus
extern "C" {
#endif
int factorial(const int i) { ... }
#ifdef __cplusplus
}
#endif

/* fix 2 (hdr.c): include the generated prototypes; a definition after an extern "C"
   declaration of the same function inherits C linkage. No extern "C" needed in this file. */
#include "Vtop__Dpi.h"
int factorial(const int i) { ... }

Fix 2 is better: it also type-checks (next section). Both programs print identical logs (asserted in check.py).

0.6The generated header: your type checker

The book (§12.1.4) says it plainly: "The SystemVerilog simulator cannot compare the types as it is unable to read the C code." It mentions VCS's vc_hdrs.h and Questa's incl.h as guides. In Verilator the file is V<top>__Dpi.h in the -Mdir. Here is the real one for hello.sv:

// Verilator output: Prototypes for DPI import and export functions.
// Manually include this file where DPI .c import functions are declared to ensure
// the C functions match the expectations of the DPI imports.
#include "svdpi.h"
#ifdef __cplusplus
extern "C" {
#endif
    // DPI IMPORTS
    extern int access(const char* path, int mode);
    extern int addmul(int a, int b, int* sum);
    extern int expect();
    extern double fabs(double x);
    extern int factorial(int i);
    extern double pow(double x, double y);
    extern double sin(double x);
#ifdef __cplusplus
}
#endif

It is also the fastest way to learn the type mapping: write the SV import, build, read the header. Step 02 does exactly that for every type.

What a mismatch looks like without the header. mismatch.sv imports function longint negate(input longint v); the C author wrote extern "C" int negate(int v) { return -v; }. There is no mangling to save you: the names match and it links. Then:

LOG mismatch v=4294967301 negate=4294967291 expected=-4294967301
x86-64 System V ABI: args in rdi, rsi…; integer result in rax SV: longint v 0x000000010x00000005rdi (C reads only edi = 5) C: return -v 0x000000000xFFFFFFFBrax (eax write zero-extends) SV reads all of rax as longint: 0x00000000_FFFFFFFB = 4294967291 expected -4294967301. Undefined behavior; this is what x86-64 happens to do.
Fig. 0.6 Neither side is "wrong" from its own point of view. The ABI moved bits exactly as each side's prototype said. Only the C compiler, shown both prototypes, could have caught it.

With the header. mismatch_hdr.c adds #include "Vtop__Dpi.h" and keeps the wrong definition inside extern "C":

../../mismatch_hdr.c:3:16: error: functions that differ only in their return type cannot be overloaded

Newer clang (16+) words the same error as error: conflicting types for 'negate'.

The header only checks what has C linkage. mismatch_hdr2.c includes the header but defines int negate(int v) outside extern "C". The parameter type differs from the header's long long negate(long long), so C++ sees a new overload, compiles it with a mangled name, and the build fails only at link time: Undefined symbols … "_negate". So: include the header and make the definitions match it. Copy the prototype from the header, never retype it.

0.7Missing and hidden symbols

If an imported function is never defined, the generated code still calls it, so the link fails. missing.c forgets addmul:

Undefined symbols for architecture x86_64:
  "_addmul", referenced from:
      Vtop___024root____Vdpiimwrap_top__DOT__addmul_TOP(unsigned int, unsigned int, unsigned int&, unsigned int&) in ...

Read the "referenced from" line: the wrapper name tells you the SV scope (top) and the SV name (addmul) of the import that wants it. With a renamed import (0.9) the symbol is the c_name and the wrapper carries the SV name. That is how you tell them apart.

The same error appears if the function exists but is static (internal linkage, invisible outside its own .o; exercise E12), and with Linux GNU ld it reads undefined reference to `addmul'.

Simulators that load the C as a shared library at run time (vsim -sv_lib, xrun -sv_lib) report a missing symbol when the simulation loads or at elaboration, not at link: Questa's message names the unresolved DPI import. Same cause, later moment. Not measured here.

0.8output arguments, for int

Arguments default to input. An input int arrives as a C int (by value). An output int or inout int arrives as int*. ref is not allowed in imports (§12.1.3), because C cannot hold a reference to an SV variable across the call. Verilator 5.052 does not enforce this: import "DPI-C" function void bump(ref int x); compiles without a warning (even with -Wall) and the header shows void bump(int* x), i.e. it is treated like inout (lab/ref.sv). Commercial tools reject it, so don't write it. Sample 12.4:

import "DPI-C" function int addmul(input int a, input int b, output int sum);
...
p = addmul(6, 7, s);      // LOG addmul p=42 s=13
int addmul(int a, int b, int* sum) { *sum = a + b; return a * b; }

Rules for now, the full table is in step 02:

0.9Names: renaming, keywords, one C namespace

The optional c_name = separates the linker symbol from the SV name. The book's Sample 12.3 needs it because expect is an SV keyword, so a C function called expect cannot be imported under its own name:

import "DPI-C" function int expect();      // keyword.sv: syntax error, unexpected expect
import "DPI-C" \expect = function int fexpect();   // hello.sv: works -> LOG rename fexpect=42

The backslash in Sample 12.3 (\expect =) is not decoration. Without it the SV lexer sees the keyword expect before it knows it is a c_name: Verilator says syntax error, unexpected expect, expecting function or task. The escaped identifier \expect (terminated by the space) is passed to the linker as plain expect. The book's second example, stat = function int file_exists(...), cannot work as printed: stat takes a struct stat*, not an int[1000]. The lab uses libc access(path, F_OK) instead: LOG access sv=0 none=-1.

Renaming also lets one C function appear under a friendlier SV name, or several SV names map to one C function (same signature). The reverse is forbidden. All imports share one global C namespace, and a C symbol can have only one SV signature in the whole design, even across packages:

package pa; import "DPI-C" function int  twice(input int x);  endpackage
package pb; import "DPI-C" function real twice(input real x); endpackage
%Error: dup.sv:3:42: Duplicate declaration of DPI function with different signature: 'twice'

SV has no overloading of routines, and C has no overloading of symbols. If you need int and real versions, write two C functions.

What "one signature" includes. §35.5.4: every declaration of a given C name (explicit c_name = or implied by the SV name), in any scope, must have exactly the same type signature: return type, number, order, direction and type of every argument including array bounds, the pure/context qualifier, and the "DPI-C"/"DPI" string. Argument names and default values may differ. The lab (cid.sv) imports twice in top and, as dbl(input int y), in a submodule: legal, LOG cid top=4 u=6. Making the second one context is a different signature: Duplicate declaration of DPI function with different signature: 'twice'. The C names themselves must be valid C identifiers; a name that collides with an SV keyword is written as an escaped identifier and the tool strips the backslash (§35.4), which is exactly Sample 12.3's \expect.

0.10Importing libc and libm directly

Because DPI is just a C call, any C library function whose types map cleanly can be imported with no wrapper (Sample 12.6). The executable already links libc and libm:

import "DPI-C" pure function real fabs(input real x);
import "DPI-C" pure function real sin(input real x);
import "DPI-C" pure function real pow(input real x, input real y);
import "DPI-C" access = function int file_access(input string path, input int mode);
// LOG libm fabs=1.500 sin30=0.500000 pow=1024.0
// LOG access sv=0 none=-1

It only works if Verilator's prototype is identical to the system header's. Verilator's generated C++ (through its runtime headers) includes <string.h> and friends, so the two declarations meet in one translation unit. strlen returns size_t, which is unsigned long on LP64 systems (macOS, Linux). No SV type generates unsigned long: longint unsigned becomes unsigned long long, the same width but a different C++ type. Result (exercise E5):
error: functions that differ only in their return type cannot be overloaded … size_t strlen(const char *__s).
The cure is a one-line wrapper. double/int/const char* functions like sin, pow, log10 and access match exactly and import fine.

Compiler builtins like __builtin_popcount are not symbols at all (the compiler inlines them), so they always need a wrapper (exercise E11).

0.11pure

IEEE 1800-2023 §35.5.2: a pure function's result depends only on its input arguments, and it has no side effects: no global or static state, no file I/O, no printing, no calls to exported SV routines. In return the simulator may optimize as if it were a mathematical function. It may skip a call whose result is unused, evaluate f(2)+f(2) once, or hoist a call out of a loop. §35.5.1.3 and §35.5.2 also restrict who may be pure: only nonvoid functions with no output or inout arguments. A void function has no result to reuse, an output is a side effect, and an imported task can never be pure (the grammar only allows context on tasks).

Verilator does not enforce these three rules. The lab (pure_rules.sv) builds pure function void, pure task and pure function int f(input int a, output int b): all three compile without an error or warning and run (LOG prule case=out r=3 b=4). A commercial compiler may reject them, or accept them and then drop the call. Do not write them. Listed in What doesn't work in Verilator.

The lab tells a deliberate lie: ping() is imported as pure but counts its calls in a C static.

SV codecalls the LRM allowscalls in Verilator 5.052
void'(ping(1));0 or 11
y = ping(2) + ping(2);1 or 22
for (k=0;k<4;k++) y += ping(3);1 to 44
same with the non-pure ping_rawexactly 22

Verilator calls every time today. That is the dangerous outcome: the lie goes unnoticed until a simulator (or a future Verilator) uses the license the LRM gives it, and your scoreboard silently loses transactions. Mark a function pure only if it really is. Factorial, CRC, sin: yes. A model with internal state, anything that logs, anything that reads a file: no. The opposite of pure is not "plain" but context, which is about calling back into SV; that is step 05.

0.12Where imports live

An import is a declaration and can go wherever a function declaration can: a module, interface, program, package, or the compilation unit ($unit). Its SV name is local to that scope; its C name is global. Put them in a package and import the package (§12.1.2), so there is exactly one SV declaration per C function:

package dpi_pkg;
  import "DPI-C" pure function int factorial(input int i);
endpackage
module child; import dpi_pkg::*; initial $display("LOG pkg who=%m f6=%0d", factorial(6)); endmodule
// LOG pkg who=top.c2 f6=720
// LOG pkg who=top.c1 f6=720
// LOG pkg who=top f7=5040

Two instances of child call the same C function. C cannot tell them apart unless it asks for the caller's scope (context, step 05) or you pass a handle (step 03).

0.13Widths and overflow

SV's int is exactly 32-bit signed two's complement. C's int is "at least 16 bits"; on every platform you will simulate on (LP64 Linux/macOS, LLP64 Windows) it is 32. svdpi.h does not change that. The mapping int ↔ int and longint ↔ long long is what the standard specifies, so use those C types, not int32_t/int64_t typedefs (same width but possibly different C++ types, the 0.10 trap again).

13! = 6227020800 needs 33 bits. The DPI returns what the C int holds:

$$13! \bmod 2^{32} = 6227020800 - 2^{32} = 1932053504$$

which is the lab's LOG fact i=13 f=1932053504. Strictly, signed overflow is undefined behavior in C: the compiler may assume it never happens. Golden models must be bit-exact, so do wrap-around arithmetic in unsigned types (defined to wrap mod 2N) and convert at the end, as ex2_fact64.c does. Hardware wraps; your model must wrap the same way by definition, not by luck.

0.14Commercial simulators (not measured here)

Same language rules (clause 35), different build mechanics. These lines are from vendor documentation; this lab could not run them:

Simulatorcompile C with the designprebuilt shared libraryprototype header
Questavlog -sv top.sv dpi.cvsim -sv_lib ./libdpi topvlog -dpiheader dpi.h
Xceliumxrun top.sv dpi.cxrun -sv_lib libdpixrun -dpiheader dpi.h
VCSvcs -sverilog top.sv dpi.csimv -sv_lib libdpivc_hdrs.h (automatic)
Verilatorverilator --binary top.sv dpi.cdpi.a / -LDFLAGSV<top>__Dpi.h (automatic)

Two practical differences: they compile .c files as C, so missing extern "C" only bites with .cpp files there; and with -sv_lib the library is dlopened at run time, so the C side can be rebuilt without recompiling the design. The #ifdef __cplusplus pattern in 0.5 makes one source file work everywhere.

The loading switches come from Annex J, which recommends (informatively) the same names for every tool: -sv_lib takes the library path without its extension (-sv_lib lib/dpi; the tool adds .so, .dylib or .dll), -sv_liblist file names a bootstrap file whose first line is #!SV_LIBRARIES followed by one library per line, and -sv_root dir is prepended to relative paths. Bootstrap entries load before -sv_lib switches, in order, and each library loads once (J.3, J.4, J.4.1). Verilator has none of this: it links the C into the executable at build time.

import "DPI" (no -C) is not just an old spelling. It selects the deprecated SV3.1a argument passing for packed types, where C gets opaque svBitPackedArrRef handles instead of canonical svBitVecVal chunks (§35.5.4, H.14), so C written for "DPI-C" would read garbage through it. §35.5.4 requires a compile-time warning or error for it. Verilator's error, Unsupported DPI type 'DPI': Use 'DPI-C', is the compliant answer; some commercial tools warn and fall back to the old semantics.

0.15Which stage failed? A map of DPI errors

Every failure in this step happens at one of four stages, and the stage tells you what to look at. Find the stage first; the message is then usually self-explanatory. In Verilator all four stages run inside one verilator --binary command, so the first job is to find which tool printed the error. Verilator prints %Error:, the compiler prints file.c:line:col: error:, the linker prints Undefined symbols or undefined reference, and the simulation prints whatever your code prints.

1 · Verilator%Error: file.sv:… 2 · C++ compilemodel.c:3:16: error: 3 · linkUndefined symbols 4 · run timeno message at all keyword as SV nameimport "DPI"duplicate signaturereturn type ≠ headerclash with libc headerwrite to const inputno extern "C" (mangled)function not definedstatic functionparam ≠ header (overload)type mismatch, no headerlying pureoutput read before writesigned overflow (UB) cheap to findexpensive to find Goal of every rule in this step: move bugs to the left.
Fig. 0.7 The generated header moves "type mismatch" from column 4 to column 2. extern "C" fixes column 3. Column 4 has no tool support at all, only discipline and self-checking tests.

Column 4 deserves the respect. A wrong value from a golden model does not crash anything. It shows up as a DUT mismatch, and the natural reaction is to debug the RTL. Before you open the waveform, make the model prove itself. Run the C function standalone against known vectors (the CRC-32 check value of "123456789" is 0xCBF43926; factorials are in every table), and check the generated header against your C prototypes. Steps 06 and 08 build this into the flow: the golden model gets its own self-test before it judges the DUT.

Imported tasks. Everything so far imported functions. You can also write import "DPI-C" task t(...). In C it is a function returning int (0 normally, 1 if the SV side disabled the task while it was inside an exported task; the "disable protocol" of §35.9). An imported task may call exported SV tasks that consume time, which is how C code can wait for clock edges. That needs context and exports, so it waits for step 05. For pure computation, which is every golden model, use functions.

0.16Recipe: a new import, first time right

  1. Write the SV import in a package. Default to no property; add pure only for true functions.
  2. Build once. Open V<top>__Dpi.h and copy the prototype into your C file as the definition's signature.
  3. In the C file, #include "V<top>__Dpi.h" (Verilator) or the vendor's header, so the compiler checks both sides and gives C linkage.
  4. Write every output on every path. Use unsigned arithmetic for anything that may wrap.
  5. On a link error, read the wrapper name in "referenced from": it names the SV scope and the SV function.

Next, step 02 does the same for every type: vectors wider than 64 bits, 4-state values, strings, and the svBitVecVal word layout.

§1 · ObjectivesWhat you can do after this step

Prerequisites: SV functions and packages; C compilation and linking. Nothing from other steps.

§2 · Exercises12 graded exercises, with solutions

★ basic · ★★ intermediate · ★★★ real-world. Every code solution is in lab/ex/ (or the lab root) and is built, run and asserted by check.py.

E1 ★Ex. 12.1 (book). Write a C function shift_c with a 32-bit unsigned input i and an integer shift amount n: n > 0 shifts left, n < 0 shifts right, n == 0 returns i. Write a self-checking SV module that tests each feature, including the corners.

Show solution
import "DPI-C" pure function int unsigned shift_c(input int unsigned i, input int n);
// t(i, n, expected) calls shift_c, prints, compares (ex/ex1_shift.sv)
t(32'h0000_00F0,   4, 32'h0000_0F00);   t(32'h0000_00F0, -4, 32'h0000_000F);
t(32'h0000_00F0,   0, 32'h0000_00F0);   t(32'h8000_0001,  1, 32'h0000_0002);
t(32'h8000_0001,  -1, 32'h4000_0000);   // logical shift: the type is unsigned
t(32'hFFFF_FFFF,  32, 32'h0000_0000);   t(32'hFFFF_FFFF, -40, 32'h0000_0000);
#include "Vtop__Dpi.h"
unsigned int shift_c(unsigned int i, int n) {
  if (n >= 32 || n <= -32) return 0;       /* x << 32 is undefined behavior in C */
  return n > 0 ? i << n : n < 0 ? i >> -n : i;
}

Output: 7 LOG shift lines, then PASS ex1. Lessons: int unsigned maps to unsigned int (check the header), which makes >> logical. Without the clamp, a shift by 32 is undefined behavior in C; on x86 it typically gives back i (the shift instruction masks the count to 5 bits), while SV's << gives 0. A golden model has to define those corners explicitly.

E2 ★Predict. factorial64 returns longint and computes in unsigned long long. What does SV print for 13!, 20! and 21!? (21! = 51090942171709440000.)

Show solution

LOG fact64 f13=6227020800 f20=2432902008176640000 f21=-4249290049419214848. 20! fits in 63 bits. For 21!: $51090942171709440000 \bmod 2^{64} = 14197454024290336768$, which is above $2^{63}$, so read as signed it is $14197454024290336768 - 2^{64} = -4249290049419214848$. The C code multiplies in unsigned long long (wrap is defined) and casts at the end; doing the loop in long long would be signed overflow, i.e. undefined behavior (0.13).

E3 ★Find the bug. A colleague's C library has int expect(void). They write import "DPI-C" function int expect(); and get syntax error, unexpected expect. Fix it without touching the C code.

Show solution

import "DPI-C" \expect = function int fexpect();. expect is an SV keyword (property expect statements), so it cannot be the SV name, and as a c_name it must be written as an escaped identifier (backslash, name, then whitespace). The linker symbol is still expect. In the lab: keyword.sv fails, hello.sv prints LOG rename fexpect=42.

E4 ★★Find the bug. verilator --binary hello.sv noextern.c fails with Undefined symbols … "_factorial", yet noextern.c clearly defines int factorial(const int i). Explain with one command, and give two fixes.

Show solution

nm build/vl_noextern/noextern.o shows T __Z9factoriali: Verilator compiled the .c as C++ and the name got mangled. Fix 1: wrap the definitions in #ifdef __cplusplus extern "C" { … } #endif. Fix 2 (preferred): #include "Vtop__Dpi.h" at the top. The header's extern "C" declarations give the later definitions C linkage and also type-check them. Both fixes are in the lab (hello.c, hdr.c), with identical output.

E5 ★★Find the bug. To avoid writing C, someone imports libc directly: import "DPI-C" pure strlen = function longint unsigned c_strlen(input string s);. The build fails in C++ compilation. Why, and what is the smallest fix?

Show solution

Verilator declares extern "C" unsigned long long strlen(const char* s), and the system header already declares size_t strlen(const char*) with size_t = unsigned long. Same width, different C++ type, and the two declarations collide: functions that differ only in their return type cannot be overloaded. No SV type produces unsigned long. Fix: a wrapper (ex/ex5_fix.c):

#include <string.h>
#include "Vtop__Dpi.h"
long long c_strlen(const char* s) { return (long long)strlen(s); }

with import "DPI-C" pure function longint c_strlen(input string s); → LOG strlen empty=0 hello=5 len=5.

E6 ★★Write gcd(a, b, steps): return the GCD (Euclid) and report the number of loop iterations through an output int. Predict the result for (1071, 462), (17, 5) and (8, 0). Is it legal to declare it pure?

Show solution
int gcd(int a, int b, int* steps) {
  *steps = 0;                          /* output: starts undefined, write it first */
  while (b != 0) { int t = a % b; a = b; b = t; (*steps)++; }
  return a;
}

1071, 462 → 21 in 3 steps (1071 mod 462 = 147, 462 mod 147 = 21, 147 mod 21 = 0). 17, 5 → 1 in 3 steps. 8, 0 → 8 in 0 steps. Not pure: pure functions cannot have output or inout arguments (§35.5.2). If you want it pure, return the steps from a second function, or pack both into a longint.

E7 ★★Convert −3 dB to a linear gain with libm (no C file): import pow and log10, compute $10^{-3/20}$, compare with SV's own 10.0 ** (-3.0/20.0), and convert back to dB. Are the two results bit-identical?

Show solution
import "DPI-C" pure function real pow(input real x, input real y);
import "DPI-C" pure function real log10(input real x);
real c = pow(10.0, -3.0 / 20.0), s = 10.0 ** (-3.0 / 20.0);
// LOG db c=0.7079457844 sv=0.7079457844 same=1 back=-3.000000

Bit-identical in Verilator (c == s is 1), because its ** on reals calls the same libm pow. Do not assume that across simulators. Floating-point golden models compare with a tolerance unless you control both implementations. Step 06 returns to this.

E8 ★★Predict. SV: import "DPI-C" function longint negate(input longint v);. C: extern "C" int negate(int v) { return -v; }. It links. What does negate(64'h1_0000_0005) return on x86-64?

Show solution

4294967291 (LOG mismatch v=4294967301 negate=4294967291). The argument goes in rdi; C reads edi = 5, returns −5 in eax = 0xFFFFFFFB; writing eax zeroes the top half of rax; SV reads rax = 0x00000000FFFFFFFB (Fig. 0.6). It is undefined behavior, so other ABIs (or optimization levels) may differ. Including Vtop__Dpi.h turns it into a compile error.

E9 ★★★Find the bug. Same mismatch as E8, but now the C file starts with #include "Vtop__Dpi.h" and defines int negate(int v) (no extern "C"). It compiles. Why, and what happens at link?

Show solution

The header declares extern "C" long long negate(long long). A definition with a different parameter list is, in C++, a different function: a legal overload with C++ linkage and a mangled name. So it compiles, and the C-linkage negate stays undefined: Undefined symbols … "_negate" (mismatch_hdr2.c). When only the return type differs, C++ cannot overload and you get the clean compile error (mismatch_hdr.c). Lesson: the header is a strong check, not a complete one. Copy prototypes from it verbatim.

E10 ★★★Predict. ping(x) returns x and increments a C static counter, but is imported as pure. How many calls does each statement make in Verilator 5.052, and how many could a standard-conforming simulator make? void'(ping(1));, y = ping(2) + ping(2);, a loop of 4 y += ping(3).

Show solution

Verilator: 1, 2, 4. It executes every call (LOG pure case=… calls=…). A conforming simulator may legally make 0, 1 and 1: drop the unused call, share the common subexpression, hoist the loop-invariant call. The C code breaks the pure contract (static state), so both answers are "correct" and the program's behavior is simulator-dependent. Remove pure or remove the side effect.

E11 ★★★Your first golden model: check SV's $countones against a C popcount on 10,000 values, corners (0 and all-ones) first. Why can't you import __builtin_popcount directly the way you imported pow?

Show solution
#include "Vtop__Dpi.h"
int popcount32(unsigned int x) { return __builtin_popcount(x); }
import "DPI-C" pure function int popcount32(input int unsigned x);
for (int k = 0; k < 10000; k++) begin
  x = (k < 2) ? (k == 0 ? 32'h0 : 32'hFFFF_FFFF) : $urandom;
  if (popcount32(x) != $countones(x)) errors++;
end   // LOG popcount n=10000 errors=0 / PASS ex11

__builtin_popcount is a compiler intrinsic. It becomes a popcnt instruction or an inline sequence, and there is no symbol in any library for the linker to find. Anything that is a macro, an intrinsic or an inline-only function needs a real C function around it.

E12 ★★Find the bug. A C file wraps everything in extern "C" { … } but declares static int factorial(const int i) ("it's a helper, keep it private"). The link fails on _factorial. Why does extern "C" not help?

Show solution

static at file scope means internal linkage: the symbol never leaves the object file, whatever its language linkage. extern "C" controls the name (no mangling), not the visibility. The generated wrapper in Vtop__ALL.o needs an external _factorial, so the link fails (ex/ex12_static.c). Helpers can be static; DPI entry points cannot.

§3 · Quiz14 questions, self-grading

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

1. During a DPI import call, what happens to simulation time and to other SV processes?

(c) An import is a plain function call in the same process and thread (Fig. 0.3). (b) describes socket co-simulation (the legacy/sockets_dpi examples in this repo), which you would build on top of DPI. pure only licenses optimizations; it does not change the execution model.

2. In Verilator, which compiler compiles model.c given on the command line?

(b) The generated Vtop.mk rule is $(CXX) … -c -o model.o model.c, and clang warns "treating 'c' input as 'c++'". That is why C linkage must be explicit. Commercial simulators do compile .c as C, which is why code that "worked in Questa" can fail to link in Verilator.

3. The link fails with Undefined symbols: "_factorial", and nm model.o shows T __Z9factoriali. The cause is:

(d) __Z9factoriali is the Itanium C++ mangling of factorial(int). The object is present (a is wrong) and the symbol is global (T, so c is wrong; a static would show t or nothing). const on a by-value parameter is not part of the function type, so (b) changes nothing.

4. What does including Vtop__Dpi.h in your C file give you?

(a) The header holds extern "C" prototypes generated from the SV imports; the generated file even says "Manually include this file…". It converts nothing (c): a mismatch is either a compile error or, if the parameters differ, a silent C++ overload (Q5).

5. With the header included, you define int negate(int v) (no extern "C") while the header says long long negate(long long v). Result?

(c) Different parameter types mean a legal overload in C++, with its own mangled name, so the C-linkage negate is never defined. (a) is what you get when only the return type differs, or when the definition is inside extern "C". Verilator never sees C (d).

6. SV imports function longint f(input longint v), C defines extern "C" int f(int v), no header. What is guaranteed?

(b) C symbols carry no types, so the link succeeds. The lab measured negate(0x1_0000_0005) = 4294967291. (c) is tempting, but SV converts to the type in its own prototype (long long); it cannot know C expects 32 bits. There is no run-time type check in DPI (d).

7. Which of these may legally be declared pure? (select all)

(a) and (c). (b) has state, so the result depends on more than the inputs. (d) has an output argument, which §35.5.2 forbids for pure functions. libm functions are pure as long as you ignore errno, which simulators conventionally do.

8. In Verilator 5.052, y = ping(2) + ping(2) with a side-effecting pure ping calls C:

(b), measured. The LRM would also allow (a). Today's behavior is the trap: the lying pure passes every test until a tool starts optimizing.

9. import "DPI-C" function int expect(); fails with "syntax error, unexpected expect". The correct import is:

(d), Sample 12.3. (b) fails in Verilator with the same syntax error, because the lexer sees the keyword. (a) is legal SV, but it uses an escaped identifier as the SV name, which is legal and unreadable at every call site. The c_name is an identifier, not a string (c).

10. Two packages import the C function twice, one as int twice(int) and one as real twice(real). What happens?

(c). Verilator: Duplicate declaration of DPI function with different signature: 'twice'. SV names are scoped; C names are global (0.9). SV has no routine overloading (b).

11. import "DPI-C" function int addmul(input int a, b, output int sum);. What does C receive for sum, and what is *sum on entry in Verilator?

(b). The generated wrapper declares int sum__Vcvt;, passes &sum__Vcvt, and copies it back after the call. Pointing at the SV variable (c) is an implementation choice the standard allows, so never rely on either. DPI is a C interface, so there are no references (d).

12. Why does import "DPI-C" pure function real sin(input real x); work with no C file, while importing strlen as longint unsigned fails to compile?

(a). Both libraries are linked (b is wrong; access from libc works). Strings pass fine as const char* (c). Compilers do treat strlen specially, but a real libc symbol exists; the error is the C++ type clash.

13. A golden model computes 21! with signed long long arithmetic and matches the RTL in your simulator today. What is the real risk?

(c). Only unsigned arithmetic wraps by definition (C11 6.2.5p9). Optimizers do exploit signed-overflow UB (loop and comparison transformations). longint is always 64 bits (b), and DPI does no arithmetic at all (d).

14. You need C to inspect arbitrary signals of the DUT by hierarchical name at run time, without passing them as arguments. Which interface?

(b). DPI is deliberately non-reflective (Fig. 0.1). context imports (step 05) let C call exported SV functions in a scope, but not walk the hierarchy. There is no svGetSignalByName in svdpi.h (d).

§4 · LabRun it, break it, read the messages

Everything above comes from tutorial/01_first_call/lab/. One command builds about 25 small experiments in parallel, runs them, and asserts 53 facts:

$ cd tutorial/01_first_call/lab
$ python3 check.py          # ~3 s with ccache warm, ~1–2 min cold
check.py: 53 checks passed
$ bash run.sh                # just build + run; logs in build/

Before running, predict: (1) what factorial(13) prints; (2) which builds fail and at which stage (Verilator, C++ compile, or link); (3) the value negate returns; (4) the call counts in pure.log. Then compare with the table.

ExperimentFilesStageResult (measured)
first importshello.sv + hello.crunsf13=1932053504, sin30=0.500000, fexpect=42, access sv=0 none=-1, p=42 s=13
generated header instead of extern "C"hello.sv + hdr.crunssame log as hello
no extern "C"noextern.clinkUndefined symbols "_addmul" "_expect" "_factorial"; nm: __Z9factoriali
symbol missingmissing.clinkonly "_addmul" undefined
static definitionex/ex12_static.clink"_factorial" undefined
type mismatch, no headermismatch.sv/.cruns (wrong)negate=4294967291 (expected −4294967301)
type mismatch, headermismatch_hdr.cC++ compilefunctions that differ only in their return type cannot be overloaded
mismatch, header, no extern "C"mismatch_hdr2.clink"_negate" undefined (C++ overload)
keyword as SV namekeyword.svVerilatorsyntax error, unexpected expect
import "DPI"legacy.svVerilatorUnsupported DPI type 'DPI': Use 'DPI-C'
same C name, two signaturesdup.svVerilatorDuplicate declaration of DPI function with different signature: 'twice'
libc strlen imported directlyex/ex5_strlen.svC++ compileclash with size_t strlen from <string.h>
lying purepure.sv/.crunscalls: unused=1, twice=2, loop4=4 (no elimination)
import in always_comb/assigncomb.sv + pure.cruns2 calls at t=0, then 1 per change per site: 22 total
pure on void / task / with output (illegal)pure_rules.sv/.crunsall three accepted silently; r=3 b=4
one C name in two scopescid.sv/.cruns / Verilatortop=4 u=6; with context on one: Duplicate declaration … 'twice'
ref argument (illegal)ref.svlinkaccepted silently as int*; only "_bump" undefined
imports in a packagepkg.sv/.crunstop.c1, top.c2: 720; top: 5040

Where to look. build/<name>.build holds the full Verilator + make + compiler + linker output; build/<name>.log holds the simulation output. Open build/vl_hello/Vtop__Dpi.h (0.6) and search build/vl_hello/Vtop___024root__0.cpp for Vdpiimwrap to see the wrappers (0.3). Try it yourself: add import "DPI-C" function shortint f(input byte b, input real r); to a file, build, and read what C types the header gives you. That is the start of step 02.

run.sh sources ../../common/lab.sh, which defines vlb (background build with verilator --binary -j 2 --timing --assert into build/vl_<name>) and vlr (run). Run it with bash; the helpers rely on bash word splitting. With ccache installed, a rebuild takes seconds. The clang warning treating 'c' input as 'c++' only appears on a cache miss.

§5 · Cheat sheetStep 01 on one screen

ConstructSV declarationC side (as in V<top>__Dpi.h)Pitfall
basic importimport "DPI-C" function int f(input int a);int f(int a);C linkage required in Verilator
pureimport "DPI-C" pure function real g(input real x);double g(double x);no state, no I/O, no outputs; calls may be skipped
renameimport "DPI-C" c_fn = function int sv_fn();int c_fn();keyword c_name must be escaped: \expect =
output intoutput int sint* sinitial value undefined; always write
inout intinout int sint* sholds SV value on entry, copied back
int unsignedinput int unsigned uunsigned int uuse it to get logical >>
longintlongintlong longnot int64_t/long; signed overflow is UB
longint unsignedlongint unsignedunsigned long longnot size_t (unsigned long)
string inputinput string sconst char* s(details in step 02/04)
libm directimport "DPI-C" pure function real pow(input real x, y);none: libmprototype must equal the system header's

Rules of thumb

  1. DPI = C calling convention + type mapping. Same process, same thread, zero simulation time.
  2. The simulator never sees C. Only the C/C++ compiler can compare both sides, and only if you #include the generated prototype header.
  3. Verilator compiles .c as C++: use extern "C" or the header. Commercial tools compile .c as C; #ifdef __cplusplus keeps one source portable.
  4. Link errors name the symbol; the "referenced from" wrapper names the SV scope and function.
  5. One C symbol, one SV signature, design-wide. SV names are scoped, C names are global.
  6. pure is a promise the tool may exploit. Never lie, even if today's simulator doesn't care.
  7. Outputs are write-only on entry. Unsigned arithmetic for anything that wraps.
  8. Keep imports in one package; copy C prototypes from the header, never retype them.