Plugin system¶
Plugins are host-facing instruments — sniffers, monitors, connectors, debug tools — that observe a running emulator through its seams. They are not modelled silicon: they have no MMIO, no place in the SoC memory map, and the core library never depends on a concrete plugin. They are a standalone-only extension surface (the SMP2 wrapper never instantiates them).
| Peripheral | Plugin | |
|---|---|---|
| Models | real silicon (a GRLIB core) | a host-side instrument |
| In the memory map | yes (MMIO) | no |
| Dependencies | core, dep-light | its own, opt-in (e.g. ZeroMQ) |
| Lifecycle owner | the SoC recipe | EmulatorConfig::plugins |
| Reaches the guest via | the bus / the CPU | observation seams |
The canonical plugins today are the comms sniffers (CAN, SPI, MIL-STD-1553, SpaceWire, serial), which republish observed bus traffic over ZeroMQ for external tools. They are the worked example throughout this page.
The IPlugin interface¶
IPlugin (lince/iplugin.hpp) is deliberately minimal — lifecycle and
packaging only. A plugin gains its capability by consuming the typed seams it
reaches through its PluginContext, not by this interface growing.
class IPlugin {
virtual std::string_view name() const = 0;
virtual Result<void> attach(const PluginContext& ctx) = 0; // wire into seams
virtual Result<void> start() = 0; // acquire resources
virtual void stop() noexcept = 0; // release; idempotent
};
Lifecycle¶
Plugins are declared in EmulatorConfig::plugins as a PluginSpec
(instance_name + factory), the plugin-world analogue of PeripheralSpec.
During Emulator::initialize(), after the peripherals are created and their
ports/buses are connected, the runtime:
- runs each spec's factory to build the plugin,
- calls
attach(ctx)on every plugin (resolve targets, register observers), - calls
start()on every plugin once they have all attached (open sockets),
and at teardown calls stop() on each (idempotent — it also runs from the
plugin's destructor). Running attach before any start means a plugin can rely on
its observation target existing before it acquires external resources.
Resolution: through the entity, never a protocol registry¶
A plugin must find what to observe. The rule (an
architectural decision) is that the Emulator is not a registry
of every protocol's buses and ports — that would make it a god-object. The
board owns the list of entities; it does not know about protocols. So the
only name-resolution seam in PluginContext is:
struct PluginContext {
IEntityRegistry* entities; // find_entity(name) — the ONLY resolver
IEmulatorObserver* observer; // per-instruction hook (tracers)
ILogger* logger;
ITimeSource* time;
std::uint32_t num_cores;
};
find_entity ranges over every entity the machine holds (peripherals and
host-facing plugins) and returns the common IEntity base; the plugin then
navigates to the capability it needs with interface_cast<T>(...). A plugin
reaches a protocol endpoint through the entity that owns it:
| Endpoint | Owner | How the plugin reaches it |
|---|---|---|
| SpaceWire link | a GrSpw2 / router port |
interface_cast<IPeripheral>(e)->find_port(name) → ISpaceWirePort |
| CAN bus | an OcCan / GrCan controller |
interface_cast<ICanBusProvider>(e) → can_bus() |
| SPI bus | a SpiCtrl master |
interface_cast<ISpiBusProvider>(e) → spi_bus() |
| MIL-1553 bus | a Gr1553b / B1553Brm core |
interface_cast<IMilStdBusProvider>(e) → mil1553_bus() |
| UART stream | an ApbUart |
interface_cast<ICharacterDeviceProvider>(e) → character_device() |
A point-to-point port is a typed IPort, so it is reached with the existing
find_port mechanism. A shared bus is not a port; the controller on it exposes
it through a small provider interface (it already holds the bus pointer from
its on_connected / connect_bus hook). The shared-bus object is still
board-created — it is the wire between chips, owned by no single controller —
but it is accessed through a controller on it, so the runtime stays out of the
protocols.
Observation seams¶
The passive-sniffer hooks live on the bus / port / device the peripheral owns, never on the Emulator. Each is a default-no-op virtual on the protocol interface, so impls that don't support observation still conform:
| Protocol | Seam | Notifier |
|---|---|---|
| CAN | ICanBusObserver |
CanBus (after node fan-out, uncounted) |
| SPI | ISpiBusObserver |
SpiBus::transfer (cs, mosi, miso) |
| SpaceWire | ISpaceWireObserver |
GrSpw2 TX and RX (packets + time-codes), pre-DMA-gate so the full wire is seen |
| serial | ICharacterDeviceObserver |
StdoutCharDevice::write_char |
| MIL-1553 | (none — native Bus Monitor) | MilStdBus notifies every is_monitor() terminal |
An observer is passive: it is never counted as an interested receiver (a CAN
node would otherwise be read as an acknowledge), has no acceptance filter, and
cannot transmit. MIL-1553 needs no new seam — the Bus Monitor is a first-class
1553 terminal type, so the sniffer simply attaches as an IMilStdTerminal with
is_monitor() == true.
The comms sniffer family¶
The five sniffers share one shape:
- a socket-free envelope library (
*_envelope.cpp) that builds the msgpack payload and the ZeroMQ topic — unit-tested without a socket; - the sniffer itself (
IPlugin+ the protocol's observer), which resolves its peripheral inattach(), binds instart(), and on each observed event encodes + publishes; - a
make_<proto>_sniffer_spec(name, config)helper that builds thePluginSpec.
They share the ZeroMQ transport — lince_plugin_common's ZmqPublisher (a
non-blocking PUB endpoint, drop-on-overflow so a slow subscriber never
back-pressures the simulation). The plugins/ aggregator declares the heavy
dependencies once and exposes a lince_add_sniffer(proto …) helper; everything
is gated behind the opt-in LINCE_BUILD_SNIFFERS CMake option.
Dependency policy¶
Plugin dependencies are flexible at the plugin level, not the project level.
ZeroMQ and msgpack are the sniffers' dependencies, fetched (via FetchContent)
only when LINCE_BUILD_SNIFFERS is on — the core, runtime, RTEMS and SMP2 paths
stay dep-light and a normal build / CI fetch nothing new. Each plugin lives in
its own subdirectory; this is the convention a future plugin follows.
Wire protocol¶
Each sniffer publishes a ZeroMQ PUB socket. Every message is two frames —
[topic, payload]:
- the topic encodes the identifier, so subscribers filter natively with
ZeroMQ
SUBSCRIBE(prefix match) without receiving payloads they don't want, and change the filter live without reconfiguring the simulation; - the payload is a self-describing msgpack map.
Filtering is therefore a subscriber concern: a sniffer always publishes the whole wire (full visibility is its purpose, and these buses are low-rate so source-side dropping would save nothing and be redundant with what any consumer can do).
Topics¶
Format <proto>/<id>/, with a trailing slash so an exact-id subscription is not
a prefix of a longer id; a shorter prefix selects a range, <proto>/ a whole
protocol, "" everything.
| Protocol | Topic | Example |
|---|---|---|
| CAN | can/<hexid>/ |
can/100/ |
| SPI | spi/cs<n>/ |
spi/cs1/ |
| MIL-1553 | 1553/rt<n>/ |
1553/rt7/ |
| SpaceWire | spw/<hexaddr>/, spw/tc/ |
spw/20/ |
| serial | serial/tx/, serial/rx/ |
serial/tx/ |
Envelopes¶
Each payload is a msgpack map with a proto discriminator and a t (simulated
observation time, ns):
- CAN —
bus(controller name),origin(node/external),id,ext,rtr,dlc,data(bin). - SPI —
dev,cs,bits,mosi,miso. - MIL-1553 —
src,rt,tr(tx/rx),sub,wc,rt2(or-1),data(array of words),status(word or nil). - SpaceWire —
src,kind(packet/timecode),dir; packets addeep data(bin), time-codes addraw.- serial —
src,dir,byte.
Bind: the bidirectional connector¶
Sniffing is read-only. A connector is the other half — a bidirectional
bridge between a bus and an external system (QEMU-style binding). The
CanConnector is the worked example:
- Outbound (bus → external): it observes the bus and publishes, exactly like the sniffer (same envelope + topic).
- Inbound (external → bus): an external source
PUSHes injection requests to a ZeroMQPULLsocket (ZmqPuller); the connector decodes each (decode_can_frame— a msgpack{id, ext?, rtr?, dlc?, data?}) and injects it onto the bus as an external injector (bus->send(nullptr, frame)).
The inbound poll is the crux. A plugin has no per-round callback, and injection
must happen on the simulation thread (the bus has no internal lock). So the
connector is also an IEvent: it schedules a recurring poll through
PluginContext::scheduler (the same IScheduler a timed peripheral uses), and
each firing drains the PULL socket, injects, and re-arms — so external input is
applied race-free at a bounded cadence even while the bus is idle. A frame the
connector injects is not echoed on its own outbound stream (it flips an
injecting_ guard during bus->send), though other observers still see it — it
is real bus traffic.
Consuming it¶
The lince-sniffer Python package
decodes these envelopes into typed objects and provides a SUB client + CLI. The
sniffer-demo tool (built with the plugins) runs an emulator with a serial
sniffer on the console UART, so a guest's output flows to both stdout and the
sniffer stream — the end-to-end check that the whole pipeline works.