A C reference model behind DPI

Put a reference model written in C behind the scoreboard, keep its state per instance on the C side, and diagnose a mismatch that comes from the hand-off rather than from the design.

Lesson
U9.3
Time
30 min
Runs on
Verilator with UVM
Check
5 questions

The architecture team hands over their pipeline model as a C file, and the scoreboard starts calling it. One beat in mode 2 fails: the design halves 200 to 100, the model says 228. The designer is sure the RTL is right; the architect is sure the model is right. Both are right about their own code: the value changed type on its way into C.

Five results, one disagreement
01234result at (ns)45658595115beat1010020015mode0 add1 double2 halve3 invertC sees10100-5615design y1120050100240model y1120050228240c8 crosses as -56

Columns are results. Only a byte of 128 or more in the halving mode disagrees: the import passes a signed byte and C shifts in the sign bit.

Why the model is in C

U9.2's model was a SystemVerilog class. Teams reach for C or C++ for three reasons. The model already exists: architects wrote it to explore the design and it encodes their intent. It must be fast: an image filter or a cipher round is a lot of arithmetic per transaction. Or it is shared: the same file runs in the software team's tests, the firmware simulator and the bench.

The Direct Programming Interface (DPI) is the SystemVerilog feature that lets the two languages call each other. An import "DPI-C" declaration describes a C function to SystemVerilog, which can then call it like its own function. The simulator compiles the C file into the same executable, so a call costs about as much as a C function call.

