Contents

DPI-C from scratch · Part A · Step 02

Data types: what C actually receives

Every DPI argument has two declarations, one in SystemVerilog and one in C, and no tool compares them for you unless you ask. This step maps every type that can cross the boundary, measures what Verilator really passes, and shows where the bits you did not think about end up: unused upper bits, sign bits, X and Z, and outputs nobody wrote.

Book: Spear & Tumbush 3rd ed., §12.1.3–12.1.4, §12.2.3–12.2.5 (pp. 417–427; PDF page = book page + 36)Normative: IEEE 1800-2023 §35.5, Annex H.7–H.10, Annex ISimulator: Verilator 5.052, macOS x86_64

§0 · TutorialThe step in one idea

The simulator and the C compiler each lay out data their own way, and DPI is the contract that says how a value of one is presented to the other. For small types the contract is plain C: an int is an int. For everything that has no C equivalent (vectors of arbitrary width, 4-state values, strings owned by the simulator) the contract is a canonical representation defined in IEEE 1800 Annex H and the header svdpi.h (Annex I). Learn the canonical forms once and you can pass anything a hardware testbench needs.

Three questions run through the whole step. What bits does C see? (layout, word order, unused bits, signedness). What bits does SV get back? (masking, 4-state planes, outputs C never wrote). Who guarantees it? The LRM, or just the simulator you happen to run. The difference matters: a model that works on Verilator because Verilator zeroes and masks unused bits can break on a commercial simulator that does neither, and the LRM allows both behaviors.

Every number below comes from the lab: one testbench per topic, a check.py with 72 assertions. Where the book (2012, IEEE 1800-2009, VCS-era) is wrong or dated, a Book erratum callout says so. Step 01 covered the build, linkage and the generated Vtop__Dpi.h header; this step assumes them.

0.1Three ways across the boundary

Annex H sorts every argument into one of three passing modes, and once you know the mode the C type follows mechanically (Fig. 0.1).

SYSTEMVERILOGC SEES input int asmall type int aa copy (register / stack) by value input bit [39:0] vpacked vector const svBitVecVal* v→ uint32_t[2] by const pointer output int oany output / inout temp int* opoints at the temp writes copied back (and masked) after C returns
Fig. 0.1The three passing modes of Annex H: by value (H.8.3), by reference (H.8.4), by handle (H.8.6). The mode, not the SV type alone, picks the C type: int is int as an input and int* as an output; bit [39:0] is always a pointer. Section 0.9 opens the "temp" box.

