Skip to content
VLSI Mentor

C Part II · Module 20 · DPI-C Type Mapping

Strings Across the Boundary

An address is only useful while the storage behind it still exists, which makes this the first crossing in the module where the hard question is time rather than representation. Five ways a C function can hand characters back, measured and compared on two questions: who owns the bytes, and when do they stop being valid. One of the five is the trap — a second call rewrote the first result through a pointer nothing had touched, and both calls returned the same address. The chapter ends on how far the tools get with the bug it exists to prevent: returning a local buffer is caught with no warning flags at all, and the same bug one function hop away produces no warning at any level and no AddressSanitizer report under its default settings.

Expert24 min readCPart IIVerificationDPI-C

Part II · Module 20 · Chapter 20.8 · Strings Across the Boundary

1. The First Type That Is Not a Value

Chapters 20.3 through 20.7 all asked the same shape of question: how many bits, what do they mean, and what happens at the top one. Integers, packed vectors and reals all cross as values — bits copied from one side to the other, after which the two copies are independent.

A SystemVerilog string does not. From the LRM, it crosses as a C const char * — a pointer to characters terminated by a '\0', the representation Module 6 established.

So the copying happens somewhere, but the thing that crossed is an address. And that changes the question entirely: an address is only useful while the storage behind it still exists, which is why this chapter is about lifetime rather than representation, and why its purpose says "exactly how long it stays valid".

2. The Two Lifetime Rules

From the LRM, in each direction:

A string coming in is valid for the duration of the call. You may read it, and you must not modify it — the const is not decoration. If your model needs it after the call returns, copy it.

A string going out must point at storage that is valid at the moment you return. The simulator reads the characters then.

Both rules are about the C side, both are about time, and neither is checkable by the compiler at the boundary. The rest of the chapter is what to do with them.

Five ways a C function can hand a string back, compared by who owns the storage, how long it stays valid, and whether it is safe to return across the boundary. A string literal is owned by the program, valid for the whole run, and always safe. A static buffer is owned by the function, valid only until the next call to that same function, and unsafe if more than one result is held at a time: measured, a second call changed the first result through an untouched pointer and both calls returned the same address. A caller-supplied buffer is owned by the caller, valid as long as the caller keeps it, and is the recommended default. An allocated buffer is owned by whoever frees it, valid until then, and is safe only if the ownership is written down. An automatic buffer is owned by nobody after the function returns: it is undefined behaviour, and measured, the compiler catches the direct form with no flags at all but says nothing when the address passes through one more function.How the string isreturnedfive waysWho owns thestorageand must keep it aliveValid untilthe moment it stops beingyoursSafe to handacross?the DPI answerString literalreturn "IDLE";the programstatic storage durationthe program exitsnothing can invalidate italwaysmeasured: survives latercallsOne static bufferstatic char buf[32];the functionone buffer, every callerthe next callto that same functiononly one result ata timemeasured: both callsreturned the same addressCaller-suppliedbufferout pointer plus a sizethe callerit was their arraythe caller decidesits own scopeyes, and prefer itmeasured: two results,both correctAllocated buffermalloc, and someone mustfreewhoever frees itsay so in the headerit is freedand not one call lateronly with the rulewritten downAutomatic buffera local array, returnednobodyits lifetime ended atreturnit already endedbefore the caller ranneverundefined behaviour, andsilent one hop away12
Figure 1 — five ways a C function can hand a string back, compared on the only two questions that matter: who owns the storage and when it stops being valid. The measured column is the one to read first. A literal survives anything. One static buffer is the dangerous middle case because it works perfectly until two results are alive at once, and then it fails without any pointer being touched. A caller-supplied buffer puts the lifetime where the caller can see it, which is why section 5 recommends it. An allocated buffer is fine provided somebody has written down who frees it. An automatic buffer is never correct, and section 7 measures how much of that the tools will tell you.

3. A Literal Is Free

The easiest correct answer, and the one to reach for whenever the set of strings is fixed:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static const char *lit_name(int code) {
    switch (code) { case 0: return "IDLE"; case 1: return "BUSY"; default: return "UNKNOWN"; }
}

Measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  first call  : IDLE
  second call : BUSY
  is the first still correct after the second? yes

A string literal has static storage duration — Chapter 9.7's longest lifetime. It exists before main runs and until the program exits, nothing can invalidate it, and two results can be alive at once without interfering.

For state names, opcode names, error strings and every other fixed vocabulary, this is the whole answer.

4. One Static Buffer: The Dangerous Middle Case

When the string must be built rather than chosen, the obvious next move is a static buffer:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static const char *static_fmt(int code) {
    static char buf[32];
    snprintf(buf, sizeof buf, "code=%d", code);
    return buf;
}

It compiles without a warning, it returns storage that outlives the call, and it works — right up to the moment two results are alive at once. Measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  after the 1st call, p = "code=7"
  after the 2nd call, p = "code=9"   <- p changed without being touched
  p == q (same address): YES
  the 1st result is unrecoverable: there was only ever one buffer

