Contents

DPI-C tutorial · Part A · Step 04 · Spear & Tumbush §12.4–12.6

Arrays, structs and strings across the boundary

Scalars cross DPI by value. Everything bigger crosses by reference: C gets a pointer or a handle into storage that SystemVerilog owns. This step is about what that storage looks like, who may touch it, and for how long.

Prereqs: 02 Data types (svBitVecVal, aval/bval), 03 State and handlesBook pages: PDF = book + 36Solutions are hidden: think first

§0 · TutorialThe one rule: SystemVerilog owns the storage

A golden model rarely works on one number. It takes a packet, a frame of samples, a header, a matrix of coefficients, and returns another one. In the first three steps every argument was a scalar or a packed vector of a known width. Now we pass aggregates: unpacked arrays of fixed and unknown size, packed arrays of wide elements, structs and strings.

All of them obey one rule, and the rest of this page is detail around it: SystemVerilog owns every byte that crosses the boundary. C receives a pointer (fixed-size arrays, packed structs), a handle to a descriptor (open arrays) or a const char* (strings). That access is valid only for the duration of the call. C cannot allocate an array and give it to SV; SV cannot keep a pointer into C memory. Spear says it in one line under Sample 12.19: "There is no way to allocate an array in C and reference it in SystemVerilog." If C needs the data later, it copies. If SV needs C's data, C writes it into an SV-owned argument or returns a string that SV immediately copies.

The second theme is layout. For a few types (int, byte, longint, real, packed vectors in canonical form) the C view is fixed by IEEE 1800 Annex H (H.7.3, H.7.4, H.7.7). So is the element order of an unpacked array: lower index first, whatever the declared direction (H.7.3, H.7.6). For the rest (whether a whole open array is in C layout, how a simulator stores an unpacked array of bit [12:0]) the standard gives you access functions instead of a layout, and portable code uses them (H.12.3–H.12.6). The lab measures where Verilator 5.052 differs from what you might assume, and where it does not implement the standard at all (dynamic arrays, queues and unpacked structs as DPI arguments).

SystemVerilog (owner) bit [31:0] d[20] · RGB_T px int a[6:1][8:3] (open formal [][]) string s C (borrower, during the call) svBitVecVal* data svOpenArrayHandle h const char* s pointer handle char*
Fig. 0.1Three ways an aggregate reaches C. In each case the arrow points into SV-owned memory (or a simulator temporary), and it is only valid until the imported function returns.

0.1Fixed-size unpacked arrays (Samples 12.18–12.21)

The simplest aggregate is an unpacked array with constant bounds in the import declaration. H.7.3 and H.11.4 map it to a C array of the element type, which C sees as a pointer to the first element. Each element is represented exactly as a scalar argument of that type would be (step 02): int is an int, byte is a char, a packed bit [31:0] is one svBitVecVal word, a packed logic [31:0] is one svLogicVecVal (an aval/bval pair).

// fib.sv (Samples 12.19 / 12.21, in a module instead of a program)
import "DPI-C" function void fib2(output bit   [31:0] data[20]);
import "DPI-C" function void fib4(output logic [31:0] data[20]);
import "DPI-C" function int  sum8(input int v[8]);
import "DPI-C" function void bytes_in(input byte b[4], output int packed_le);
// fib.c (Samples 12.18 / 12.20). Verilator compiles .c as C++: wrap in extern "C".
void fib2(svBitVecVal data[20]) {          // [20] is documentation: C sees svBitVecVal*
  data[0] = 1;
  data[1] = 1;
  for (int i = 2; i < 20; i++) data[i] = data[i-1] + data[i-2];
}
void fib4(svLogicVecVal data[20]) {        // 4-state: write aval AND bval
  data[0].aval = 1; data[0].bval = 0;
  data[1].aval = 1; data[1].bval = 0;
  for (int i = 2; i < 20; i++) {
    data[i].aval = data[i-1].aval + data[i-2].aval;
    data[i].bval = 0;
  }
}
int  sum8(const int* v)                     { int s = 0; for (int i = 0; i < 8; i++) s += v[i]; return s; }
void bytes_in(const char* b, int* packed_le) { memcpy(packed_le, b, 4); }

Do not guess the C prototype: Verilator writes it for you in V<top>__Dpi.h (step 01). For this file it generated, verbatim:

extern void bytes_in(const char* b, int* packed_le);
extern void fib2(svBitVecVal* data);
extern void fib4(svLogicVecVal* data);
extern int sum8(const int* v);

Note the const on input arrays and its absence on output. Include the header in your C file and the compiler checks your definition against it. The run prints:

LOG fib2 d0=1 d1=1 d19=6765
LOG fib4 d19=6765 unknown=0
LOG sum8 sum=140
LOG bytes_in le=44332211

bytes_in shows that a byte array is a plain char array in memory, one byte per element: copying four of them into an int on a little-endian x86 host puts b[0] in the least significant byte.

bit [31:0] d[20] → svBitVecVal* (4 bytes / element) d[0] d[1] d[2] … d[19] logic [31:0] d[20] → svLogicVecVal* (aval, bval: 8 bytes / element) aval[0] bval[0] aval[1] bval[1] … byte b[4] → const char* (1 byte / element) 11 22 33 44 as int on x86: 0x44332211 C's [20] is not checked: writedata[20] and you corrupt whateverthe simulator put next. bval: 0 = known bit. aval/bval0/1 = Z, 1/1 = X (H.7.7).
Fig. 0.2What the pointer points at. The element encoding is the scalar encoding from step 02, repeated. Nothing in the pointer tells C how many elements there are: the size is a contract between the two declarations.

The bval you forgot. In a 4-state array C must write bval too; uninitialized it is garbage and a commercial simulator will show X or Z. The lab's fib4_bad writes aval = i and bval = 0xFFFFFFFF (all X by the encoding): Verilator prints d2=00000002 unknown=0. It is 2-state and simply ignores bval. A model that "passes" on Verilator can produce X on Questa or VCS. If your model is 2-state, declare the SV side with bit and use svBitVecVal; that is the honest contract.

