Contents

DPI-C tutorial · Part B · Step 07

Embedding Python in a SystemVerilog simulation

DPI gives you C. CPython is a C library. Link it into the simulator, start the interpreter once, and every Python function becomes callable from SystemVerilog at a cost of tens of nanoseconds. What makes or breaks it is the boring part: lifecycle, reference counts, errors, buffers and threads.

Prereqs: steps 01–05 (import/export, types, chandle, open arrays, context)Measured on Verilator 5.052 + Python 3.14.7, macOS x86_64Answers are hidden: think first

§0 · TutorialThe step, explained

Part A ended with a golden model written in C. C is fast and DPI speaks it natively, but C is a poor language for reference models: no big integers, no complex numbers, no numpy, no zlib, no quick prototyping. The model you trust most is often a Python script that the algorithm team already wrote. This step shows how to call it in-process from SystemVerilog: the simulator links libpython, your DPI C code owns an interpreter, and an SV function call becomes a Python function call.

Nothing here is specific to Python: it is ordinary DPI-C (steps 01–05) plus the CPython C API, the same API every embedder uses (Blender, GDB, LibreOffice). The C API is large, but an embedder needs about twenty functions. The hard parts are elsewhere. One is the interpreter's lifecycle, which has to fit inside the simulator's lifecycle. Another is ownership: every PyObject* has a reference count, and a C bug there is a leak or a crash, never a compile error. The rest is errors that must reach the SV log, two output buffers fighting over the same file descriptor, and the GIL as soon as Verilator runs multithreaded. Every claim below is measured in the lab and asserted by check.py.

0.1Three ways to reach Python from a testbench

Spear §12.9 ("Connecting Other Languages") shows the oldest way: a DPI wrapper around system() that runs a Perl script and returns its exit status (Samples 12.51–12.53). It works for anything with a command line, and it costs a fork+exec+interpreter start per call. The second way is a socket: a Python server process and a DPI client, as in this repo's legacy/sockets_dpi/ and the AMIQ example. The third is to embed the interpreter in the simulator process.

(a) spawn per call — Spear §12.9 simulator system() new python3 41 ms / call (fork + exec + startup) (b) socket to a server process simulator python server a round trip + serialization per call (c) embedded — this step SV + DPI C C call libpython 0.04 µs / call (builtin abs)
Fig. 0.1Three ways to reach Python. The two numbers are measured in exercise E11, which ports Sample 12.52 to this machine: about a millionfold between spawning and embedding. Sockets sit in between and are the right choice only when the model must live in another process or on another machine.

Embedding wins on cost and on data types: arguments cross as C values and become Python objects without serialization. It loses on isolation: a crash in Python's C code is a crash of your simulator, and one process means one interpreter state shared by the whole testbench. For a golden model called millions of times per test, embedding is the default; the rest of this step makes it robust.

0.2Building and linking

The simulator executable must contain (or load) libpython3.14, and your DPI C file must see Python.h. Python ships the flags in python3-config, with one trap: since Python 3.8, python3-config --ldflags omits -lpython3.X, because extension modules must not link libpython (they get its symbols from the host process). An embedder must link it, and asks for it with --embed:

$ python3-config --ldflags
-L/usr/local/opt/python@3.14/.../config-3.14-darwin -ldl -framework CoreFoundation
$ python3-config --embed --ldflags
-L/usr/local/opt/python@3.14/.../config-3.14-darwin -lpython3.14 -ldl -framework CoreFoundation

Without --embed the link fails with Undefined symbols for architecture x86_64: "_Py_Initialize" (lab build noembed). The shared helper common/lab.sh wraps the right flags in py_flags, which turns each word into a Verilator -CFLAGS or -LDFLAGS argument:

# run.sh: build in the background, then run
vlb basic basic.sv basic.c $(py_flags)
# expands to: verilator --binary ... basic.sv basic.c \
#   -CFLAGS -I.../include/python3.14 -LDFLAGS -L.../config-3.14-darwin -LDFLAGS -lpython3.14 ...
basic.sv verilator Vtop*.cpp Vtop__Dpi.h basic.c Python.h #include clang++ ldVtop -lpython3.14
Fig. 0.2What verilator --binary does with a .c file and Python. Two details matter: the .c is compiled as C++ (the lab prints __cplusplus=201703), and including the generated Vtop__Dpi.h gives every definition the extern "C" linkage of its prototype and turns signature mismatches into compile errors (step 01). The highlighted input is the one --embed adds.

Questa, VCS and Xcelium usually load your DPI code as a shared library (-sv_lib, -sv_liblist) with dlopen instead of linking it into the executable. Then the shared library itself must link libpython (-shared ... $(python3-config --embed --ldflags)), and C extension modules like numpy's need libpython's symbols to be globally visible. If the simulator opens your library with RTLD_LOCAL, the classic symptom is undefined symbol: PyExc_... when importing numpy; the usual fix is to dlopen("libpython3.X.so", RTLD_NOW | RTLD_GLOBAL) in your init function before Py_Initialize. This is not measured here (no commercial simulator in this lab); on Verilator the executable links libpython directly, and numpy imports fine (0.10).

0.3Lifecycle: start once, call many times, finalize once

The interpreter has three phases: Py_Initialize* (tens of milliseconds: the sys module, the import system, the encodings, site), any number of calls, and Py_FinalizeEx (flush sys.stdout, run atexit handlers, tear down modules). Map them onto the simulation:

// basic.sv (excerpt): the whole lifecycle in SV
import "DPI-C" function int  py_init(input string moddir);
import "DPI-C" function void py_fin();
initial begin
  void'($value$plusargs("pydir=%s", dir));
  if (py_init(dir) != 0) $fatal(1, "python init failed");
  ...
end
final py_fin();
/* basic.c (excerpt) */
int py_init(const char *moddir) {
  if (Py_IsInitialized()) return 0;
  Py_Initialize();
  PyObject *path = PySys_GetObject("path");      /* borrowed: do NOT decref */
  PyObject *dir = PyUnicode_FromString(moddir);  /* new */
  PyList_Insert(path, 0, dir);
  Py_DECREF(dir);
  PyObject *mod = PyImport_ImportModule("toys"); /* new */
  if (!mod) { PyErr_Print(); return -1; }
  f_add = PyObject_GetAttrString(mod, "add");    /* new, cached until py_fin */
  ...
  Py_DECREF(mod);                                /* sys.modules keeps it alive */
  return 0;
}
void py_fin(void) {
  Py_CLEAR(f_add); ...                            /* drop cached refs first */
  Py_FinalizeEx();
}

