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.

FileLanguageIts job
main.svSystemVerilogOpens the bridge, sends {1, 2, 3}, checks that the answer is 6, closes the bridge
bridge.cppC++ with the CPython C APIStarts the interpreter, imports model, copies each packet into a bytes object, converts the result
model.pyPythonchecksum(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.

One packet, there and backArrows are calls. The request travels right from the testbench to the model; the result travels back along the same edges.
One packet, there and backArrows are calls. The request travels right from the testbench to the model; the result travels back along the same edges. SystemVerilog main.sv py_open,py_sum(data, 3,total), py_close C bridge.cpp drives theinterpreter throughthe C API library CPython one interpreter,started by py_open Python model.py checksum(data):sum(data) & 0xff object bytes object a copy of the packet,released after thecall import "DPI-C"C APIchecksum(bytes)PyBytes_FromStringAndSizeargument
  1. Open

    py_open starts the interpreter, puts the run directory on sys.path, imports model and keeps the checksum function.

  2. Copy

    py_sum copies the three bytes from the open array into a new bytes object. Python never sees simulator memory.

  3. Sum

    The bridge calls checksum(bytes). Python adds the bytes and returns the integer 6.

    Follow the calls, values and waits
    1. call checksum(bytes): bridge.cpp → CPython
    2. call checksum(bytes): CPython → model.py
    3. return 6: model.py → CPython
    4. return 6: CPython → bridge.cpp
  4. Return

    The bridge converts the integer, checks that it fits a byte, writes total, and releases the result and the bytes object.

    Follow the calls, values and waits
    1. return 6: bridge.cpp → main.sv
  5. Close

    py_close releases 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 bridgeReferenceWhat the bridge does with it
PyImport_ImportModule("model")newPy_DECREF as soon as checksum has been taken from it
PyObject_GetAttrString(module, "checksum")newKept until py_close, then released
PySys_GetObject("path")borrowedUsed for one insert; never released
PyBytes_FromStringAndSize(nullptr, count)newReleased right after the call into the model
The result of PyObject_CallFunctionObjArgsnewReleased 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.

  1. Enter Python

    The bridge builds a bytes object from the SystemVerilog array and calls the kept function.

    SystemVerilog payload1, 2, 3
    Python expressionsum(data) & 0xff
  2. Return to the check

    The bridge converts and range-checks the result before writing the output argument.

    status0
    total6

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.

RUNVerilator + C/C++ over DPI · simulator service
Edit the code, then run it
Expected: python_checksum=6
Read 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

Predict the outputThe model returns sum(data) & 0xff. What line does this program print?
`timescale 1ns/1ps
module tb;
  import "DPI-C" function int py_open();
  import "DPI-C" function int py_sum(input byte unsigned data[], input int count, output int unsigned total);
  import "DPI-C" function int py_close();
  byte unsigned data[2] = '{200, 100};
  int unsigned total;
  initial begin
    void'(py_open());
    void'(py_sum(data, 2, total));
    void'(py_close());
    $display("%0d", total);
    $finish;
  end
endmodule
Read 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
ConceptWhen py_sum runs, which interface does the simulator call?
Spot the bugEach option is the body of the test. check prints PASS when the status is 0 and the total is 6. Which option prints FAIL?
Read 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
ConceptAfter taking checksum from the module, the bridge releases the module with Py_DECREF. Why does checksum stay usable?
ConceptWhich of these Py_DECREF calls would be a bug in this bridge?

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_DECREF at a stated point; never release a borrowed one.
  • The packet crosses as a copy into a bytes object; 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.