A model with state behind a handle

Give every caller its own C model behind a chandle, reset it without freeing it, free it once, and refuse a null handle on the C side.

Lesson
D1.6
Time
25 min
Runs on
Verilator with C/C++
Check
5 questions

Two checkers share a C model that keeps its running total in one global variable. The APB checker resets the model at the end of its phase, and the AXI checker's sum restarts from zero in the middle of a burst. Alone, each checker passes, because alone it has the global to itself. This lesson gives every caller a model of its own, behind a handle that C hands out and SystemVerilog never looks inside.

One global total, two checkers
01234callAPB +3AXI +2APB +4APB resetAXI +5shared total35905AXI expects-2-7APB resets the global

Columns are calls in order. The AXI side expects 7 after its second packet and reads 5: the APB reset wiped its count as well.

One struct per caller

A chandle is a SystemVerilog variable that holds a C pointer and nothing more. You can store it, copy it, compare it with null and pass it back to C; you cannot read through it. model_new allocates one Model struct and returns its address as a chandle. Every other routine takes that handle first, so the C side knows which struct the call is about.

Two handles, two structsArrows from the handles show which struct each one points at. Arrows from the API show the struct a call reads or writes; the handle argument picks it.
Two handles, two structsArrows from the handles show which struct each one points at. Arrows from the API show the struct a call reads or writes; the handle argument picks it. SystemVerilog chandle a an addressSystemVerilog neverreads SystemVerilog chandle b a second address C model_add / reset/ delete the handle comesfirst struct Model A int total struct Model B int total points atpoints atreads or writesreads or writes
  1. Allocate

    Two calls to model_new give two structs with separate totals. The testbench keeps the two addresses in a and b.

  2. Add through each handle

    A receives 3 and then 4, B receives 2. Each call lands on the struct its handle names, so A reports 7 and B reports 2.

    Follow the calls, values and waits
    1. call model_add(a, 3): model_add / reset / delete → Model A
    2. return total 7: Model A → model_add / reset / delete
    3. call model_add(b, 2): model_add / reset / delete → Model B
    4. return total 2: Model B → model_add / reset / delete
  3. Reset A

    Reset clears A's total and keeps the struct where it is. B still holds 2, and the handles still point where they did.

    Follow the calls, values and waits
    1. call model_reset(a): model_add / reset / delete → Model A
  4. Delete A

    Delete returns the struct's memory. The variable a still holds the old address, so the testbench sets it to null at once.

    Follow the calls, values and waits
    1. call model_delete(a): model_add / reset / delete → Model A
Read the diagram as text
  • chandle a: an address SystemVerilog never reads
  • chandle b: a second address
  • model_add / reset / delete: the handle comes first
  • Model A: int total
  • Model B: int total
  • chandle a → Model A: points at
  • chandle b → Model B: points at
  • model_add / reset / delete → Model A: reads or writes
  • model_add / reset / delete → Model B: reads or writes

A global would be the single struct in the middle, shared by every caller. A handle per instance keeps the two totals apart.

The example runs these steps. After the adds it prints a=7 b=2. After resetting A and adding 1 it prints after reset a=1 b=2. After deleting both and clearing the handles, a call with null returns status 1 and a total of 0.

Copy, reset and delete do three different things

StatementWhat happens to the structWhat the handle holds afterwards
c = a;Nothing. Both variables now name the same struct, and an add through c shows up through a.The same address, twice
model_reset(a)Its total goes back to zero. The memory stays where it is.The same address, still valid
model_delete(a)Its memory is returned to C. Nothing is left to read.The old address, until you write a = null

Copying a chandle copies an address. Only model_new makes a new struct.

Match reset to the testbench's own reset moment. When the bench flushes its queues on a design reset, reset the C model in the same step, so the two sides forget the same traffic. Delete each model once, at a defined end, usually where the component that created it finishes.

What the null check catches

model_add refuses a null handle with status 1 and leaves the output total at 0, so a call on a model that was never created fails loudly. It cannot tell a freed address from a live one: a dangling pointer is non-null and C reads whatever is now in that memory. The guard is on the SystemVerilog side. Set the variable to null right after model_delete, and check it before each call.

Delete and keep the address

void'(model_delete(a));
// a still holds the old address
void'(model_add(a, 1, total));

Compiles and runs. The add writes into memory that was returned, and may even report a plausible total.

Delete, then forget the address

void'(model_delete(a));
a = null;
assert (model_add(a, 1, total) == 1);

The null check refuses the call with status 1 and a total of 0.

Failure paths stay on the C side

model_new allocates with new(std::nothrow), which returns null when memory runs out instead of throwing. The testbench checks a != null before the first call and stops with a fatal otherwise. A C++ exception must never cross into the simulator: catch it in the bridge and turn it into a status. model_add also returns status 2 when the total would leave the range of an int, and leaves the output at 0, so a caller that checks status never sees a wrapped sum.

Try it

Run it as it is. Then, after model_reset(a), add zero to b and check that it still reports 2: a reset that cleared a shared global would fail that check.