The lab's lifecycle experiment calls a Python function that does print("PY hello from python") and registers an atexit handler, with stdout redirected to a file as in any regression. Four runs:

RunWhat SV doesLog (measured)
finfinalize in finalSV after say · PY hello from python · PY atexit handler ran · LOG fin rc=0 · SV final block done
nofinnever finalizesSV after say · SV final block done
guardfinalize, then call again through a guarded wrapper… LOG guard refused: interpreter not initialized · LOG guard rc=-1
rawfinalize, then call a cached PyObject*… LOG raw calling a stale object · RUN_RC=139 (SIGSEGV)

Read the nofin row twice: the Python output is gone, and the atexit handler never ran. Python's sys.stdout is its own buffer (0.8); only finalize flushes it, and the process exits through C exit(), which knows nothing about Python's buffers. Any Python-side cleanup (closing a results file, dumping coverage, writing a waveform of model internals) is lost the same way.

The raw row is the other classic: a pointer cached before finalize points into freed interpreter memory. Use Py_CLEAR on every cached pointer in the finalize function, and guard every entry point with a flag (or Py_IsInitialized()) if anything can call after final.

Do not initialize and finalize per call. The repo's 2014 legacy/python-embedded-C/irun_tests/dpi.c calls Py_Initialize(); Py_Finalize(); inside the DPI function. Measured cost: about 24 ms per cycle on an idle machine (life_cycle_ms, importing json; under load it grows several-fold), a million times the cost of a call. Worse, it is not even correct: CPython does not support re-initialization for C extensions. Importing numpy in a second cycle fails with ImportError: cannot load module more than once per process (measured: 1 of 3 cycles succeeds).

$finish does not stop the calling process immediately: the statements after it in the same initial still execute (the lab prints LOG after_finish still running), and then final blocks run. Do not write code after $finish that calls into Python assuming it will not run. In the lab every SV branch ends at its $finish.

sim time initial: py_initinit · sys.path · import DPI calls: one PyObject_Call each final: py_finPy_CLEAR · Py_FinalizeEx t = 0finish flushes sys.stdout, runs atexit call → SIGSEGV failure here is visible at t = 0
Fig. 0.3The interpreter's lifecycle mapped onto the simulation. Everything expensive happens once; everything Python buffers is released only by the finalize in final.

0.4Configuring the interpreter: PyConfig, PyInitConfig, venvs

Py_Initialize() configures Python like the python3 command with no arguments, reading PYTHONPATH, PYTHONHOME and friends from the environment. To control it from C there are two APIs. PyConfig (PEP 587, Python 3.8+) is a struct you fill and pass to Py_InitializeFromConfig. PyInitConfig (PEP 741, new in 3.14) sets options by name, so your code does not depend on the struct layout:

/* config.c: PyConfig (3.8+) */
PyConfig cfg;
PyConfig_InitPythonConfig(&cfg);                    /* like `python3`; Isolated = like `python3 -I` */
PyStatus st = PyConfig_SetBytesString(&cfg, &cfg.program_name, "dpi-sim");
if (*venv) st = PyConfig_SetBytesString(&cfg, &cfg.executable, venv);
cfg.buffered_stdio = 0;                              /* like `python3 -u` */
st = Py_InitializeFromConfig(&cfg);
PyConfig_Clear(&cfg);
if (PyStatus_Exception(st)) ... st.err_msg ...

/* config.c: PyInitConfig (3.14) — same result, options by name */
PyInitConfig *c = PyInitConfig_Create();
int bad = PyInitConfig_SetStr(c, "program_name", "dpi-sim")
       || PyInitConfig_SetInt(c, "buffered_stdio", 0)
       || (*venv && PyInitConfig_SetStr(c, "executable", venv))
       || Py_InitializeFromInitConfig(c);
PyInitConfig_Free(c);

What the lab measured, printing sys.executable, whether a venv is active (sys.prefix != sys.base_prefix) and whether numpy imports:

Runsys.executablevenvnumpy
cfg_sys / cfg_initthe simulator binary …/vl_config/Vtop0yes (80–130 ms import)
cfg_venv / cfg_initvenv…/build/venv/bin/python1no: ModuleNotFoundError
cfg_venv_sys…/build/venv_sys/bin/python1yes (--system-site-packages)
cfg_replaceinit fails: Failed to import encodings module

Three lessons. First, an embedded interpreter believes it is the simulator binary; that is harmless, but it means Python finds its standard library from the build-time prefix, not from a venv. To use a project venv, set executable to the venv's bin/python: CPython's path calculation finds pyvenv.cfg next to it. Use an absolute path, since a relative one prints RuntimeWarning: Unexpected value in sys.prefix. A plain venv sees only its own packages, so numpy disappears; that is the isolation you want for reproducible regressions, once the venv has its requirements installed. Second, PyConfig and PyInitConfig give identical results. Prefer PyInitConfig on 3.14+, and PyConfig if you must support 3.8–3.13. Third, the pitfall in cfg_replace: setting module_search_paths_set = 1 to add your model directory replaces the computed sys.path, the stdlib disappears, and init dies before anything runs. Add model directories after init (PyList_Insert on sys.path, as basic.c does), or through PYTHONPATH.

Make the model directory a plusarg (+pydir=...) passed to py_init, and the venv an absolute path set at init. The simulation command line then fully determines which Python code runs, which is what a regression needs. Relying on the user's shell PYTHONPATH is how "works on my machine" golden models happen.

0.5Calling Python and converting values

Every call has the same five beats: build argument objects, call, check for NULL, convert the result to C, release the result. The C API has shortcuts for the first two. PyObject_CallFunction(f, fmt, ...) builds a tuple from a Py_BuildValue format string. PyObject_CallOneArg and PyObject_CallNoArgs skip the tuple (vectorcall, 3.9+). PyObject_CallMethod(obj, "name", fmt, ...) calls a method. The format letters that matter for DPI types:

SV type (C type, step 02)Build letterPython objectBack to CTrap
int (int)"i"intPyLong_AsLongreturns a 64-bit long: a cast to int silently wraps
int unsigned (unsigned int)"I"int ≥ 0PyLong_AsUnsignedLongwith "i" the value arrives negative in Python
longint (long long)"L"intPyLong_AsLongLong-1 + OverflowError at 263
longint unsigned"K"intPyLong_AsUnsignedLongLongwider vectors: step 08 (PyLong_AsNativeBytes)
real (double)"d"floatPyFloat_AsDoubleaccepts ints too; a str gives TypeError
string (const char*)"s"str (UTF-8 decode)PyUnicode_AsUTF8the pointer lives only as long as the str object
byte array (open array, step 04)"y#"bytesPyBytes_AsStringVerilator: the actual must be a fixed-size array
bit (svBit)"O" + Py_True/Py_FalseboolPyObject_IsTrue"i" works too but gives 0/1
chandle (void*)"O"any object you ownthe pointer itselfownership: 0.6

