C Part II · Module 20 · DPI-C Type Mapping
chandle and Opaque Pointers
A DPI call carries no identity, so a model that accumulates anything — a checksum, a scoreboard, a decoder — has no way to know which stream it is being called about. chandle is the answer: an opaque pointer the other side may hold and hand back but never look inside, and the opacity is the feature rather than a gap. Holding the state is four lines, measured here as two interleaved streams producing two independent results. Managing the lifetime of what the handle points to is the rest of the chapter, and it ends on a finding worth carrying out of the module: the magic-number check people add to validate a stale handle is itself a use-after-free, and the sanitizer reports this one by default while staying silent about the closely related bug chapter 20.8 measured.
Expert26 min readCPart IIVerificationDPI-C
Part II · Module 20 · Chapter 20.10 · chandle and Opaque Pointers
1. The Type That Describes Nothing
Chapters 20.3 through 20.9 were variations on one activity: pin down a layout precisely enough that two languages agree about it. Widths, signedness, four-state planes, chunk arithmetic, byte order, padding — all of it in service of a shared description.
chandle inverts that completely. From the LRM, it is a SystemVerilog type that holds an opaque pointer, mapping to C's void *, and SystemVerilog's permitted operations on one are: assign it, compare it against null, and pass it back to C. It cannot be dereferenced, indexed, arithmetic'd or inspected.
So the layout is not under-specified — it is deliberately not described, and that is what the type is for. The other side holds an address and is forbidden from doing anything with it except handing it back.
Measured:
sizeof(void*)=8 sizeof(uintptr_t)=8 equal: yes
sizeof(crc_state)=24 <- the other side never learns thisEight bytes cross. The 24-byte object they point at is entirely yours, and you can change it — add members, reorder them, replace the whole structure — without the SystemVerilog side being affected or even recompiled, because nothing on that side ever described it. That is a real engineering benefit, and it is the reverse of Chapter 20.9's problem.
2. Why a Model Needs One
Chapter 19.7 established that a DPI call is an ordinary function call into your model's process, and that your model persists between calls. What the boundary does not carry is identity: nothing in a call says "this is the same stream as the last one".
For a model that computes a running result — a CRC, a scoreboard, a protocol decoder, anything with accumulated state — that matters as soon as there is more than one of them.
typedef struct crc_state { uint32_t crc; uint64_t bytes; uint32_t magic; } crc_state;Measured, two handles driven alternately through seven separate calls:
distinct handles: yes
stream A ("ABC", 3 bytes) crc = 0xE719AB55
stream B ("ZZZZ", 4 bytes) crc = 0x1169ACF4
state survived across 7 separate calls: yesTwo independent results from interleaved calls. The handle is what made them independent; §9 is what happens without one.
3. The Protocol
Three functions, and the shape is always the same.
static void *crc_create(void) {
crc_state *s = malloc(sizeof *s);
if (!s) return NULL;
s->crc = 0xFFFFFFFFu; s->bytes = 0; s->magic = CRC_MAGIC;
return s; /* implicit conversion to void * */
}
static void crc_update(void *h, uint8_t b) { … }
static uint32_t crc_final(void *h) { … }
static void crc_destroy(void **h) {
if (!h || !*h) return;
((crc_state *)*h)->magic = 0u; /* poison before free */
free(*h); *h = NULL; /* and clear the caller's copy */
}Three details worth naming.
The conversion to and from void * needs no cast on the way out — any object pointer converts to void * implicitly — and the cast on the way in is yours to get right. Nothing checks that the void * you were handed points at a crc_state.
crc_destroy takes void **. It clears the caller's copy, which is the only reason the double-destroy below is safe. A destroy(void *) that frees and returns leaves every copy of the handle dangling and has no way to say so.
Measured:
after destroy, the caller's handle is NULL: yes
crc_valid(a) after destroy: no (rejected)
destroying twice is safe because the pointer was cleared: survivedA note on the handle's own portability, since this module has been careful about such things. Measured:
(void*)(uintptr_t)a == a : yes
bytes still readable through the round-tripped pointer: 3From the C standard, uintptr_t is the integer type that a void * can round-trip through and compare equal, which is what makes it the right type when a handle has to be stored as an integer somewhere. It is also optional — an implementation need not provide it — so a check that it exists belongs in the build rather than in your assumptions.
4. Who Owns It, and For How Long
Chapter 20.8 asked this about a string's characters for the duration of one call. chandle asks it about an object across the whole simulation, which makes the answer both simpler and harder.
Simpler: the C side owns it, always. You allocated it; SystemVerilog cannot free it, cannot copy its contents and cannot inspect it. There is no ambiguity about who is responsible.
Harder: the handle outlives every call. SystemVerilog holds it between calls, can copy it into several variables, can store it in a class member that is itself collected at some point you do not observe. The C side cannot see any of that. So the lifetime is yours to decide, and you must decide it against a correspondent whose own lifetimes you cannot see.
Three rules follow, and they are the practical content of the purpose's second half.
Create and destroy must be explicit calls. There is no destructor and no collection. If there is no destroy in the interface, the memory is never released — which for a simulation that creates a handle per transaction is a leak that grows for the length of the run.
Destroy exactly once, and clear the handle when you do. Hence void **. A handle freed twice is Chapter 13.6's double free; a handle freed once and used afterwards is §7.
Decide what a null handle means and enforce it at the top of every entry point. Measured:
crc_valid(NULL): no (rejected)A NULL check is the one validity test that is always correct and always cheap — it dereferences nothing. Everything beyond it is §7's problem.
5. What the Opacity Buys
Worth stating plainly, because it is the reason to prefer a chandle over any scheme that exposes the state.
Your structure is free to change. Add a member, reorder, swap the algorithm — the other side was never told the layout, so nothing there can break. Contrast Chapter 20.9, where adding one member silently moved every offset.
There is nothing to get wrong on the other side. No width, no signedness, no four-state collapse, no chunk arithmetic, no padding. The module's entire catalogue of hazards does not apply to a value nobody interprets.
It is the only mapping in the module that is tool-independent in practice — an address is an address, and the one thing done with it is to pass it back unexamined.
The price is the whole of §4 and §7: you have traded every representation problem for a single lifetime problem.
6. The Three Bad Handles
A model will eventually be handed a handle it did not expect. There are exactly three kinds.
Null. Cheap and completely reliable to detect, because the test dereferences nothing. Reject it.
Garbage — a value you never issued. Dereferencing it is undefined behaviour, so any check that reads through it is already too late. There is nothing a raw pointer handle can do here.
Stale — a handle you issued, used, and freed, where the other side kept a copy. This is the realistic one, because it arises from ordinary code on the far side holding a variable slightly too long, and it is §7.
7. The Finding: The Check Is the Bug
The standard defence is a magic number — a known value in the structure, verified before use:
static int crc_valid(void *h) { return h && ((crc_state *)h)->magic == CRC_MAGIC; }It works for a live handle, measured throughout §2 and §3. Now apply it to a stale one — freed by us, still held by the other side:
void *kept = p; /* the other side's copy */
p->magic = 0u; free(p); /* we poison and free */
…
valid(kept); /* reads freed memory */Reading through kept is undefined behaviour — Chapter 13.6's dangling pointer — so this program was compiled and run under AddressSanitizer for its diagnostic only; no value read through a freed pointer appears here.
==72699==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d0
READ of size 4 at 0x6020000000d0 thread T0
#0 0x000104a849b8 in valid stale.c:9
#1 0x000104a84900 in main stale.c:16Read the first stack frame: the error is inside valid. The function written to protect the model against a bad handle is the function committing the undefined behaviour. Poisoning the magic before freeing does not help, because once the memory is freed the bytes are not yours to read whatever you wrote in them; the allocator may have reused them, and the standard does not care either way.
And the contrast with Chapter 20.8 is the part to carry away. That chapter measured a stack-use-after-return that AddressSanitizer did not report under its default settings — it needed detect_stack_use_after_return=1. This heap-use-after-free was reported with no options at all.
Same tool, same class of bug, two different default answers. So "we run the model under AddressSanitizer" is not a single statement about what you are protected from — it is a statement about the heap, and about the stack only if somebody turned the option on.
8. The Handle That Can Be Validated
The way out is to stop sending an address. A handle can be any 8-byte value, so make it an index plus a generation counter:
typedef struct { uint32_t gen; int live; uint64_t bytes; } slot;
static slot table[NSLOT];
static slot *resolve(uint64_t h) {
uint32_t idx = (uint32_t)(h & 0xFFFFFFFFu), gen = (uint32_t)(h >> 32);
if (idx >= NSLOT) return NULL; /* bounds */
if (!table[idx].live || table[idx].gen != gen) return NULL; /* staleness */
return &table[idx];
}The generation is bumped every time a slot is handed out, so a handle for an earlier occupant of that slot no longer matches — which is exactly the case §7 could not catch. Measured:
handle a = 0x0000000100000000 resolves: yes bytes=3
after release, a resolves: no (rejected)
handle b = 0x0000000200000000 (same slot, generation bumped)
the STALE handle a still resolves: no (rejected)
garbage handle 0xDEADBEEFDEADBEEF resolves: no (rejected)
and no pointer supplied by the caller was ever dereferencedThe last line is the entire argument. Validation that never dereferences the caller's value cannot itself be the bug — which is the defect §7 found in the magic-number check. Bounds and staleness are both decided from the table, which is yours and is alive.
Three costs, stated honestly: a fixed slot count unless you grow the table, one indirection per call, and the handle is now an integer rather than a pointer. The third is free — chandle is opaque, so nothing on the other side ever knew the difference.
Use a raw pointer handle when the C side issues and reclaims every handle within a scope it fully controls. Use index-plus-generation when a handle can outlive the thing it refers to — which, once the other side can copy it into a variable you cannot see, it can.
9. The Misconception: A Static Would Be Simpler
"A
chandleis extra machinery. The C model can just keep its state in a file-scope variable — the data is in C either way, and there is one less thing to pass."
Measured false by §2's interleave. Two streams, driven alternately:
stream A ("ABC", 3 bytes) crc = 0xE719AB55
stream B ("ZZZZ", 4 bytes) crc = 0x1169ACF4One file-scope state cannot produce those two results, because the calls were interleaved — every update for B would have landed in the same accumulator as A's, and the single answer would have been the CRC of a seven-byte interleaving that exists nowhere in the design.
Three further reasons, each already established:
- Reentrancy (19.7): a DPI call is an ordinary call in the simulator's process. One static is shared across any calls in flight.
- Reset and reuse: a static has one lifetime — the program's. Starting a second test in the same run means remembering to reset every field, by hand, correctly.
- It is Chapter 20.8 §4 one level up. That chapter measured one static buffer failing the moment two results were alive at once; this is one static object failing the moment two streams are. The shape repeats because the cause is the same.
The corrected position:
A static works until there are two of something, and there is always eventually two of something. A
chandleis not extra machinery — it is the thing that makes the count a parameter instead of an assumption.
And the honest limit of the recommendation: if your model genuinely has one global thing — a log file, a configuration read once — a file-scope variable is correct and a handle would be ceremony. The test is whether two of them could ever be alive at once.
10. What You Should Be Able to Say Now
chandledescribes nothing on purpose — from the LRM, an opaque pointer mapping tovoid *, which SystemVerilog may only assign, compare against null, and pass back.- Measured: 8 bytes cross; the 24-byte object behind it is never described to the other side, so you may change it freely.
- The boundary carries no identity, so a model with per-stream state needs a handle. Measured: two streams,
0xE719AB55over 3 bytes and0x1169ACF4over 4, across 7 interleaved calls. destroytakesvoid **so it can clear the caller's copy — measured, that is what makes a second destroy safe.- The C side owns the object always; the handle outlives every call, and the other side's copies are invisible to you.
- A
NULLcheck is always correct because it dereferences nothing — measured, rejected. - Three bad handles: null (detectable), garbage (undefined to inspect), stale (the realistic one).
- The finding: the magic-number check on a stale handle is itself a heap-use-after-free — the sanitizer named
validas the faulting frame. Poisoning beforefreedoes not help. - ASan's defaults are not one answer: this heap case was reported with no options; Chapter 20.8's stack case needed
detect_stack_use_after_return=1. - Index plus generation can be validated — measured: stale rejected, garbage rejected, nothing the caller supplied was dereferenced.
- A static works until there are two of something — 20.8 §4's failure one level up.
11. Exercises
- Implement the create / update / final / destroy quartet for a running checksum. Drive two handles alternately and confirm two independent results.
- Change
destroyto takevoid *instead ofvoid **. What can the caller no longer be told, and which measurement in §3 stops being reproducible? - Round-trip a handle through
uintptr_tand confirm it compares equal. Then check whether your implementation definesuintptr_tat all. - Add a magic-number check. Verify it rejects
NULL, then build the stale case under AddressSanitizer and read which frame it names. Do not run it without the sanitizer. - Run Chapter 20.8's stack case and this chapter's heap case under default AddressSanitizer. Which is reported? What does that tell you about the sentence "the model runs clean under ASan"?
- Implement the index-plus-generation table. Confirm a released handle is rejected after its slot is reissued, and explain why the generation is necessary and the index alone is not.
- Add a member to the structure behind a
chandle. What has to change on the SystemVerilog side? Compare your answer with the same question for Chapter 20.9's struct. - For a model you have written, list the state that is currently file-scope. For each item, could two of them be alive at once?
12. Module 20 in One Page
Ten chapters, one argument: a SystemVerilog type and a C type are two descriptions of storage, and the mapping between them is a translation you read rather than an equivalence you assume. What the module established, with every C-side claim measured on this machine:
- The integers map cleanly at fixed widths —
byte/shortint/int/longinttoint8_t/int16_t/int32_t/int64_t, and never tolong, which is 8 bytes here and 4 elsewhere (20.3). - Signedness is right for small values and wrong past the top bit — measured,
0x00/0x01/0x7Fagree and0x80/0xFFdo not (20.4). - Four-state values cross losslessly in two words and are lost by reading one — measured,
ZX01→0101, withZindistinguishable from a real zero (20.5). - Packed data has a specified layout — 32-bit chunks, pairs for four-state; measured 72 bits → 3 chunks / 12 bytes and 40 four-state bits → 16 bytes (20.6).
- The
realcrossing is exact; the losses are the literal, the arithmetic, andshortreal's narrowing — and a tolerance check writtend > tolsilently passes a NaN (20.7). - A string crosses as a pointer, so the question is lifetime — and the dangling return is caught with no flags directly, and by nothing at all one function away (20.8).
- A struct's layout is the ABI's, not the standard's — measured
sizeof12 / 8 / 6 for the same three members, half of the first being padding (20.9). chandleis the inverse: describe nothing, own everything, and the only remaining question is lifetime (20.10).
And the thread running through all ten, which is the module's real lesson: every one of these bugs compiles. The wrong width, the wrong signedness, the collapsed X, the guessed chunk layout, the mismatched struct, the dangling buffer, the stale handle — not one of them produces a diagnostic at the boundary, and most of them produce plausible numbers. The defence has been the same every time: know which half of the mapping is specified, measure the half that is yours, and write down the part that is neither.
Everything in this module crossed one value at a time — a number, a vector, a string, a pointer to state. The next question is the one case where the thing being passed has no fixed size at the point the C function is written, and where the C side cannot even ask how big it is without machinery built for the purpose.
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.
