Calling back into the testbench
Let C call an exported SystemVerilog function in the same call chain, know which instance it is talking to, and keep simulation time where it belongs.
- Lesson
- D1.7
- Time
- 25 min
- Runs on
- Verilator with C/C++
- Check
- 5 questions
A C packet model needed the design to accept each packet before it predicted the next one, so its author put sleep(1) into the import "to give the design time". The regression took twenty minutes of wall time, and the waveform showed the clock frozen at the first packet for all of them. No simulation time passes inside a C call, however long the call takes. This lesson shows how C talks back to the testbench, which instance it reaches, and why waiting belongs on the SystemVerilog side.
Each column is one second of wall time. The model handles a packet per second; the simulation clock never leaves 15 ns.
The call goes out and comes back in
An import lets SystemVerilog call C. An export does the reverse: export "DPI-C" function sv_accept; makes the SystemVerilog function sv_accept callable from C under its plain name. Verilator writes its prototype into Vtb__Dpi.h next to the imports, so the bridge calls it like any C function. In the example, the testbench calls c_visit(3), C calls sv_accept(3), the counter grows, and both calls return in turn.
- Call out
Instance
u0calls the import. The simulator notes which scope made the call.Follow the calls, values and waits
- call c_visit(3): u0.visit(3) → c_visit(3)
- Ask for the scope
svGetScopereturns the calling instance,tb.u0. Withoutcontexton the import, C has no such answer. - Call back in
C calls the export. It runs in
tb.u0, so it updates that instance'saccepted, on the same thread, right now.Follow the calls, values and waits
- call sv_accept(3): c_visit(3) → sv_accept(3)
- data 3: sv_accept(3) → u0.accepted
- Return twice
The export returns 3 to C, and C returns 3 to the testbench. No simulation time has passed.
Follow the calls, values and waits
- return 3: sv_accept(3) → c_visit(3)
- return 3: c_visit(3) → u0.visit(3)
Read the diagram as text
- u0.visit(3): a call from instance u0
- c_visit(3): context import
- svGetScope(): answers: tb.u0
- sv_accept(3): exported function
- u0.accepted: 0, then 3
- u0.visit(3) → c_visit(3): import call
- c_visit(3) → svGetScope(): which instance?
- c_visit(3) → sv_accept(3): export call
- sv_accept(3) → u0.accepted: accepted += 3
One thread, one chain of calls. The C function is in the middle of it, and the testbench waits for it to return.
The whole chain is synchronous. c_visit does not return until sv_accept has, and the initial block that called visit is waiting the whole time. A call back into SystemVerilog is a plain function call that happens to cross a language.
Context: which instance the export belongs to
The example has two instances of acceptor, and each has its own accepted. When u1.visit(10) calls c_visit, the export must land in u1, and it does: the import is declared context, so the simulator keeps the calling scope with the call. svGetScope returns it, and the example prints that name from C. An import without context has no scope: the standard forbids it to call an export, and Verilator hands svGetScope a null with a warning.
A bridge may also pick an instance itself. svSetScope(s) makes s the current scope for the exports that follow; use a scope obtained from svGetScopeFromName("tb.u1") or saved earlier from svGetScope. Restore the previous scope afterwards, so the caller's own callbacks keep going to the right place.
tb.u1 are the elaborated hierarchy; keep them in one place rather than spread through the bridge.extern "C" int c_visit_other(int value) {
const svScope mine = svGetScope(); // remember who called
svSetScope(svGetScopeFromName("tb.u1")); // talk to u1 for a moment
const int r = sv_accept(value);
svSetScope(mine); // and put the caller back
return r;
}| Import kind | May call an export | Knows the calling scope | Simulation time inside the call |
|---|---|---|---|
pure function | No | No | None |
plain function | No | No | None |
context function | Yes | Yes, through svGetScope | None |
context task | Yes, including exported tasks | Yes | May pass, inside an exported task it calls |
Only a context import may call back. Only a task can let simulation time pass, and only inside SystemVerilog.
Time belongs to the simulator
Every function in this chain returns without advancing simulation time, the imports and the export alike. A sleep or a busy loop in C holds the simulator's thread: wall time passes, the clock stays still, and every other process waits. That is the hook's frozen waveform.
When C needs to wait for a clock edge, the wait has to happen in SystemVerilog. The DPI way is an exported task that waits, called from an import declared as a context task; the task carries the delay, C only asks for it. The example keeps to functions. A simpler design often wins: let C return a prediction, and let the testbench decide when to compare it, as the UVM scoreboards in U9 do.
Try it
Run it as it is and read the three lines C prints: the scope changes with the instance that called. Then add a third visit of -2 on u0 and check that both the return value and u0.accepted show 5.
Then remove the word context from the import in acceptor and run again. The build still succeeds, but at run time Verilator warns that the import is missing context, svGetScope returns null, c_visit returns -1, and the first assertion stops the run.
c_visit(3) from tb.u0 c_visit(10) from tb.u1 c_visit(4) from tb.u0 u0=7 u1=10Read included file: bridge.cpp
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdint>
#include <climits>
#include <cstring>
#include <cstdio>
// context import: the simulator keeps the caller's scope available to this function.
extern "C" int c_visit(int value) {
const svScope caller = svGetScope(); // the instance whose visit() called us
if (!caller) return -1;
printf("c_visit(%d) from %s\n", value, svGetNameFromScope(caller));
return sv_accept(value); // runs in that instance, returns before we do
}
Check yourself
Remember
- An
export "DPI-C"makes a SystemVerilog function callable from C under its plain name; the generated header declares it beside the imports. - A call back into SystemVerilog is synchronous: the export runs and returns before the C statement after it, on the simulator's thread.
- Declare an import
contextwhen it calls an export or askssvGetScopewhich instance called;svSetScopecan pick another instance, and should be restored afterwards. - No simulation time passes inside a C call. A sleep in C freezes the whole simulation; waiting belongs in SystemVerilog.
- Letting C wait for a clock means an exported task called from a context task; often a prediction returned to the testbench is the simpler design.