Your first DPI-C call
Call a C function from SystemVerilog, see which file supplies each half of the call, and check the answer in the testbench.
- Lesson
- D1.1
- Time
- 20 min
- Runs on
- Verilator with C/C++
- Check
- 5 questions
The architecture team hands over their checksum routine as crc.cpp, and your import line compiles without a complaint. Then the link step stops: undefined reference to crc_byte, although the function is right there in the file. The compiler treated it as a C++ function and gave it a mangled name, so nothing called crc_byte exists for the simulator to find. This lesson shows which file supplies each half of a DPI call, and why that name has to stay plain C.
Vtb__ALL.cpp:(.text+0xc4): undefined reference to `crc_byte'
collect2: error: ld returned 1 exit status
make: *** [Vtb.mk:67: Vtb] Error 1Three files, one call
The Direct Programming Interface (DPI) is the part of SystemVerilog that lets a testbench call C, and C call back. An import "DPI-C" line gives SystemVerilog the name, the argument directions and the types of one C routine. It is a declaration only: the body lives in a C file that is compiled and linked into the same simulation. The simulator keeps time, the clock and the end of the run; a call into C starts no new simulator and no new thread.
- Declare
The import line names the function, its result type and its two
intinputs. Nothing in SystemVerilog says what it does. - Generate
Verilator reads every import and writes
Vtb__Dpi.h: one C prototype per import, with C linkage. - Define
The bridge includes that header and defines the function. The compiler compares the definition with the prototype and stops on any difference.
- Link
The generated simulation code and the bridge are compiled into one program. The linker joins the call to the C definition.
- Call
At run time the testbench calls
add_small(7, 5). C adds, returns 12, and the testbench checks the value before printing it.Follow the calls, values and waits
- call add_small(7, 5): main.sv → add_small(7, 5)
- call add_small(7, 5): add_small(7, 5) → bridge.cpp
- return 12: bridge.cpp → add_small(7, 5)
- return 12: add_small(7, 5) → main.sv
Read the diagram as text
- main.sv: import "DPI-C" function int add_small(input int a, b);
- Vtb__Dpi.h: extern "C" int add_small(int a, int b);
- bridge.cpp: int add_small(int a, int b) { return a + b; }
- Vtb: one program: the simulation and the bridge
- add_small(7, 5): the testbench calls, C returns 12
- main.sv → Vtb__Dpi.h: Verilator writes
- Vtb__Dpi.h → bridge.cpp: included and compared
- main.sv → Vtb: compiled in
- bridge.cpp → Vtb: compiled in
- main.sv → add_small(7, 5): call
- add_small(7, 5) → bridge.cpp: runs the body
Read the three files as one unit: the import declares, the generated header carries the prototype, the bridge defines.
Keep the generated prototype as your reference when you change an argument type. The bridge includes Vtb__Dpi.h, so the C++ compiler compares every definition with the prototype Verilator wrote from your import. Change the import to input byte a, b and the build stops in the bridge with conflicting declaration: the header now says char, the definition still says int. Without the include, that same mismatch would build cleanly and hand C the wrong bytes.
Why the name has to stay plain C
C++ gives every function a mangled name that encodes its argument types, so that two overloads can live side by side. C has no overloads: a C function's symbol is its name, and that plain name is what the simulator looks for. The generated header wraps its prototypes in extern "C", and a definition that follows such a declaration keeps the C linkage. Writing extern "C" on the definition as well keeps the file right even when someone drops the include.
A C++ function with a C++ name
// crc.cpp, no header, no linkage
int crc_byte(int a) { return a * 7; }
Compiles, but the symbol is _Z8crc_bytei. The link step looks for crc_byte and stops.
The same body with C linkage
#include "Vtb__Dpi.h"
extern "C" int crc_byte(int a) { return a * 7; }
The header checks the types, the symbol is crc_byte, and the link succeeds.
| Import | What the C code may do | Use it for |
|---|---|---|
import "DPI-C" function | Read and write its own C state, call other C code; it must return without consuming simulation time | Most models and wrappers |
import "DPI-C" pure function | Compute from its arguments only, changing nothing anyone can observe | Arithmetic, lookups, format conversions |
import "DPI-C" context function | Everything above, plus call exported SystemVerilog routines and ask which scope called it | Callbacks into the testbench (D1.7) |
Three kinds of import. The default is the middle ground; pure promises more, context asks for more.
Mark a function pure only when it is
add_small depends on its two inputs alone and changes nothing, and its documented operand range keeps the sum inside a C int. That makes it a fair pure import. The simulator may then drop a pure call whose result is unused, and reuse a result when the arguments repeat. A file writer, a random generator, a counter or anything that calls back into SystemVerilog must stay a plain import, or its side effects may silently vanish.
A plain import function still returns before the next statement runs, on the simulator's own thread. No simulation time passes inside it: a sleep in C stalls the wall clock and the whole simulation with it, while the SystemVerilog clock stays still.
Check the answer where you can see it
The testbench asserts the hand-calculated result, 7 + 5 = 12, before it prints anything. Keep the check in SystemVerilog: the C side computes, the testbench decides. A number that matches the number the model printed proves nothing when both came from the same code.
Resolve the symbol
The generated prototype and the linked definition agree on
int add_small(int, int)with C linkage.SystemVerilog int add_small(int, int)C symbol add_smallCheck a known answer
The caller compares the returned value with a literal it worked out by hand, then prints.
inputs 7, 5expected 12
Industrial reference models usually arrive as C libraries. Keep the wrapper thin, write down the valid input ranges, and test the wrapper as carefully as the model behind it.
Try it
Run it as it is. Then change the inputs to -8 and 3 and add a second assertion for 0 + 0, keeping both operands inside the documented range where the C addition cannot overflow.
Then change the import to input byte a, b and run again. The build stops in bridge.cpp with conflicting declaration: the header Verilator wrote now says char, while the definition still says int.
sum=12Read included file: bridge.cpp
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdint>
#include <climits>
#include <cstring>
// The generated header above declares: extern "C" int add_small(int a, int b);
// The definition below must agree with it, or the build stops here.
extern "C" int add_small(int a, int b) { return a + b; }
Check yourself
Remember
- An
import "DPI-C"line declares a C routine: name, directions and types. The body is a C file compiled and linked into the same simulation. - Verilator writes
Vtb__Dpi.hfrom your imports. Include it in the bridge, so a definition that disagrees with an import stops the build instead of passing wrong values. - The C function keeps a plain C name. Use
extern "C"on the definition; a mangled C++ name shows up as undefined reference at link time. - Declare
pureonly for a function that depends on its arguments alone and changes nothing; the simulator may skip or reuse such calls. - Keep the check in SystemVerilog: the C side computes, the testbench compares against a value it worked out by hand.