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.

Wall time passes, simulation time stands still
0123wall clock (s)0123packets handed to C1234simulation time (ns)15clk edges seenfirst sleep(1) returns

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.

Out to C, back into the same instanceArrows are calls. The request travels from the testbench into C and back into SystemVerilog; each return travels the same arrow backwards.
Out to C, back into the same instanceArrows are calls. The request travels from the testbench into C and back into SystemVerilog; each return travels the same arrow backwards. SystemVerilog u0.visit(3) a call from instanceu0 C c_visit(3) context import C svGetScope() answers: tb.u0 SystemVerilog sv_accept(3) exported function state u0.accepted 0, then 3 import callwhich instance?export callaccepted += 3
  1. Call out

    Instance u0 calls the import. The simulator notes which scope made the call.

    Follow the calls, values and waits
    1. call c_visit(3): u0.visit(3) → c_visit(3)
  2. Ask for the scope

    svGetScope returns the calling instance, tb.u0. Without context on the import, C has no such answer.

  3. Call back in

    C calls the export. It runs in tb.u0, so it updates that instance's accepted, on the same thread, right now.

    Follow the calls, values and waits
    1. call sv_accept(3): c_visit(3) → sv_accept(3)
    2. data 3: sv_accept(3) → u0.accepted
  4. 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
    1. return 3: sv_accept(3) → c_visit(3)
    2. 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.

Selecting another instance from C. Names like 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 kindMay call an exportKnows the calling scopeSimulation time inside the call
pure functionNoNoNone
plain functionNoNoNone
context functionYesYes, through svGetScopeNone
context taskYes, including exported tasksYesMay 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.

RUNVerilator + C/C++ over DPI · simulator service
Edit the code, then run it
Expected: c_visit(3) from tb.u0 c_visit(10) from tb.u1 c_visit(4) from tb.u0 u0=7 u1=10
Read 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

Predict the outputc_visit_twice calls sv_accept(value) and then returns a second sv_accept(value). The program calls c_visit_twice(3), then c_visit(1). What does it print?
`timescale 1ns/1ps
module tb;
  import "DPI-C" context function int c_visit(input int value);
  import "DPI-C" context function int c_visit_twice(input int value);
  export "DPI-C" function sv_accept;
  int accepted = 0;
  function int sv_accept(input int value);
    accepted += value;
    return accepted;
  endfunction
  int r1, r2;
  initial begin
    r1 = c_visit_twice(3);
    r2 = c_visit(1);
    $display("%0d %0d", r1, r2);
    $finish;
  end
endmodule
Read included file: bridge.cpp
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdint>
#include <climits>
#include <cstring>
extern "C" int c_visit(int value) { return sv_accept(value); }
extern "C" int c_visit_twice(int value) {
  sv_accept(value);              // the first call back has already run when the next line starts
  return sv_accept(value);
}
Spot the bugThe packet must reach C after at least one clock period of simulation time, and the check reads $time afterwards. c_visit_after_pause sleeps two milliseconds of wall time in C before calling back. Which option prints FAIL?
Read included file: bridge.cpp
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdint>
#include <climits>
#include <cstring>
#include <unistd.h>
extern "C" int c_visit(int value) { return sv_accept(value); }
// "Give the design time": sleeps for two milliseconds of wall time, then calls back.
extern "C" int c_visit_after_pause(int value) { usleep(2000); return sv_accept(value); }
ConceptWhy is c_visit declared a context import?
ConceptA C model calls sleep(1) between packets to let the design catch up. What happens?
ConceptC needs to wait for the next clock edge before it continues. What does DPI offer for that?

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 context when it calls an export or asks svGetScope which instance called; svSetScope can 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.