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.
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.
- 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_inknows that the model is now written in C.Follow the calls, values and waits
- data op_item x = 200 (c8): Input monitor → write_in
- call model.step(200): write_in → SV wrapper
- The boundary65 ns (illustrative)
input byteis 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
charparameter is signed on this toolchain as well.Follow the calls, values and waits
- call alu_model_step(h, 8'hc8): SV wrapper → input byte
- call step(h, x = -56): input byte → model.c
- 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 >> 1on a negative char shifts in the sign bit. The cast tounsigned charturns -28 into 228, hex e4.Follow the calls, values and waits
- return returns 228, queued as e4: model.c → expected[$]
- Waiting: expected[$] — e4 waits for the result
- 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. Declaringinput byte unsigned xfixes the hand-off, not either model.Follow the calls, values and waits
- data expected e4: expected[$] → write_out
- 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: 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.
- Per instance
alu_model_newreturns 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
- call step(h2, 7): other → struct #2
- return 14 (mode 1): struct #2 → other
- call step(h1, 7): sb.model → struct #1
- return 248 (mode 3): struct #1 → sb.model
- Reset one instance
resetandfreeact on that struct only. Afterreset(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
- call reset(h1): sb.model → struct #1
- call step(h1, 7): sb.model → struct #1
- return 8 (mode 0): struct #1 → sb.model
- A global instead
Both wrappers would write one variable: the second
set_modewins 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
- call set_mode(3) from sb.model: sb.model → C global
- 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
| Question | This model | With a C global instead |
|---|---|---|
| Two scoreboards, two pipelines | Two structs: a mode change on one leaves the other alone | Both share one mode; the second scoreboard sees the first one's mode beats |
| Reset while beats are in flight | reset() returns the struct to mode 0, as a design reset would | Needs its own reset entry point anyway, and resets every user |
| End of test | free() in final_phase returns the memory | Never 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?
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 : 1Read 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
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, withnew,resetandfree, and check the handle before each call; a C global is shared by every caller. - Include the generated
Vtb__Dpi.hin the C file so a declaration mismatch fails the build. - When a C model and the RTL disagree, check the hand-off types first:
byteis signed, and only some modes and inputs expose it.
Practice
- Three channels and a model that disagrees Fix · Medium