Read the second line carefully: p was never assigned to between the two prints. Nothing in the program touched it. The second call rewrote the characters it points at, because there was only ever one buffer and p and q are the same address.

This is why it is the dangerous case rather than simply a wrong one. A test that calls the function once and prints the result passes. A test that calls it once per transaction passes. It fails the first time something holds two results — a comparison, a log line with both the expected and actual value, a queue — and when it fails, the symptom is a value that changed on its own.

Three further reasons it is a poor default, beyond the measurement:

  • Reentrancy. Chapter 19.7 established that a DPI call is an ordinary call into your model. If two calls can be in flight, one static buffer is shared between them.
  • The next call may be yours. The buffer is invalidated by the next call to that function, including one your own model makes internally.
  • It cannot be freed or resized, so the 32 becomes a limit you discover later.

5. The Caller-Supplied Buffer

The fix is to stop returning storage and start filling storage the caller already owns:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static void caller_fmt(char *out, size_t n, int code) {
    snprintf(out, n, "code=%d", code);
}

Measured, two results alive simultaneously:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  u = "code=7"   v = "code=9"   both still correct: yes

The lifetime question has not been solved so much as moved to where it can be answered: u and v are the caller's arrays, and the caller knows their scope. That is the same move Chapter 14.7 made for buffers generally — pass a pointer and a length, and let the owner own.

Note the size_t n. Without it the function cannot know how much room it has, which is Chapter 13.6's overflow waiting to happen. With it, snprintf truncates rather than overflowing — and measured, truncation is detectable:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  buffer 4 bytes, snprintf returned 10 (what it WANTED to write)
  tiny = "cod"  strlen 3  terminated: yes
  truncation happened: YES   <- test `want >= (int)sizeof buf`

Two facts worth keeping. snprintf always terminates within the size you gave it — the result was 3 characters plus a terminator in 4 bytes. And its return value is the length it wanted, not the length it wrote, so want >= (int)n is the truncation test. Chapter 11.4 established both; here they are the difference between a short string and a silently wrong one.

6. The Allocated Buffer, and the Incoming String

Allocating is legitimate, and it converts the lifetime question into an ownership question:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static char *heap_fmt(int code) {
    char *p = malloc(32); if (!p) return NULL;
    snprintf(p, 32, "code=%d", code); return p;
}

Measured: two calls returned different addresses, so both results were valid at once — the problem §4 had is gone. What replaces it is that somebody must free them, and the C type system will not say who. Write the rule in the header beside the declaration, in the same spirit as Chapter 16.6's access-type column: the information exists, nothing enforces it, and a comment is the enforcement.

The matching case is an incoming string your model wants to keep. Rule one from §2 says the pointer is valid only for the duration of the call, so keeping the pointer is not keeping the string:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
size_t n = strlen(incoming);
char *own = malloc(n + 1);          /* +1 for the terminator -- Module 6 */
if (own) memcpy(own, incoming, n + 1);

Measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  copied 13 bytes + terminator; the copy is valid until I free it
  copy = "packet-header"  equal to the original: yes

The + 1 is the whole of Module 6 in one character. strlen does not count the terminator; malloc(n) would be one byte short and the memcpy would write past the end.

7. The Finding: How Far the Tools Get

The bug this chapter exists to prevent is returning a buffer whose lifetime has ended:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static const char *auto_fmt(int code) {
    char b[32];                        /* automatic -- lifetime ends at return */
    snprintf(b, sizeof b, "code=%d", code);
    return b;                          /* the object is gone before the caller sees it */
}

Reading through that pointer is undefined behaviour — Chapter 13.6's dangling pointer. The programs below were therefore compiled, and the sanitizer's diagnostic is reported; no value read through a dead pointer appears here.

The direct form is caught, and caught generously. Measured with no warning flags at all — not -Wall, not -Wextra, nothing:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  dangle.c:5:12: warning: address of stack memory associated with local
  variable 'b' returned [-Wreturn-stack-address]

That is unusually good. Most of this module's hazards compile silently; this one does not.

Now move the address one function away — the shape any real formatting helper takes:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static char *fill(char *b, size_t n, int code) { snprintf(b, n, "code=%d", code); return b; }
static const char *auto_fmt(int code) {
    char b[32];
    return fill(b, sizeof b, code);    /* the same dangling pointer, one hop away */
}

Measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  -Wall -Wextra -Wpedantic          : 0 warnings
  -Wall -Wextra -Wpedantic -O2      : 0 warnings
  AddressSanitizer, default settings: no report -- ran to completion,
                                      exited 0

The same bug. Zero diagnostics at any warning level, and AddressSanitizer did not report it either — the program ran to completion, printed a result and exited cleanly.

What it printed is not reproduced here, and the reason is the lesson: it was read through a pointer whose object no longer existed, so the characters are undefined behaviour and the number derived from them means nothing — including when it happens to look right. Chapter 13.2's point in its sharpest form: the dangerous kind of undefined behaviour is the kind that returns a plausible answer, and here every tool in the default build agreed there was nothing to report.

