Skip to content
VLSI Mentor

C Part II · Module 19 · DPI-C Foundations

Where DPI Code Runs: The Simulator Execution Context

Your C is not a library your program calls. It is code loaded into somebody else's program, and four assumptions that hold for a standalone function stop holding there. A static variable is shared by every instance that calls you. Your debug output arrives in the log after the lines it should have appeared between — measured here, printed last despite being called in the middle. Memory you allocate outlives the call and nothing says who frees it. And whether two calls can overlap is a property of the simulator, not of your code.

Advanced22 min readCPart IIVerificationDPI-C

Part II · Module 19 · Chapter 19.7 · Where DPI Code Runs: The Simulator Execution Context

1. Somebody Else's Program

Chapter 19.6 ended with a shared object the simulator can load and a symbol it can find. What happens when it calls is a different question, and the answer starts from one fact:

Your C is not a library your program calls. It is code loaded into a program you did not write.

That program has its own lifetime, its own output stream, its own memory management, and possibly its own threads. Four assumptions that are safe in a standalone C program stop being safe there, and all four are ordinary C problems rather than DPI ones — which is why Part I's chapters keep being the ones cited.

2. State Is Shared

The first and most surprising.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static uint32_t counter = 0;
uint32_t c_next(void) { return ++counter; }

Measured, calling it from two different places in one program:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
three calls from 'instance A': 1 2 3
one call from 'instance B'   : 4   <- SAME counter, not a fresh one

There is one counter, belonging to the shared object. Four SystemVerilog instances of a module that each call c_next share it; two unrelated testbench components share it; a monitor and a scoreboard share it.

This catches people because the SystemVerilog side has instances and the C side does not. A module instantiated four times has four copies of its variables. A C function imported into that module has one copy of its statics, for all four.

The fix is the ordinary C one: do not keep per-caller state in a static. Either pass the state in:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
struct model_state { uint32_t counter; /* ... */ };
uint32_t c_next(struct model_state *s) { return ++s->counter; }

or hand out opaque handles the caller holds, which is the same thing with the allocation moved. Either way the state belongs to the caller, which is where the instances are.

And one consequence worth naming: a function with statics cannot be declared pure — Chapter 19.5 §3 established why, and this measurement is the reason it was taken.

3. Output Ordering Is Wrong by Default

This one is measured, and it is the chapter's most practically useful finding.

Your model prints a debug line. The simulator prints lines before and after the call. The log should show them in that order.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static void sim_print(const char *s) { write(1, s, strlen(s)); }   /* "the simulator" */

int main(void) {
    sim_print("[sim] before the DPI call\n");
    printf("[dpi] inside the C function\n");       /* your model */
    sim_print("[sim] after the DPI call\n");
}

Measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[sim] before the DPI call
[sim] after the DPI call
[dpi] inside the C function

The model's line is last, and it was called in the middle. Measured both to a terminal and through a pipe — which is what a log file or a tool capture looks like — with the same result.

The cause is Chapter 12.4's subject arriving where it does real damage. printf writes into a FILE buffer that is flushed when it fills, at exit, or at a newline if the stream is line-buffered. The simulator's own output does not go through your FILE buffer at all. Two independent paths to the same destination, flushed on different schedules.

The fix:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
printf("[dpi] inside the C function\n");
fflush(stdout);

Measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[sim] before the DPI call
[dpi] inside the C function
[sim] after the DPI call

Correct order.

4. Lifetime Is Not Yours

A model returns a pointer. Who owns what it points at, and for how long?

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static char *last = NULL;

const char *c_describe(int code) {
    free(last);                          /* free the previous one */
    last = malloc(32);
    if (!last) return "";
    snprintf(last, 32, "code=%d", code);
    return last;                         /* valid until the NEXT call */
}

Measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
first  : code=7
second : code=7
third  : code=9

That works, and the contract is invisible. A caller that keeps the pointer from the second call and reads it after the third is reading freed memory — Chapter 9.10's use-after-free, with nothing in the interface warning about it.

Chapter 9.8 established the rule and it is unchanged here: for each allocation, exactly one piece of code is responsible for freeing it, and that responsibility is written down. What DPI adds is that the two pieces of code are now in different languages, so there is no shared header to write it in.

The three workable contracts:

The model owns it, valid until the next call. The measured example. Cheap, and the caller must copy if it needs it longer. Write it in the comment.

The caller supplies the buffer. The model writes into memory the caller owns, and a length comes with it — Chapter 14.7's discipline, which Chapter 19.4 §3 already applied to the length argument.

The model allocates and the caller frees, through a release function the model also provides. Most flexible, most error-prone, and it requires the caller to be disciplined in a language where your free is not available.

The second is almost always right for DPI, for the reason Chapter 14.7 gave: a pointer that crosses a boundary should have a length crossing with it, and a caller-supplied buffer makes both explicit.