The integer row deserves its demo. Python integers are unbounded, so add(0x7fffffff, 1) returns 2147483648. PyLong_AsLong converts it happily into a 64-bit long, and the (int) cast at the DPI boundary wraps it to -2147483648 (lab: LOG wrap r=-2147483648). No error, no warning: the SV side sees a plausible negative number. A golden model that silently wraps is worse than none. The checked version asks for overflow explicitly and returns a status:

/* returns 0 ok, 1 result does not fit in int, -1 Python error */
int py_add_checked(int a, int b, int *sum) {       /* SV: output int sum */
  PyObject *r = PyObject_CallFunction(f_add, "ii", a, b);
  if (!r) { PyErr_Print(); return -1; }
  int overflow;
  long long v = PyLong_AsLongLongAndOverflow(r, &overflow);
  Py_DECREF(r);
  if (overflow || v < INT32_MIN || v > INT32_MAX) return 1;
  *sum = (int)v;
  return 0;
}

Measured: py_add_checked(0x7fffffff, 1) returns 1; py_add_checked(-5, 3) returns 0 with sum=-2. With longint the boundary moves: py_pow2(62) returns 4611686018427387904, while 263 and 264 make PyLong_AsLongLong return -1 and raise OverflowError. A C caller cannot tell that -1 from a real -1 without asking PyErr_Occurred().

Strings run into C's ownership problem in reverse. A DPI function that returns string returns a const char*, and the simulator copies it when the call returns (H.8.10: the pointer must stay valid until then). PyUnicode_AsUTF8(obj) returns a pointer into the str object's own buffer, which is valid exactly as long as the object lives. So you cannot Py_DECREF the result before returning its UTF-8 pointer. The lab keeps the last result alive in a static and replaces it on the next call:

static PyObject *last_str;                         /* owns the buffer returned to SV */
const char *py_reverse(const char *s) {
  PyObject *r = PyObject_CallFunction(f_reverse, "s", s);
  if (!r) { PyErr_Print(); return ""; }
  Py_XSETREF(last_str, r);                          /* DECREFs the previous result */
  return PyUnicode_AsUTF8(last_str);
}
SVC (DPI)Python objects int a, b CallFunction(f, "ii", a, b) (a, b) tuple toys.add int r (new ref) PyLong_AsLong(r); DECREF (int) int result the (int) cast is where 2147483648 becomes −2147483648
Fig. 0.4One call, five beats. Python objects (teal) exist only between the build and the DECREF; what crosses back to SV is a C value. The narrowing cast at the boundary is the only place a value can change silently.

0.6Reference counting: new, borrowed, stolen

Every PyObject has a reference count; when it drops to zero the object is freed. Each C API function documents what it hands you, and you need exactly three words:

A missing DECREF is a leak; an extra one is a use-after-free that crashes later, somewhere else. The lab measures the leak with sys.getallocatedblocks() around 1000 calls of a Python function that returns a fresh 1000-element list: 0 blocks per call with the DECREF, 2 blocks per call without it (the list object and its item array). In a regression with 107 transactions that is 20 million blocks and several GB, so the run dies of memory exhaustion instead of failing a check.

The lab prints Py_REFCNT(Py_None) = Py_REFCNT(7) = 3221225472 and Py_REFCNT(1000000) = 1. Since PEP 683 (3.12), None, True, small ints and interned strings are immortal: their count is pinned and INCREF/DECREF on them do nothing. A refcount bug that only touches small ints is therefore invisible, and the same bug crashes as soon as the model returns 1000000. Test bridges with big values and fresh objects, not with 0 and 1.

The chandle from step 03 composes naturally with this: a Python object created for an SV-side instance is a new reference owned by the SV side. The handle is the PyObject* itself, and the matching "destroy" import decrefs it. Exercise E10 builds the book's shift-register exercises (Ex. 12.3–12.6) this way.

C bridge (owns) static f_add static last_str path (borrowed) SV chandle h Python heap function add str "skcor…" list sys.path Shifter object leaked list+2 blocks / call sh_new … sh_free
Fig. 0.5Who owns what. Solid arrows are owned (new) references that someone must release; the dashed arrow is borrowed. The highlighted chandle makes SV the owner of a Python object, so freeing it is SV's job (sh_free), exactly like the C heap objects of step 03.

0.7Errors: from a Python exception to $error

The C API reports failure in-band: functions that return objects return NULL; functions that return numbers return -1 (or -1.0); in both cases a thread-local error indicator holds the exception. You must check every call and then either handle and clear the indicator or propagate it by returning failure yourself. The bridge is the last C frame before SV, so it must handle it: SV cannot see a Python exception.

PyErr_Print() is the quick way: it prints the traceback to Python's sys.stderr and clears the indicator. For a testbench you want the traceback in the SV log, at the SV time, counted as an error. Since 3.12, PyErr_GetRaisedException() returns the exception as one object and clears the indicator, replacing the three-pointer PyErr_Fetch. Format it with the traceback module and hand the text to an exported SV function. That makes the import context (step 05):

// errors.sv
import "DPI-C" context function int err_divide(input int a, input int b, output int q);
export "DPI-C" function sv_py_error;
function void sv_py_error(string where, string text);
  n_py_errors++;
  $display("LOG sv_py_error n=%0d where=%s", n_py_errors, where);
  $display("%s", text);
endfunction
/* errors.c */
static void report(const char *where) {
  PyObject *exc = PyErr_GetRaisedException();               /* new ref; indicator cleared */
  PyObject *lines = PyObject_CallOneArg(f_format, exc);     /* traceback.format_exception */
  PyObject *sep = PyUnicode_FromString("");
  PyObject *text = lines ? PyUnicode_Join(sep, lines) : NULL;
  sv_py_error(where, text ? PyUnicode_AsUTF8(text) : "<unformattable exception>");
  Py_XDECREF(text); Py_DECREF(sep); Py_XDECREF(lines); Py_DECREF(exc);
}
int err_divide(int a, int b, int *q) {
  PyObject *r = PyObject_CallFunction(f_divide, "ii", a, b);
  if (!r) { report("divide"); return -1; }
  *q = (int)PyLong_AsLong(r);
  Py_DECREF(r);
  return 0;
}

Measured output for err_divide(7, 0, q), a full traceback with 3.11+ column markers, inside the simulation log:

