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 founderror. - Headers: compile against the installed headers of the Lince you will
load into. The
lince_component_abi_versionhandshake 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):
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 theComponentLibraryhandle, 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
IAmbaPnpand are mapped like any other device; the generative PnP builder publishes them automatically (suppress withwrite 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 sameComponentRegistry::add()— so moving one component between the two forms later is a packaging decision, not an architectural one.