Python behind a C bridge
Start a Python interpreter inside the simulation from a C bridge, hand a packet to a Python model, and get the checksum back as a checked SystemVerilog value.
- Lesson
- D2.1
- Time
- 30 min
- Runs on
- Verilator with C/C++
- Check
- 5 questions
The firmware team already has the checksum in Python, with tests, and nobody wants a second copy in SystemVerilog. The standard only knows C: there is no import "DPI-Python". So the bridge is a C file that starts a Python interpreter inside the simulation, imports the model once, and hands each packet across as bytes. This lesson follows one call from SystemVerilog into Python and back, and shows who releases what along the way.
| File | Language | Its job |
|---|---|---|
main.sv | SystemVerilog | Opens the bridge, sends {1, 2, 3}, checks that the answer is 6, closes the bridge |
bridge.cpp | C++ with the CPython C API | Starts the interpreter, imports model, copies each packet into a bytes object, converts the result |
model.py | Python | checksum(data): the sum of the bytes, kept to eight bits |
Three files, one program. The simulator calls C; only C talks to Python.
One interpreter inside the simulation
DPI stops at C. A Python model is reached through a C function that embeds the interpreter: the bridge links against the CPython library and drives it through the C API. The interpreter runs inside the simulation process, on the simulator's thread, and only while a DPI call is executing. Other teams reach Python through a simulator plugin or a separate process over a pipe; the bridge here is the smallest pattern, and the one you can read in full.
- Open
py_openstarts the interpreter, puts the run directory onsys.path, importsmodeland keeps thechecksumfunction. - Copy
py_sumcopies the three bytes from the open array into a newbytesobject. Python never sees simulator memory. - Sum
The bridge calls
checksum(bytes). Python adds the bytes and returns the integer 6.Follow the calls, values and waits
- call checksum(bytes): bridge.cpp → CPython
- call checksum(bytes): CPython → model.py
- return 6: model.py → CPython
- return 6: CPython → bridge.cpp
- Return
The bridge converts the integer, checks that it fits a byte, writes
total, and releases the result and thebytesobject.Follow the calls, values and waits
- return 6: bridge.cpp → main.sv
- Close
py_closereleases the kept function and finalises the interpreter.
Read the diagram as text
- main.sv: py_open, py_sum(data, 3, total), py_close
- bridge.cpp: drives the interpreter through the C API
- CPython: one interpreter, started by py_open
- model.py: checksum(data): sum(data) & 0xff
- bytes object: a copy of the packet, released after the call
- main.sv → bridge.cpp: import "DPI-C"
- bridge.cpp → CPython: C API
- CPython → model.py: checksum(bytes)
- bridge.cpp → bytes object: PyBytes_FromStringAndSize
- bytes object → model.py: argument
The model neither drives pins nor advances time. It receives a copy and returns a number.
Open once, keep the callable
py_open does four things, once. It starts the interpreter from an isolated configuration, which ignores environment variables and the user's site directory, so the model runs the same on every machine. It switches Python's signal handlers off: Ctrl-C belongs to the simulator. It puts the run directory at the front of sys.path, so import model finds model.py next to main.sv and nothing else. And it imports the module once and keeps the checksum function, so a call per packet costs one function call and no import.
If Python is already running when py_open is called, the bridge returns status 1 and touches nothing. A simulator plugin or another library may have started that interpreter, and two pieces of code that both start and stop one interpreter end in a crash at the finalisation.
Owned and borrowed references
Every object the C API hands back is either new or borrowed. A new reference is yours: release it with Py_DECREF, or the object lives until the interpreter ends. A borrowed reference belongs to someone else: release it and you free memory that is still in use. The documentation states which kind each function returns, and the bridge follows that line by line. Between calls it holds exactly one object.
| Call in the bridge | Reference | What the bridge does with it |
|---|---|---|
PyImport_ImportModule("model") | new | Py_DECREF as soon as checksum has been taken from it |
PyObject_GetAttrString(module, "checksum") | new | Kept until py_close, then released |
PySys_GetObject("path") | borrowed | Used for one insert; never released |
PyBytes_FromStringAndSize(nullptr, count) | new | Released right after the call into the model |
The result of PyObject_CallFunctionObjArgs | new | Released after PyLong_AsUnsignedLong has read it |
Five objects cross the bridge. One is borrowed; the other four are released at a stated point.
Releasing the module early is safe. The function object has its own reference count, and the module stays alive through sys.modules either way. What the bridge must keep is the one reference it uses on every packet.
Copy the packet, check the answer in SystemVerilog
The packet crosses as a copy. py_sum creates a bytes object of count bytes and fills it element by element through svGetArrElemPtr1, walking from the array's left bound. Python never keeps a pointer into simulator memory, so the array may change or disappear after the call. The model returns a Python integer; PyLong_AsUnsignedLong converts it, the bridge checks that it fits in a byte, and only then writes total.
The testbench still decides. It asserts total == 6, a value worked out by hand, before it prints anything. A model that prints its own answer and a testbench that echoes it would agree on any wrong number.
Every call runs on the simulator's thread and holds the interpreter lock from open to close. That is right for a model called from one thread. A bridge that starts worker threads, or releases the lock, needs its own thread-state design; this lesson stays with the single thread.
Enter Python
The bridge builds a
bytesobject from the SystemVerilog array and calls the kept function.SystemVerilog payload 1, 2, 3Python expression sum(data) & 0xffReturn to the check
The bridge converts and range-checks the result before writing the output argument.
status 0total 6
Try it
Run it as it is. Then change the payload to {10, 20, 30} and check for 60 in SystemVerilog before printing. No Python code changes; the model stays reusable because the caller supplies the data and the expected value.
Then call py_sum once more after py_close and print the status: the bridge answers 1 and leaves total at zero.
python_checksum=6Read included file: bridge.cpp
#include <Python.h> // first: it sets the feature macros the C library headers read
#include "Vtb__Dpi.h"
#include "svdpi.h"
#include <cstdio>
// One interpreter, opened and closed from SystemVerilog, on the simulator's thread. Between calls
// the bridge holds exactly one Python object: the checksum function.
static PyObject* checksum = nullptr;
// Take the pending Python exception, print it as one line, and return the status code.
static int py_error(int code) {
PyObject* exc = PyErr_GetRaisedException(); // clears the error indicator; the object is ours
if (exc) {
PyObject* text = PyObject_Str(exc); // new reference
std::printf("python %s: %s\n", Py_TYPE(exc)->tp_name, text ? PyUnicode_AsUTF8(text) : "?");
Py_XDECREF(text);
Py_DECREF(exc);
}
return code;
}
extern "C" int py_open() {
if (checksum) return 0; // already open: nothing to do
if (Py_IsInitialized()) return 1; // some other code runs this interpreter; leave it alone
PyConfig config;
PyConfig_InitIsolatedConfig(&config); // ignore environment variables and the user site directory
config.install_signal_handlers = 0; // Ctrl-C stays with the simulator
PyStatus status = Py_InitializeFromConfig(&config);
PyConfig_Clear(&config);
if (PyStatus_Exception(status)) return 2;
PyObject* sys_path = PySys_GetObject("path"); // borrowed reference: never Py_DECREF
PyObject* here = PyUnicode_FromString("."); // new reference: ours to release
if (!here || PyList_Insert(sys_path, 0, here) < 0) { Py_XDECREF(here); py_error(3); Py_FinalizeEx(); return 3; }
Py_DECREF(here);
PyObject* module = PyImport_ImportModule("model"); // new reference
if (module) { checksum = PyObject_GetAttrString(module, "checksum"); Py_DECREF(module); } // new reference, kept
if (!checksum || !PyCallable_Check(checksum)) {
Py_XDECREF(checksum); checksum = nullptr; py_error(4); Py_FinalizeEx(); return 4;
}
return 0;
}
extern "C" int py_sum(const svOpenArrayHandle data, int count, unsigned int* total) {
*total = 0; // the output is set on every path
if (!checksum) return 1; // closed bridge
if (svDimensions(data) != 1 || count < 0 || count > svSize(data, 1) || count > 1024) return 2;
const int left = svLeft(data, 1), step = left <= svRight(data, 1) ? 1 : -1;
PyObject* bytes = PyBytes_FromStringAndSize(nullptr, count); // new reference
if (!bytes) return py_error(3);
char* target = PyBytes_AsString(bytes);
for (int k = 0; k < count; ++k) {
const unsigned char* p = static_cast<const unsigned char*>(svGetArrElemPtr1(data, left + k * step));
if (!p) { Py_DECREF(bytes); return 2; }
target[k] = static_cast<char>(*p); // a copy: Python never keeps a pointer into the simulator
}
PyObject* result = PyObject_CallFunctionObjArgs(checksum, bytes, nullptr); // new reference, or null
Py_DECREF(bytes);
if (!result) return py_error(4); // the model raised
const unsigned long value = PyLong_AsUnsignedLong(result);
Py_DECREF(result);
if (PyErr_Occurred()) return py_error(5); // negative, or no integer at all
if (value > 255) return 6; // a checksum is one byte
*total = static_cast<unsigned int>(value);
return 0;
}
extern "C" int py_close() {
Py_XDECREF(checksum);
checksum = nullptr;
return Py_IsInitialized() ? Py_FinalizeEx() : 0;
}
Read included file: model.py
"""The reference calculation only. SystemVerilog keeps the clock, the stimulus and the check."""
def checksum(data):
if not isinstance(data, bytes):
raise TypeError("expected bytes")
if len(data) > 64:
raise ValueError("packet exceeds 64 bytes")
return sum(data) & 0xff
Check yourself
Remember
- DPI reaches C only. A Python model is called through a C bridge that embeds the interpreter with the CPython C API, inside the simulation process.
- Open once: an isolated configuration, signal handlers off, the run directory on
sys.path, one import, and one kept function used on every packet. - Every reference the C API returns is new or borrowed. Release new ones with
Py_DECREFat a stated point; never release a borrowed one. - The packet crosses as a copy into a
bytesobject; Python never holds a pointer into simulator memory. - The testbench checks the result against a value it worked out by hand. The model computes; SystemVerilog decides.