Then add a third handle c = a and add 10 through c before the first $display. The line becomes a=17 b=2, because c is the same struct as a.

RUNVerilator + C/C++ over DPI · simulator service
Edit the code, then run it
Expected: a=7 b=2 after reset a=1 b=2 null call status=1 total=0
Read included file: bridge.cpp
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdint>
#include <climits>
#include <cstring>
#include <new>
struct Model { int total = 0; };

// One struct per call. new(std::nothrow) gives null on failure; no C++ exception crosses into SV.
extern "C" void* model_new() { return new(std::nothrow) Model; }

extern "C" int model_add(void* h, int value, int* total) {
  *total = 0;
  if (!h) return 1;                       // null is refused; a dangling pointer is NOT detected
  auto* m = static_cast<Model*>(h);
  const int64_t next = static_cast<int64_t>(m->total) + value;
  if (next > INT_MAX || next < INT_MIN) return 2;
  m->total = static_cast<int>(next);
  *total = m->total;
  return 0;
}
extern "C" int model_reset(void* h) { if (!h) return 1; static_cast<Model*>(h)->total = 0; return 0; }
extern "C" int model_delete(void* h) { delete static_cast<Model*>(h); return 0; }

Check yourself

Predict the outputHandles a and b are two models. a receives 5 and 6, b receives 1, then c = a and 10 is added through c. What does the program print?
`timescale 1ns/1ps
module tb;
  import "DPI-C" function chandle model_new();
  import "DPI-C" function int model_add(input chandle h, input int value, output int total);
  import "DPI-C" function int model_reset(input chandle h);
  import "DPI-C" function int model_delete(input chandle h);
  chandle a, b, c;
  int t, ta, tb_total;
  initial begin
    a = model_new(); b = model_new();
    void'(model_add(a, 5, t));
    void'(model_add(a, 6, t));
    void'(model_add(b, 1, t));
    c = a;                       // a copy of the handle, not of the struct
    void'(model_add(c, 10, t));
    void'(model_add(a, 0, ta));
    void'(model_add(b, 0, tb_total));
    $display("a=%0d b=%0d", ta, tb_total);
    void'(model_delete(a)); void'(model_delete(b)); a = null; b = null; c = null;
    $finish;
  end
endmodule
Read included file: bridge.cpp
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdint>
#include <climits>
#include <cstring>
#include <new>
struct Model { int total = 0; };

// One struct per call. new(std::nothrow) gives null on failure; no C++ exception crosses into SV.
extern "C" void* model_new() { return new(std::nothrow) Model; }

extern "C" int model_add(void* h, int value, int* total) {
  *total = 0;
  if (!h) return 1;                       // null is refused; a dangling pointer is NOT detected
  auto* m = static_cast<Model*>(h);
  const int64_t next = static_cast<int64_t>(m->total) + value;
  if (next > INT_MAX || next < INT_MIN) return 2;
  m->total = static_cast<int>(next);
  *total = m->total;
  return 0;
}
extern "C" int model_reset(void* h) { if (!h) return 1; static_cast<Model*>(h)->total = 0; return 0; }
extern "C" int model_delete(void* h) { delete static_cast<Model*>(h); return 0; }
Spot the bugChecker A counts 3, resets at the end of its phase, then counts 4; checker B counts 2 and must still report 2 at the end. The bridge offers both the per-handle model and a shared version with its total in one C global. Which option prints FAIL?
Read included file: bridge.cpp
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdint>
#include <climits>
#include <cstring>
#include <new>
struct Model { int total = 0; };
extern "C" void* model_new() { return new(std::nothrow) Model; }
extern "C" int model_add(void* h, int value, int* total) {
  *total = 0; if (!h) return 1;
  auto* m = static_cast<Model*>(h); m->total += value; *total = m->total; return 0;
}
extern "C" int model_reset(void* h) { if (!h) return 1; static_cast<Model*>(h)->total = 0; return 0; }
extern "C" int model_delete(void* h) { delete static_cast<Model*>(h); return 0; }

// The same model with its total in one C global: every caller shares it.
static int shared_total = 0;
extern "C" int shared_add(int value, int* total) { shared_total += value; *total = shared_total; return 0; }
extern "C" int shared_reset() { shared_total = 0; return 0; }
ConceptRight after model_delete(a) returns, what does the variable a hold?
ConceptDeleting a model and creating a new one also gives a total of zero. Why have model_reset at all?
Conceptmodel_new returns null at the start of a test. What should the testbench do?

Remember

  • A chandle holds a C pointer that SystemVerilog stores, copies, compares with null and passes back, but never reads.
  • Give every caller its own struct from model_new, and take the handle first in every routine; a C global is one state shared by everyone.
  • Copying a handle copies an address. Reset clears the contents and keeps the address; delete returns the memory and leaves the old address in the variable.
  • Set the variable to null right after model_delete and check it before each call; C refuses null but cannot detect a freed pointer.
  • Allocation failure and range errors come back as status values; no C++ exception crosses the DPI call.