One beat across the language boundaryArrows are calls and returns. The value changes sign at the import, not inside either model.
One beat across the language boundaryArrows are calls and returns. The value changes sign at the import, not inside either model. monitor Input monitor op_item x = 200 (c8) scoreboard write_in model.step(t.x),unchanged from U9.2 class SV wrapper alu_model_step(h, x) DPI input byte c8 arrives as char-56 C model.c mode 2: -56 >> 1 =-28 queue expected[$] int 228 narrowed toe4 compare write_out design y = 100:mismatch writecallimport callC callreturnpop, 30 ns later
  1. SystemVerilog side65 ns (illustrative)

    The scoreboard calls the wrapper exactly as it called the SV model.

    More context

    The beat 200 is taken at 65 ns, in mode 2. Nothing in write_in knows that the model is now written in C.

    Follow the calls, values and waits
    1. data op_item x = 200 (c8): Input monitor → write_in
    2. call model.step(200): write_in → SV wrapper
  2. The boundary65 ns (illustrative)

    input byte is signed: c8 crosses as -56. The call takes no simulation time.

    More context

    The bits do not change, only their meaning: hex c8 is 200 unsigned and -56 signed. The C char parameter is signed on this toolchain as well.

    Follow the calls, values and waits
    1. call alu_model_step(h, 8'hc8): SV wrapper → input byte
    2. call step(h, x = -56): input byte → model.c
  3. Back again65 ns (illustrative)

    C halves -56 to -28 and returns it as the unsigned value 228. The wrapper narrows it to e4, and the scoreboard queues that prediction.

    More context

    x >> 1 on a negative char shifts in the sign bit. The cast to unsigned char turns -28 into 228, hex e4.

    Follow the calls, values and waits
    1. return returns 228, queued as e4: model.c → expected[$]
    2. Waiting: expected[$] — e4 waits for the result
  4. The result appears95 ns (illustrative)

    The design's 100 meets the queued e4: a mismatch, although each model is right about its own input.

    More context

    The error reads y=100, model said 228 for the beat taken at 65 ns. Declaring input byte unsigned x fixes the hand-off, not either model.

    Follow the calls, values and waits
    1. data expected e4: expected[$] → write_out
    2. Released: expected[$]
Read the diagram as text
  • Input monitor: op_item x = 200 (c8)
  • write_in: model.step(t.x), unchanged from U9.2
  • SV wrapper: alu_model_step(h, x)
  • input byte: c8 arrives as char -56
  • model.c: mode 2: -56 >> 1 = -28
  • expected[$]: int 228 narrowed to e4
  • write_out: design y = 100: mismatch
  • Input monitor → write_in: write
  • write_in → SV wrapper: call
  • SV wrapper → input byte: import call
  • input byte → model.c: C call
  • model.c → expected[$]: return
  • expected[$] → write_out: pop, 30 ns later

Both models are right about their own inputs; the hand-off type is wrong.

These imports are functions, so a call takes no simulation time and this model is untimed. Everything U9.2 said still applies: ask it at the event the design acts on.

An import function cannot wait, and the C side cannot see the bench's clock. A C model that needs time would call back into SystemVerilog through an export "DPI-C" task, from an import declared as a context task. A reference model rarely needs that: keep it untimed and let the scoreboard decide when to ask.

The C file and the SystemVerilog wrapper

The model has six functions. alu_model_new allocates a struct and returns it; SystemVerilog keeps the pointer in a chandle, a variable type that holds a C pointer it never looks inside. Every other function takes that handle first.

model.c, compiled with the example. The page edits main.sv only; this file is shown here in full.
// model.c: the pipeline's reference model in C, built together with main.sv.
// Each instance keeps its own state in a struct; SystemVerilog holds it as a chandle.
#include <stdlib.h>
#include "svdpi.h"
#include "Vtb__Dpi.h"   // prototypes generated from the imports: a mismatch fails the build

typedef struct {
  int      mode;        // 0 add 1, 1 double, 2 halve, 3 invert
  unsigned beats;       // data beats seen since new or reset
} alu_model_t;

void* alu_model_new(void) {
  // calloc: mode 0 and no beats, the design's power-up state
  return calloc(1, sizeof(alu_model_t));
}
void alu_model_reset(void* h) {
  alu_model_t* m = (alu_model_t*)h;
  m->mode = 0;
  m->beats = 0;
}
void alu_model_set_mode(void* h, int mode) { ((alu_model_t*)h)->mode = mode & 3; }
int  alu_model_beats(void* h) { return (int)((alu_model_t*)h)->beats; }
void alu_model_free(void* h) { free(h); }

// One data beat. SystemVerilog passes a byte, which arrives as a C char.
int alu_model_step(void* h, char x) {
  alu_model_t* m = (alu_model_t*)h;
  m->beats++;
  switch (m->mode) {
    case 0:  return (unsigned char)(x + 1);
    case 1:  return (unsigned char)(x * 2);
    case 2:  return (unsigned char)(x >> 1);   // char is signed with gcc on x86-64: c8 arrives as -56
    default: return (unsigned char)~x;
  }
}

The wrapper class in main.sv has the same methods as U9.2's alu_model: set_mode and step, plus reset, beats and free. Because the scoreboard only calls methods, swapping a SystemVerilog model for a C one changes no scoreboard code. That is the reason to wrap the imports in a class rather than calling them from the scoreboard directly.

Including Vtb__Dpi.h matters. Verilator writes that header from the import declarations. If the C definition does not match, for example unsigned char x against input byte x, the build fails with conflicting types instead of passing garbage at run time.

Where the state lives

The model's mode and its beat count live in C memory, in one struct per alu_model_new call. A global variable would be shared by every caller.

Where the model's state livesArrows are chandle references from SystemVerilog objects to C memory.
Where the model's state livesArrows are chandle references from SystemVerilog objects to C memory. sv object sb.model chandle h1 sv object other chandle h2 c memory struct #1 mode 3, beats 5 c memory struct #2 mode 1 c memory C global one mode for everycaller h1h2if globalif global
  1. Per instance

    alu_model_new returns a new struct for each wrapper. The same call, step(7), gives each instance's answer from its own mode.

    More context

    The example prints second instance in mode 1: step(7)=14; first instance in mode 3: step(7)=248, after the checks.

    Follow the calls, values and waits
    1. call step(h2, 7): other → struct #2
    2. return 14 (mode 1): struct #2 → other
    3. call step(h1, 7): sb.model → struct #1
    4. return 248 (mode 3): struct #1 → sb.model
  2. Reset one instance

    reset and free act on that struct only. After reset(h1) the first instance is back in mode 0; struct #2 is untouched.

    More context

    The example prints first instance after reset: step(7)=8, beats 1: the beat count started again too.

    Follow the calls, values and waits
    1. call reset(h1): sb.model → struct #1
    2. call step(h1, 7): sb.model → struct #1
    3. return 8 (mode 0): struct #1 → sb.model
  3. A global instead

    Both wrappers would write one variable: the second set_mode wins for both (the spot item).

    More context

    An alternative design, not a continuation. After the second call both wrappers are in mode 1, and the first one's mode 3 is gone.

    Follow the calls, values and waits
    1. call set_mode(3) from sb.model: sb.model → C global
    2. call set_mode(1) from other: other → C global
Read the diagram as text
  • sb.model: chandle h1
  • other: chandle h2
  • struct #1: mode 3, beats 5
  • struct #2: mode 1
  • C global: one mode for every caller
  • sb.model → struct #1: h1
  • other → struct #2: h2
  • sb.model → C global: if global
  • other → C global: if global

One copy of the C program runs in the simulation; only a handle per instance keeps models apart.

Three questions a C global gets wrong
QuestionThis modelWith a C global instead
Two scoreboards, two pipelinesTwo structs: a mode change on one leaves the other aloneBoth share one mode; the second scoreboard sees the first one's mode beats
Reset while beats are in flightreset() returns the struct to mode 0, as a design reset wouldNeeds its own reset entry point anyway, and resets every user
End of testfree() in final_phase returns the memoryNever freed, never reinitialised between tests in one process

Per-instance state behind a chandle answers the questions a global cannot.

The example shows all three. After the checks, the test creates a second instance and puts it in mode 1: it doubles 7 to 14 while the first instance, still in mode 3, inverts 7 to 248. Then the first instance is reset: it is back in mode 0, so 7 gives 8, and its beat count starts again at 1.

A handle is valid only between new and free. C does not check: a step after free reads released memory and may even return plausible values. So the wrapper sets h = null in free and checks the handle before every call; a late call stops with a UVM_FATAL before it reaches C.

This pipeline has no reset input, so the example shows only the model's side: reset() returns one instance to its power-up state. A design with a reset defines what happens to work in flight; some discard it, others finish it. Call reset() at the event where the design resets its mode, and treat the queued predictions by the same rule (U10.2).

The beat that disagrees

Run the example. Four beats match. The beat 200 in mode 2 does not: y=100, model said 228 for the beat taken at 65 ns. Which one is right? The specification says the bytes are unsigned and halving is a logical shift, so 200 halves to 100. The design is right.

The model's C code is also correct for what it receives. The fault is at the hand-off. input byte x declares a signed 8-bit value, so 200 (hex c8) crosses as -56. With this toolchain (g++ on x86-64) char is signed and x >> 1 on a negative value shifts in the sign bit; C leaves both choices to the implementation. The result is -28, hex e4, which reads as 228 when it is narrowed to an unsigned byte.

The type that looked natural

import "DPI-C" function int
  alu_model_step(input chandle h, input byte x);
// model.c
int alu_model_step(void* h, char x);

In SystemVerilog byte is signed, and char is signed here too: every value of 128 or more arrives negative.

The type the specification means

import "DPI-C" function int
  alu_model_step(input chandle h, input byte unsigned x);
// model.c
int alu_model_step(void* h, unsigned char x);

Both sides now say "unsigned byte". 200 arrives as 200 and halves to 100.

Only one mode and half the inputs expose this. Add, double and invert give the same low eight bits for a signed or unsigned byte, so they all match. A test that never halved a large value would pass for ever. When a model and the RTL disagree, check the hand-off types first: width, signedness and which end of an array is index 0.

Try it

The scoreboard is U9.2's, unchanged; only alu_model now forwards to C. The one UVM_ERROR is the beat 200 in mode 2: y=100, model said 228. The last three lines show the state on the C side: the beat count, a second instance with its own mode, and a reset.

Change the script to halve 127 and then 128 and see where the disagreement starts. Which modes can never show it?

RUNVerilator + UVM 2020.3.1 · simulator service
Edit the code, then run it
Expected among the UVM messages: 15 ns model x=10 -> 11, 1 waiting 45 ns match y=11 (beat taken at 15 ns) 65 ns match y=200 (beat taken at 35 ns) 85 ns model x=15 -> 240, 3 waiting 85 ns match y=50 (beat taken at 55 ns) 115 ns match y=240 (beat taken at 85 ns) checked 5, mismatches 1, C model counted 5 beats second instance in mode 1: step(7)=14; first instance in mode 3: step(7)=248 first instance after reset: step(7)=8, beats 1 UVM_ERROR : 1
Read included file: model.c
// model.c: the pipeline's reference model in C, built together with main.sv.
// Each instance keeps its own state in a struct; SystemVerilog holds it as a chandle.
#include <stdlib.h>
#include "svdpi.h"
#include "Vtb__Dpi.h"   // prototypes generated from the imports: a mismatch fails the build

typedef struct {
  int      mode;        // 0 add 1, 1 double, 2 halve, 3 invert
  unsigned beats;       // data beats seen since new or reset
} alu_model_t;

void* alu_model_new(void) {
  // calloc: mode 0 and no beats, the design's power-up state
  return calloc(1, sizeof(alu_model_t));
}
void alu_model_reset(void* h) {
  alu_model_t* m = (alu_model_t*)h;
  m->mode = 0;
  m->beats = 0;
}
void alu_model_set_mode(void* h, int mode) { ((alu_model_t*)h)->mode = mode & 3; }
int  alu_model_beats(void* h) { return (int)((alu_model_t*)h)->beats; }
void alu_model_free(void* h) { free(h); }

// One data beat. SystemVerilog passes a byte, which arrives as a C char.
int alu_model_step(void* h, char x) {
  alu_model_t* m = (alu_model_t*)h;
  m->beats++;
  switch (m->mode) {
    case 0:  return (unsigned char)(x + 1);
    case 1:  return (unsigned char)(x * 2);
    case 2:  return (unsigned char)(x >> 1);   // char is signed with gcc on x86-64: c8 arrives as -56
    default: return (unsigned char)~x;
  }
}

Check yourself

Predict the outputThis program mirrors what each import declaration hands to C for the byte c8, and what an arithmetic halving then gives. What line does it print?
module tb;
  bit [7:0]     v;
  byte          s;           // what an "input byte" import argument carries
  byte unsigned u;           // what an "input byte unsigned" argument carries
  int           a, b;
  initial begin
    v = 8'hC8;
    s = v;
    u = v;
    a = s;
    b = u;
    $display("%0d %0d %02h %02h", a, b, 8'(a >>> 1), 8'(b >>> 1));
    $finish;
  end
endmodule
ConceptThe design halves 200 to 100 and the C model says 228. The specification says data bytes are unsigned. Which statement is right?
Spot the bugTwo pipelines each get their own alu_model: A is put in mode 1 (double) and B in mode 3 (invert), then each steps 7. The C file here also has shared_set_mode and shared_step, the same model with its mode in one C global. Each option is the body of the wrapper class. Which wrapper gives a wrong answer?
Read included file: model.c
// model.c: the pipeline's reference model in C, built together with main.sv.
// Each instance keeps its own state in a struct; SystemVerilog holds it as a chandle.
#include <stdlib.h>
#include "svdpi.h"
#include "Vtb__Dpi.h"   // prototypes generated from the imports: a mismatch fails the build

typedef struct {
  int      mode;        // 0 add 1, 1 double, 2 halve, 3 invert
  unsigned beats;       // data beats seen since new or reset
} alu_model_t;

void* alu_model_new(void) {
  // calloc: mode 0 and no beats, the design's power-up state
  return calloc(1, sizeof(alu_model_t));
}
void alu_model_reset(void* h) {
  alu_model_t* m = (alu_model_t*)h;
  m->mode = 0;
  m->beats = 0;
}
void alu_model_set_mode(void* h, int mode) { ((alu_model_t*)h)->mode = mode & 3; }
int  alu_model_beats(void* h) { return (int)((alu_model_t*)h)->beats; }
void alu_model_free(void* h) { free(h); }

// One data beat. SystemVerilog passes a byte, which arrives as a C char.
int alu_model_step(void* h, char x) {
  alu_model_t* m = (alu_model_t*)h;
  m->beats++;
  switch (m->mode) {
    case 0:  return (unsigned char)(x + 1);
    case 1:  return (unsigned char)(x * 2);
    case 2:  return (unsigned char)(x >> 1);   // char is signed with gcc on x86-64: c8 arrives as -56
    default: return (unsigned char)~x;
  }
}

// For comparison: the same model with its mode in one C global.
static int shared_mode = 0;
void shared_set_mode(int mode) { shared_mode = mode & 3; }
int  shared_step(char x) {
  switch (shared_mode) {
    case 0:  return (unsigned char)(x + 1);
    case 1:  return (unsigned char)(x * 2);
    case 2:  return (unsigned char)(x >> 1);
    default: return (unsigned char)~x;
  }
}
ConceptWhy does the example's model.c include the header Vtb__Dpi.h that Verilator generates?
ConceptA colleague proposes moving the model's call from write_in into write_out "because the C model is fast, so it does not matter when it runs". What is wrong with that?

Remember

  • A DPI import lets the scoreboard call a C model like a SystemVerilog function; these import functions take no simulation time, so the model is untimed and U9.2's prediction point still applies.
  • Wrap the imports in a class with the same methods as an SV model, so the scoreboard does not care which language the model is in.
  • Keep model state in a struct per instance behind a chandle, with new, reset and free, and check the handle before each call; a C global is shared by every caller.
  • Include the generated Vtb__Dpi.h in the C file so a declaration mismatch fails the build.
  • When a C model and the RTL disagree, check the hand-off types first: byte is signed, and only some modes and inputs expose it.

Practice