C++ Part II · Module 28 · C/C++ and SystemVerilog DPI
Strings Across the Boundary
A SystemVerilog string is a dynamic first-class type; a C string is a pointer and a convention. DPI bridges them in the only way it can, by handing over a const char*, and the entire difficulty is the sentence that follows: the pointer is valid for the duration of the call and no longer. That is one rule, and almost every DPI string bug is a violation of it or of its mirror image on the return path. This chapter measures both. Storing the incoming pointer and reading it after the caller's storage has gone produces no compiler warning whatsoever, which makes it the more dangerous of the two; returning a pointer to a local buffer is warned about, and still printed the correct text at optimisation level two, which is the shape of bug this module keeps returning to. AddressSanitizer caught both, with the second needing an option that is off by default — and knowing that option exists is most of what makes the sanitizer useful here.
Expert19 min readC++Part IIVerificationDPI
Part II · Module 28 · Chapter 28.10 · Strings Across the Boundary
1. The Mapping and the Rule
A SystemVerilog string crosses as const char*:
// Reference syntax -- not executed here, per Chapter 28.1 §5.
import "DPI-C" function void feed_name(input string name);
import "DPI-C" function string describe(input int code);extern "C" void feed_name(const char* name);
extern "C" const char* describe(int code);And then the rule that is the whole chapter, quoted from the standard's requirements rather than measured:
An incoming const char* is valid only for the duration of the call. The simulator owns that storage and may reuse or free it as soon as the function returns.
And a returned const char* must point to storage that outlives the call, because the simulator will read it after you return.
Two rules, both about lifetime, pointing in opposite directions. The failure for each is to assume the other's lifetime.
2. Both Violations, Measured
Storing the incoming pointer
The mistake is one assignment:
static const char* gStored = nullptr; // the mistake: keeping the pointer
extern "C" void feed_name(const char* name) // a DPI import would look like this
{
gStored = name; // WRONG: valid only during the call
}The model now holds a pointer into storage the caller owns. Read it later, after the owner has gone, and the behaviour is undefined — which under AddressSanitizer becomes a report:
arm 1 (stored incoming pointer) exit=134
==…==ERROR: AddressSanitizer: stack-use-after-scope on address 0x… at pc …
SUMMARY: AddressSanitizer: stack-use-after-scope strings_asan.cpp in mainExit 134, stack-use-after-scope, named. The addresses and the process id vary per run and are elided; the error class and the exit code do not.
Returning a pointer to a local
The mirror image:
extern "C" const char* describe_bad(int code)
{
char buf[32]; // WRONG: dies when we return
std::snprintf(buf, sizeof buf, "code-%d", code);
return buf;
}ASan reports it too, with exit 134 — and with one detail worth knowing, because the option is not on by default:
arm 2 (returned local buffer) exit=134That arm needed ASAN_OPTIONS=detect_stack_use_after_return=1. It is off by default, which is a fact this track has recorded before, and it means a sanitizer run that finds nothing has not necessarily looked for this. The report came back as stack-use-after-scope rather than -after-return because at -O1 the function was inlined into its caller, so the buffer became a scope within main — the same defect, named by where it ended up rather than by how it was written.
3. What the Compiler Says About Each
This is the asymmetry that decides which of the two you should worry about.
warnings from -Wall -Wextra: 2
strings.cpp:21:12: warning: address of stack memory associated with local variable 'buf' returned [-Wreturn-stack-address]| violation | -Wall -Wextra | ASan |
|---|---|---|
| returning a pointer to a local | warned — -Wreturn-stack-address | caught, exit 134 |
| storing the incoming pointer | silent | caught, exit 134 |
The returned local is warned about and the stored pointer is not. That is not a gap in the compiler — the first is decidable from one function's body, and the second depends on what happens to a pointer after it leaves, which is a whole-program question.
So the practical ranking is the reverse of the intuitive one. The stored incoming pointer is the more dangerous violation, because nothing in an ordinary build mentions it, and it is also the more natural mistake: keeping a name for later is exactly what a model wants to do.
And one more measurement, which is the module's recurring shape:
describe_bad(7) printed code-7. The dangling read returned the right text at -O2, because nothing had yet reused the frame. So a test of the bad version passes, and the bug surfaces later when an intervening call happens to overwrite that stack — Chapter 25.4's measured behaviour, arriving through a different door.
4. The Correct Patterns
Incoming: copy it immediately.
extern "C" void feed_name(const char* name)
{
gModel.setName(std::string{name}); // a copy, owned by the model
}One std::string construction, and the lifetime question disappears. The copy costs an allocation for anything over the small-string threshold — Chapter 25.9 measured libc++ holding 22 characters inline — which against a simulation step is nothing.
Two details. Check for null, because a simulator may pass one for an empty or uninitialised string and std::string{nullptr} is undefined behaviour. And do not call std::strlen on it first for any purpose other than the copy; the copy already does that, and a second traversal is a second chance to be wrong about the lifetime.
Returning: own the storage, and say how long it lasts.
extern "C" const char* describe_good(int code)
{
static std::string s; // outlives the call
s = "code-" + std::to_string(code);
return s.c_str();
}That works, and it has a property the caller must know about:
C. the correct version
describe_good(7) -> code-7
describe_good(9) -> code-9 (the previous pointer is now stale)The second call invalidates the first call's pointer. For a simulator that copies the string into its own storage immediately on return — which is the normal behaviour — that is fine. For any caller that holds the pointer across another call it is not, so the contract to document is "valid until the next call to this function".
The three ownership strategies, ranked:
| strategy | lifetime | when |
|---|---|---|
a static/member std::string | until the next call | the default; simple and adequate |
| storage owned by the model, keyed by a handle | until the model is destroyed | several strings alive at once |
| a caller-provided buffer | the caller's business | the most explicit, and the most verbose |
The third deserves a mention because it removes the question entirely:
extern "C" int describe_into(int code, char* out, int cap); // returns length, or -1The caller allocates, the callee fills and reports the length, and nobody owns anything across the boundary. It is the C-library convention — snprintf's — and it is the right answer when the string is large or when several must be alive simultaneously.
5. The Other Direction: Output String Arguments
A SystemVerilog output string argument is a char**, and it has its own ownership question with an unsatisfying answer.
The C side must assign a pointer to storage that outlives the call, which is §4's returning problem again. And because the simulator's handling of that storage varies, the portable advice is short: prefer a string return value or a caller-provided buffer over an output string argument. One fewer ownership convention to get right, and the two alternatives are both unambiguous.
Where you must use one, the same ranking applies — a static/member buffer with a documented "valid until the next call", or a caller-provided buffer with a capacity.
6. Text That Is Not a String
Two cases worth distinguishing, because treating them as strings is a bug.
Binary data with embedded zeros. A packet payload is not a string. const char* with no length cannot carry a zero byte, so a payload crosses as a byte array plus a length — Chapter 28.8 §2 — and the moment someone passes it as a string the data silently truncates at the first zero.
And a byte array is not text either, even when it happens to contain ASCII. Declaring it string to make printing easier imports the whole lifetime problem for the convenience of one %s.
The rule: string for text whose length is its content; a byte array and a length for everything else.
7. The Table
| the question | the answer | measured |
|---|---|---|
what does a SystemVerilog string become | const char* | §1 |
| how long is an incoming pointer valid | the duration of the call | §1 |
| what must a returned pointer satisfy | it must outlive the call | §1 |
| what did ASan say about storing it | stack-use-after-scope, exit 134 | §2 |
| what did ASan say about a returned local | the same class, exit 134 | §2 |
| was that second one on by default | no — detect_stack_use_after_return=1 | §2 |
| which violation does the compiler warn about | only the returned local | §3 |
| how many warnings in total | 2, both about the returned buffer | §3 |
| did the dangling read give a wrong answer | no — it printed code-7 correctly | §3 |
| which violation is more dangerous | the stored pointer, because nothing mentions it | §3, §4 callout |
| what should you do with an incoming string | copy it immediately into a std::string | §4 |
what is the contract on a static return buffer | valid until the next call | §4 |
| what removes ownership entirely | a caller-provided buffer and a length | §4 |
| what about binary data | a byte array plus a length, never a string | §6 |
8. What to Actually Do
- Copy an incoming
const char*immediately into astd::stringthe model owns, and check it for null first. - Never store an incoming pointer in a
static, a member, a global or a container. - Grep the boundary file for a pointer parameter assigned to anything durable. There should be none.
- Return a pointer to storage that outlives the call — a
staticor memberstd::string, not a local buffer. - Document the return's lifetime as "valid until the next call", and prefer a caller-provided buffer when several strings must be alive at once.
- Prefer a
stringreturn or a filled buffer over anoutput stringargument. - Pass binary data as a byte array and a length, never as a
string. - Run the model under ASan with
detect_stack_use_after_return=1, because it is off by default. - Do not trust a passing test of a dangling read. It printed the right text at
-O2. - Treat the unwarned violation as the likely one when reviewing: ask what each line assumes about the other side.
9. Mistakes Worth Making Once
Storing the incoming const char* for later. No compiler warning at all; ASan reports stack-use-after-scope, exit 134.
Returning a pointer to a local buffer. Warned about — and it still printed the correct text.
Concluding from that correct text that the code is fine. Chapter 25.4's shape.
Running ASan without detect_stack_use_after_return=1 and believing a clean result.
std::string{nullptr} from an unchecked incoming pointer.
Holding a describe_good pointer across another call to it. The static buffer has been overwritten.
Using an output string argument when a return value or a filled buffer would do.
Passing a packet payload as a string. It truncates at the first zero byte.
Calling strlen on the incoming pointer before copying it. A second traversal of storage you do not own.
10. Interview Relevance
The frozen curriculum marks this High, and the question is usually how do you pass a string through DPI? — where the mapping is the easy half and the lifetime is the answer.
State both rules. An incoming const char* is valid only for the duration of the call, because the simulator owns that storage and may reuse it the moment you return; and a returned const char* must point at storage that outlives the call, because the simulator reads it after you return. Then the two correct patterns: copy the incoming one into a std::string immediately, and return a pointer into a static or member std::string whose contract is "valid until the next call".
The detail that marks experience is the diagnostic asymmetry. Returning a pointer to a local is caught by -Wall as -Wreturn-stack-address, so it rarely survives a build. Storing the incoming pointer produces no warning at all, because whether it is a defect depends on a caller's lifetime in another language — so it is the one that ships, and it is also the more natural thing to want to do. Mentioning that AddressSanitizer catches both with exit 134, and that the returned-local case needs detect_stack_use_after_return=1 which is off by default, is the kind of specific that is hard to fake.
And the measurement worth quoting: the dangling read still printed the correct text at -O2, because nothing had reused the frame yet — so a test of the broken version passes, and the failure arrives later looking like a new bug.
If there is room, the cleanest senior point is that a caller-provided buffer with a capacity — the snprintf convention — removes the ownership question entirely, and that binary payloads must cross as a byte array and a length because a const char* cannot carry a zero byte.
11. What You Should Be Able to Say Now
- That a SystemVerilog
stringcrosses asconst char*, and that the mapping is the easy half. - That an incoming pointer is valid only for the duration of the call, and a returned pointer must outlive the call.
- That storing the incoming pointer produced no compiler warning and ASan reported
stack-use-after-scope, exit 134. - That returning a pointer to a local is warned about —
-Wreturn-stack-address, 2 warnings from-Wall -Wextra— and that ASan neededdetect_stack_use_after_return=1, which is off by default. - That the dangling read nonetheless printed
code-7correctly at-O2, so a test of the broken version passes. - That the unwarned violation is the dangerous one, because no single compiler can see a disagreement about a lifetime owned by the other side — and because caching a name is a natural thing to want.
- The correct patterns: copy immediately into an owned
std::stringafter a null check; return a pointer into astaticor member string documented as valid until the next call. - That a caller-provided buffer with a capacity removes the ownership question entirely, and is right for large or multiple strings.
- That an
output stringargument is achar**and is better replaced by a return value or a filled buffer. - That binary data crosses as a byte array and a length, never as a
string, becauseconst char*cannot carry a zero byte. - The review question that finds the quiet class: "what does this line assume about the other side?"
12. Exercises
Exercise 1 — the two rules. Write the import declarations for a string argument and a string return, and the matching C signatures.
Exercise 2 — the unwarned violation. Store an incoming const char*, let the caller's storage go out of scope, and read it. Count the warnings from -Wall -Wextra, then run it under ASan.
Exercise 3 — the warned one. Return a pointer to a local buffer. Confirm the warning, then confirm the program still prints the right text at -O2.
Exercise 4 — the default that hides it. Run arm 2 under ASan without detect_stack_use_after_return=1, then with it.
Exercise 5 — the correct patterns. Rewrite both functions correctly, then call the returning one twice and show that the first pointer is now stale.
Exercise 6 — the buffer convention. Write the caller-provided-buffer variant returning a length, and handle the too-small case.
Exercise 7 — the grep. Search a boundary file for a pointer parameter assigned to anything durable, and write the rule down as a review item.
13. Summary
- A SystemVerilog
stringcrosses asconst char*, and the whole difficulty is lifetime: an incoming pointer is valid only for the duration of the call, and a returned pointer must outlive it. - Both violations were measured. Storing the incoming pointer: ASan
stack-use-after-scope, exit 134. Returning a pointer to a local: the same class and exit code, withdetect_stack_use_after_return=1— which is off by default. - The compiler's coverage is asymmetric: the returned local is warned about by
-Wall(-Wreturn-stack-address, 2 warnings), while the stored pointer produces nothing at all, because the defect depends on a lifetime owned by the other language. - So the unwarned one is the one that ships — it is invisible to any single compiler, it looks like ordinary caching, and the dangling read printed the correct text at
-O2, so a test of it passes. - Copy an incoming string immediately into an owned
std::string, after a null check. Never store the pointer in astatic, member, global or container. - Return a pointer into a
staticor memberstd::string, documented as valid until the next call — and prefer a caller-provided buffer with a capacity when the string is large or several must be alive at once, since that removes ownership from the boundary entirely. - Prefer a return value or a filled buffer to an
output stringargument. - Binary data is not text: it crosses as a byte array and a length, because a
const char*cannot carry a zero byte. - And the review question that finds this class is "what does this line assume about the other side?" — with the concrete form being a grep for any pointer parameter assigned to something durable.
Chapter 28.11 moves from storage to promises: what pure and context let the simulator assume, and what it costs when the claim is false.
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.
