C++ Part II · Module 28 · C/C++ and SystemVerilog DPI
extern "C" and Why a C++ Reference Model Needs It
Chapter 28.5 established what extern "C" does to a symbol. This chapter is about where to put it, which is a design question rather than a syntax one, and the answer is narrower than people expect: on the entry points, and on nothing else. The misunderstanding worth clearing first is that extern "C" constrains the implementation — that a C-callable model must be written in some C-like subset, without classes or templates or the standard library. It does not. It changes the linkage of a name, not the language of the body, and the measurement here is a model built on a class with std::vector and std::map members that presents exactly five C-visible symbols to the outside world. A pure C driver, compiled by a C compiler that has never heard of any of those types, reaches it through those five symbols and reproduces a published CRC check value. That shape — a thin C-linkage layer over an ordinary C++ library — is what the rest of the module builds on.
Expert20 min readC++Part IIVerificationDPI
Part II · Module 28 · Chapter 28.6 · extern "C" and Why a C++ Reference Model Needs It
1. What the Keyword Does and Does Not Do
The frozen declaration for this chapter:
extern "C" int model_step(const unsigned char* in, unsigned len);What it does: gives that function C linkage, so the compiler emits the symbol model_step — _model_step on Mach-O — rather than a mangled name. Chapter 28.5 §2 measured both.
What it does not do, and this is the whole chapter:
It does not constrain the body. The implementation may use classes, templates, the standard library, new, RAII, lambdas, constexpr, exceptions internally — everything the previous twenty-seven modules covered.
It does not make the function C. It is still compiled by a C++ compiler under C++ rules.
It does not apply to anything else. Only the names it is applied to.
That last point is the design freedom. extern "C" is applied per declaration, so you choose exactly which names are C-visible and the rest of the program stays ordinary C++.
2. Where the Boundary Goes
The rule: C linkage on the entry points, and nothing else.
Concretely, three layers:
| layer | contents |
|---|---|
| the model's header | a class with std::vector and std::map members — ordinary C++ |
| the boundary file | extern "C" entry points, and nothing else |
| the caller | pure C, which knows none of the above |
The model itself has no idea it will be called from C:
namespace rm {
class Model {
public:
explicit Model(std::uint8_t poly) : poly_(poly) {}
std::uint8_t step(std::uint8_t data);
std::uint8_t crc() const { return crc_; }
long count() const { return count_; }
std::size_t distinct() const { return seen_.size(); }
private:
std::uint8_t poly_, crc_ = 0;
long count_ = 0;
std::map<std::uint8_t, long> seen_; // STL state
};
}And the boundary is a separate file whose entire job is translation:
#include "rmodel.hpp"
extern "C" {
void* rm_create(unsigned char poly)
{
try { return new rm::Model{(std::uint8_t)poly}; }
catch (...) { return nullptr; } // no exception may cross
}
int rm_step(void* h, unsigned char data)
{
if (!h) return -1;
try { return (int)static_cast<rm::Model*>(h)->step((std::uint8_t)data); }
catch (...) { return -1; }
}
int rm_count(void* h) { return h ? (int)static_cast<rm::Model*>(h)->count() : -1; }
int rm_distinct(void* h) { return h ? (int)static_cast<rm::Model*>(h)->distinct() : -1; }
void rm_destroy(void* h) { delete static_cast<rm::Model*>(h); }
}Five functions, all scalars and a void*. Note what has happened to the C++ features: the class is behind an opaque pointer, the constructor is rm_create, the destructor is rm_destroy, and the exceptions are caught and converted to a return value.
3. The Measurement
What the object file actually exports:
the C-visible symbols, and nothing else:
0000000000000228 T _rm_count
0000000000000000 T _rm_create
0000000000000250 T _rm_destroy
000000000000023c T _rm_distinct
000000000000004c T _rm_stepFive C-visible symbols, for a model whose header contains a class, a std::map and a std::vector. None of the C++ machinery is in the interface.
And it is reachable from a caller that knows nothing about any of it — rm_driver.c, compiled with a C compiler at -std=c17:
extern void* rm_create(unsigned char poly);
extern int rm_step(void* h, unsigned char data);
extern int rm_count(void* h);
extern void rm_destroy(void* h); handle created: yes
after "123456789": crc=0xF4 count=9 distinct=9
published CRC-8/SMBUS check value is 0xF4 -> MATCH
a call on a null handle returns -1Four things in that transcript, and the third is the important one.
A void* carried a C++ object across. rm_create returned a pointer to a heap-allocated rm::Model and C held it as an opaque void* for nine subsequent calls.
The STL state survived between calls. distinct=9 is the size of the model's std::map after nine distinct bytes — so the C++ container accumulated across nine separate crossings from C.
The model reproduces the published CRC-8/SMBUS check value 0xF4 for "123456789". That is an authority from outside the project, which is Chapter 27.11 §2's step — so this is not merely a model that is self-consistent, it is one that has been validated.
And a null handle returns −1 rather than crashing. The defensive boundary, which matters for the reason Chapter 28.2 §5 gave: a dereference here would take the simulator with it.
4. The Four Translations the Boundary Performs
Every C++ feature that cannot cross has a standard conversion, and the boundary file is where they happen.
| C++ feature | what crosses instead |
|---|---|
| a class instance | a void* handle from a create function |
| a constructor | rm_create(...) returning the handle |
| a destructor | rm_destroy(handle), called explicitly |
| an exception | caught, converted to an error return |
std::string | const char*, with ownership stated — Chapter 28.10 §2 |
std::vector<T> | a pointer and a length |
| a member function | a free function taking the handle first |
| an overload set | distinct names — rm_step_i, rm_step_d |
| a template | one instantiation per entry point, named by hand |
Two of those rows have a hazard worth naming.
The handle is a void* on the C side, so the type system cannot help. Passing the wrong handle to the wrong function compiles and corrupts. The mitigation is a magic number inside the object checked on every entry, or — where the simulator supports it — a distinct chandle per model type and the discipline that one handle type is used by one family of entry points. Chapter 28.13 §2 shows the validating version.
And the explicit destroy is a leak waiting to happen. C has no destructors, so rm_destroy must be called, and nothing enforces it. In a simulation the practical answer is that the model lives for the run and is destroyed at the end — but if models are created per test, the create and destroy must be paired by the testbench, and a leak check on the C++ side is worth having. Chapter 25.7's leaks route is how you would confirm it here.
5. The Shape of the Entry Points
Five conventions make a boundary predictable, and together they are most of what makes one maintainable.
The handle comes first. rm_step(void* h, unsigned char data) — like this, which is what it is.
Every entry point validates the handle. if (!h) return -1; on every one, because a null here is a dead simulation rather than an exception.
Every entry point catches everything. catch (...) returning an error code. An escaping exception is undefined behaviour — Chapter 28.2 §5 — and catch (...) is the one place in C++ where the unselective catch is right.
Errors are return values, not exceptions or globals. A sentinel like −1, or a status plus an output argument.
And the entry points do no work. They translate and delegate. A boundary function with logic in it is logic that cannot be unit-tested without the boundary, which defeats the point of the separation.
The shape, generalised:
extern "C" int rm_something(void* h, int arg)
{
if (!h) return -1; // validate
try { return static_cast<rm::Model*>(h)->something(arg); } // delegate
catch (...) { return -1; } // never let it escape
}6. What Must Not Be in the Interface
| do not expose | why | what instead |
|---|---|---|
| a class or struct by value | the C++ ABI is not the C ABI for non-trivial types | a handle |
std::string, std::vector | no C representation at all | const char*, pointer and length |
| a reference parameter | C has no references | a pointer |
| a default argument | the caller is C; defaults are a C++ source feature | separate entry points |
| an overloaded name | C linkage has no room for it | distinct names |
| a template | nothing to instantiate from C | one named instantiation |
| anything that can throw out | undefined behaviour | catch (...) at the entry |
a bool | its size is not fixed across ABIs | int |
The last row is worth a sentence because it looks pedantic and is not. bool is one byte in the common C++ ABIs and _Bool in C is also typically one byte, so it usually works — and "usually works across two compilers and two ABIs" is not a property a boundary should rely on. int costs three bytes and removes a question.
7. The Build
Two facts, both measured in this module.
The boundary file is compiled as C++, because it contains extern "C" and includes the model's header. Only the caller is C.
And the final link needs the C++ driver. Chapter 28.5 §6's distinction: undefined symbols naming your function mean linkage; naming library internals means you linked with clang instead of clang++. The experiment in §3 compiled rm_driver.c with clang -std=c17 and linked the pair with clang++, which is the correct arrangement.
For a real DPI flow the artefact is a shared object rather than an executable, which adds -fPIC and a shared link — and the Chapter 27.1 §5 trap applies if the simulator's own wrapper is doing the compiling, since it may impose an older C++ standard on your file.
8. The Table
| the question | the answer | measured |
|---|---|---|
what does extern "C" change | the linkage of a name, not the language of the body | §1 |
| may the body use classes and the STL | yes — all of it | §1, §3 |
| where does the boundary go | the entry points, and nothing else | §2 |
| how many C symbols did the model expose | five | §3 |
| what carried the C++ object across | a void* handle | §3 |
| did STL state survive between calls | yes — distinct=9 after nine crossings | §3 |
| was the model validated | yes — published CRC-8/SMBUS check value 0xF4 | §3 |
| what does a null handle do | returns −1, not a crash | §3 |
| how does a constructor cross | as a create function returning the handle | §4 |
| how does an exception cross | it does not — catch (...) at the entry | §4, §5 |
| what must the entry points not contain | work — they translate and delegate | §5 |
| why keep the boundary tiny | it is the one layer that cannot be unit-tested | §5 callout |
why not expose bool | its size is not fixed across ABIs | §6 |
| which file is compiled as C++ | the boundary; only the caller is C | §7 |
9. What to Actually Do
- Put
extern "C"on the entry points only, in one small file that includes the model's header. - Write the model as ordinary C++ — classes, templates, the standard library — and let it know nothing about the boundary.
- Carry objects across as an opaque handle from a create function, with an explicit destroy.
- Put the handle first in every entry point, and validate it in every one.
- Wrap every entry point in
try/catch (...)and return an error code. Nothing may escape. - Keep all logic out of the entry points. They translate and delegate; the layer you cannot unit-test should be dull.
- Use
intrather thanbool, a pointer rather than a reference, distinct names rather than overloads, and one named instantiation rather than a template. - Never expose a class by value, a
std::stringor astd::vector. - Check for leaks if models are created per test rather than per run — C has no destructors.
- Link the final artefact with the C++ driver, and compile the caller as C.
10. Mistakes Worth Making Once
Believing a C-callable model must avoid classes and the STL. extern "C" changes a name, not a language.
Applying extern "C" to the whole model. Now the templates and overloads are broken for no benefit.
Putting logic in the entry points. It now lives in the only layer you cannot unit-test.
Omitting the handle check. A null gives you a dead simulator with no waveform.
Letting an exception escape. Undefined behaviour, inside the simulator's process.
Exposing a std::string or a class by value. There is no C representation.
Using bool at the boundary. Its size is not guaranteed across ABIs.
Forgetting to call destroy. C has no destructors, and nothing warns.
Passing the wrong handle to the wrong entry point. A void* is a void*; it compiles and corrupts.
Compiling the boundary file as C. It contains extern "C" and includes a C++ header.
11. Interview Relevance
The frozen curriculum marks this Critical, and the question is usually practical: how do you make a C++ reference model callable from SystemVerilog?
The answer that lands starts by correcting the assumption inside the question. You do not write the model in a C-like subset. extern "C" changes the linkage of a name, not the language of the body — so the model is ordinary C++ with classes, templates and the standard library, and extern "C" goes on a handful of entry points in one separate file. The measured example is worth quoting: five C-visible symbols over a model with a class and an std::map, reached by a driver compiled with a C compiler.
Then the four translations, which is the content: a class crosses as an opaque void* handle returned by a create function, the destructor becomes an explicit destroy call, exceptions are caught at the entry and converted to error codes, and std::string becomes a const char* with the ownership stated.
Then the conventions, briefly: handle first, validate it on every entry point, catch (...) on every entry point, errors as return values, and no logic in the entry points at all.
The reasoning that marks a senior answer is why that last one matters. The boundary is the one layer that cannot be unit-tested, and because the C++ runs in the simulator's process its failure modes are the worst available — a dead simulation, a corrupted heap, a wrong answer with no diagnostic. So the goal is not a tidy interface; it is to make the untestable layer as small and dull as possible, pushing every piece of logic down into the model where a millisecond-long unit test can reach it.
12. What You Should Be Able to Say Now
- That
extern "C"changes the linkage of a name, not the language of the body — the implementation may use classes, templates, the standard library and exceptions internally. - That it is applied per declaration, so the boundary is exactly the set of names you choose.
- That the measured model exposed five C-visible symbols (
_rm_create,_rm_step,_rm_count,_rm_distinct,_rm_destroy) over a header containing a class and anstd::map. - That a pure C driver reached it, that STL state survived nine crossings (
distinct=9), and that the model reproduced the published CRC-8/SMBUS check value 0xF4 — an external authority. - That a null handle returned −1 rather than crashing, which matters because the C++ is in the simulator's process.
- The four translations: a class becomes a
void*handle, a constructor a create function, a destructor an explicit destroy, and an exception a caught error code. - The five conventions: handle first, validate it,
catch (...), errors as return values, and no work in the entry points. - That the reason for the last one is that the boundary is the only layer that cannot be unit-tested, and it has the worst failure modes in the flow.
- What must not be in the interface: classes by value,
std::string,std::vector, references, default arguments, overloads, templates, anything that throws, andbool. - That the boundary file is compiled as C++, only the caller is C, and the final link uses the C++ driver.
13. Exercises
Exercise 1 — the frozen declaration. Write extern "C" int model_step(const unsigned char* in, unsigned len);, implement it over a class with an STL member, and confirm with nm that exactly one symbol is C-visible.
Exercise 2 — the three layers. Split a model you have into header, boundary and caller, with the caller compiled by a C compiler.
Exercise 3 — the handle. Add create, step and destroy entry points returning and taking a void*. Confirm state survives several calls.
Exercise 4 — validate the external answer. Make your model reproduce a published check value with no simulator involved.
Exercise 5 — the null handle. Call every entry point with a null handle and confirm each returns an error rather than crashing. Then remove one check and run it under ASan.
Exercise 6 — the escaping exception. Make the model throw, call it from C without a catch (...), and record what happens. Then add the wrapper.
Exercise 7 — the logic that should not be there. Move a piece of logic from an entry point into the model, and write the unit test that is now possible.
14. Summary
extern "C"changes the linkage of a name, not the language of the body. A C-callable reference model is ordinary C++ with classes, templates, RAII and the standard library.- It is applied per declaration, so the boundary is exactly the names you choose — and it should be the entry points and nothing else, in one small file.
- Measured: five C-visible symbols over a model whose header contains a class and an
std::map, and a pure C driver reached it successfully. - A
void*handle carried the C++ object across, STL state survived nine separate crossings (distinct=9), the model reproduced the published CRC-8/SMBUS check value 0xF4, and a null handle returned −1 instead of crashing. - The four translations: a class becomes an opaque handle, a constructor a create function, a destructor an explicit destroy, and an exception is caught at the entry and returned as a code.
- Five conventions: handle first, validate it on every entry,
catch (...)on every entry, errors as return values, and no logic in the entry points. - The last one is the important one, because the boundary is the single layer that cannot be unit-tested, and — since the C++ runs inside the simulator's process — its failures are a dead simulation, a corrupted heap, or a wrong answer with no diagnostic. Keep it small and dull; push the logic down where a millisecond test can reach it.
- Keep out of the interface: classes by value,
std::string,std::vector, references, default arguments, overloads, templates, anything that throws, andbool. - Compile the boundary as C++, the caller as C, and link with the C++ driver.
Chapter 28.7 starts on the contract those entry points commit to, one scalar at a time.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
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.
