Skip to content

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:

  1. runs each spec's factory to build the plugin,
  2. calls attach(ctx) on every plugin (resolve targets, register observers),
  3. 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 in attach(), binds in start(), and on each observed event encodes + publishes;
  • a make_<proto>_sniffer_spec(name, config) helper that builds the PluginSpec.

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):

  • CANbus (controller name), origin (node/external), id, ext, rtr, dlc, data (bin).
  • SPIdev, cs, bits, mosi, miso.
  • MIL-1553src, rt, tr (tx/rx), sub, wc, rt2 (or -1), data (array of words), status (word or nil).
  • SpaceWiresrc, kind (packet/timecode), dir; packets add eep
  • data (bin), time-codes add raw.
  • serialsrc, 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 ZeroMQ PULL socket (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.