This post is about the current implementation of cocotb bootstrap from VPI to python. I wrote an earlier post about it but things change all the time and there has been a good refactor (in 2.0, I think?).

Setup Link to heading

When cocotb Makefiles are used, they build COCOTB_BOOTSTRAP which has shared object and entry point separated with ,

GPI_ENTRY_   := $(python -m cocotb_tools.config --gpi-entry-point)   # libgpi.so,gpi_initialize
PYGPI_ENTRY_ := $(python -m cocotb_tools.config --pygpi-entry-point) # cocotb.simulator*.so,pygpi_initialize
COCOTB_BOOTSTRAP := $(GPI_ENTRY_):$(LIBPYTHON_LOC):$(PYGPI_ENTRY_)

VPI bootstrap Link to heading

First point of entry is VPI callback registered with vlog_startup_routines, where it registers call to cocotb_bootstrap_entry in src/cocotb/share/lib/gpi/bootstrapper.cpp

// VPI: Verilog simulators call vlog_startup_routines[].
COCOTB_EXPORT void (*vlog_startup_routines[])() = {cocotb_bootstrap_entry,
                                                   nullptr};

load_bootstrap iterates over COCOTB_BOOTSTRAP and calls the entry point in each library in the following format LIB:FUNC,LIB:FUNC. This effectively calls gpi_initialize and pygpi_initialize