The full table, with what Verilator 5.052 generates in Vtop__Dpi.h (every row is in the lab's types or proto experiments):

SystemVerilogC, inputC, output/inoutNotes (measured)
bytecharchar*plain char: signedness is the C compiler's choice (0.2)
byte unsignedunsigned charunsigned char*
shortint [unsigned][unsigned] short[unsigned] short*
int [unsigned][unsigned] int[unsigned] int*
longint [unsigned][unsigned] long long[unsigned] long long*book says long int*: wrong on Windows
realdoubledouble*
shortrealfloat (LRM)float*Verilator: double (0.3)
bitsvBitsvBit*uint8_t; output masked to bit 0
logic, regsvLogicsvLogic*uint8_t, encoding 0/1/2(Z)/3(X)
bit [N:0]const svBitVecVal*svBitVecVal*also bit [0:0]
logic [N:0], reg [N:0], integer, timeconst svLogicVecVal*svLogicVecVal*also logic [0:0]
stringconst char*const char**Verilator: const char** for output too
chandlevoid*void**book says const void* for input
enumsame as its base typeenum byte → char
open array []const svOpenArrayHandlesvOpenArrayHandlestep 04

Table 12.1 of the book maps longint outputs to long int*. The LRM says long long. On LP64 Unix both are 64 bits, so the bug is invisible on Linux and macOS; on Windows (LLP64) long is 32 bits and the upper half of every longint output is lost. The table also omits the unsigned variants and integer/time, which are 4-state packed arrays and travel as svLogicVecVal*, not as C integers.

Do not write this table from memory. Let the simulator write it: Verilator's Vtop__Dpi.h, VCS's vc_hdrs.h, Questa's -dpiheader file and Xcelium's -dpiheader option all emit the exact prototypes. #include that header in your C file and the C++ compiler checks every definition against the SV declaration (step 01, 0.6). Section 0.3 shows that check catching a real bug.

0.2The integer types and the char trap

The C-compatible integer types are the boring, safe part: the round trips in the lab hit every extreme value and come back intact.

LOG sizeof char=1 short=2 int=4 longlong=8 float=4 double=8 svBit=1 svLogic=1 svBitVecVal=4 svLogicVecVal=8 ptr=8 char_signed=1
LOG byte min=-128 max=127
LOG short min=-32768 max=32767
LOG int min=-2147483648 max=2147483647
LOG long min=-9223372036854775808 max=9223372036854775807
LOG ulong max=18446744073709551615

The one trap is byte. The LRM maps it to plain char, and C leaves the signedness of plain char to the implementation: signed on x86 and on Apple ARM, unsigned on ARM Linux (and on PowerPC). The lab rebuilds the same code with -funsigned-char to emulate an ARM Linux host:

default (x86):     LOG c_byte v=-128     LOG c_enum v=-2
-funsigned-char:   LOG c_byte v=128      LOG c_enum v=254
both:              LOG byte min=-128 max=127      LOG enum v=BUSY

Read the last line carefully: the round trip still works, because the bit pattern 0x80 goes to C and comes back unchanged. What breaks is anything C computes with the value: a comparison b < 0, a widening to int, an index. That is the worst kind of bug, one that passes a loopback test and fails in the model (Exercise 9).

/* byte arrives as plain char: never compute with it directly */
int c_isneg(char b) { return (signed char)b < 0; }   /* or: int v = (signed char)b; */

Enumerations cross as their base type (typedef enum byte {...} → char), so C sees the encoding, not the name. If the C model needs the names, mirror the enum in a C header and keep the two in sync, or pass an int and convert at the boundary.

0.3real and shortreal

real is an IEEE-754 double on both sides and needs no comment: rt_real(0.1) returns 0.10000000000000001, the nearest double to 0.1. shortreal is supposed to be a C float. Verilator does not implement single precision at all: it warns SHORTREAL: Unsupported: shortreal being promoted to real and generates double in the prototype. A C file written to the LRM (float rt_sreal(float v)) still links, because the symbol name is all the linker checks, and then reads the wrong register:

C declared float :   LOG c_sreal v=-1.5881868392106856e-23     LOG sreal v=1.2731974747767896e-314
C declared double:   LOG c_sreal v=0.10000000000000001         LOG sreal v=0.10000000000000001

On x86-64 both float and double arguments travel in xmm0; the callee reads the low 32 bits of a double as a float and gets noise, and the caller reads a float's bits as a double and gets a denormal. Nothing crashes. Including the generated header turns this into a compile error (lab build types_hdr):

../../types.c:37:9: error: functions that differ only in their return type cannot be overloaded

Newer clang (16+) words the same error as error: conflicting types for 'rt_sreal'.

Note also the second line of the "double" run: the value is exactly the double 0.1, not the float 0.1 (0.100000001490116…). On Verilator a shortreal model never sees single-precision rounding, so a bit-exact float golden model cannot be checked through shortreal here.

Portable rule: do not use shortreal in DPI signatures. If the model is single precision, pass real (double) or the raw 32-bit pattern as int unsigned and convert with memcpy in C and $bitstoshortreal in SV.

0.4bit and logic scalars

A single-bit argument travels as a byte: svBit and svLogic are both uint8_t in svdpi.h. A 2-state bit uses values 0 and 1. A 4-state logic packs the two planes described in 0.8 into bits 0 and 1 of the byte, which gives the four constants of svdpi.h (Fig. 0.2).

svLogic = uint8_t unused (bits 7..2) bval aval bit 1bit 0 sv_0 = 0sv_1 = 1sv_z = 2sv_x = 3 b=0 a=0b=0 a=1b=1 a=0b=1 a=1 "is X or Z" test: (v & 2) != 0
Fig. 0.2The scalar 4-state encoding (Table 12.2 of the book, Annex H.10.1.1). bval set means "not a plain 0/1": Z when aval is 0, X when it is 1.

Two measurements matter here. First, Verilator is a 2-state simulator: a logic that holds X or Z in the source is stored as 0, so C receives sv_0 for X, Z and 0 alike (LOG logic v0=0 v1=1 vx=0 vz=0). A literal Z argument does not even build: rt_logic(1'bz) stops Verilator with Unsupported tristate construct: ARG; the lab keeps the X and Z in variables to get past that. Second, C can write any byte into a scalar output. The book warns that this can corrupt the SV variable. Verilator masks it: the wrapper does b = (1U & b__Vcvt), so C writing 0xFE gives SV a clean 0 (LOG badbit b=0 l=0). The LRM does not require the mask, so write only sv_0…sv_x.

0.5Packed vectors: the canonical representation

A packed bit vector of any width crosses as an array of 32-bit chunks, svBitVecVal (= uint32_t). The layout is fixed by the LRM (H.7.7), independent of the simulator's internal storage:

bit [39:0] v = 40'h12_3456_789A → const svBitVecVal* v (SV_PACKED_DATA_NELEMS(40) = 2) v[0] 0x3456789A 310 v[1] unused: undetermined (LRM) 0x12 634032 Verilator input: unused bits = 0 (measured: w1=00000012). Do not rely on it.
Fig. 0.3Canonical layout of a 40-bit 2-state value (Fig. 12.1 of the book). Measured in the lab: LOG v40 w0=3456789a w1=00000012. A 200-bit value takes 7 chunks with 8 used bits in the last one: w6=000000c7 for a value whose top byte is C7.

The C side always works with three numbers: the width $W$, the chunk count $N = \lceil W/32 \rceil$, and the used bits of the last chunk $W \bmod 32$. svdpi.h gives you SV_PACKED_DATA_NELEMS(W) and SV_MASK(n) ($2^n-1$ for $n < 32$):

/* 200-bit add (lab vec.c): ripple the carry through 7 chunks, then clean the top chunk */
void c_add200(const svBitVecVal* a, const svBitVecVal* b, svBitVecVal* sum) {
  unsigned long long c = 0;
  for (int k = 0; k < 7; k++) { c += (unsigned long long)a[k] + b[k]; sum[k] = (svBitVecVal)c; c >>= 32; }
  sum[6] &= SV_MASK(200 - 6 * 32);          /* 8 used bits: never leave junk above them */
}

What about the unused bits? The book (§12.2.3) warns that "the simulator may not mask the upper bits after calling a DPI routine", so junk that C leaves there can corrupt the SV variable. The lab asks Verilator directly: C writes 0xFFFFFFFF into both chunks of a bit [39:0] output.

LOG dirty d=ffffffffff eq=1 plus1=0000000000

Verilator masks: the variable is the 40-bit all-ones value, it compares equal to 40'hFF_FFFF_FFFF, and adding 1 wraps to 0. The generated wrapper copies back with VL_SET_Q_SVBV(40, v, v__Vcvt + 0), which keeps exactly 40 bits. Commercial simulators that pass a pointer straight into their own storage (to avoid the copy) may not.

Rule of thumb for the unused bits: on input, mask them before you use them; on output, write them as zero. Both are one line (& SV_MASK(W % 32)), both are free, and together they make the code correct under every simulator the LRM allows. Verilator's zeroing and masking hide the bug, which is exactly why you should not test portability on Verilator alone.

Which representation is this? svDpiVersion() (H.10.1.3) returns a string naming the DPI C layer the simulator implements: "1800-2005" means the canonical svBitVecVal/svLogicVecVal chunks described here; "SV3.1a" would mean the older opaque-handle representation of H.14 that the deprecated "DPI" string still selects. Verilator 5.052 returns 1800-2005 (lab: ver.sv). A C library shipped to several teams can check it once at start-up instead of trusting the build.

0.6Signed vectors and sign extension

Declaring a vector signed changes how SV interprets it, not how DPI lays it out. The canonical representation carries the $W$ bits and nothing else; the sign bit is just bit $W-1$, sitting in the middle of the last chunk. Nobody extends it into the unused bits for you:

c_dump_s40(-40'sd2)   →   LOG s40 w0=fffffffe w1=000000ff

Bits [63:40] of chunk 1 are zero, so (long long)v[1] << 32 | v[0] gives +1 099 511 627 774, not −2 (Exercise 6). The correct conversion masks to $W$ bits, then moves bit $W-1$ to bit 63 and shifts back arithmetically:

long long c_s40(const svBitVecVal* v) {               /* bit signed [39:0] → long long */
  unsigned long long u = ((unsigned long long)(v[1] & SV_MASK(8)) << 32) | v[0];
  return (long long)(u << 24) >> 24;                   /* 64 - 40 = 24 */
}

svdpi.h offers SV_GET_SIGNED_BITS(VALUE, N) for this, and here is a surprise: the macro, copied verbatim from Annex I into every vendor's header, tests the wrong bit.

#define SV_GET_SIGNED_BITS(VALUE, N) \
    ((N) == 32 ? (VALUE) : \
    (((VALUE) & (1 << (N))) ? ((VALUE) | ~SV_MASK(N)) : ((VALUE) & SV_MASK(N))))
                       /* ^^^^^^^^ bit N is the first UNUSED bit; the sign bit is N-1 */
LOG sign8_c raw=00000080 macro=128 right=-128
LOG sign8_c raw=000000ff macro=255 right=-1

For an 8-bit -128 the macro returns +128. It only "works" if the unused bits happen to be sign-extended already, which no simulator promises. Write the two-line version yourself.

SV_GET_SIGNED_BITS in svdpi.h (Verilator 5.052's copy, and the Annex I text it reproduces) sign-extends from bit N instead of bit N−1. Measured: SV_GET_SIGNED_BITS(0x80, 8) = 128. Do not use it.

0.7Wide vectors and the select helpers

Nothing changes past 64 bits: a 200-bit value is 7 chunks and C loops over them, as the 200-bit adder in 0.5 does (the lab checks s == a + b in SV). For field access across chunk boundaries svdpi.h provides bit- and part-select helpers that work on the canonical form, so you do not hand-code the word/offset arithmetic:

FunctionDoes
svBit svGetBitselBit(const svBitVecVal* s, int i)returns bit i
void svPutBitselBit(svBitVecVal* d, int i, svBit s)writes bit i
void svGetPartselBit(svBitVecVal* d, const svBitVecVal* s, int i, int w)d[w-1:0] = s[i+w-1:i], w ≤ 32
void svPutPartselBit(svBitVecVal* d, const svBitVecVal s, int i, int w)d[i+w-1:i] = s[w-1:0], w ≤ 32; other bits of d untouched
svGetBitselLogic / svPutBitselLogic / svGetPartselLogic / svPutPartselLogicsame on svLogicVecVal (both planes)

The index is the bit position in the value (bit 0 = LSB), not in the declared range. The last column of svPutPartselBit is the catch. The lab extracts a 16-bit field at bit 100 into an output and inserts 0xBEEF at bit 70 of a 200-bit output:

LOG partsel_in w0=00003039 w2=00000000 w6=00000000      ← C prints the output BEFORE writing it
LOG partsel f=dead w_ok=0 w=00000000000000000000000000002fbbc00000000000003039

The field landed correctly (0x2fbbc at hex digit 17 is 0xBEEF << 70), but the low chunk still holds 0x3039 = 12345, which is the b operand of the previous call (c_add200). The output buffer arrived with stale stack contents, svPutPartselBit touched only 16 bits, and the rest went back into SV. Section 0.9 explains why; Exercise 7 fixes it with one memset.

output bit [199:0] w → svBitVecVal w[7] w6 w5 w4 w3 w2 w1 w0 199..19295..6431..0 svPutPartselBit(w, 0xBEEF, 70, 16) → bits [85:70] = w2[21:6] stale 0x3039 never written by C
Fig. 0.4Part-select into a 200-bit output. The helper is correct; the bug is that the other 184 bits of an output are whatever the temporary held. Measured: w_ok=0, low chunk 00003039.

0.84-state vectors: aval and bval

A logic/reg vector needs two bits per bit of value. The canonical form keeps them as two parallel planes, chunk by chunk: svLogicVecVal is struct { uint32_t aval; uint32_t bval; } (identical to VPI's s_vpi_vecval), and a $W$-bit value is an array of $\lceil W/32 \rceil$ of them (Fig. 0.5). The per-bit encoding is the one in Fig. 0.2: (bval, aval) = 00 → 0, 01 → 1, 10 → Z, 11 → X.

logic [39:0] v → const svLogicVecVal* v (2 structs of {aval, bval}) v[0].avalaval [31:0] v[0].bvalbval [31:0] v[1].avalunusedaval [39:32] v[1].bvalunusedbval [39:32] bit k is X or Z ⇔ bval bit k = 1 · "no X/Z anywhere" ⇔ every bval (masked) = 0
Fig. 0.5Canonical layout of a 40-bit 4-state value (Fig. 12.2 of the book). Integer types that are 4-state by definition (integer = logic signed [31:0], time = logic [63:0]) use the same form.

Two shapes look alike and are not. input logic a is a scalar: one svLogic byte by value. input logic [0:0] b is a one-bit vector: const svLogicVecVal*. The lab's prototype dump confirms both (Spear's warning on p. 425 holds). Mixing them up compiles if you skip the generated header and reads garbage.

In Verilator the bval plane is always zero, because the simulator stores no X or Z to put in it. The lab passes 40'h12_3456_78zx and C reads:

LOG ldump w0.aval=34567800 w0.bval=00000000 w1.aval=00000012 w1.bval=00000000

The z and x nibbles arrive as 0 with no flag. The reverse direction loses information too: C writes aval=0x55, bval=0x33, which in a 4-state simulator is 8'b01zx_01zx; Verilator keeps only aval and SV prints 01010101.

source4'bzx01 Verilator storage4'b0001 C seesa=0001 b=0000 X,Z → 0 C writes bval=1 → dropped, only aval returns
Fig. 0.64-state information in a 2-state simulator. All the aval/bval code you write is still correct and still needed (it is the only portable form for logic), but Verilator cannot exercise its X/Z branches. Test them with a C unit test (Exercise 11) or on a 4-state simulator.

Converting a model from 2-state to 4-state (§12.2.5) is mechanical: bit→logic and int→integer in the SV declarations; svBitVecVal→svLogicVecVal in C; read .aval where you read the word before; check .bval on inputs; write .bval = 0 on outputs unless you mean to drive X/Z.

0.9Directions: who owns which bytes

The LRM's rules for input, output and inout (§35.5.1.2 and §35.6.1 on the SV side, H.8.7–H.8.8 on the C side) are short:

The generated code shows how Verilator implements this. For import "DPI-C" function void c_skip_out(output int o, output bit [39:0] v) the wrapper is:

void Vtop___024unit____Vdpiimwrap_c_skip_out_TOP____024unit(IData &o, QData &v) {
    o = 0U;
    v = 0ULL;
    int o__Vcvt;                         // uninitialized local
    svBitVecVal v__Vcvt[2];              // uninitialized local
    c_skip_out(&o__Vcvt, v__Vcvt);       // C gets pointers to the locals
    o = (o__Vcvt);                       // copy back, whatever is there
    VL_SET_Q_SVBV(40, v, v__Vcvt + 0);   // copy back, masked to 40 bits
}
SV variable o77 stack temp o__Vcvtjunk (stale) C functionint* o points to not copied in after return: o = o__Vcvt (masked to the width) C wrote it → your value · C did not → the junk overwrites 77
Fig. 0.7An output in Verilator. The SV value never reaches C, and the copy back is unconditional. inout is the same picture plus a copy in (io__Vcvt = io;) before the call.

The lab measures every consequence:

ExperimentMeasured (one run; junk values vary)Meaning
C reads *o before writing; SV had o = 77c_peek in=56579259C sees stack junk, never the SV value (number varies per run)
C never writes its outputsskip o=32759 v=1234567890SV variables are overwritten with junk: the old value is not kept
… where did 1234567890 come from?c_scratch(40'h12_3456_7890)the previous call's 40-bit temp, same stack slot
inout int io = 10, C does *io += 1inout io=11copy in, copy out
c_alias(x, x): same variable as input and outputi_after_write=5, x=100the input is a copy; writing the output does not change it

"If my C function does not write the output, the SV variable keeps its old value" is false. On Verilator it gets stack junk, sometimes a plausible-looking leftover from another call (the 1234567890 above, or 0x3039 in 0.7). Every return path of a C function must write every output, including error paths. The book's own Sample 12.12 gets this wrong (0.12).

0.10string and chandle, briefly

A string input arrives as const char* to a NUL-terminated copy owned by the simulator, valid only during the call: c_strlen("hello, DPI") returns 10 and c_strlen("") returns 0 (never NULL). If C needs the text later, it copies it (strdup). Strings going out (output arguments and string results) raise the question of who owns the memory, which is step 04's topic.

A chandle is an opaque C pointer that SV can store, copy, compare with null and hand back, but never dereference. It is how C objects get an identity in SV:

chandle h = c_new_box(42);         // C: void* c_new_box(int v) { int* p = malloc(...); *p = v; return p; }
$display(c_box_get(h));            // 42
$display(h == null, c_is_null(null));   // 0 1 : SV null arrives in C as NULL

Lifetime, per-instance state and C++ objects behind a chandle are step 03.

0.11What a function may return

A function result travels in a register, so §35.5.5 (repeated in H.8.9) limits it to "small values": void, byte, shortint, int, longint, real, shortreal, chandle, string, and scalar bit and logic. That is the whole list: a packed vector, even bit [6:0], is not on it and goes through an output argument. Verilator 5.052 is more permissive than the LRM: it also accepts packed bit results up to 32 bits (returned as an svBitVecVal), which is an extension that will not port. Its messages for the rest:

Result typeVerilator 5.052
bit [31:0], bit signed [15:0], bit [6:0]svBitVecVal f();
bit [32:0]%Error: DPI function may not return a > 32 bits wide type other than basic types.
logic [6:0], integer, time%Error: DPI function may not return a 4-state type other than a single 'logic' (IEEE 1800-2023 35.5.5)
logicsvLogic f();
string, chandleconst char* f();   void* f();

§12.1.4 says a function "cannot return a vector such as bit [6:0]", and the book is right: §35.5.5 and H.8.9 allow only scalar bit/logic results. Verilator accepts the vector anyway (the lab's rt_bit7 returns 100 through an svBitVecVal result), so code that relies on it compiles here and fails on a strict tool. Return an int or use an output bit [6:0]. The confusion has a source: the LRM's own example in §35.5.4, import "DPI-C" function bit [15:0] getStimulus();, contradicts its normative list (see the LRM errata).

0.12Sample 12.12 on a 2-state simulator

Samples 12.11/12.12 turn the 7-bit counter (Sample 12.9) into a 4-state model that rejects X/Z on reset, load and i. The lab ports it (io_printf → printf; the chandle as void*) and drives exactly the cases the checks exist for: an uninitialized reset (X in a 4-state simulator) and a load value 7'b000_zzxx.

LOG c4 step=xreset out=1      ← reset X seen as 0: the counter counted
LOG c4 step=xload out=0       ← zzxx seen as 0000: loaded 0
LOG c4 step=reset out=0       (no "c4err" line at all)

On Verilator none of the three error branches can fire, so the model silently does something; on Questa or Xcelium the same testbench prints two errors. Reading the sample with 0.5 and 0.9 in mind also reveals two latent bugs (the subject of Exercise 12):

  1. if (i->bval) tests all 32 bits of the chunk, including 25 undetermined ones. On a simulator that leaves junk there, a clean input raises an error.
  2. Every error branch does return without writing count. The SV variable then receives an undetermined value (0.9). The fix is to drive X explicitly: count->aval = count->bval = SV_MASK(7).

The book also mentions vpi_control(vpiFinish, 0) to stop the simulation from C. In practice, return a status and let SV call $fatal, or use an exported SV function (step 05).

0.13Commercial simulators (not measured here)

The LRM permits behaviors Verilator never shows, so code tested only here can still break. Expect on Questa/VCS/Xcelium: real X and Z in bval (your 4-state branches finally run); shortreal as a true float; undetermined unused bits in input chunks unless the tool documents otherwise; outputs that may not be masked on return. Their header generators (vc_hdrs.h, -dpiheader) are the equivalent of Vtop__Dpi.h: always include them. And they reject ref in imports, which Verilator lets through.

0.14Rules that keep you safe

  1. Include the simulator-generated prototype header in every DPI C file.
  2. Prefer fixed-size C types for computation (int32_t, uint64_t); cast byte's char to signed char before using it.
  3. Avoid shortreal in signatures.
  4. Packed inputs: mask the last chunk with SV_MASK(W % 32) before using it; sign-extend by hand from bit $W-1$.
  5. Packed outputs: write every chunk; write the unused bits as zero.
  6. Outputs: write every output on every return path; never read one before writing it.
  7. 4-state: check bval on inputs, write bval = 0 on outputs; test the X/Z paths outside Verilator.
  8. Results wider than 32 bits, or 4-state, go through output arguments.

§1 · ObjectivesWhat you can do after this step

Prerequisite: step 01 (build, extern "C", the generated header). Next: step 03 (C state and chandle).

§2 · Exercises12 exercises, graded, with solutions

★ basic · ★★ intermediate · ★★★ real-design. Every code solution is in lab/ex/, built by run.sh and checked by check.py. "Ex. 12.N" is an exercise from the book (which has no solutions).

E1 ★Write the C prototype for this import, without looking at a generated header. Then check it against build/ex_protos.h.

import "DPI-C" function void e1(input byte unsigned a, output longint b, input bit [63:0] c,
                                output logic [3:0] d, input string e, inout bit f, output chandle g);
Show solution
void e1(unsigned char a, long long* b, const svBitVecVal* c, svLogicVecVal* d,
        const char* e, svBit* f, void** g);

That is exactly what Verilator generates (asserted by check.py). Points people miss: bit [63:0] is a pointer to two chunks, not a uint64_t, even though it would fit; logic [3:0] is svLogicVecVal* even for 4 bits; an inout bit is svBit*; an output chandle is void**.

E2 ★Ex. 12.1. Write shift_c(i, n): i is a 32-bit unsigned input, n an int; positive n shifts left, negative shifts right, 0 does nothing. Test every case from SV, including n = ±32 and ±40, with SV's own shift as the reference.

Show solution
import "DPI-C" function int unsigned shift_c(input int unsigned i, input int n);
function automatic int unsigned shift_sv(int unsigned i, int n);   // reference
  return n >= 0 ? i << n : i >> -n;
endfunction
unsigned int shift_c(unsigned int i, int n) {
  if (n >= 32 || n <= -32) return 0;        /* SV semantics; in C this would be UB */
  return n >= 0 ? i << n : i >> -n;
}

The type mapping is trivial; the trap is C. Shifting a 32-bit value by 32 or more is undefined behavior in C, and x86 masks the count to 5 bits. The lab's shift_naive_c (no guard) returns 800000f1 for n=32 (unchanged) and 0000f100 for n=40 (shifted by 8), where SV gives 0. A golden model that inherits C's shift semantics will disagree with the RTL exactly at the boundary cases. Lab: ex/shift.sv, 11 values of n, PASS.

E3 ★Ex. 12.2. Add a load flag: shift_ld_c(i, n, ld). When ld is 1, i shifted by n is loaded into an internal 32-bit register; when 0, the register itself is shifted. Return the register. What is the type of ld on each side?

Show solution
unsigned int shift_ld_c(unsigned int i, int n, svBit ld) {
  static unsigned int reg;                  /* one register for the whole simulation */
  reg = shift_c(ld ? i : reg, n);
  return reg;
}

input bit ld ↔ svBit ld (a uint8_t by value). The lab loads 0xFF shifted left by 4, shifts right by 8, shifts by 40, and checks each step against an SV model. The static register is a design smell: two SV instances would share it. Step 03 fixes that with a chandle (Ex. 12.3–12.6).

E4 ★Predict, on Verilator: (a) C writes *o = 0x1FF into output bit [7:0] o; (b) C writes *b = 2 into output bit b. What does SV hold? What does the LRM guarantee?

Show solution

(a) 8'hFF: Verilator copies the chunk back masked to 8 bits (the 40-bit version is measured in the lab: dirty d=ffffffffff plus1=0000000000). (b) 0: scalars are copied back as 1U & value, and 2 has bit 0 clear (measured with 0xFE: badbit b=0). The LRM guarantees neither: writing bits outside the declared width, or a scalar value other than 0/1 (2-state) or 0–3 (4-state), is the C code's bug, and another simulator may store the junk. Correct C writes clean values.

E5 ★★Write int c_popcount200(input bit [199:0] v) in C. Make it correct on a simulator that leaves junk in unused bits. Test against $countones on 1000 random values plus all-zeros and all-ones.

Show solution
int c_popcount200(const svBitVecVal* v) {
  const int W = 200, N = SV_PACKED_DATA_NELEMS(W);          /* 7 chunks */
  int n = 0;
  for (int k = 0; k < N; k++) {
    svBitVecVal w = (k == N - 1 && W % 32) ? v[k] & SV_MASK(W % 32) : v[k];
    n += __builtin_popcount(w);
  }
  return n;
}

Without the mask the function passes on Verilator (unused bits are 0 there) and can over-count elsewhere by up to 24. Lab: ex_popcount all_ones=200 errs=0.

E6 ★★Find the bug. The function should return a bit signed [39:0] as a long long. What does it return for −2? Fix it.

long long c_s40_bad(const svBitVecVal* v) { return (long long)((unsigned long long)v[1] << 32 | v[0]); }
Show solution

It returns 1 099 511 627 774 (0xFF_FFFF_FFFE): the sign bit is bit 39, and chunk 1 carries only bits [39:32] with nothing above them (measured: s40 w1=000000ff). Fix: mask, then shift the sign bit to bit 63 and back arithmetically.

long long c_s40(const svBitVecVal* v) {
  unsigned long long u = ((unsigned long long)(v[1] & SV_MASK(8)) << 32) | v[0];
  return (long long)(u << 24) >> 24;
}

The lab checks 6 values including the extremes 40'sh7F_FFFF_FFFF and -40'sh80_0000_0000: bad_m2=1099511627774 good_m2=-2 errs=0. (Right-shifting a negative signed value is implementation-defined before C++20/C23 but arithmetic on every compiler you will use.)

E7 ★★Find the bug and fix it. The SV side checks w == (200'hBEEF << 70) and fails.

void c_put16(svBitVecVal* w, const svBitVecVal* f, int lsb) {   /* output bit [199:0] w */
  svPutPartselBit(w, *f, lsb, 16);
}
Show solution

w is an output: its initial contents are undetermined, and svPutPartselBit writes only bits [85:70]. The other 184 bits go back to SV as whatever the stack held (in the lab, the previous call's operand). Clear the whole output first:

  memset(w, 0, SV_PACKED_DATA_NELEMS(200) * sizeof(svBitVecVal));
  svPutPartselBit(w, *f, lsb, 16);

The lab's test calls a function with an all-ones 200-bit input first, to leave junk in the stack slot; with the memset: ex_partsel ok=1. If the SV side really wanted "insert a field into an existing value", the argument should have been inout.

E8 ★★A colleague's model uses import "DPI-C" function shortreal gain(input shortreal x); and float gain(float x) { return 2.0f * x; }. It links and runs on Verilator. Predict roughly what gain(1.5) prints, explain why, and give two fixes.

Show solution

Garbage, not 3.0. Verilator promotes shortreal to real and calls double gain(double); the C function reads the low 32 bits of the double in xmm0 as a float, and its float result is read back as a double, typically a tiny denormal (the lab's identity function gives 1.27e-314 for 0.1). The linker only matches names, so nothing complains. Fixes: (1) use real/double on both sides; (2) #include "Vtop__Dpi.h", which turns the mismatch into the compile error functions that differ only in their return type cannot be overloaded (lab build types_hdr). If single-precision rounding matters for the model, pass the IEEE bit pattern as int unsigned.

E9 ★★This passes all tests on your Mac and on the x86 farm. On the new ARM Linux farm, the scoreboard reports that negative samples are never detected. Why, and what is the fix?

int c_isneg(char b) { return b < 0; }     /* import: function int c_isneg(input byte b) */
Show solution

The LRM maps byte to plain char, and plain char is unsigned on ARM Linux: b < 0 is always false. Fix: return (signed char)b < 0;. The lab builds the same file twice; with -funsigned-char the bad version returns 0 for −5 (bad_m5=0) while the fixed one passes all 256 values in both builds.

E10 ★★★Write c_mul128(input longint unsigned a, b, output bit [127:0] p): the full 128-bit product, without __int128. Check against SV's 128'(a) * 128'(b) on 2000 random pairs and on the maximum.

Show solution
void c_mul128(unsigned long long a, unsigned long long b, svBitVecVal* p) {
  unsigned int x[2] = {(unsigned int)a, (unsigned int)(a >> 32)};
  unsigned int y[2] = {(unsigned int)b, (unsigned int)(b >> 32)};
  unsigned long long acc[4] = {0, 0, 0, 0};
  for (int i = 0; i < 2; i++) {                 /* schoolbook, 32-bit limbs */
    unsigned long long carry = 0;
    for (int j = 0; j < 2; j++) {
      unsigned long long t = (unsigned long long)x[i] * y[j] + acc[i + j] + carry;
      acc[i + j] = t & 0xFFFFFFFFu;
      carry = t >> 32;
    }
    acc[i + 2] += carry;
  }
  for (int k = 0; k < 4; k++) p[k] = (svBitVecVal)acc[k];   /* all 4 chunks written */
}

The limb width equals the chunk width, so the result maps 1:1 onto p[0..3]. Each partial sum fits in 64 bits: $(2^{32}-1)^2 + 2(2^{32}-1) = 2^{64}-1$. Lab: max=fffffffffffffffe0000000000000001 errs=0, which is $(2^{64}-1)^2 = 2^{128} - 2^{65} + 1$.

E11 ★★★Write int count_xz(const svLogicVecVal* v, int width): how many bits of a 4-state vector are X or Z. What does it return under Verilator for 40'hzz_xxxx_0001? How do you test it at all?

Show solution
int count_xz(const svLogicVecVal* v, int width) {
  int n = 0;
  for (int k = 0; k < SV_PACKED_DATA_NELEMS(width); k++) {
    unsigned int b = v[k].bval;                                   /* X or Z ⇔ bval = 1 */
    if (k == SV_PACKED_DATA_NELEMS(width) - 1 && width % 32) b &= SV_MASK(width % 32);
    n += __builtin_popcount(b);
  }
  return n;
}

Under Verilator it returns 0 (ex_xz verilator=0): the X and Z became 0 before DPI saw them. To test the logic, build the same file as a plain C program (-DUNIT) with hand-made svLogicVecVal values, including junk in the unused bits: ex_xz unit=24 (8 Z + 16 X bits). This is the general technique for 4-state C code on a 2-state simulator.

E12 ★★★Review Sample 12.12 (the 4-state counter7, ported in lab/counter4.c). Find two latent bugs that a 4-state simulator would expose, and fix them. Why does the lab's run on Verilator not show either?

Show solution

(1) if (i->bval) tests the whole chunk; 25 of its bits are undetermined, so a clean input can raise a false error. Use i->bval & SV_MASK(7). (2) The error branches return without writing count, so SV receives an undetermined value (0.9). Drive X instead, so the testbench sees the problem:

  if ((reset & 2) || (load & 2) || (i->bval & SV_MASK(7))) {
    printf("Z or X detected on an input\n");
    count->aval = count->bval = SV_MASK(7);      /* 7'bx */
    return;
  }

On Verilator the unused bits are zero (bug 1 hidden) and no input is ever X/Z, so the error branches never run (bug 2 hidden): counter4.log and ex_c4fix.log are identical, with no c4err line. Full fix: lab/ex/counter4_fix.c.

§3 · Quiz14 questions, self-grading

1. What does C receive for input bit [7:0] x?

Every packed vector, whatever its width, goes by const pointer to canonical chunks. unsigned char is what byte unsigned maps to; svBit is for a scalar bit.

2. A bit [39:0] holds 40'h12_3456_789A. What is in v[1] per the LRM?

Chunk 0 holds the LS 32 bits, chunk 1 the next ones from its bit 0. Verilator measures 00000012, but the LRM only guarantees the 8 used bits; (c) is the Verilator-specific answer.

3. C does printf("%d", *o) before writing output int o; SV set o = 77 before the call. What prints on Verilator?

Outputs are not copied in. Verilator passes a pointer to an uninitialized local (measured: c_peek in=56579259). It zeroes the SV variable before the call, not the temporary, so even 0 is not what C sees.

4. C returns early and never writes output int o. SV had o = 77. After the call, on Verilator, o is…

The copy back is unconditional (o = o__Vcvt;). Measured: skip o=32759. "Unchanged" is the tempting wrong answer; X is impossible in a 2-state simulator and not what the LRM says either (it says undetermined).

5. Which return type is legal for an imported function?

§35.5.5 lists only C-like types (byte…longint, real, shortreal, chandle, string) and scalar bit/logic; longint unsigned is the legal way to return 64 bits. (d) is the tempting one: Verilator 5.052 compiles it (an extension, returned as svBitVecVal), but no packed vector is a legal result, so it will not port. (a) and (b) are rejected even by Verilator.

6. input logic a and input logic [0:0] b arrive in C as…

A scalar is a byte by value; a one-bit vector is still a vector, so it goes as a pointer to an aval/bval pair (Spear p. 425, and the lab's prototype dump).

7. A logic scalar has the value Z in a 4-state simulator. What byte does C get?

sv_z = 2: bval (bit 1) = 1, aval (bit 0) = 0. X is 3. On Verilator it would be 0, because Z is stored as 0 before DPI sees it.

8. c_dump_s40(-40'sd2) on Verilator: what is v[1]?

Bits [39:32] of −2 are all ones; nothing sign-extends into bits [63:40] (measured w1=000000ff). 0xFFFFFFFE is v[0].

9. SV_GET_SIGNED_BITS(0x80, 8) from svdpi.h returns…

The macro tests bit N (8), an unused bit, instead of bit N−1 (7). Measured: macro=128 right=-128. It is in the Annex I header that every vendor ships.

10. Verilator, a shortreal argument, C declares float, no generated header included. What happens?

Verilator passes a double (with only a warning). With extern "C" the linker matches the name only, so the float/double mismatch is silent (measured 0.1 → −1.59e−23). No conversion happens because the caller and the callee never see each other's prototype.

11. On ARM Linux, c_byte(char v) receives SV's byte value −128. What does printf("%d", v) print, and what does SV get back if C returns v?

Plain char is unsigned there, so C computes with 128, but the bit pattern 0x80 travels back unchanged and SV's signed byte reads −128 (lab, -funsigned-char build). Loopback tests cannot catch this.

12. You call c_alias(x, x) with (input int i, output int o). C writes *o = 100, then reads i. It reads…

The input is passed by value (a copy taken at the call) and the output goes to a separate temporary, so they cannot alias. Measured: i_after_write=5, then x=100 after the copy back.

13. Which statement about ref in a DPI import is right?

Spear §12.1.3: "The direction ref is not supported." Verilator generates int* without a warning (lab a_ref), so code using it is not portable to commercial simulators.

14. Your C model reads a logic [6:0] input. Which checks make it correct on every LRM-compliant simulator? (two answers)

Both planes have 25 undetermined bits in the chunk. Testing bval != 0 (the book's code) can flag a clean input, and relying on Verilator's zeroing is exactly what breaks on other simulators.

§4 · LabMeasure the mapping yourself

Everything lives in 02_data_types/lab/. Run it:

cd tutorial/02_data_types/lab
python3 check.py          # runs run.sh (18 clean builds, 2 fail on purpose; ~10-60 s with ccache), then 72 assertions
python3 check.py --no-run # re-check existing build/ logs

Predict first, then open the log: what does C see for X and Z? What happens to an output C never writes? What does a float-declared function read when Verilator passes shortreal? What is in the upper bits of a negative bit signed [39:0]?

Experiment (files)What it showsKey result (measured)
types.sv/.cscalar mapping, extremes round trip, sizesall extremes intact; X,Z → 0
types_fixshortreal with double in C0.10000000000000001
types (float)shortreal with float in Cc_sreal −1.588e−23
types_hdrinclude Vtop__Dpi.hcompile error, as wanted
types_uchar-funsigned-char (ARM Linux)c_byte 128, enum 254
zlit.sv1'bz literal argumentUnsupported tristate construct
vec.sv/.clayout, signed, dirty output, 200-bit add, part-select, sign macro, 4-statew1=00000012; s40 w1=000000ff; dirty masked; macro=128
dir.sv/.coutput/inout/alias/scalar maskingjunk in, junk out; inout ok; input is a copy
counter4.sv/.cSamples 12.11/12.12no X/Z error ever fires
build/proto/*prototypes and illegal result typessee 0.11; ref accepted
ver.sv, ver.csvDpiVersion() (H.10.1.3)LOG ver dpi=1800-2005
ex/*exercise solutionsall PASS

Things to look at beyond the logs: build/vl_dir/Vtop___024unit__0.cpp (the generated wrappers of 0.9: the uninitialized temporaries and the masking copy-backs) and build/vl_types/Vtop__Dpi.h (the prototype table of 0.1, written by the tool). The junk values in dir.log (c_peek in=…, skip o=…) change from run to run; check.py asserts only that SV received exactly what C saw. One lab detail is also an SV lesson: $display(ok ? "PASS" : "FAIL") printed 1346458451, because a string literal inside an expression is an integral value ("PASS" = 0x50415353); the testbenches use an if.

§5 · Cheat sheetOne table, eight rules

SV declarationC prototypePitfall
input byte bchar bsignedness of char: cast to signed char
input longint xlong long xnot long (32 bits on Windows)
input shortreal ffloat f (LRM) / double (Verilator)silent garbage: avoid shortreal
input bit s / logic ssvBit s / svLogic slogic: 0,1,2=Z,3=X; Verilator only 0/1
input bit [W-1:0] vconst svBitVecVal* v⌈W/32⌉ chunks, LS first; mask last chunk
input bit signed [W-1:0] vconst svBitVecVal* vno sign extension; SV_GET_SIGNED_BITS is buggy
input logic [W-1:0] vconst svLogicVecVal* vcheck bval & mask; X/Z never reach C on Verilator
output T oT* ostarts as junk; write it on every path
output bit [W-1:0] osvBitVecVal* owrite all chunks; zero the unused bits
inout T ioT* iothe only direction where C may read first
input string sconst char* svalid during the call only; copy to keep
input chandle hvoid* hSV null = C NULL
function bit [≤31:0] f()svBitVecVal f()wider or 4-state results: use an output
ref int r(illegal)Verilator accepts it: not portable
  1. The simulator writes the prototypes: include its header, always.
  2. Three modes: small input by value, packed input by const pointer, every output/inout by pointer to a temp.
  3. $N = \lceil W/32 \rceil$ chunks, chunk 0 = bits [31:0], last chunk has $W \bmod 32$ used bits.
  4. Mask unused bits on read, zero them on write.
  5. Sign-extend by hand from bit $W-1$.
  6. Outputs are write-only and must be fully written on every return path.
  7. 4-state = aval/bval planes; bval 1 means X or Z. A 2-state simulator erases them before C runs.
  8. Verilator's kindness (zeroed inputs, masked outputs) is not the LRM's guarantee.

Spear & Tumbush, SystemVerilog for Verification, 3rd ed. (Springer, 2012), §12.1.3–12.1.4 and §12.2.3–12.2.5; IEEE Std 1800-2023 §35.5 and Annexes H and I. Lab: Verilator 5.052 on macOS x86_64 (Apple clang). All numbers on this page come from lab/ and are asserted by lab/check.py.