5. Threading Is Not Guaranteed

From the LRM and tool documentation — and this is the one that cannot be measured here, so it is marked as such.

Simulators may execute SystemVerilog processes concurrently, and a multi-core simulation may call your imported function from more than one thread. Whether a particular simulator does, and under what conditions, is a property of that tool.

What follows if it does is Chapter 17.5's three non-guarantees becoming live:

  • a static counter is not atomic — 17.5 §2 measured v++ as three instructions, and volatile does not help
  • there is no mutual exclusion — 17.5 §4, and nothing in DPI provides one
  • ordering between your accesses and anything else is not guaranteed — 17.5 §3

The practical response is the one §2 already argued for a different reason: a function with no shared mutable state has no threading problem. Pass the state in, and the question does not arise.

Where shared state is genuinely required — a cache, a log, a counter of calls — then it needs a real mechanism, and Chapter 17.5 §7 named them: _Atomic for a counter, a lock for a critical section. volatile is not one of them, which is the thing 17.5 exists to say.

6. What This Means for How You Write the C

Four rules, each from one of the sections above:

No per-caller state in statics. Pass a state pointer, or hand out handles. §2.

Do not print; report through an export. Or flush every line. §3.

Write the lifetime contract next to the declaration, and prefer a caller-supplied buffer with a length. §4.

Assume you may be called concurrently unless the tool promises otherwise, which mostly means having no shared mutable state at all. §5.

Every one of those is also good standalone C — Chapter 13.11 would endorse all four — which is the thread running through this module. DPI does not require you to write C differently. It removes the margin you had for writing it badly.

7. The Misconception: It Is Just a Function

"It is an ordinary C function. The simulator calls it, it returns, nothing else is different."

The function is ordinary. The process it runs in is not yours, and each of the four sections above is an assumption that holds in a standalone program and fails here.

In a standalone program: a static is yours alone, your printf ordering is the ordering you see, memory you allocate lives until you free it, and you know whether you have threads.

In a simulator's process: the static is shared by everything that calls you, your output is one of two unsynchronised streams, your memory outlives calls with no agreed owner, and whether you have threads is the tool's decision.

The accurate framing:

The C is ordinary. The context is not. Everything that goes wrong here is an ordinary C problem that a standalone program would not have exposed.

Which is why every fix in this chapter came from Part I — Chapter 12.4 for buffering, Chapter 9.8 for ownership, Chapter 9.10 for use-after-free, Chapter 17.5 for what volatile does not do. There is no DPI-specific defensive technique, because there is no DPI-specific problem.

8. Module 19 in One Page

The module's description was "the SystemVerilog/C boundary explained before any declaration is written… syntax arrives only after the architecture that makes it readable", and it had an unusual constraint: DPI does not run on this machine. Chapter 19.1 §4 verified that at both declaration scopes, with zero mentions in the tool's manual and no svdpi.h on the system, and every chapter has marked its claims measured or from the LRM.

ChapterThe measured result
19.1DPI rejected at module and $unit scope; man iverilog mentions it zero times. The C side is ordinary C — c_add(40,2) = 42, check value 0xF4, sanitiser-clean, no DPI header
19.2The C is ordinary in both directions. An export is an undefined symbol — nm -u shows _sv_report, linking alone gives one error. The stub practice runs the whole C half with no simulator
19.3An imported task returns int as a completion status with outputs via pointers — measured rc=0 data=84, rc=1 output untouched
19.4Four vectors — 0xF4, 0x52, 0x00, 0x00 — and a wrong length is global-buffer-overflow at crc.c:5 under ASan, or 40 and a clean exit without it
19.5__attribute__((const)) collapsed two calls into one; a debugging printf in such a function printed one line for two calls
19.6_c_add as C, __Z5c_addii as C++, restored by extern "C"; a static function is absent entirely from nm -gU
19.7One static counter across callers — 1 2 3 then 4; a model's printf arriving last despite being called in the middle

Three threads run through all seven.

The C half is ordinary C, and that is the module's most useful fact. No header for the scalar cases, no marking, no simulator awareness — which means it can be written, compiled, tested, sanitised and debugged with no simulator present. Chapters 19.2 §5, 19.4 §6 and 19.6 §7 each built on that, and the payoff was measured: a length bug found by AddressSanitizer in milliseconds, with a line number, that a simulation would have reported as a wrong checksum.

The failures that are quiet are the ones you can check offline. A mangled symbol, a static function, a wrong length, a broken purity promise — all diagnosable with nm, a sanitizer and a test program. The failures that need a simulator — a declaration mismatch, a missing library path — are the loud ones.

Every defensive technique came from Part I. Buffering from 12.4, ownership from 9.8, pointer-and-length from 14.7, the optimiser's licence from 17.1, what volatile does not promise from 17.5. There is no DPI-specific discipline, and §7 argues that is because there is no DPI-specific problem.