LOG sv_py_error n=1 where=divide
Traceback (most recent call last):
  File ".../lab/py/errs.py", line 6, in divide
    return _inner(a, b)
  File ".../lab/py/errs.py", line 10, in _inner
    return a // b                      # ZeroDivisionError when b == 0
           ~~^^~~
ZeroDivisionError: division by zero

LOG divide zero rc=-1

An uncleared error poisons the next call. err_stale calls PyLong_AsLong on a str (returns -1, sets TypeError), ignores it, and calls a perfectly good Python function. Measured: the call returns NULL with SystemError: <function divide at 0x…> returned a result with an exception set. The error surfaces in the wrong function, one call late. Rule: after any conversion that can fail, check v == -1 && PyErr_Occurred().

If the exported function calls $error, the first Python exception ends the simulation: Verilator's default error limit is 1, so $error becomes $stop, prints Aborting... and exits with status 1, without running final blocks (exercise E8, measured). Python is then never finalized, so its buffered output is lost too. Run with +verilator+error+limit+N to keep going (-Info: … $stop, ignored due to +verilator+error+limit), or count errors in SV and report them at the end as errors.sv does. Commercial simulators continue after $error by default.

raise ZeroDivision NULL GetRaisedException format_exception export sv_py_error() SV caller: rc = -1 return -1 the indicator is clear when control returns to SV
Fig. 0.6The error path. The exception never crosses into SV as such: the bridge consumes it, turns it into text plus a count, and returns an ordinary status code.

0.8Two stdout buffers, one file descriptor

$display in Verilator goes through C stdio (printf on stdout). Python's print goes through sys.stdout, an io.TextIOWrapper with its own buffer that ends in a write() on file descriptor 1. On a terminal both are line-buffered and everything looks fine. When the log is redirected to a file, as in every regression, both are block-buffered and the two streams no longer interleave. The buffering experiment alternates $display("SV i") and Python print("PY i") three times:

RunPython flushC fflush before each Python callLog order (measured)
buf_nonenonoSV 0, SV 1, SV 2, PY 0, PY 1, PY 2
buf_pyflush=TruenoPY 0, PY 1, PY 2, SV 0, SV 1, SV 2
buf_bothflush=TrueyesSV 0, PY 0, SV 1, PY 1, SV 2, PY 2
buf_unbufbuffered_stdio=0yesSV 0, PY 0, SV 1, PY 1, SV 2, PY 2

Flushing only one side makes it worse, not better: in buf_py Python's text jumps ahead of SV text that was printed earlier. Both buffers must be drained at every hand-over. The robust setup is buffered_stdio = 0 in the config (Python writes through, like python -u) plus fflush(stdout) at the top of every bridge function that may print. The alternative is to never print from the model and return text to SV for $display, which also puts it at the right simulation time.

display (SV) print (Python) C stdio buffer sys.stdout buffer fflush / full flush / finalize fd 1 → sim.log
Fig. 0.7Why output order breaks only when the log is a file. The file sees drains, not calls; each buffer drains when it is full, when flushed, or (for Python) at finalize.

0.9The GIL, and what --threads does to your bridge

CPython (the default, GIL-enabled build) lets only the thread holding the Global Interpreter Lock run Python code, and each OS thread that calls into Python needs a thread state. Py_Initialize creates one for the calling thread and leaves that thread holding the GIL. In a single-threaded simulation that is all you need: every DPI call arrives on the same thread that initialized Python, and the bridge never mentions the GIL. All of 0.2–0.8 relies on this.

Verilator's --threads N partitions the evaluated design into tasks run by N threads. The lab's threads experiment has eight independent lanes, each calling a Python function from its own always_ff, 400 clocks, and records which OS threads call the DPI function:

BuildImportGIL handlingResult (measured)
th_singlenon-purenoneOK · 1 thread · all 3200 calls on the init thread
th_npnon-pure, --threads 4noneSIGSEGV (rc 139)
th_np_safenon-pure, --threads 4Ensure/ReleaseOK · 1 thread · 0 calls on the init thread
th_purepure, --threads 4noneSIGSEGV (rc 139)
th_pure_safepure, --threads 4Ensure/ReleaseOK · 4 threads · 800 calls on the init thread
th_all_safenon-pure, --threads 4 --threads-dpi allEnsure/ReleaseOK · 4 threads

The surprising row is th_np_safe. With the default --threads-dpi pure, Verilator serializes non-pure DPI calls onto one thread, but that thread is not the one that ran the initial block and Py_Initialize. So even a non-pure import, which you might believe is "single-threaded", crashes without GIL handling (th_np). Marking imports pure (or --threads-dpi all) spreads them over all four threads, which is legal only because of the GIL calls below. Python still runs one call at a time, so you gain no speed in the model, only correctness.

/* threads.c with -DGIL_SAFE: the pattern for any multithreaded host */
void th_init(void) {
  Py_Initialize();
  ...                                         /* import, cache callables */
  main_ts = PyEval_SaveThread();              /* release the GIL held since init */
}
int th_call(int x) {
  PyGILState_STATE g = PyGILState_Ensure();   /* any thread: get a thread state + GIL */
  PyObject *r = PyObject_CallFunction(f_add, "ii", x, 1);
  int v = r ? (int)PyLong_AsLong(r) : -1;
  Py_XDECREF(r);
  PyGILState_Release(g);
  return v & 0xffff;
}
void th_fin(void) {
  PyEval_RestoreThread(main_ts);              /* take the GIL back on the init thread */
  Py_FinalizeEx();
}

Skipping PyEval_SaveThread after init is the deadlock variant: the init thread keeps the GIL while waiting for the worker threads, and the workers wait in PyGILState_Ensure forever. Calls are not free either: PyGILState_Ensure/Release add a mutex hand-off per call, which step 08 measures.

thread mainworker 1worker 2worker 3 init + SaveThread GIL GIL GIL GIL waits Restore + Finalize PyGILState_Ensure … Release around every call: one Python call at a time
Fig. 0.8The GIL pattern for a multithreaded simulator. The main thread gives the GIL away right after init and takes it back only to finalize; every DPI call, on whatever thread Verilator picked, brackets its Python work with PyGILState_Ensure/Release.

Python 3.13+ also ships a free-threaded build (python3.14t, PEP 703) without a GIL. The thread-state rules still apply (PyGILState_Ensure still attaches a thread state), but your Python model then runs truly in parallel and must be thread-safe itself. Not measured here; this lab uses the default GIL build (sys._is_gil_enabled() is True).

0.10Extension modules: does numpy work inside the simulator?