static int load_bootstrap() {
    const char *bootstrap_env = getenv("COCOTB_BOOTSTRAP");
    if (!bootstrap_env || !bootstrap_env[0]) {
        fprintf(stderr, "[%s:%d]: COCOTB_BOOTSTRAP is not set or is empty\n",
                __FILE__, __LINE__);
        return -1;
    }

    std::string entries = bootstrap_env;
    std::string::size_type start = 0;
    while (start <= entries.size()) {
        const auto next_separator = entries.find(entry_separator, start);
        const auto end = next_separator == std::string::npos ? entries.size()
                                                             : next_separator;
        const std::string entry =
            trim_whitespace(entries.substr(start, end - start));

        if (!entry.empty()) {
            const auto function_separator = entry.rfind(entry_point_separator);
            const bool has_entry_point =
                function_separator != std::string::npos;
            const std::string library =
                has_entry_point
                    ? trim_whitespace(entry.substr(0, function_separator))
                    : entry;
            const std::string function =
                has_entry_point
                    ? trim_whitespace(entry.substr(function_separator + 1))
                    : "";
            if (library.empty() || (has_entry_point && function.empty())) {
                fprintf(stderr,
                        "[%s:%d]: Invalid entry '%s' in COCOTB_BOOTSTRAP\n",
                        __FILE__, __LINE__, entry.c_str());
                return -1;
            }

            void *library_handle = utils_dyn_open(library.c_str());
            if (!library_handle) {
                fprintf(stderr, "[%s:%d]: Unable to load library '%s'\n",
                        __FILE__, __LINE__, library.c_str());
                return -1;
            }

            if (has_entry_point) {
                void *function_handle =
                    utils_dyn_sym(library_handle, function.c_str());
                if (!function_handle) {
                    fprintf(
                        stderr,
                        "[%s:%d]: Unable to find entry point '%s' in library "
                        "'%s'\n",
                        __FILE__, __LINE__, function.c_str(), library.c_str());
                    return -1;
                }

                auto entry_func =
                    reinterpret_cast<bootstrap_entry_func>(function_handle);
                const int error = entry_func();
                if (error) {
                    fprintf(
                        stderr,
                        "[%s:%d]: Entry point '%s' in library '%s' failed\n",
                        __FILE__, __LINE__, function.c_str(), library.c_str());
                    return -1;
                }

gpi_initialize Link to heading

gpi_initialize calls the core GPI depending on simulator, mainly choosing VPI/FLI/VHPI so python can access signals and register value change callbacks. GPI is an interesting piece of engineering and worth several posts.

int gpi_initialize() {
    gpi_init_logging_and_debug();
    gpi_entry_point();

    // Initialization requires at least one GPI implementation.
    return gpi_has_registered_impl() ? 0 : 1;
}
void gpi_entry_point() {
    LOG_TRACE("=> [ GPI Init ]");

    /* Lets look at what other libs we were asked to load too */
    char *lib_env = getenv("GPI_IMPL");

    if (lib_env) {
        std::string lib_list = lib_env;
        std::string const delim = ",";
        std::vector<std::string> to_load;

        size_t e_pos = 0;
        while (std::string::npos != (e_pos = lib_list.find(delim))) {
            std::string lib = lib_list.substr(0, e_pos);
            lib_list.erase(0, e_pos + delim.length());

            to_load.push_back(lib);
        }
        if (lib_list.length()) {
            to_load.push_back(lib_list);
        }

        gpi_load_libs(to_load);
    }

    gpi_print_registered_impl();

    LOG_TRACE("[ GPI Init ] =>");
}

pygpi_initialize Link to heading

At this point, in src/cocotb/share/lib/pygpi/embed.cpp, pygpi_initialize sets up the python interpreter and registers the start of sim callback start_of_sim_time.

extern "C" PYGPI_EXPORT int pygpi_initialize(void) {
    pygpi_init_debug();
    pygpi_logging_initialize();

    PYGPI_LOG_TRACE("GPI Init => [ PYGPI Init ]");
    DEFER(PYGPI_LOG_TRACE("[ PYGPI Init ] => GPI Init"));

    if (python_init_called) {
        // LCOV_EXCL_START
        PYGPI_LOG_ERROR("PyGPI library initialized again!");
        return -1;
        // LCOV_EXCL_STOP
    }
    python_init_called = 1;

    // must set program name to Python executable before initialization, so
    // initialization can determine path from executable

    static wchar_t interpreter_path[PATH_MAX], sys_executable[PATH_MAX];

    if (get_interpreter_path(interpreter_path, sizeof(interpreter_path))) {
        // LCOV_EXCL_START
        return -1;
        // LCOV_EXCL_STOP
    }
    PYGPI_LOG_INFO("Using Python %s interpreter at %ls", PY_VERSION,
                   interpreter_path);

    /* Use the new Python Initialization Configuration from Python 3.8. */
    PyConfig config;
    PyStatus status;

    PyConfig_InitPythonConfig(&config);
    DEFER(PyConfig_Clear(&config));

    PyConfig_SetString(&config, &config.executable, interpreter_path);

    status = PyConfig_SetArgv(&config, 1, argv);
    if (PyStatus_Exception(status)) {
        // LCOV_EXCL_START
        PYGPI_LOG_ERROR("Failed to set ARGV during the Python initialization");
        if (status.err_msg != NULL) {
            PYGPI_LOG_ERROR("\terror: %s", status.err_msg);
        }
        if (status.func != NULL) {
            PYGPI_LOG_ERROR("\tfunction: %s", status.func);
        }
        return -1;
        // LCOV_EXCL_STOP
    }

    status = Py_InitializeFromConfig(&config);
    if (PyStatus_Exception(status)) {
        // LCOV_EXCL_START
        PYGPI_LOG_ERROR("Failed to initialize Python");
        if (status.err_msg != NULL) {
            PYGPI_LOG_ERROR("\terror: %s", status.err_msg);
        }
        if (status.func != NULL) {
            PYGPI_LOG_ERROR("\tfunction: %s", status.func);
        }
        return -1;
        // LCOV_EXCL_STOP
    }

    /* Sanity check: make sure sys.executable was initialized to
     * interpreter_path. */
    PyObject *sys_executable_obj = PySys_GetObject("executable");
    if (sys_executable_obj == NULL) {
        // LCOV_EXCL_START
        PYGPI_LOG_ERROR("Failed to load sys.executable");
        // LCOV_EXCL_STOP
    } else if (PyUnicode_AsWideChar(sys_executable_obj, sys_executable,
                                    sizeof(sys_executable)) == -1) {
        // LCOV_EXCL_START
        PYGPI_LOG_ERROR("Failed to convert sys.executable to wide string");
        // LCOV_EXCL_STOP
    } else if (wcscmp(interpreter_path, sys_executable) != 0) {
        // LCOV_EXCL_START
        PYGPI_LOG_ERROR(
            "Unexpected sys.executable value (expected '%ls', got '%ls')",
            interpreter_path, sys_executable);
        // LCOV_EXCL_STOP
    }

    gpi_register_start_of_sim_time_callback(start_of_sim_time, nullptr);
    gpi_register_finalize_callback(finalize, nullptr);

    /* Before returning we check if the user wants pause the simulator thread
       such that they can attach */
    const char *pause = getenv("COCOTB_ATTACH");
    if (pause) {
        unsigned long sleep_time = strtoul(pause, NULL, 10);
        /* This should check for out-of-range parses which returns ULONG_MAX and
           sets errno, as well as correct parses that would be sliced by the
           narrowing cast */
        if (errno == ERANGE || sleep_time >= UINT_MAX) {
            // LCOV_EXCL_START
            PYGPI_LOG_ERROR(
                "COCOTB_ATTACH only needs to be set to ~30 seconds");
            return -1;
            // LCOV_EXCL_STOP
        }
        if ((errno != 0 && sleep_time == 0) || (sleep_time <= 0)) {
            // LCOV_EXCL_START
            PYGPI_LOG_ERROR(
                "COCOTB_ATTACH must be set to an integer base 10 or omitted");
            return -1;
            // LCOV_EXCL_STOP
        }

        PYGPI_LOG_INFO(
            "Waiting for %lu seconds - attach to PID %d with your debugger",
            sleep_time, getpid());
        sleep((unsigned int)sleep_time);
    }
    return 0;
}

Then start_of_sim_time calls python load_entry which calls cocotb test discovery and execution.

src/cocotb/share/lib/pygpi/embed.cpp

static int start_of_sim_time(void *) {
    PYGPI_LOG_TRACE("GPI Start Sim => [ PYGPI Start ]");
    DEFER(PYGPI_LOG_TRACE("[ PYGPI Start ] => GPI Start Sim"));

    // Check that we are not already initialized
    if (embed_init_called) {
        // LCOV_EXCL_START
        PYGPI_LOG_ERROR("PyGPI library initialized again!");
        return -1;
        // LCOV_EXCL_STOP
    }
    embed_init_called = 1;

    // Ensure that the current thread is ready to call the Python C API
    auto gstate = PyGILState_Ensure();
    DEFER(PyGILState_Release(gstate));

    c_to_python();
    DEFER(python_to_c());

    auto entry_utility_module = PyImport_ImportModule("pygpi.entry");
    if (!entry_utility_module) {
        // LCOV_EXCL_START
        PyErr_Print();
        return -1;
        // LCOV_EXCL_STOP
    }
    DEFER(Py_DECREF(entry_utility_module));

    auto cocotb_retval =
        PyObject_CallMethod(entry_utility_module, "load_entry", nullptr);

Python load_entry Link to heading

In src/pygpi/entry.py, load_entry calls the following registered python functions.

def load_entry() -> None:
    """Gather entry point information by parsing :envvar:`PYGPI_USERS`."""

    entry_points_str: list[str] = _env.get_list(
        "PYGPI_USERS",
        (
            "cocotb_tools._coverage:start_cocotb_library_coverage",
            "cocotb.logging:_configure",
            "cocotb._init:init_package_from_simulation",
            "cocotb.regression:_run_regression",
        ),
    )

    # Parse the entry point string of the form "module:func,module:func,...".
    # Any failure prevents any entry points from being loaded.
    entry_points: list[tuple[str, str]] = []
    try:
        for entry_point_str in entry_points_str:
            entry_module_str, entry_func_str = entry_point_str.split(":")
            # TODO maybe some basic validation of the module and function names.
            # WITHOUT IMPORTING THEM.
            entry_points.append((entry_module_str, entry_func_str))
    except Exception as e:
        raise RuntimeError(f"Failure to parse PYGPI_USERS {entry_point_str!r}") from e

    # Run all entry points.
    # Expect failure to stop the loading of any additional entry points.
    for entry_module_str, entry_func_str in entry_points:
        entry_module = importlib.import_module(entry_module_str)
        entry_func: Callable[[], object] = operator.attrgetter(entry_func_str)(
            entry_module
        )
        entry_func()

Regression - Discovery and running Link to heading

Finally, _run_regression calls _setup_regression_manager, which creates the RegressionManager and discovers tests, then calls _manager_inst.start_regression() to run them.

def _run_regression() -> None:
    """Setup and run a regression."""

    # sys.path normally includes "" (the current directory), but does not appear to when Python is embedded.
    # Add it back because users expect to be able to import files in their test directory.
    sys.path.insert(0, "")

    # From https://www.python.org/dev/peps/pep-0565/#recommended-filter-settings-for-test-runners
    # If the user doesn't want to see these, they can always change the global
    # warning settings in their test module.
    if not sys.warnoptions:
        warnings.simplefilter("default")

    _setup_regression_manager()

    if _env.get_bool("COCOTB_LIST_TESTS", False):
        _manager_inst.list_tests()
    else:
        _manager_inst.start_regression()
        shutdown.register(_manager_inst._on_sim_end)