9. What You Should Be Able to Say Now

  1. Your C runs in somebody else's process — its lifetime, its output stream, its memory management, possibly its threads.
  2. State is shared. Measured: one static counter across callers — 1 2 3 then 4. SystemVerilog has instances; your statics do not. Pass state in.
  3. A function with statics cannot be pure (19.5 §3).
  4. Output ordering is wrong by default. Measured: the model's line printed last despite being called in the middle, both to a terminal and through a pipe. fflush fixes it; measured correct afterwards.
  5. That is Chapter 12.4's buffering — two unsynchronised paths to one destination.
  6. Prefer an export to a printf so the message goes through the testbench's own mechanism, in order, with its severity levels (19.2 §2).
  7. Instrumenting a system you do not control changes it — the third instance in Part II, after 17.6 §4's barrier and 19.5 §3's elided call.
  8. Lifetime has no default contract. Three workable ones; a caller-supplied buffer with a length is almost always right (14.7). Write it beside the declaration.
  9. Threading is the tool's decision — not measurable here. If it happens, 17.5's three non-guarantees are live, and no shared mutable state means no problem.
  10. The C is ordinary; the context is not. Every fix in this chapter came from Part I.

10. Exercises

  1. Reproduce §2's counter and call it from two functions. Then rewrite it to take a state pointer and confirm two independent counters.
  2. Reproduce §3's interleaving, both to a terminal and through a pipe. Add fflush and confirm the order. Then try setvbuf(stdout, NULL, _IONBF, 0) instead and compare.
  3. §3's callout prefers an export to a printf. Write the C side of that — a prototype and a call — and the stub you would test it with (19.2 §5).
  4. Implement §4's three lifetime contracts for the same function. For each, write the comment that states it, and say which you would ship.
  5. Construct the use-after-free in §4's measured example — keep the pointer from one call and read it after the next. Confirm what AddressSanitizer says.
  6. A model keeps a cache of previously computed results. List what §2 and §5 together require of it, and say whether it can be pure.
  7. For each of §6's four rules, name the Part I chapter it comes from and state the rule in that chapter's own terms.

11. Summary

  • Your C is loaded into a program you did not write. Four standalone assumptions fail.
  • State is shared — measured 1 2 3 then 4. SystemVerilog has instances; your statics do not. Pass state in.
  • Output ordering is wrong by default — measured, the model's line printed last; fflush fixes it. Chapter 12.4's buffering, two unsynchronised paths.
  • Prefer an export to a printf — right order, right mechanism, right severity.
  • Instrumenting a system you do not control changes it — the third instance in Part II.
  • Lifetime has no default owner. Prefer a caller-supplied buffer with a length (14.7, 9.8); write the contract beside the declaration.
  • Threading is the tool's decision — not measurable here. No shared mutable state, no problem; otherwise 17.5's mechanisms, and not volatile.
  • The C is ordinary; the context is not. Every defence came from Part I.

12. What Comes Next

This module covered the boundary's architecture — the two directions, functions against tasks, the qualifiers, the build, and the process your C runs in. One thing was deferred at every opportunity: what actually happens to a value as it crosses.

Chapter 19.4 §3 declared input byte unsigned data[] and said an array arrives as a pointer. Chapter 19.1 §3 said SystemVerilog's int maps to C's int and measured sizeof(int) as 4. Both are true and both are the easy cases.

Module 20, DPI-C Type Mapping, is the hard ones, and its own description is blunt about why it matters: "the heart of DPI and the source of most of its bugs. A SystemVerilog type and a C type are two different descriptions of storage."

It begins with why the mapping is not one to one (20.1) and with svdpi.h itself (20.2) — the header this machine does not have, which Chapter 19.1 §4 noted. Then the integer types (20.3) and signedness across the boundary (20.4), which is Chapter 14.2 §4's decision arriving where two type systems disagree about it. Then the one with no C counterpart at all: 2-state against 4-state values (20.5), because C has no way to represent x or z, and packed data through svBitVecVal and svLogicVecVal (20.6). Then real and floating point (20.7), strings (20.8), structures (20.9) — where Chapter 14.5's padding judgement returns one level up — and chandle, the opaque pointer that is how §2's state-passing advice is actually implemented (20.10).

That last one is worth looking forward to. This chapter argued that per-caller state must not live in a static and should be passed in. chandle is the SystemVerilog type that holds a C pointer, and it is what makes that advice expressible across the boundary.

Standards & specifications

Governing standard
IEEE Std 1800 (SystemVerilog)(opens IEEE in a new tab)

Defines SystemVerilog language semantics — syntax, data types, scheduling and the behaviour a conforming simulator must produce. It does not define tool-specific synthesis support or vendor methodology.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C Programming curriculum.