Yes, on this setup. cfg_sys imports numpy in 80–130 ms and computes np.arange(4) @ np.arange(4) = 14 inside the Verilator process. numpy's C extension needs libpython's symbols (PyExc_TypeError, PyLong_FromLong, …) at load time. Here the simulator executable links libpython3.14 (a framework dylib on macOS), so the dynamic loader resolves them from the process. Two things to remember:

With a commercial simulator that dlopens your DPI library, revisit the RTLD_GLOBAL note in 0.2.

0.11What changed since the Python 2 examples in this repo

The repo's legacy/python-embedded-C/ (Python 2.6, irun, CentOS 6) and legacy/bridge_irun/ follow the classic recipe from the 2000s: Py_Initialize, PyRun_SimpleString, PyObject_CallObject, PYTHONPATH exported in the shell. The ideas are unchanged. These are the details that now differ:

Then (Python 2.x)Now (3.12–3.14)
PyString_* for text and bytes, PyInt_*PyUnicode_* (text) vs PyBytes_* (bytes); only PyLong_*, unbounded
Py_SetProgramName, Py_SetPythonHome, PySys_SetArgvdeprecated since 3.11 (still in the 3.14 headers): use PyConfig (3.8) or PyInitConfig (3.14)
link -lpython2.6 by handpython3-config --embed --ldflags (3.8+)
PyEval_InitThreads() required before threadsno-op since 3.7, deprecated since 3.9 (the GIL exists from init)
PyErr_Fetch(&type, &value, &tb)PyErr_GetRaisedException() (3.12); PyErr_Fetch still works but is deprecated
Py_Initialize(); Py_Finalize(); per call (irun_tests/dpi.c)once per process; numpy cannot re-import after finalize
refcounts on everythingimmortal None/small ints (3.12): small-value tests hide bugs
vpi_printf / io_printf for outputprintf + fflush; mind Python's own buffer (0.8)

0.12Recipe: a robust Python bridge

  1. Build with python3-config --embed; include V<top>__Dpi.h in the bridge.
  2. One py_init(dir) from initial: PyConfig/PyInitConfig with buffered_stdio=0 and an absolute venv executable; then insert the model directory (plusarg) into sys.path; import every module (including numpy) and cache the callables. Return a status; $fatal on failure.
  3. Each bridge function: fflush(stdout); build args; call; on NULL report the exception through an exported SV function and return a status; convert with overflow checks; DECREF everything you own; keep returned strings alive in a static.
  4. If the simulator may call from other threads (--threads, commercial MT engines): PyEval_SaveThread after init, PyGILState_Ensure/Release around every call, PyEval_RestoreThread before finalize.
  5. final: Py_CLEAR every cached pointer, Py_FinalizeEx once; guard entry points against calls after it.
  6. Test the bridge with large values and fresh objects (immortals hide bugs), and run with the log redirected to a file.

Step 08 applies this recipe to real golden models (zlib.crc32 and a numpy FIR for the DUTs in common/rtl), adds arbitrary-width integers and arrays, and measures the cost per call against the C model of step 06.

§1 · ObjectivesWhat you should be able to do

Prerequisites: 02 (C types of DPI arguments), 03 (chandle), 04 (open arrays, string lifetime), 05 (export, context).

§2 · Exercises12 graded exercises, with solutions

★ basic · ★★ intermediate · ★★★ real design. Code solutions live in lab/ex/ and every output quoted below is asserted by check.py. "Ex. 12.N" is an exercise from Spear & Tumbush ch. 12 (the book has no solutions), redone with Python as the model.

