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.
§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.
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:
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);
}
output int; the standard allows either.Three consequences you will use for the rest of the tutorial:
- The call blocks the simulator. If C takes 2 s, the whole simulation stops for 2 s. No other SV process runs and no time passes. That is the right behavior for a golden model, and the reason a slow model (a Python one, step 08) makes the whole simulation slow.
- An
outputis write-only from C's point of view. The temporary starts uninitialized, so reading*sumbefore writing it reads garbage. The standard does not define the initial value of anoutputformal, and commercial simulators may pass a pointer to something other than a fresh temporary. Same rule everywhere: write it, never read it first. - Overhead is a few conversions plus one call. For scalars that is nanoseconds. Step 06 measures it.
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.
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.
0.5Linkage: why extern "C"
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
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
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:
input: by value for small types. H.8.7 says input arguments of imported functions "shall always have aconstqualifier" in C (Sample 12.5 does it). For by-value scalars it changes nothing at the ABI level, which is why Verilator's header omits it, but write it: it is the standard's rule and the compiler then rejects writes to an input.output: write it on every path. Its initial value is undefined (Fig. 0.3).inout: the pointer's target holds the SV value on entry, and whatever C leaves there is copied back.- Return types are limited to "small values":
void,byte,shortint,int,longint,real,shortreal,chandle,string, and scalarbit/logic. Abit [6:0]cannot be returned; use anoutput(§12.1.4).
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 code | calls the LRM allows | calls in Verilator 5.052 |
|---|---|---|
void'(ping(1)); | 0 or 1 | 1 |
y = ping(2) + ping(2); | 1 or 2 | 2 |
for (k=0;k<4;k++) y += ping(3); | 1 to 4 | 4 |
same with the non-pure ping_raw | exactly 2 | 2 |
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:
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:
| Simulator | compile C with the design | prebuilt shared library | prototype header |
|---|---|---|---|
| Questa | vlog -sv top.sv dpi.c | vsim -sv_lib ./libdpi top | vlog -dpiheader dpi.h |
| Xcelium | xrun top.sv dpi.c | xrun -sv_lib libdpi | xrun -dpiheader dpi.h |
| VCS | vcs -sverilog top.sv dpi.c | simv -sv_lib libdpi | vc_hdrs.h (automatic) |
| Verilator | verilator --binary top.sv dpi.c | dpi.a / -LDFLAGS | V<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.
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
- Write the SV import in a package. Default to no property; add
pureonly for true functions. - Build once. Open
V<top>__Dpi.hand copy the prototype into your C file as the definition's signature. - 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. - Write every
outputon every path. Use unsigned arithmetic for anything that may wrap. - 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
- Explain what DPI is (a calling convention plus a type mapping) and when VPI is still the right tool.
- Write an
import "DPI-C"with the right property (pureor none), an optional c_name, andinput/outputintarguments. - Build SV + C with Verilator, and know that
.cfiles are compiled as C++. - Use
V<top>__Dpi.hto get C linkage and compile-time type checking, and know its blind spot (overloads). - Diagnose from the exact message: mangled name, missing symbol,
staticsymbol, keyword clash, duplicate signature, libc prototype clash. - Predict what a type mismatch that links does at the ABI level.
- Decide when
pureis legal, and why a lyingpureis a latent bug even when today's simulator is unaffected.
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?
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?
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:
__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?
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?
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?
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)
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:
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:
10. Two packages import the C function twice, one as int twice(int) and one as real twice(real). What happens?
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?
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?
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?
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?
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.
| Experiment | Files | Stage | Result (measured) |
|---|---|---|---|
| first imports | hello.sv + hello.c | runs | f13=1932053504, sin30=0.500000, fexpect=42, access sv=0 none=-1, p=42 s=13 |
generated header instead of extern "C" | hello.sv + hdr.c | runs | same log as hello |
no extern "C" | noextern.c | link | Undefined symbols "_addmul" "_expect" "_factorial"; nm: __Z9factoriali |
| symbol missing | missing.c | link | only "_addmul" undefined |
static definition | ex/ex12_static.c | link | "_factorial" undefined |
| type mismatch, no header | mismatch.sv/.c | runs (wrong) | negate=4294967291 (expected −4294967301) |
| type mismatch, header | mismatch_hdr.c | C++ compile | functions that differ only in their return type cannot be overloaded |
mismatch, header, no extern "C" | mismatch_hdr2.c | link | "_negate" undefined (C++ overload) |
| keyword as SV name | keyword.sv | Verilator | syntax error, unexpected expect |
import "DPI" | legacy.sv | Verilator | Unsupported DPI type 'DPI': Use 'DPI-C' |
| same C name, two signatures | dup.sv | Verilator | Duplicate declaration of DPI function with different signature: 'twice' |
libc strlen imported directly | ex/ex5_strlen.sv | C++ compile | clash with size_t strlen from <string.h> |
lying pure | pure.sv/.c | runs | calls: unused=1, twice=2, loop4=4 (no elimination) |
import in always_comb/assign | comb.sv + pure.c | runs | 2 calls at t=0, then 1 per change per site: 22 total |
pure on void / task / with output (illegal) | pure_rules.sv/.c | runs | all three accepted silently; r=3 b=4 |
| one C name in two scopes | cid.sv/.c | runs / Verilator | top=4 u=6; with context on one: Duplicate declaration … 'twice' |
ref argument (illegal) | ref.sv | link | accepted silently as int*; only "_bump" undefined |
| imports in a package | pkg.sv/.c | runs | top.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
| Construct | SV declaration | C side (as in V<top>__Dpi.h) | Pitfall |
|---|---|---|---|
| basic import | import "DPI-C" function int f(input int a); | int f(int a); | C linkage required in Verilator |
| pure | import "DPI-C" pure function real g(input real x); | double g(double x); | no state, no I/O, no outputs; calls may be skipped |
| rename | import "DPI-C" c_fn = function int sv_fn(); | int c_fn(); | keyword c_name must be escaped: \expect = |
| output int | output int s | int* s | initial value undefined; always write |
| inout int | inout int s | int* s | holds SV value on entry, copied back |
| int unsigned | input int unsigned u | unsigned int u | use it to get logical >> |
| longint | longint | long long | not int64_t/long; signed overflow is UB |
| longint unsigned | longint unsigned | unsigned long long | not size_t (unsigned long) |
| string input | input string s | const char* s | (details in step 02/04) |
| libm direct | import "DPI-C" pure function real pow(input real x, y); | none: libm | prototype must equal the system header's |
Rules of thumb
- DPI = C calling convention + type mapping. Same process, same thread, zero simulation time.
- The simulator never sees C. Only the C/C++ compiler can compare both sides, and only if you
#includethe generated prototype header. - Verilator compiles
.cas C++: useextern "C"or the header. Commercial tools compile.cas C;#ifdef __cpluspluskeeps one source portable. - Link errors name the symbol; the "referenced from" wrapper names the SV scope and function.
- One C symbol, one SV signature, design-wide. SV names are scoped, C names are global.
pureis a promise the tool may exploit. Never lie, even if today's simulator doesn't care.- Outputs are write-only on entry. Unsigned arithmetic for anything that wraps.
- Keep imports in one package; copy C prototypes from the header, never retype them.