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.
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.
- Allocate
Two calls to
model_newgive two structs with separate totals. The testbench keeps the two addresses inaandb. - 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
- call model_add(a, 3): model_add / reset / delete → Model A
- return total 7: Model A → model_add / reset / delete
- call model_add(b, 2): model_add / reset / delete → Model B
- return total 2: Model B → model_add / reset / delete
- 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
- call model_reset(a): model_add / reset / delete → Model A
- Delete A
Delete returns the struct's memory. The variable
astill holds the old address, so the testbench sets it tonullat once.Follow the calls, values and waits
- 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
| Statement | What happens to the struct | What 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.
a=7 b=2 after reset a=1 b=2 null call status=1 total=0Read 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
Remember
- A
chandleholds a C pointer that SystemVerilog stores, copies, compares withnulland 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
nullright aftermodel_deleteand 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.