Because SV owns the storage, the size must be known on the SV side before the call. If the model decides how much data it produces (a decoder, a variable-length packet), split it in two calls: a query (int c_len()) and a fill (c_fill(output … buf[])), with the SV side sizing its buffer in between. Exercise 9 does it for the reverse direction.

0.2Directions: who copies what, and when

For aggregates the direction means more than for scalars. The LRM lets the simulator pass arrays either by pointer to the actual or by pointer to a temporary that it copies in before the call and out after it. The rules C can rely on are the ones that hold in both implementations:

The last rule bites. The lab's outdir.sv fills three arrays with 5, then calls a C function that writes only element 1 with 7:

LOG c_sees out0=0                                  <- output open array: C sees a zeroed temporary
LOG outdir out=0,7,0 io=5,7,5 outf0_is5=0 outf1=7  <- output: unwritten elements LOST; inout: kept

With an output open array Verilator hands C a zero-filled temporary and copies all of it back, so the 5s become 0. With an output fixed array the unwritten element came back as stack garbage (not 5; the exact value is not reproducible and the check only asserts "not 5"). With inout the untouched elements keep their 5. The practical rule: use output only when C writes every element; otherwise use inout. Exercise 10 shows a subtler version: a function that returns an error code before touching its output still overwrites it.

SV: before callC body runsSV: after return input read-only viewunchanged inout read + write written elements change output (not copied in)undefined on entry ALL elements replaced
Fig. 0.3Copy-in / copy-out semantics. Whether the simulator really copies or passes a direct pointer is its business; code written to these rules works either way.

0.3Open arrays: one C function for any size

A fixed-size formal ties the C code to one array size and, worse, to a layout you reverse-engineered. Spear's §12.5 calls that "fragile". The alternative is the open array: write the unpacked dimension(s) as [] in the import, and the same C function accepts any actual size and any range. C now receives an svOpenArrayHandle. It is not a pointer to data; it is an opaque handle to a descriptor that the simulator builds for this call: element type and width, number of unpacked dimensions, the declared range of each dimension, and where the data lives.

// openarr.sv (Samples 12.22 / 12.24, corrected)
import "DPI-C" function void fib_oa(output bit [31:0] data[]);   // one [] = 1 unpacked dim
import "DPI-C" function void query(input int a[][], output int q[11]);
import "DPI-C" function void mydisplay(inout int a[][]);         // [][] = 2 unpacked dims
module top;
  bit [31:0] fibs[20];
  int a[6:1][8:3];          // descending, not 0-based: the C side must not care
  int up[0:2][0:3];         // ascending
  ...
void fib_oa(const svOpenArrayHandle h) {            // Sample 12.23, corrected
  svBitVecVal* d = (svBitVecVal*)svGetArrayPtr(h);  // contiguous storage (may be NULL, see 0.4)
  int n = svSize(h, 1);
  d[0] = 1; d[1] = 1;
  for (int i = 2; i < n; i++) d[i] = d[i-1] + d[i-2];
}

Two bugs in the book's open-array example. Sample 12.22 calls fib_oa(data, SIZE) with two arguments but imports a one-argument function (the size travels inside the handle; the extra argument is a compile error). Sample 12.23 loops for (i=2; i<=svSize(data_oa,1); i++), which writes one element past the end of the array. The loop bound is i < svSize(h,1).

