Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  sizeof(void*)=8  sizeof(uintptr_t)=8  equal: yes
  sizeof(crc_state)=24  <- the other side never learns this

Eight 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
typedef struct crc_state { uint32_t crc; uint64_t bytes; uint32_t magic; } crc_state;

Measured, two handles driven alternately through seven separate calls:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  distinct handles: yes
  stream A ("ABC",   3 bytes) crc = 0xE719AB55
  stream B ("ZZZZ", 4 bytes) crc = 0x1169ACF4
  state survived across 7 separate calls: yes

Two 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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: survived

A note on the handle's own portability, since this module has been careful about such things. Measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  (void*)(uintptr_t)a == a : yes
  bytes still readable through the round-tripped pointer: 3

From 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.

Two ways to design the opaque handle a C model hands back, compared on the questions that decide whether a bad handle can be rejected. A raw pointer handle carries the address of your allocation; validating it means dereferencing whatever the other side supplied. A handle that is an index plus a generation counter carries no address at all; validating it is a table lookup. On a correct handle both work. On a garbage handle, dereferencing a raw pointer is undefined behaviour, while the table lookup rejects it: measured, the value 0xDEADBEEFDEADBEEF was rejected. On a stale handle the difference is decisive: reading a magic number out of freed memory is itself a heap use after free, and measured, AddressSanitizer reported it by default, whereas the generation counter rejected the stale handle with nothing dereferenced. The recommendation is the index and generation design whenever handles can outlive what they refer to.The questionabout the handle the other sideholdsRaw pointer handlethe address of your allocationIndex plus generationno address crosses at allWhat actually crosses8 bytes either wayan addressmeasured: 8 bytes, round-tripsthrough uintptr_ta slot number and acountermeasured: 0x0000000100000000A correct handlethe ordinary caseworksmeasured: 7 calls, state intactworksmeasured: resolves, bytes intactA garbage handlea value you never issueddereferencing it isundefinedthe check is the bugrejectedmeasured: a table lookup, nothingdereferencedA stale handleyou freed it, the other side keptita magic check reads freedmemorymeasured: heap use after free,reported by defaultrejectedmeasured: the generation nolonger matchesWhen to choose itthe decisiononly if you also own everyhandleand clear each one on releasewhenever a handle canoutlive what it refers toand it usually can12
Figure 1 — two ways to design the handle, compared on the question that actually decides the outcome: when the other side hands you back something wrong, can you tell? Both are eight bytes and both work perfectly on a correct handle, so the first two rows separate nothing. The last three do. A raw pointer can only be validated by dereferencing it, which means a garbage value is undefined behaviour and a stale one is a use-after-free — the check is the bug. An index-plus-generation handle is validated by a table lookup, so a garbage value and a stale value are both rejected with nothing dereferenced at all. The recommendation in the bottom row follows from one question: can a handle outlive the thing it refers to? If the other side keeps copies, it can.

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  ==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:16

Read 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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 dereferenced

The 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 chandle is 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  stream A ("ABC",   3 bytes) crc = 0xE719AB55
  stream B ("ZZZZ", 4 bytes) crc = 0x1169ACF4

One 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 chandle is 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

  1. chandle describes nothing on purpose — from the LRM, an opaque pointer mapping to void *, which SystemVerilog may only assign, compare against null, and pass back.
  2. Measured: 8 bytes cross; the 24-byte object behind it is never described to the other side, so you may change it freely.
  3. The boundary carries no identity, so a model with per-stream state needs a handle. Measured: two streams, 0xE719AB55 over 3 bytes and 0x1169ACF4 over 4, across 7 interleaved calls.
  4. destroy takes void ** so it can clear the caller's copy — measured, that is what makes a second destroy safe.
  5. The C side owns the object always; the handle outlives every call, and the other side's copies are invisible to you.
  6. A NULL check is always correct because it dereferences nothing — measured, rejected.
  7. Three bad handles: null (detectable), garbage (undefined to inspect), stale (the realistic one).
  8. The finding: the magic-number check on a stale handle is itself a heap-use-after-free — the sanitizer named valid as the faulting frame. Poisoning before free does not help.
  9. 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.
  10. Index plus generation can be validated — measured: stale rejected, garbage rejected, nothing the caller supplied was dereferenced.
  11. A static works until there are two of something — 20.8 §4's failure one level up.

11. Exercises

  1. Implement the create / update / final / destroy quartet for a running checksum. Drive two handles alternately and confirm two independent results.
  2. Change destroy to take void * instead of void **. What can the caller no longer be told, and which measurement in §3 stops being reproducible?
  3. Round-trip a handle through uintptr_t and confirm it compares equal. Then check whether your implementation defines uintptr_t at all.
  4. 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.
  5. 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"?
  6. 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.
  7. 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.
  8. 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/longint to int8_t/int16_t/int32_t/int64_t, and never to long, 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/0x7F agree and 0x80/0xFF do not (20.4).
  • Four-state values cross losslessly in two words and are lost by reading one — measured, ZX01 → 0101, with Z indistinguishable 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 real crossing is exact; the losses are the literal, the arithmetic, and shortreal's narrowing — and a tolerance check written d > tol silently 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 sizeof 12 / 8 / 6 for the same three members, half of the first being padding (20.9).
  • chandle is 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.