Skip to content

Custom components without recompiling Lince

Lince's composition layer (lince_compose) resolves the type names a board uses — create ApbUart apbuart0 in a .lince script, m.create("ApbUart", …) in C++ — through a ComponentRegistry. A component library extends that registry at runtime: a shared object you build against the public Lince headers that registers new component types, so a custom peripheral becomes instantiable from board scripts without rebuilding the emulator.

The working example lives in examples/custom-component-lib/.

1. Implement the peripheral and register it

// scratchpad_component.cpp → libscratchpad_component.so
#include "lince/compose/component_plugin.hpp"
#include "lince/iperipheral.hpp"

namespace {

class ScratchPad final : public lince::IPeripheral { /* mmio_read/write… */ };

void register_components(lince::compose::ComponentRegistry& r) {
    using lince::compose::ComponentKind;
    using lince::compose::ParamBag;
    r.add("ScratchPad",
          {.kind = ComponentKind::Peripheral,
           .make = [](lince::PhysAddr base, const ParamBag& props)
               -> lince::Result<lince::runtime::PeripheralFactory> {
               // read `write pad0 key=value` properties from `props` here
               return lince::runtime::PeripheralFactory{
                   [base](const lince::PeripheralContext&) {
                       return std::make_unique<ScratchPad>(base);
                   }};
           }});
}

}  // namespace

LINCE_COMPONENT_LIBRARY(register_components)

LINCE_COMPONENT_LIBRARY exports the two C entry points the loader resolves: an ABI-version handshake (lince_component_abi_version) and the registration hook (lince_register_components). The handshake is checked before any C++ type crosses the boundary; a library built against an incompatible Lince reports a clean PluginLoadError instead of corrupting the process.

2. Build it as a module, headers only

find_package(Lince REQUIRED)           # or in-tree: lince::interfaces + include dirs
add_library(my_components MODULE my_components.cpp)
target_link_libraries(my_components PRIVATE lince::interfaces)

No Lince library is linked into the module — the hosting executable provides the symbols (lince-emu is built with CMake's ENABLE_EXPORTS; do the same in your own host application).

Toolchain contract — read before building

The entry points are C symbols, but everything behind them is C++. That works because, on a given Linux toolchain, C++ does have a stable binary contract: the Itanium C++ ABI (name mangling, vtable layout, calling conventions — implemented identically by GCC and Clang) plus the libstdc++ ABI, which has been backward-compatible since GCC 5. A component library and its host agree on that contract as long as you stay inside it:

  • Compiler: any GCC ≥ 13 or Clang ≥ 17 — the same floor as Lince itself (the public headers are C++20; an older compiler cannot build them anyway).
  • libstdc++ runtime rule: the runtime loaded by the host process must be at least as new as the one your compiler targets. Build the module with a toolchain of the same vintage as (or older than) the system's; a too-new module fails cleanly at load with a GLIBCXX_x.y.z not found error.
  • Headers: compile against the installed headers of the Lince you will load into. The lince_component_abi_version handshake rejects a library built against an incompatible Lince contract before any C++ type crosses the boundary.

Do NOT change any of the following — each one silently breaks the layout agreement without changing the mangled names, so nothing fails at load and the process corrupts instead:

  • Do not build with Clang's -stdlib=libc++. The Linux default (libstdc++) must stay on both sides.
  • Do not define _GLIBCXX_USE_CXX11_ABI=0 (some old devtoolset/conda toolchains set it). The modern default (1) must match the host's.
  • Do not build the module with -D_GLIBCXX_DEBUG (debug-mode containers have different layouts).

The asymmetry to keep in mind: a mangling-level mismatch (wrong compiler family, wrong stdlib) fails loudly at dlopen thanks to RTLD_NOW; a layout-level mismatch (the three bullets above) is silent. The list is short and absolute for exactly that reason.

3. Load and compose

From the CLI (repeatable, loaded before the script is parsed):

lince-emu --component-lib libscratchpad_component.so \
          --machine board.lince --turbo
# board.lince
create ScratchPad pad0
write  pad0 magic=0xCAFE0001
map    mem0 0x80000800 0x100 pad0

Or programmatically:

#include "lince/compose/plugin_loader.hpp"

auto registry = lince::compose::builtin_registry();
std::string diag;
auto lib = lince::compose::load_component_library(
    "libscratchpad_component.so", registry, &diag);
if (!lib) { /* diag explains: missing file / symbol / ABI mismatch */ }

lince::compose::Machine m{registry};
auto pad = m.create("ScratchPad", "pad0");

Lifetime and platform notes

  • Libraries are loaded RTLD_NOW | RTLD_LOCAL | RTLD_NODELETE: an unresolved symbol fails at load (not later inside a factory), plugin internals never collide between libraries, and the code stays resident for the process lifetime — destruction order between the ComponentLibrary handle, the registry, and any emulator built from it does not matter.
  • POSIX only (dlopen). Non-POSIX hosts get PluginLoadError.
  • Custom peripherals that should appear in the AMBA Plug&Play table implement IAmbaPnp and are mapped like any other device; the generative PnP builder publishes them automatically (suppress with write dev pnp_publish=0).

Policy: when to ship a component as a plugin

Compiled-in by default; plugin when the component has an owner, a license, or a dependency that is not Lince's:

  • Built-in peripherals stay compiled into the library. They participate in whole-system gates (the byte-exact PnP tables, JIT==switch determinism, the RTEMS suites) that are versioned and tested as one artifact; fragmenting them into shared objects would trade that for version-skew and deployment surface, and would turn every internal header refactor into an ecosystem-wide ABI event.
  • Use the plugin format for: third-party / out-of-tree models (the designed case — mission payloads, proprietary devices), components carrying heavy optional dependencies, and quick experiments against an installed lince-emu.
  • The seam is already uniform — builtin_registry() and a plugin's registration call the same ComponentRegistry::add() — so moving one component between the two forms later is a packaging decision, not an architectural one.