The descriptor is read with the query functions of H.12.2 (Spear's Table 12.3). Dimensions are numbered from 1, outermost first. Here is what C sees for the two arrays above, measured:

QueryMeaningint a[6:1][8:3]int up[0:2][0:3]
svLeft(h,d) / svRight(h,d)bounds as written in the declaration6, 1 · 8, 30, 2 · 0, 3
svLow(h,d) / svHigh(h,d)min / max index: use these in loops1, 60, 2
svIncrement(h,d)1 if left ≥ right, else −11−1
svSize(h,d)elements in dim d (high − low + 1)6 · 63 · 4
svDimensions(h)number of unpacked dimensions22
svSizeOfArray(h)bytes of storage (0 if not contiguous C layout)14448

The sign of svIncrement surprises people: it is +1 for the "Verilog-style" descending range [6:1] and −1 for [0:2]. Despite the name it is not the step that walks from left to right (that step is −1 for [6:1]); it is the Verilog notion of "is the range written big-endian". You will rarely need it: iterate from svLow to svHigh and you are always right. The sizes check out: $6 \times 6 \times 4 = 144$ bytes and $3 \times 4 \times 4 = 48$.

svOpenArrayHandle h(opaque) descriptor (per call) element: int (32 bits) udims = 2 dim 1: left 6, right 1 dim 2: left 8, right 3 C layout? yes data → ● data (SV-owned) 36 ints, row-majora[1][3], a[1][4], …… a[6][8] svLeft/svLow/svSize…read the descriptor svGetArr*Ptr → here
Fig. 0.4An open array is a handle to a descriptor. Queries never touch the data; element access goes through the descriptor so the C code never has to know the declared ranges.

0.4Reaching the elements

H.12.4 and H.12.5 offer two ways in (Spear's Table 12.4):

The element-pointer form is the portable one, because it does not assume an order in memory. How much does that matter? The lab asks Verilator where things are, for int v[10:1] (filled with v[i] = i) and for a[6:1][8:3] after mydisplay stored a[i][j] = i*j:

LOG flat v0=1 v_last=10 v_at_low=0 m0=3 m1=4 m6=6

Offset 0 holds v[1], the low index, even though the declaration starts at 10. In two dimensions the storage is row-major starting at the low corner: m0 = a[1][3] = 3, m1 = a[1][4] = 4, m6 = a[2][3] = 6. A C programmer who reads [10:1] as "element 10 comes first" is wrong, and not only in Verilator: H.7.3 and H.7.6(c) fix the order for every tool, "elements with lower indices go first", the element with SV index min(L,R) has C index 0. Still write svGetArrElemPtr1(h, i) with SV indices: it is correct for any range and survives arrays that are not in C layout.

int sum_oa(const svOpenArrayHandle h) {             // any size, any range, any direction
  int s = 0;
  for (int i = svLow(h, 1); i <= svHigh(h, 1); i++)
    s += *(int*)svGetArrElemPtr1(h, i);
  return s;
}
int probe_oob(const svOpenArrayHandle h) {          // index 0 does not exist in [10:1]
  return svGetArrElemPtr1(h, 0) == NULL ? -1 : 0;
}
LOG sum_oa fixed=55
%Warning: DPI svOpenArrayHandle function index 1 out of bounds; 0 outside [10:1].
LOG oob ret=-1

The 0-based reflex. for (i = 0; i < svSize(h,1); i++) svGetArrElemPtr1(h, i) is the most common open-array bug: correct for int a[10], a NULL dereference for int a[10:1] (index 0 does not exist). In the lab (ex/bug_loop) the simulator dies with SIGSEGV (exit code 139) before printing anything. The loop variable of an open-array loop is an SV index: start at svLow, stop at svHigh.

Each svGetArrElemPtr* call walks the descriptor and bounds-checks every index. For a 1 k-element golden-model loop that is noise; for 1 M elements called every clock it is not. When it matters, check once that svGetArrayPtr(h) is non-NULL and the element type is a C type (int, byte, longint, real), then index the flat block yourself from svLow, with a fallback to element access. Step 06 measures the cost per call.

0.5Multi-dimensional open arrays (Samples 12.24–12.25)

Each [] in the formal is one unpacked dimension. The actual must have the same number of unpacked dimensions; their sizes and ranges are free. Spear's example walks a 2-D array with svLow/svHigh and writes it back through inout:

void mydisplay(const svOpenArrayHandle h) {         // Sample 12.25 (io_printf -> printf)
  for (int i = svLow(h, 1); i <= svHigh(h, 1); i++)
    for (int j = svLow(h, 2); j <= svHigh(h, 2); j++) {
      int* a = (int*)svGetArrElemPtr2(h, i, j);     // SV indices, both dimensions
      *a = i * j;
    }
}

SV filled a[i][j] = i + j before the call; the C side saw a[6][3] = 9 and replaced every element with i*j; back in SV, a[6][3] = 18, a[1][3] = 3, a[4][8] = 32. This is also the answer to Spear's Ex. 12.7 (what do the query functions return for Sample 12.24): svLeft(h,1)=6, svLeft(h,2)=8, svRight(h,1)=1, svRight(h,2)=3, svSize(h,1)=6, svSize(h,2)=6, svDimensions(h)=2, svSizeOfArray(h)=144; all measured in the table of 0.3.

Passing a 2-D array to a 1-D open formal (or the opposite) is a compile-time mismatch. Calling svGetArrElemPtr1 on a 2-D handle is caught at run time: Verilator warns "called on 2 dimensional array using 1 dimensional function" and returns NULL.

int a[6:1][8:3] in Verilator's flat storage (offsets) j=345678 i=1 0 1 2 3 4 5 i=2 6 7 8 … 11 … 12 … 29 i=6 30 … 34 35 measured: m0 = a[1][3] = 3 m1 = a[1][4] = 4 m6 = a[2][3] = 6 svGetArrElemPtr2(h, i, j) hides this map: use it.
Fig. 0.5Row-major from the low corner. The declared direction ([6:1], [8:3]) does not change the memory order. This is the LRM's rule (H.7.3, H.7.6), not a Verilator choice; the access functions remain the safe way because they also work when svGetArrayPtr is NULL.

0.6Dynamic arrays, queues and slices as actuals

IEEE 1800-2023 §35.6.1 says "it is sometimes permissible to pass a dynamic array as an actual argument" to an import, under the same rules as for native subroutines (§7.7); by those rules a queue qualifies too, an associative array does not. Commercial simulators accept both. That is the main reason open arrays exist in a testbench: a packet is usually a byte payload[] or a byte q[$]. Verilator 5.052 implements only fixed-size actuals. The lab builds the same call with four actuals:

Actual passed to input int a[]Verilator 5.052
int a[4] (fixed)works (sum=10)
int a[] (dynamic)%Error-UNSUPPORTED: Unsupported: DPI argument of type DYNARRAYDTYPE
int a[$] (queue)%Error-UNSUPPORTED: Unsupported: DPI argument of type QUEUEDTYPE
fixed4[1:2] (slice)%Error: Internal: Unexpected Call

Commercial simulators accept all four. On Verilator the workaround is to move the data through a fixed-size buffer and pass the count explicitly; a wrapper function in SV keeps the call sites clean (Exercise 9 sums a 1000-element dynamic array through a 256-element chunk buffer and gets 499500, like d.sum()). It costs one copy, which is what a commercial simulator usually does internally anyway.

If you write a golden model meant to be shared with a team running Questa or VCS, keep the C signature as an open array (input byte data[]) and put the Verilator-specific chunking in an SV wrapper under `ifdef VERILATOR. The C code does not change, and on the commercial simulator you call it directly with the dynamic array.

0.7Packed elements: one packed dimension of any width (Samples 12.26–12.27)

An open array has exactly one packed dimension (the element's bits) plus one or more unpacked ones. The actual may have several packed dimensions as long as they flatten to the same total width: Spear passes bit [1:0][0:3][6:-1] bpack[9:1] (2·4·8 = 64 bits) to a bit [63:0] b64[] formal.

void view_pack(const svOpenArrayHandle h) {         // Sample 12.27, corrected
  for (int i = svLow(h, 1); i <= svHigh(h, 1); i++) {   // book: i < svHigh -> skips bpack[9]
    unsigned long long v = *(unsigned long long*)svGetArrElemPtr1(h, i);
    printf("C: b64[%d]=%016llx\n", i, v);
  }
}
LOG sv bpack2=1234567890abcdef bpack2_0=90abcdef bpack2_0_0=90
LOG c b64_1=0000000000000001
LOG c b64_2=1234567890abcdef
LOG c b64_9=0000000000000009

Sample 12.27 loops i < svHigh(h,1): with bpack[9:1] it prints elements 1 to 8 and never reaches 9. The bound must be <=. (Its sibling Sample 12.23 has the opposite bug, one past the end: these two together are the best argument for always writing svLow…<= svHigh.)

Reading a 64-bit element through an unsigned long long* works because Verilator stores a 64-bit element as a 64-bit integer. That is an accident of this element width. H.7.7 defines a canonical representation for packed data (svBitVecVal words, 32 bits each, least significant word first). For an array passed to an open-array formal, H.7.3 says packed elements are "in canonical form", and H.12.3 lets a simulator use a compacted storage instead only if it then refuses direct pointers: H.12.4 says svGetArrElemPtr* "shall return NULL if the representation of the array elements differs" from that of a single value. The lab asks Verilator how many bytes each element really takes:

Element typeCanonical (svBitVecVal words)Verilator storage (measured)
bit [7:0]4 bytes (1 word)1 byte
bit [12:0]4 bytes (1 word)2 bytes
bit [63:0]8 bytes (2 words)8 bytes
bit [99:0]16 bytes (4 words)16 bytes

So the "obvious" code below is wrong for anything under 33 bits. With b8 = '{11, 22, 33, 44} it returns 44332211: it read four 1-byte elements as one word.

int raw8(const svOpenArrayHandle b) {               // BUG: assumes canonical layout
  return *(svBitVecVal*)svGetArrElemPtr1(b, 0);    // Verilator: 4 elements at once -> 0x44332211
}

The portable path copies an element into (or out of) canonical form, whatever the simulator does internally:

svBitVecVal e[4];                                   // 100 bits -> 4 words, LSW first
svGetBitArrElem1VecVal(e, h, i);                    // copy element i OUT, canonical
svPutBitArrElem1VecVal(h, e, i);                    // copy element i IN
svGetLogicArrElem1VecVal(&lv, h, i);                // 4-state flavor: svLogicVecVal
svBit b = svGetBitArrElem1(h, i);                   // 1-bit elements: bit m[]

The lab sums three 100-bit elements with a 32-bit carry chain over the canonical words and gets 3ffffffff0000000100000002, identical to SV's own w[0]+w[1]+w[2]; svPutBitArrElem1VecVal writes f0000000000000000cafef00d into w[1]; and svGetLogicArrElem1VecVal on a logic [7:0] element returns aval=a5 with bval overwritten to 0 (the lab pre-poisons it with 0xDEAD).

bit [7:0] b8[4] = '{11, 22, 33, 44} Verilator storage 11 22 33 44 *(svBitVecVal*)ptr reads 4 bytes = 0x44332211 ✗ canonical copy 00 00 00 11 00 00 00 22 … svGetBitArrElem1VecVal(&w, h, 0) → w = 0x00000011 ✓ (the copy functions translate; the raw pointer does not)
Fig. 0.6Native vs canonical. For packed elements, a raw element pointer is only safe when the element is a C type (byte, shortint, int, longint) or when you checked the width. The Get/Put…VecVal functions are always safe.

0.8Structs (Samples 12.28–12.29)

A packed struct is, for DPI, just a packed vector of its total width: it crosses as svBitVecVal* (or svLogicVecVal* if any member is 4-state) in canonical form. The first member declared occupies the most significant bits. Spear's pixel struct packed { bit [7:0] r, g, b; } is 24 bits with r in [23:16], g in [15:8], b in [7:0], all inside word 0.

typedef struct { unsigned char b, g, r; } *p_rgb;   // Sample 12.28: overlay, reversed order
void invert(svBitVecVal* v) {
  p_rgb rgb = (p_rgb)v;                             // little-endian: byte 0 = bits [7:0] = b
  rgb->r = ~rgb->r; rgb->g = ~rgb->g; rgb->b = ~rgb->b;
}
void invert_bits(svBitVecVal* v) { *v = ~*v & 0x00FFFFFFu; }   // portable: shifts and masks
LOG invert r=ed g=cb b=a9          <- 12,34,56 inverted (book version, x86)
LOG invert_bits r=12 g=34 b=56     <- inverted back (portable version)

The book's overlay works, and the book is honest about why: the struct lists the bytes backwards because x86 is little-endian. It is still a trap: it breaks on a big-endian host, and for a struct wider than 32 bits it depends on how fields straddle 32-bit words. The robust method treats the struct as a bit vector and extracts fields by bit position, with shifts or with Annex H's part-select helpers svGetPartselBit(dst, src, lsb, width) / svPutPartselBit(dst, value, lsb, width) (width ≤ 32).

typedef struct packed { bit [47:0] dst, src; bit [15:0] etype; } eth_hdr_t;   // 112 bits
import "DPI-C" function int  etype_of(input eth_hdr_t h);    // C: const svBitVecVal* h (4 words)
import "DPI-C" function void make_hdr(output eth_hdr_t h);
int etype_of(const svBitVecVal* h) { svBitVecVal e; svGetPartselBit(&e, h, 0, 16); return e; }
void make_hdr(svBitVecVal* h) {                     // dst=[111:64] src=[63:16] etype=[15:0]
  svPutPartselBit(h, 0x86DD,      0, 16);
  svPutPartselBit(h, 0xA0A0A0A0, 16, 32);  svPutPartselBit(h, 0xB0B0,     48, 16);
  svPutPartselBit(h, 0x55667788, 64, 32);  svPutPartselBit(h, 0x1122,     96, 16);
}
LOG etype c=00000800
LOG make_hdr dst=112255667788 src=b0b0a0a0a0a0 etype=86dd
eth_hdr_t (112 bits) as svBitVecVal h[4] h[3]: bits 127..96h[2]: 95..64h[1]: 63..32h[0]: 31..0 unused dst hi 16 dst lo 32 src hi 32 src lo 16 etype First member declared (dst) = most significant bits. Fields straddle words: src = bits [63:16] lives in h[1] and the top half of h[0]. Extract by bit position (svGetPartselBit), never by C struct overlay.
Fig. 0.7A packed struct is a bit vector in canonical form. Word boundaries do not respect field boundaries, which is why a C struct overlay only works for small, aligned cases like Spear's 24-bit pixel.

Unpacked structs are different: the LRM allows them as DPI arguments when every member is a DPI-compatible type, mapped to a C struct with the same members (and the C compiler's padding). Verilator does not implement it:

%Error-UNSUPPORTED: structs.sv:10:50: Unsupported: DPI argument of type REFDTYPE 'ups_t'

Use a packed struct (a bit vector), or pass the members as separate arguments. Classes never cross: Spear's §12.6 already says to pack a class into a struct and unpack it on return (Sample 12.29's pack()/unpack() methods); that remains the pattern, and in UVM the pack_ints/unpack_ints field automation does the same job.

0.9Strings: in, out, returned (Samples 12.30–12.31)

An SV string is a dynamic object in the simulator's heap; C sees it as a NUL-terminated const char*. The generated prototypes tell the whole story:

extern int c_strlen(const char* s);                 // input string
extern const char* hex_static(int v);               // function string
extern void name_of(int code, const char** name);   // output string
extern void upcase(const char** s);                 // inout string

The ownership rules from §35.5.1.4 / H.6.6 (memory) and H.8.10 (strings), which every example in the lab follows:

const char* hex_static(int v) {                     // Sample 12.30: a static buffer is alive after return
  static char s[12];
  snprintf(s, sizeof s, "%08x", v);
  return s;
}
void name_of(int code, const char** name) {         // output: point at long-lived storage
  static const char* names[] = {"IDLE", "BUSY", "DONE"};
  *name = (code >= 0 && code < 3) ? names[code] : "???";
}

Spear worries (before Sample 12.31) that a static buffer breaks when one statement calls the function twice, e.g. $display(print(a), print(b)): if the simulator kept the pointers instead of copying, both would show the second result. His fix is a ring of buffers. Verilator copies a returned string into a std::string immediately, so even the single static buffer works there:

LOG static_two a=00001111 b=00002222     <- two calls in one $display, one static buffer
LOG ring a=00000001 b=00000002           <- Sample 12.31's ring buffer, same result

Do not rely on that if the code has to run elsewhere: when the copy happens is an implementation detail, Spear wrote Sample 12.31 because he met simulators where it mattered, and a ring of N buffers costs nothing. The real trap is the local array:

const char* to_hex(int v) {                         // ex/bug_str.c
  char s[12];                                       // dies when the function returns
  snprintf(s, sizeof s, "%08x", v);
  return s;
}

clang catches it at build time: warning: address of stack memory associated with local variable 's' returned [-Wreturn-stack-address]. Verilator shows C compiler warnings only in its build log, so read it (or add -CFLAGS -Werror=return-stack-address).

callreturn input const char* s valid dangling: copy before return return static / heap buffer SV copies here stack char s[12] gone before the copy ✗
Fig. 0.8Who keeps the bytes alive. Input strings: SV, until return. Returned and output strings: C, at least until SV has copied them.

0.10Verilator 5.052 vs the LRM, for aggregates

Feature (IEEE 1800-2023 cl. 35 / Annex H)Verilator 5.052 (measured)
Fixed-size unpacked arrays, all element typesyes
Open arrays from fixed-size actuals, 1–3 dims, all query functionsyes
Dynamic array / queue / slice as open-array actualno (UNSUPPORTED / internal error)
svGetArrayPtr, svSizeOfArrayyes; NULL / 0 if not C layout
Element storage of packed elements (canonical, H.7.3; else NULL pointers, H.12.4)native: 1/2/4/8 bytes up to 64 bits, 32-bit words above, with non-NULL pointers (deviation)
svGet/PutBitArrElem*VecVal, svGet/PutLogicArrElem*VecValyes (logic: bval always 0)
4-state values (bval)ignored on input to SV, 0 on output to C
Packed structsyes (bit vector)
Unpacked structsno (UNSUPPORTED REFDTYPE)
Strings: input / output / inout / returnyes; returned string copied at once
output array, unwritten elementsopen array: 0; fixed array: garbage

The pattern that survives every simulator: open arrays of C-compatible element types (byte, int, longint, real) for bulk data, accessed with svLow/svHigh/svGetArrElemPtr1; packed vectors and packed structs for headers and wide words, accessed with canonical svBitVecVal words and part-selects; strings that C copies on the way in and returns from static or heap storage. Step 06 builds the golden model with exactly these pieces, and step 08 passes the same arrays to Python.

§1 · ObjectivesWhat you can do after this step

Prerequisites: 02 (canonical svBitVecVal/svLogicVecVal, type table) and 03 (C-side state and lifetimes).

§2 · Exercises12 graded exercises, with solutions

★ basic · ★★ intermediate · ★★★ real design. Every solution with code is in lab/ex/ and checked by check.py; the numbers quoted come from the run.

E1 ★Write. A C function ex1_sum that returns the sum (as longint) of an int open array of any size and range. Test it with int a10[10:1] (a10[i] = i) and int a37[3:7] (a37[i] = -i).

Show solution
import "DPI-C" function longint ex1_sum(input int a[]);
long long ex1_sum(const svOpenArrayHandle h) {
  long long s = 0;
  for (int i = svLow(h, 1); i <= svHigh(h, 1); i++) s += *(int*)svGetArrElemPtr1(h, i);
  return s;
}

Result: a10=55 a37=-25. Lesson: longint maps to long long (int64); the loop never mentions 0 or the declared direction.

E2 ★Predict. bit [7:0] m[2:5][3:0] is passed to input bit [7:0] m[][]. What do svLeft, svRight, svLow, svHigh, svIncrement, svSize return for dimensions 1 and 2, and what are svDimensions and svSizeOfArray in Verilator?

Show solution

Dim 1 ([2:5]): left 2, right 5, low 2, high 5, increment −1 (left < right), size 4. Dim 2 ([3:0]): left 3, right 0, increment +1, size 4. svDimensions = 2 in Verilator (unpacked dims only). H.12.2 says the queries follow the semantics of §20.7, where $dimensions counts packed and unpacked dimensions (3 here), with the packed part as dimension 0; the LRM is ambiguous, so do not hard-code either answer. svSizeOfArray = 16: 16 elements × 1 byte, because Verilator stores bit [7:0] natively in a byte (0.7). With canonical words it would have been 64. All measured (LOG ex2).

E3 ★Ex. 12.7. For the code of Samples 12.24 and 12.25 (int a[6:1][8:3]), what do svLeft(h,1), svLeft(h,2), svRight(h,1), svRight(h,2), svSize(h,1), svSize(h,2), svDimensions(h), svSizeOfArray(h) return?

Show solution

6, 8, 1, 3, 6, 6, 2, 144. The last one is $36 \times 4$ bytes; Verilator reports it because int is a C type and the array is contiguous (on a simulator that does not use C layout it could legitimately be 0). Measured in openarr.log (LOG query_a).

E4 ★★Find the bug. This works for int a[10] and crashes for int a[10:1]. Why, and what is the minimal fix?

int sum_bug(const svOpenArrayHandle h) {
  int s = 0;
  for (int i = 0; i < svSize(h, 1); i++) s += *(int*)svGetArrElemPtr1(h, i);
  return s;
}
Show solution

svGetArrElemPtr1 takes an SV index. For [10:1], index 0 does not exist, the function returns NULL and the dereference segfaults. In the lab the process exits with code 139 before the $display (ex/bug_loop). Fix: for (int i = svLow(h,1); i <= svHigh(h,1); i++). Note that svSize was right; only the starting point was wrong.

E5 ★★Write. ex5_hist(input byte data[], output int hist[]): hist has 16 bins and counts the high nibble of each byte, treating bytes as unsigned. Check against an SV computation over 64 bytes data[i] = byte'(i*37).

Show solution
void ex5_hist(const svOpenArrayHandle data, const svOpenArrayHandle hist) {
  for (int b = svLow(hist, 1); b <= svHigh(hist, 1); b++) *(int*)svGetArrElemPtr1(hist, b) = 0;
  for (int i = svLow(data, 1); i <= svHigh(data, 1); i++) {
    unsigned char v = *(unsigned char*)svGetArrElemPtr1(data, i);   // byte is SIGNED char
    (*(int*)svGetArrElemPtr1(hist, v >> 4))++;
  }
}

Result: match=1 bin0=6 bin15=3. Two lessons. (1) hist is output: C must write every element, so it zeroes the bins first (0.2). (2) SV byte is signed; read it as char and v >> 4 is negative for half the values, an out-of-range index. Cast to unsigned char. (The bin index v >> 4 is used as an SV index directly, which is valid only because hist is declared [16], i.e. [0:15]; for a general range add svLow(hist,1).)

E6 ★★Predict. bit [7:0] b8[4] = '{8'h11, 8'h22, 8'h33, 8'h44} is passed to input bit [7:0] b[], and C returns *(svBitVecVal*)svGetArrElemPtr1(b, 0). What does SV print with %h on Verilator? What should portable code do?

Show solution

44332211. Verilator stores each bit [7:0] element in one byte; reading a 32-bit word at element 0 picks up four elements, little-endian. Portable: svBitVecVal w; svGetBitArrElem1VecVal(&w, b, 0); gives 0x11 on every simulator (or declare the formal as byte b[], a C type, and read chars). Measured: LOG raw8 elem0=44332211.

E7 ★★Write. For eth_hdr_t of 0.8, write ex7_swap_macs(inout eth_hdr_t h) that exchanges dst and src and leaves etype alone. No C struct overlays.

Show solution
void ex7_swap_macs(svBitVecVal* h) {                // dst = [111:64], src = [63:16]
  svBitVecVal dl, dh, sl, shi;
  svGetPartselBit(&dl, h, 64, 32); svGetPartselBit(&dh, h, 96, 16);
  svGetPartselBit(&sl, h, 16, 32); svGetPartselBit(&shi, h, 48, 16);
  svPutPartselBit(h, sl, 64, 32);  svPutPartselBit(h, shi, 96, 16);
  svPutPartselBit(h, dl, 16, 32);  svPutPartselBit(h, dh, 48, 16);
}

With dst=aaaaaaaaaaaa src=123456789abc etype=0806 the result is dst=123456789abc src=aaaaaaaaaaaa etype=0806. Lesson: part-selects are limited to 32 bits, so a 48-bit field moves in two pieces; the positions come straight from the SV declaration (last member at bit 0).

E8 ★★Find the bug. Why does this sometimes print garbage, and how would you catch it before running?

const char* to_hex(int v) {
  char s[12];
  snprintf(s, sizeof s, "%08x", v);
  return s;
}
Show solution

s lives in the function's stack frame, which is released at return; SV copies the string after that, from memory the next call may overwrite. It is undefined behavior: it may even "work" in a small test. clang flags it at compile time and the warning lands in Verilator's build log: address of stack memory associated with local variable 's' returned [-Wreturn-stack-address] (checked). Fix: static char s[12]; (Sample 12.30) or a ring of static buffers (Sample 12.31).

E9 ★★Write (Verilator workaround). Your model is int c_sum(input int a[]), but your data is a dynamic array int d[] of arbitrary length, and Verilator rejects it as an actual. Write an SV wrapper sum_dyn(const ref int d[]) that feeds the model through a fixed buffer of 256 elements. Test with 1000 elements d[i] = i.

Show solution
import "DPI-C" function int ex9_sum_n(input int chunk[256], input int n);
function automatic longint sum_dyn(const ref int d[]);
  int chunk[256];
  longint s = 0;
  for (int base = 0; base < d.size(); base += 256) begin
    int n = (d.size() - base < 256) ? d.size() - base : 256;
    for (int k = 0; k < n; k++) chunk[k] = d[base + k];
    s += ex9_sum_n(chunk, n);
  end
  return s;
endfunction

Result: sum=499500 expect=499500. Lessons: the count must travel separately because a fixed formal carries no size; and do not name the buffer buf: it is a Verilog keyword (gate primitive), a syntax error the lab hit on the first try.

E10 ★★★Write. int ex10_matmul(input int a[][], input int b[][], output int c[][]) computes c = a × b for arbitrary ranges and returns −1 if the shapes do not match. Test with int ma[1:2][0:2] (i+j), int mb[0:2][5:4] (i−j), int mc[2][2]. Then explain why calling it with a mismatching b and the same mc "erases" the previous result.

Show solution
int ex10_matmul(const svOpenArrayHandle a, const svOpenArrayHandle b, const svOpenArrayHandle c) {
  int n = svSize(a, 1), k = svSize(a, 2), m = svSize(b, 2);
  if (svSize(b, 1) != k || svSize(c, 1) != n || svSize(c, 2) != m) return -1;
  for (int i = 0; i < n; i++)                       // 0-based OFFSETS, converted below
    for (int j = 0; j < m; j++) {
      int s = 0;
      for (int t = 0; t < k; t++)
        s += *(int*)svGetArrElemPtr2(a, svLow(a, 1) + i, svLow(a, 2) + t) *
             *(int*)svGetArrElemPtr2(b, svLow(b, 1) + t, svLow(b, 2) + j);
      *(int*)svGetArrElemPtr2(c, svLow(c, 1) + i, svLow(c, 2) + j) = s;
    }
  return 0;
}

Result: c00=-16 c01=-22 c10=-25 c11=-34, bad_rc=-1. With three arrays of different ranges the clean way is to loop over 0-based offsets and add each array's own svLow. Note that "column 0" of b is SV index 4 (its low index), not 5 (its left index).

The erase: c is output. Even when the function returns −1 without touching it, the simulator copies the whole temporary back, and in Verilator that temporary is zero-filled (0.2). The lab passes a separate scratch array to the failing call. Either do that or declare c as inout.

A second lesson the lab taught by accident: the first version declared int rc = ex10_matmul(ma, mb, mc); inside a begin block of an initial. A variable declaration with initializer in a static context is initialized once at time 0, before the foreach loops filled ma/mb, so the model multiplied zeros. Declare first, assign in a statement.

E11 ★★★Write. ex11_xor(input bit [127:0] w[], output bit [127:0] x): XOR-reduce an open array of 128-bit words, portable to any simulator.

Show solution
void ex11_xor(const svOpenArrayHandle w, svBitVecVal* x) {   // x: 4 canonical words
  svBitVecVal e[4];
  memset(x, 0, 4 * sizeof(svBitVecVal));
  for (int i = svLow(w, 1); i <= svHigh(w, 1); i++) {
    svGetBitArrElem1VecVal(e, w, i);                // canonical copy, never a raw pointer
    for (int k = 0; k < 4; k++) x[k] ^= e[k];
  }
}

Matches SV's own XOR (match=1). For a scalar packed output the C side gets a pointer to ceil(128/32) = 4 words; write all of them.

E12 ★★Ex. 12.1, on arrays. Spear's Ex. 12.1 shifts one 32-bit value left for n > 0, right for n < 0, not at all for n = 0. Write ex12_shift_all(inout int unsigned v[], input int n) that applies it to every element. What should happen for n = 40?

Show solution
void ex12_shift_all(const svOpenArrayHandle v, int n) {
  for (int i = svLow(v, 1); i <= svHigh(v, 1); i++) {
    unsigned* p = (unsigned*)svGetArrElemPtr1(v, i);
    if (n >= 32 || n <= -32) *p = 0;                // C: shifting by >= the width is UB
    else if (n > 0) *p <<= n;
    else if (n < 0) *p >>= -n;
  }
}

From {80000001, f, 1, ffffffff}: n=4 → 00000010,000000f0,00000010,fffffff0; then n=-8 → 00000000,00000000,00000000,00ffffff; then n=40 → 0. Lesson: SV defines x << 40 on 32 bits as 0; C does not define it at all (x86 masks the count to 5 bits, so it would shift by 8). A golden model must reproduce the SV semantics explicitly, and the type must be unsigned so >> is logical.

§3 · Quiz14 questions, self-grading

1. import "DPI-C" function void f(output bit [31:0] d[20]); What is the C parameter type?

A fixed-size array becomes a pointer to its first element, and each bit [31:0] element is one canonical word. Not const, because it is output. A handle appears only for [] formals; C has no array-by-value parameters.

2. A C function receives output int c[] and writes only c[1]. SV had '{5,5,5} before the call. What does SV hold after, in Verilator?

output means the whole array is the result. Verilator passes a zero-filled temporary and copies all of it back (measured). '{5,7,5} is what inout gives. The LRM leaves the unwritten values undefined, so do not rely on the zeros either.

3. For int a[6:1], which loop is correct in C?

Element functions take SV indices, 1..6 here. (b) asks for index 0 (NULL). (c) starts at 6 and tests i <= 1: the body never runs. (d) never runs either.

4. What does svIncrement(h,1) return for int a[6:1]?

The definition is "1 if left ≥ right, else −1". It flags a big-endian range; it is not the loop step (that would be −1, the tempting answer). Measured: +1 for [6:1], −1 for [0:2].

5. Verilator stores int v[10:1] with v[i] = i. What is at offset 0 of svGetArrayPtr(h)?

Measured v0=1: low index first, regardless of declared direction, which is what H.7.3/H.7.6 require of every tool. svGetArrElemPtr1(h, i) with SV indices is still the safest way. svGetArrayPtr is non-NULL for C-layout arrays like int.

6. Which actual can you pass to input int a[] in Verilator 5.052?

Only fixed-size actuals. Dynamic arrays and queues give %Error-UNSUPPORTED; a slice gives an internal error. §35.6.1 permits dynamic arrays (and, through §7.7, queues); commercial simulators accept them.

7. For input bit [7:0] b[], the C code does *(svBitVecVal*)svGetArrElemPtr1(b, 0). Why is it wrong in Verilator?

Verilator stores a bit [7:0] element in 1 byte (measured 44332211) and still hands out a pointer to it. The LRM wants canonical form for packed open-array elements (H.7.3) or a NULL pointer when the storage differs (H.12.4), so (d) describes the standard, not Verilator: this is a Verilator deviation. The portable path is the copy made by svGet/PutBitArrElem*VecVal.

8. typedef struct packed { bit [7:0] r, g, b; } RGB_T; crosses as svBitVecVal* v. Which bits hold r?

First member declared = most significant. r is [23:16]. On little-endian x86 that is byte 2 in memory (why the book's C struct lists b, g, r); on big-endian it would be byte 1. Bit positions are host-independent; byte positions are not.

9. Which way of passing struct { int a; byte b; shortint c; } (unpacked) works in Verilator 5.052?

The LRM allows unpacked structs of DPI-compatible members (mapped to a C struct), but Verilator says Unsupported: DPI argument of type REFDTYPE 'ups_t'. A chandle is a C pointer SV cannot dereference; it does not carry SV data.

10. An imported function receives input string s and stores the const char* in a C global to use in the next call. What is wrong?

SV owns input strings and may reallocate them as soon as control returns (the next assignment to the variable). C copies if it needs the text later and never frees SV's pointer.

11. Which return statement is safe for import "DPI-C" function string f();?

(a) and (b) point into objects destroyed at return. (c) returns live memory, but SV never frees a returned string: it leaks one buffer per call. A static (or a ring of static buffers, Sample 12.31) is alive when SV copies.

12. import "DPI-C" function void upcase(inout string s); How does C change the string?

The C type is const char**: the text is const. C points *s at its own storage and SV copies from there after return. Writing in place or freeing SV's buffer corrupts the simulator's heap.

13. A C model writes data[i].aval but leaves bval uninitialized for a logic [31:0] data[20] output. What do you see?

Measured: with bval = 0xFFFFFFFF Verilator still shows the aval value and $isunknown = 0. A 4-state simulator decodes the garbage bval as X/Z. Bugs that hide on Verilator are the worst kind; write bval, or use bit.

14. Which formal accepts bit [1:0][0:3][6:-1] bpack[9:1]?

An open array has one packed dimension; the actual's packed dims just have to flatten to the same width (2·4·8 = 64). (c) turns packed dims into unpacked ones, a different type. (d) needs an element type equivalent to the actual's: longint is signed, the packed actual is unsigned, so the types differ.

§4 · LabEverything above, measured

Directory 04_arrays_structs_strings/lab/. One self-checking testbench per topic, top module top, Verilator 5.052 (--binary --timing, flags in ../../common/lab.sh).

cd tutorial/04_arrays_structs_strings/lab
python3 check.py          # builds everything in parallel, runs, asserts (69 checks)
python3 check.py --no-run # re-check the logs in build/ only

Predict before you run: (1) the eleven query values for int up[0:2][0:3]; (2) whether static_two prints two different strings; (3) the bytes per element for bit [12:0]; (4) which of the four kinds builds fail.

ExperimentFilesWhat to look atResult (Verilator 5.052)
Fixed arraysfib.sv/.cbuild/vl_fib/Vtop__Dpi.h, bval ignoredd19=6765; fib4_bad unknown=0
Open arraysopenarr.sv/.cquery table, flat order, NULL on bad index or dimsbytes=144; v0=1; 2 run-time warnings
Actual kindskinds.sv/.cbuild/k_*.buildfixed OK; dyn, queue, slice fail
Packed elementspacked.sv/.clayout, raw pointer vs VecVal1/2/8/16 bytes; raw8=44332211
Structsstructs.sv/.coverlay vs part-select; s_ups.buildboth invert; unpacked UNSUPPORTED
Stringsstrings.sv/.cprototypes, static vs ring, strdupall pass; returned string copied at once
Directionsoutdir.sv/.cunwritten elementsout=0,7,0 io=5,7,5
Exercisesex/*ex.log, bug_loop.log, bug_str.buildE1–E12 as quoted; bug_loop rc 139

Things worth opening by hand: build/vl_strings/Vtop__Dpi.h (the string prototypes), build/k_dyn.build (the unsupported message), and build/bug_str.build (clang's warning, buried in the make output).

§5 · Cheat sheetOne table, ten rules

SV formalC parameterAccessPitfall
input int v[8]const int*v[k], k = 0..7size is a contract, not checked
output bit [31:0] d[20]svBitVecVal*d[k]write every element
output logic [31:0] d[20]svLogicVecVal*d[k].aval/.bvalforgetting bval hides on Verilator
input byte b[4]const char*b[k]signed: cast to unsigned char
inout int a[][]svOpenArrayHandlesvGetArrElemPtr2(h,i,j), SV indices0-based loop → NULL
input bit [N-1:0] w[]svOpenArrayHandlesvGetBitArrElem1VecVal(e,h,i)raw pointer wrong if N ≤ 32 (Verilator)
inout struct packed …svBitVecVal*svGet/PutPartselBit, shiftsC struct overlay: endian, word straddle
input string sconst char*read during callcopy to keep
function string fconst char* f(…)return static/heapnever a local array
output string sconst char***s = ptrSV copies, never frees
inout string sconst char**read *s, assign new ptrno in-place writes
  1. SV owns all argument storage; C borrows it until return.
  2. Read the prototype from V<top>__Dpi.h, and #include it.
  3. output = C writes everything; otherwise inout.
  4. Open-array loops run svLow…<= svHigh on SV indices.
  5. svIncrement is not a loop step.
  6. Memory order of unpacked arrays is the simulator's business: use svGetArrElemPtr* unless you checked svGetArrayPtr.
  7. Packed elements: canonical copies (…VecVal), not raw pointers.
  8. Packed structs: bit positions, first member = MSBs.
  9. Verilator: no dynamic arrays, queues, slices or unpacked structs as DPI arguments; no 4-state.
  10. Strings: copy what comes in, return what stays alive.