The defence exists and is off by default. Measured, both of these catch it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  ASAN_OPTIONS=detect_stack_use_after_return=1  ./prog     : CAUGHT
  clang -fsanitize=address -fsanitize-address-use-after-return=always : CAUGHT
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  ==70887==ERROR: AddressSanitizer: stack-use-after-return on address …
  READ of size 7 at 0x0001022a5020 thread T0

So the actionable line is a single environment variable. If your project runs its C model under AddressSanitizer — and a DPI model is exactly the kind of code that should be — set detect_stack_use_after_return=1, because without it the sanitizer you are relying on is silent about one of the two bugs this chapter is about.

8. The Misconception: The Simulator Copies It, So a Static Buffer Is Fine

"The LRM says the simulator copies the string when the function returns. So a static buffer is safe — by the time anything else happens, SystemVerilog already has its own copy."

The premise is true and the conclusion does not follow, for three reasons, one of them measured.

Two results can be alive on the C side. Measured in §4: p changed to "code=9" without being touched, and p == q. The simulator's copy of the first result is fine; your first result is gone. Any C code that holds two — a comparison, a log line printing expected and actual, anything queued — is broken, and the DPI copy rule has nothing to say about it.

The invalidating call need not come from SystemVerilog. The buffer dies on the next call to that function, including one your own model makes between the SystemVerilog call and your use of the result.

It assumes one call at a time. Chapter 19.7 established that a DPI call is an ordinary function call in the simulator's process; one static buffer shared across concurrent calls is Chapter 17.4's shared-variable problem with a different name.

The corrected position:

The simulator's copy protects the simulator's copy. It says nothing about the validity of your pointer on the C side, which is the half you own and the half the purpose of this chapter is about.

A static buffer is acceptable in exactly one situation: one result, consumed immediately, one call at a time, documented as such. When those conditions are not written down, they are not conditions — they are assumptions, and §4 measured what happens when one of them stops holding.

9. What You Should Be Able to Say Now

  1. A string crosses as a pointer, not a value — const char * from the LRM — so the question is lifetime, not representation.
  2. Rule one: an incoming string is valid for the duration of the call, and must not be modified. Keep it by copying it.
  3. Rule two: an outgoing string must point at storage valid at the moment you return.
  4. A literal is free — static storage duration, measured: the first result survived a second call.
  5. One static buffer is the dangerous middle case. Measured: p became "code=9" without being touched, and p == q.
  6. A caller-supplied buffer moves the lifetime to where it can be answered — measured, two results correct at once. Pass a pointer and a size.
  7. snprintf always terminates, and returns the length it wanted — measured, 10 into a 4-byte buffer giving "cod". Test want >= (int)n.
  8. Allocation trades a lifetime question for an ownership question — write down who frees it.
  9. The copy needs strlen(s) + 1. strlen does not count the terminator.
  10. The finding: the direct dangling return is caught with no warning flags at all; one function hop away it produces 0 warnings at any level and no AddressSanitizer report by default — the program ran to completion and exited 0. detect_stack_use_after_return=1 catches it.

10. Exercises

  1. Write lit_name and confirm the first result is still correct after a second call. Why is this guaranteed and not luck?
  2. Write static_fmt and reproduce §4's measurement, including p == q. Then explain what changed p.
  3. Convert it to a caller-supplied buffer and show two results alive at once.
  4. Call snprintf into a 4-byte buffer with a 10-character result. Print the return value, the string and its length, and write the truncation test.
  5. Copy an incoming string into storage you own. What happens if you allocate strlen(s) bytes instead of strlen(s) + 1?
  6. Compile the direct dangling return with no warning flags. Then move the address through one helper and compile again at -O2. Compare the output. Do not run either.
  7. Build the laundered form under AddressSanitizer and run it twice — once with default options, once with detect_stack_use_after_return=1. Which run would have told you about the bug?
  8. §8 lists one situation where a static buffer is acceptable. Write the comment that would have to sit above such a function for a reviewer to accept it.

11. Summary

  • A string crosses as a pointer — const char * — so this chapter is about lifetime, not bits.
  • Two rules: an incoming string lasts only for the call; an outgoing one must be valid at return.
  • Literal: free, static storage duration, measured to survive later calls.
  • One static buffer: measured, p became "code=9" untouched and p == q — fine for one result at a time, and nothing enforces that.
  • Caller-supplied buffer: the recommended default — a pointer and a size, measured correct with two results alive.
  • snprintf terminates and returns what it wanted — measured 10 into 4 bytes; test want >= (int)n.
  • Allocation: a lifetime question becomes an ownership question — write it down.
  • Copying in needs strlen(s) + 1.
  • The finding: direct dangling return caught with no flags; one hop away: 0 warnings at any level, and ASan silent by default — it ran to completion and exited 0. Turn on detect_stack_use_after_return=1.
  • The simulator's copy protects the simulator, not your pointer.

Once a pointer crosses, the obvious next question is whether something larger can — a whole record rather than a string. The answer turns out to depend less on the standard than anything else in this module.

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.