E1 ★Predict. py_add converts the Python result with (int)PyLong_AsLong(r). What does $display("%0d", py_add(32'h7fff_ffff, 1)) print on this Mac, and what would change with PyLong_AsLongLong?

Show solution

-2147483648 (measured: LOG wrap r=-2147483648). Python computes 2147483648 exactly; PyLong_AsLong returns it as a 64-bit long (LP64), and the cast to the DPI int keeps the low 32 bits. PyLong_AsLongLong changes nothing, since the value fits in 64 bits and the truncation happens at the cast. Only an explicit range check (py_add_checked) or PyLong_AsLongLongAndOverflow plus a check against INT32_MIN/MAX catches it. On Windows (LLP64) long is 32-bit and PyLong_AsLong itself would raise OverflowError and return -1.

E2 ★Find the bug (build line). A colleague builds with

verilator --binary tb.sv bridge.c -CFLAGS "$(python3-config --cflags)" -LDFLAGS "$(python3-config --ldflags)"

and gets Undefined symbols for architecture x86_64: "_PyList_Insert" ... "_Py_Initialize". Why, and what is the fix? Why does Python make this the default?

Show solution

Since 3.8, python3-config --ldflags does not include -lpython3.X (lab build noembed, same error). The default targets extension modules, which must not link libpython: they are loaded into an interpreter that already provides the symbols (often a python3 executable with libpython linked statically), and linking the library again would load a second, separate copy. An embedder is the program that provides those symbols, so it asks for them: python3-config --embed --ldflags. Two smaller issues: quoting the whole flag string as one -LDFLAGS argument works for Verilator but is fragile (the lab's py_flags passes one word per flag), and --cflags brings optimization and warning flags you may not want. --includes is enough.

E3 ★Find the bug. This init "cleans up" after itself. It works for a while, then crashes at an unrelated import. Why?

PyObject *path = PySys_GetObject("path");
PyObject *dir = PyUnicode_FromString("py");
PyList_Insert(path, 0, dir);
Py_DECREF(dir);
Py_DECREF(path);            /* tidy up */
Show solution

PySys_GetObject returns a borrowed reference: the sys module owns sys.path. Decrefing it drops the module's own reference; when the count hits zero the list is freed while sys.path still points at it. The next import walks a freed list: a crash or garbage, far from the bug. Remove the last line. Py_DECREF(dir) is right: PyUnicode_FromString returned a new reference and PyList_Insert added its own, it does not steal.

E4 ★Predict. Stdout is redirected to a file. SV does $display("SV 0"), then calls Python print("PY 0", flush=True), then $display("SV 1"), then print("PY 1", flush=True). The bridge does not call fflush. What order lands in the file?

Show solution

PY 0, PY 1, SV 0, SV 1 (lab buf_py, with three iterations). Python's flush writes straight to fd 1; the $display text sits in C stdio's block buffer until it fills or until the exit flush. Flushing one side is worse than flushing none: with no flushes you get SV…, PY… (Python's buffer drains at finalize, after C's), which at least keeps each source in order. Fix: fflush(stdout) in the bridge before calling Python, plus flush=True or buffered_stdio=0 (buf_both, buf_unbuf).

E5 ★★Ex. 12.1 with a Python model. Write shift_py(int unsigned i, int n): a 32-bit shift, left for n > 0, right (logical) for n < 0, unchanged for n == 0. The model is Python, the bridge is C. First try the "obvious" model (i << n) & 0xFFFFFFFF: what happens for n = -4?

Show solution
# ex/py/exmods.py
MASK = 0xFFFF_FFFF
def shift(i, n):
    i &= MASK
    return (i << n) & MASK if n >= 0 else i >> -n
/* ex/shift.c: "I" passes the unsigned value, so Python sees 0x800000F1, not a negative int */
unsigned int shift_py(unsigned int i, int n, int naive) {
  PyObject *r = PyObject_CallFunction(naive ? f_naive : f_shift, "Ii", i, n);
  if (!r) { ... print type and message ...; return 0xDEADBEEF; }
  unsigned int v = (unsigned int)PyLong_AsUnsignedLong(r);
  Py_DECREF(r);
  return v;
}

Measured on 32'h8000_00F1: n=4 → 00000f10, n=-4 → 0800000f, n=0 → 800000f1; shift_py(1, 31) → 80000000; shift_py(32'hFFFF_FFFF, -40) → 0. The naive model raises ValueError: negative shift count (Python, unlike C, refuses negative shifts instead of invoking undefined behavior), and the bridge returns the sentinel deadbeef. If you had passed i with "i", Python would see -2147483407. This model masks it back to 32 bits first, but a model that does not (say return i >> 4) would sign-extend, because Python's >> on a negative int is arithmetic. Pass unsigned SV types with "I"/"K".

E6 ★★Find the bug. This works in a unit test and returns garbage strings (sometimes) in a long simulation. Why? Give two fixes.

const char *py_name(int id) {
  PyObject *r = PyObject_CallFunction(f_name, "i", id);
  const char *s = PyUnicode_AsUTF8(r);
  Py_DECREF(r);
  return s;
}
Show solution

PyUnicode_AsUTF8 returns a pointer into the str object's cached UTF-8 buffer. After Py_DECREF(r) the object may be freed (if the function returned a fresh string) and the pointer dangles until the simulator copies it. In a short test the memory is usually not reused yet. If the function returns an interned or immortal string (say a literal), the object survives and the bug hides completely. Fixes: (1) keep the result alive until the next call, as py_reverse does with Py_XSETREF(last_str, r); (2) copy into a static C buffer (strncpy) and decref immediately. Both are valid because the SV side copies the returned string at the end of the call (step 04). Not thread-safe without the GIL (0.9).

E7 ★★Write. Make the bridge self-initializing: no py_init import, the module directory comes from the environment variable PY_DPI_PATH (default .), and initialization happens exactly once whatever the call order. SV still finalizes in final, but only if Python was ever started.

Show solution
/* ex/lazy.c */
static PyObject *f_add;
static int inits;
static int ensure(void) {
  if (f_add) return 0;
  inits++;
  Py_Initialize();
  const char *dirs = getenv("PY_DPI_PATH");
  PyObject *path = PySys_GetObject("path"), *dir = PyUnicode_FromString(dirs ? dirs : ".");
  PyList_Insert(path, 0, dir);
  Py_DECREF(dir);
  PyObject *m = PyImport_ImportModule("toys");
  if (!m) { PyErr_Print(); return -1; }
  f_add = PyObject_GetAttrString(m, "add");
  Py_DECREF(m);
  return f_add ? 0 : -1;
}
int lazy_add(int a, int b) { if (ensure()) return 0; ... }
void lazy_fin(void) { if (f_add) { Py_CLEAR(f_add); Py_FinalizeEx(); } }

Measured with PY_DPI_PATH=py: LOG lazy a=3 b=7 inits=1. Trade-off: a failing import now shows up at the first transaction, not at time 0, and the error path must exist in every entry point. Note that ensure() is not thread-safe: under --threads, two first calls can race into Py_Initialize. Either init explicitly from initial or guard ensure with pthread_once.

E8 ★★Write. py_eval(string expr, output real r) evaluates a Python expression (with math available) and returns 0, or -1 after reporting TypeName: message through an exported SV function that calls $error. Test with math.sqrt(2) * 2**10, sum(range(101)), 1/0, 'abc'. What happens on Verilator, and how do you get all four results?

Show solution
/* ex/eval.c (core) */
static PyObject *globals;                  /* {'__builtins__': ..., 'math': ...} built at init */
static int fail(void) {
  PyObject *exc = PyErr_GetRaisedException();
  PyObject *msg = PyUnicode_FromFormat("%s: %S", Py_TYPE(exc)->tp_name, exc);
  sv_py_error(PyUnicode_AsUTF8(msg));        /* exported; import must be context */
  Py_DECREF(msg); Py_DECREF(exc);
  return -1;
}
int py_eval(const char *expr, double *r) {
  PyObject *v = PyRun_String(expr, Py_eval_input, globals, globals);
  if (!v) return fail();
  double d = PyFloat_AsDouble(v);
  Py_DECREF(v);
  if (d == -1.0 && PyErr_Occurred()) return fail();
  *r = d;
  return 0;
}

Measured: 1448.154688, 5050.0, then %Error: … python: ZeroDivisionError: division by zero, Verilog $stop, Aborting..., exit status 1. The fourth call never happens and neither does the final block (its LOG eval final ran is missing), so Python is never finalized. With +verilator+error+limit+10: all four run, the fourth reports TypeError: must be real number, not str (from PyFloat_AsDouble, caught by the -1.0 && PyErr_Occurred() check), and final runs.

E9 ★★Predict. A bridge that never mentions the GIL works in a single-threaded build. You rebuild with verilator --threads 4, keeping all imports non-pure ("so Verilator serializes them"). What happens, and why?

Show solution

Segmentation fault (lab th_np, rc 139). Serialized is not the same as "on the init thread". With the GIL-safe bridge, th_np_safe shows all 3200 calls on a single thread, with 0 of them on the thread that ran Py_Initialize. That worker thread has no Python thread state and does not hold the GIL, so the first PyObject_CallFunction dereferences a null thread state. Fix: PyEval_SaveThread() after init, PyGILState_Ensure/Release in every bridge function, PyEval_RestoreThread before finalize (0.9). Same result with pure imports, which Verilator does spread over 4 threads.

E10 ★★★Ex. 12.2–12.6 with Python objects. The book grows shift_c into a shift register with a load flag (12.2), multiple instances identified by address (12.3), a call counter shared by all instances (12.4), an initial value (12.5), and a class (12.6). Do all of it with the state in a Python class, one Python object per SV-side instance, held through a chandle. Who owns the object, and how do you prove nothing leaks?

Show solution
# ex/py/exmods.py
class Shifter:
    calls = 0                           # class attribute: shared (Ex. 12.4)
    def __init__(self, init=0):         # Ex. 12.5
        self.reg = init & MASK
    def step(self, i, n, ld):
        Shifter.calls += 1
        self.reg = shift(i if ld else self.reg, n)
        return self.reg
/* ex/shifter.c: the chandle IS the PyObject*, a new reference owned by SV */
void *sh_new(unsigned int init) {
  PyObject *obj = PyObject_CallFunction(cls, "I", init);
  if (!obj) { PyErr_Print(); return NULL; }
  live++;
  return obj;
}
unsigned int sh_step(void *h, unsigned int i, int n, svBit ld) {
  PyObject *r = PyObject_CallMethod((PyObject *)h, "step", "IiO", i, n, ld ? Py_True : Py_False);
  ...
}
void sh_free(void *h) { Py_XDECREF((PyObject *)h); live--; }

Measured: a = sh_new(1), b = sh_new(32'hF000_0000); first step (shift the register): a1=00000010 b1=0f000000; second (a loads 3<<1, b shifts again): a2=00000006 b2=00f00000; calls=4 live=2; after sh_free on both, live_after_free=0. SV owns each object from sh_new to sh_free: that pairing is the DPI equivalent of the book's "unique identifier = address of the storage" (Ex. 12.3), with the Python object's identity replacing the C struct's address. To prove there is no leak beyond the counter, measure sys.getallocatedblocks() before sh_new and after sh_free, as rc_blocks_per_call does. A class wrapper on the SV side (Ex. 12.6) would call sh_free from a method, since SV has no destructors.

E11 ★★★§12.9 revisited: spawn vs embed. Port Samples 12.51–12.52 (run a script with system(), return its exit status) to this machine, using python3 -c 'import sys; sys.exit(3)'. Then measure the cost per call of spawning python3 -c 'pass' against an embedded call to a builtin. Does the book's C compile as is?

Show solution
/* ex/spawn.c */
#include <sys/wait.h>          /* the book's <wait.h> is Linux-only */
int call_cmd(const char *cmd) {
  int status = system(cmd);      /* macOS WEXITSTATUS needs an lvalue */
  return WEXITSTATUS(status);
}

Two portability fixes to Sample 12.52: <wait.h> does not exist on macOS (<sys/wait.h> is POSIX), and WEXITSTATUS(system(cmd)) fails to compile here because the macOS macro takes the address of its argument: error: cannot take the address of an rvalue of type 'int' (Verilator compiles the file as C++). Measured: call_cmd returns 3; spawn costs about 40 ms per call on an idle machine; an embedded call to abs about 0.04 µs, six orders of magnitude apart. A Python call to your own function costs more than a builtin (step 08 measures it), but the conclusion holds: spawning is fine for a once-per-test script, never for a per-transaction model.

E12 ★★★Design. Your team keeps golden models in a repo with a requirements.txt (numpy, scipy) and wants regressions to use exactly that environment, not whatever is installed on each machine. How do you configure the embedded interpreter, and what do you verify at time 0?

Show solution

Create a venv per checkout (python3 -m venv .venv && .venv/bin/pip install -r requirements.txt). In py_init, use PyConfig/PyInitConfig with executable set to the absolute path of .venv/bin/python (passed as a plusarg). CPython then finds pyvenv.cfg and uses the venv's site-packages. Set buffered_stdio = 0; add the model directory to sys.path after init (not through module_search_paths, which replaces the stdlib path: cfg_replace dies with Failed to import encodings module). At time 0, print sys.executable, sys.prefix != sys.base_prefix and the versions of the imported model packages, and $fatal if the venv is not active. Measured: a plain venv makes numpy unavailable (cfg_venv: ModuleNotFoundError), which is the isolation you want; a --system-site-packages venv (cfg_venv_sys) sees the system numpy, convenient but not reproducible. Also check that the venv's Python version equals the one you linked: a venv made with 3.13 next to a simulator linked with 3.14 loads 3.14 with incompatible extension modules.

§3 · Quiz14 self-grading questions

Pick an answer and press Check. Every explanation covers the right answer and the tempting wrong one.

1. You link the simulator with python3-config --ldflags (no --embed). What happens?

Since 3.8 --ldflags omits -lpython3.X (meant for extension modules); only --embed adds it. The include path comes from --includes/--cflags, so (d) is a different mistake; nothing links libpython for you (b).

2. A test never calls Py_FinalizeEx. Python printed three lines without flush=True and registered an atexit handler. Stdout goes to a file. What is in the file?

Measured (life_nofin): Python's sys.stdout buffer is drained by Python itself, at flush or finalize. The process exits through C exit(), which only flushes C stdio. (d) is the right instinct for $display text, not for Python's buffer.

3. Why not call Py_Initialize() and Py_FinalizeEx() inside every DPI call, as the old irun_tests/dpi.c does?

Measured: ~24 ms per cycle on an idle machine, and numpy's second import fails with cannot load module more than once per process. Repeated init/finalize cycles are allowed (json works 20 times), so (b) is false. The problem is cost and extension modules.

4. PySys_GetObject("path") returns…

The sys module owns the list. Decrefing it frees sys.path under the interpreter's feet (E3). It is the real list, not a copy, which is why PyList_Insert on it changes the import path.

5. basic.c does dir = PyUnicode_FromString(moddir); PyList_Insert(path, 0, dir); Py_DECREF(dir);. Is the Py_DECREF correct?

PyList_Insert and PyList_Append do not steal (only PyList_SetItem/PyTuple_SetItem do). Only interned strings are immortal; a string built at run time from a C buffer is an ordinary object.

6. In Python 3.14, Py_REFCNT(Py_None) printed 3221225472 and a leak test with small ints showed no growth. Why is that dangerous?

PEP 683 pins the count of None, booleans, small ints and interned strings. Measured: big_int=1 for 1000000, an ordinary object. Test with values that create fresh objects.

7. A bridge function returns PyUnicode_AsUTF8(r) after Py_DECREF(r). Most tests pass. Which test is most likely to expose the bug?

The simulator copies the return value when the import returns, which is after the DECREF, so (d) is wrong. A literal is interned and stays alive (a), so the bug hides. Freshly allocated strings whose memory gets reused expose it.

8. PyLong_AsLong(s) was called on a str and the bridge ignored the -1. What does the next, correct, PyObject_CallFunction return?

Measured (err_stale): the call runs, finds an exception pending on return, and converts the situation into a SystemError. The bug surfaces one call late, in an innocent function. Check v == -1 && PyErr_Occurred() after conversions.

9. The import that reports Python errors through an exported SV function must be declared…

IEEE 1800 §35.5.3: only context imports may call exported subroutines (step 05). pure is the opposite promise. A function is enough, no time passes.

10. On Verilator, the exported error function calls $error on the first Python exception. What happens by default?

Measured (E8): Verilator's default error limit turns the first $error into a $stop; the run aborts without final. Use +verilator+error+limit+N or count errors in SV. (a) is the commercial-simulator default.

11. Stdout goes to a file. The bridge calls Python print(..., flush=True) but never fflush(stdout). Relative to earlier $display lines, Python's lines appear…

Measured (buf_py): PY 0, PY 1, PY 2, SV 0, SV 1, SV 2. Python writes through to fd 1 while the SV text waits in C's buffer. (b) is the no-flush case (buf_none).

12. With verilator --threads 4 and only non-pure imports, on which thread do the DPI calls run?

Measured (th_np_safe): distinct_threads=1 on_init_thread=0. Serialized ≠ main thread, so the GIL-unaware bridge crashes (th_np). Four threads appear with pure imports or --threads-dpi all.

13. Which sequence is correct for a bridge called from several threads?

Without PyEval_SaveThread (a) the init thread keeps the GIL and workers block forever in Ensure. PyEval_InitThreads (b) is a deprecated no-op. There is one interpreter per process here; initializing per thread (c) is meaningless.

14. You set cfg.module_search_paths_set = 1 and append your model directory to cfg.module_search_paths. What happens?

Measured (cfg_replace): setting the flag means "this is the whole path", so the stdlib is gone before init can import encodings. Insert into sys.path after init, or use PYTHONPATH.

§4 · LabRun it, predict it, check it

Everything lives in tutorial/07_embedding_python/lab/. One command builds 17 Verilator binaries in parallel, runs 29 experiments and asserts 71 facts:

cd tutorial/07_embedding_python/lab
python3 check.py            # builds + runs + asserts (about 1–3 min from scratch, seconds when cached)
python3 check.py --no-run   # only re-checks the logs in build/
FileExperimentPredict before running
basic.sv/.cinit, cached calls, every conversion, finalizewhat py_add(0x7fffffff, 1) and py_pow2(63) return
noembed (run.sh)same sources, linked without --embedwhich symbol the linker names first
lifecycle.sv/.c+mode=fin|nofin|guard|raw|cyclewhich runs lose the Python output; which crash
buffering.sv/.cflush combinations, log to filethe four line orders
errors.sv/.ctraceback to SV, stale error, leak per call, immortalsblocks leaked per call; refcount of None
config.sv/.cPyConfig vs PyInitConfig, venvs, the path trapwhether numpy imports in each venv
threads.sv/.c6 builds: --threads, pure, --threads-dpi, GIL on/offwhich builds crash; how many distinct threads call
ex/*.sv/.c, ex/pysolutions of E5, E7, E8, E10, E11the outputs quoted in §2

Results measured on this machine (Verilator 5.052, Python 3.14.7, macOS x86_64, log redirected to a file):

QuantityValueSource
embedded call to a builtin0.04 µsex_spawn
spawning python3 -c pass≈ 40 msex_spawn
init + import json + finalize≈ 24 mslife_cycle (idle machine)
first import numpy80–130 mscfg_sys, cfg_init
blocks leaked per call without DECREF (1000-element list)2errors
Py_REFCNT(None), Py_REFCNT(7)3221225472errors
numpy imports after re-init1 of 3life_cycle
calls on the init thread, --threads 4, non-pure0 of 3200th_np_safe
distinct calling threads, pure or --threads-dpi all4th_pure_safe, th_all_safe

Timings vary with machine load; check.py asserts orders of magnitude, not exact values. The behaviors (orders, crashes, error texts) are asserted exactly.

§5 · Cheat sheetThe bridge on one page

NeedSV sideC sidePitfall
link—python3-config --includes, --embed --ldflagsno --embed → undefined _Py_Initialize
initinitial if (py_init(dir)) $fatalPy_InitializeFromConfig / Py_InitializeFromInitConfigmodule_search_paths_set drops the stdlib
model path+pydir= plusargPyList_Insert(PySys_GetObject("path"), 0, dir)sys.path is borrowed
venvplusarg, absoluteexecutable = …/.venv/bin/pythonrelative path → RuntimeWarning
callimport "DPI-C" function …cached PyObject*; PyObject_CallFunction(f, "ii", …)never re-import per call
int resultoutput int + statusPyLong_AsLongLongAndOverflow + range check(int)PyLong_AsLong wraps silently
unsigned argint unsigned"I" / "K""i" makes it negative in Python
string resultfunction stringkeep the str alive (Py_XSETREF)DECREF before return = dangling
object statechandlenew ref from the constructor; Py_DECREF in freeSV owns it: free it
errorscontext import + exported reporterPyErr_GetRaisedException + traceback.format_exceptionuncleared → SystemError next call
output$displayfflush(stdout); buffered_stdio = 0one-sided flush reorders the log
threads--threads, pureSaveThread / GILState_Ensure/Release / RestoreThreadnon-pure ≠ init thread: crash
finalizefinal py_fin();Py_CLEAR all cached refs; Py_FinalizeExVerilator $error abort skips final

Rules

  1. One interpreter per process: initialize once, finalize once, at the end.
  2. Every PyObject* you get is new, borrowed or stolen; look it up and comment it.
  3. Every call can fail: check NULL / -1 && PyErr_Occurred(), and leave the indicator clear when you return to SV.
  4. Python exceptions become text in the SV log plus a status code, never a silent default value.
  5. Narrowing happens at the DPI boundary: range-check before casting.
  6. Two buffers share fd 1: flush C before calling Python, run Python unbuffered.
  7. Any thread other than the init thread needs PyGILState_Ensure, and with --threads that is every DPI thread.
  8. Heavy imports (numpy) go in py_init, so their cost lands at time 0.
  9. Test with big numbers and fresh objects: immortals hide refcount bugs.
  10. Pin the environment from the command line (plusargs, venv), never from the shell.

Sources: IEEE Std 1800-2023 clause 35, H.8.10 and H.9.3; Spear & Tumbush, SystemVerilog for Verification 3rd ed., §12.9 and Ex. 12.1–12.6; Python 3.14 documentation, Embedding Python in Another Application, Python Initialization Configuration (PEP 587, PEP 741), Initialization, Finalization, and Threads, PEP 683 (immortal objects); Verilator 5.052 manual (--threads, --threads-dpi). All measurements: lab/check.py.