Skip to content

Peripheral system

Audience — Developer Manual

This page explains how the peripheral framework is conceived: the IPeripheral contract, the declarative PeripheralSpec model, the PeripheralContext injection aggregate, the lifecycle (including the connect_ports phase), MMIO dispatch, IRQ/DMA wiring, the port system, the config validator, and the capability mix-ins (IMemoryRegion). For per-device register maps see the peripheral device pages; to write your own peripheral start at the custom-peripheral walkthrough.

After the Phase 9.3 refactor every peripheral on the emulated bus — built-in (IRQMP, GPTIMER, APBUART, MEMCTRL, GRGPIO) and user-defined alike — is described by a single declarative type, PeripheralSpec (src/runtime/include/lince/runtime/peripheral_spec.hpp), that lives inside EmulatorConfig. The recipes (gr712rc_config(), gr740_config() in src/runtime/src/emulator_config.cpp) are pure functions that return a vector of these specs; users can inspect, modify, replace, or extend any entry. There is no silicon-specific peripheral instantiation outside the recipes — even the IRQMP is just another spec.

Why a declarative model

The design follows the project's configuration-by-struct principle (see Design principles):

  • The Emulator core never branches on soc_family to decide which peripherals exist. It iterates cfg.peripherals and instantiates each spec through its factory.
  • A config is inspectable, mutable, and serialisable as plain data: the only opaque part is the factory lambda. A test, a GUI, or the future SMP2 wrapper can drop, add, or rewire a peripheral by editing the vector.
  • The same data model covers passive MMIO slaves, timed devices, interrupt sources, DMA masters, and port-wired peripherals — one interface, one spec shape.

The IPeripheral contract

Every peripheral implements lince::IPeripheral (src/interfaces/include/lince/iperipheral.hpp). One interface covers all flavours; methods a given device does not need are left empty.

class IPeripheral {
public:
    virtual std::string_view name() const = 0;          // "apbuart0" — logs/publication
    virtual AddressRange     mmio_range() const = 0;     // physical window on the bus
    virtual void             attach(const PeripheralContext& ctx) = 0;
    virtual void             reset() = 0;                 // power-on reset
    virtual Result<uint32_t> mmio_read (PhysAddr addr, AccessSize size) = 0;
    virtual Result<void>     mmio_write(PhysAddr addr, AccessSize size, uint32_t value) = 0;
    virtual void             tick(SimTimeNs now) = 0;     // advance timed state
    virtual void             publish(IPublisher& pub) = 0;

    // Optional capabilities (default no-op / nullptr):
    virtual IMemoryRegion* memory_region() noexcept { return nullptr; }
    virtual IPort*         find_port(std::string_view) noexcept { return nullptr; }
    virtual void           connect_ports(IPortResolver&) {}
};
Method Mechanism Notes
name() Short identifier, used in logs / IPublisher paths / observer hooks. Distinct from PeripheralSpec::instance_name (the latter is what Connection references).
mmio_range() Returns {base, size} (AddressRange); the SystemBus uses it for address dispatch and overlap detection. size == 0 is rejected by SystemBus::map_peripheral.
attach(ctx) Called once before reset(). The peripheral copies the service pointers it needs out of ctx. ctx pointers stay valid for the peripheral's lifetime.
reset() Returns all internal state to its reset value. Called at initialize() and on Emulator::reset().
mmio_read / mmio_write CPU-initiated loads/stores. addr is absolute (the peripheral subtracts base_); out-of-range returns ErrorCode::BusError; non-word access typically returns ErrorCode::AlignmentError. Side effects (FIFO pop, W1C, IRQ ack) live here.
tick(now) Advance time-dependent state to simulated time now. Most peripherals leave this empty and rely on the scheduler. Called once per scheduling round.
publish(pub) Register observable fields for debug / SMP2 publication. See IPublisher.
memory_region() Capability: opt into side-effect-free bulk access (PROM, descriptor RAM). Default nullptr. See IMemoryRegion.
find_port(name) Capability: expose named typed ports for peer wiring. Default nullptr. See Port system.
connect_ports(resolver) Optional custom wire-up hook run after the declarative edges are resolved. See Port system.

Data model

namespace lince::runtime {

struct Connection {
    std::string from_slot;        // Port exposed by *this* peripheral.
    std::string peer;  // Another PeripheralSpec::instance_name.
    std::string peer_slot;      // Port exposed by that peripheral.
};

using PeripheralFactory =
    std::function<std::unique_ptr<IPeripheral>(const PeripheralContext&)>;

struct PeripheralSpec {
    std::string                 instance_name;   // unique within config (case-sensitive)
    PeripheralFactory           factory;         // builds the peripheral; must be non-null
    std::vector<IrqLine>        irqs;            // 0..N IRQ lines, each in [1, 31]
    std::optional<std::size_t>  chardev_index;   // into cfg.character_devices
    std::vector<Connection> connections;     // peer-to-peer port wiring
};

}  // namespace lince::runtime
struct EmulatorConfig {
    // ... ram, prom, pacing, gdb_stub, soc_family, cpu_clock_hz, cpi, … ...
    std::vector<PeripheralSpec>    peripherals;        // the device list
    std::vector<ICharacterDevice*> character_devices;  // non-owning chardev slots
};
  • instance_name is the stable key: it appears in observer events, debug logs, and is the target of Connection::peer. The recipes use canonical names ("irqmp", "gptimer0", "apbuart0".."apbuart5", "grgpio0", "memctrl"). The PnP table builder looks devices up by these names, so custom configs that want PnP discovery for UART/GPTIMER must follow the same naming convention.
  • factory is the only place where peripheral-class knowledge lives. It receives a skeleton PeripheralContext (services already wired, no peers attached yet); the factory should construct the object but not stash the reference — canonical wiring happens through attach() immediately after. Returning a null unique_ptr is an ErrorCode::InvalidConfig at create_peripherals_from_specs time (emulator.cpp:1459).
  • irqs — each entry becomes one IrqBridge with mask 1u << irq (emulator.cpp:1423). Multi-IRQ is allowed (Tanda 2). The peripheral reads ctx.irqs[k]->raise(); single-IRQ devices read the ctx.irq alias.
  • chardev_index — when set, the runtime injects cfg.character_devices[*chardev_index] into ctx.chardev before attach().
  • connections — declarative ISignalPort → ISignalPort edges, resolved during the connect_ports lifecycle phase.

soc_family is not a peripheral selector

soc_family picks the AMBA Plug & Play layout convention (slot positions, bridge count, master device IDs, FTMCTRL placement). It does not decide which peripherals are instantiated — that is entirely cfg.peripherals. Both Gr712rc and Gr740 are valid for a custom board; arbitrary custom AMBA layouts are out of scope.

character_devices are non-owning

EmulatorConfig::character_devices holds raw, non-owning pointers. The caller must keep the ICharacterDevice instances alive until the Emulator is destroyed. The Emulator::set_uart_character_device(i, ptr) helper is the ownership-taking convenience for the apbuart{i} convention (see APBUART).

Lifecycle

Emulator::create validates the config; Emulator::initialize builds the machine. The peripheral-relevant order inside initialize() (emulator.cpp:352409) is:

flowchart TD
    A["Emulator::create(cfg)"] --> B["validate_emulator_config(cfg)<br/>shape checks only"]
    B --> C["build empty Emulator"]
    C --> D["Emulator::initialize()"]
    D --> E["map RAM · write PnP tables · load PROM image"]
    E --> F["create_peripherals_from_specs()"]
    F --> F1["for each spec:<br/>build ctx · allocate IrqBridge per irq<br/>periph = factory(ctx)<br/>wire IInterruptController / cache GPTimer<br/>periph.attach(ctx)"]
    F1 --> F2["wire PROM if prom_size > 0"]
    F2 --> G["connect_peripheral_ports()"]
    G --> G1["Pass 1: resolve declared edges<br/>ISignalPort.on_change → ISignalPort.set"]
    G1 --> G2["Pass 2: periph.connect_ports(resolver)"]
    G2 --> H["bus.map_peripheral(p) for each peripheral"]
    H --> I["set_thread_safe(MT?) · start workers"]
    I --> J["simulate bootloader GPTIMER scaler write"]
    J --> K["core.reset() · periph.reset() (via reset path)"]

Key sequencing facts, all load-bearing:

  1. Controller first. A spec that declares IRQs must come after the spec that produces an IInterruptController (the IRQMP/IRQAMP). The loop reports ErrorCode::InvalidConfig otherwise (emulator.cpp:1413). The single controller is discovered by dynamic_cast<IInterruptController*> during the spec loop (emulator.cpp:1466); a second controller is rejected.
  2. attach before connect_ports. Every peripheral's factory has run and attach() has been called before Pass 1 of port resolution, so all ports are live even when two peripherals reference each other.
  3. connect_ports before bus mapping. Port wiring is side-effect free with respect to MMIO; mapping happens last (emulator.cpp:362).
  4. GPTimer auto-discovery. The first peripherals::GPTimer is cached (emulator.cpp:1484) so initialize() can write the boot prescaler scaler (emulator.cpp:394) without re-scanning the list.
  5. reset() is invoked on every peripheral via Emulator::reset() (emulator.cpp:531); initialize() resets the cores and the GPTimer scaler is written before that path so the 1 MHz tick is established at boot.

PeripheralContext

The aggregate injected at attach() (src/interfaces/include/lince/peripheral_context.hpp). Peripherals never reach a global; everything they need is handed in.

struct PeripheralContext {
    IBusMaster*                        bus{nullptr};      // DMA into physical memory
    std::span<IInterruptSource* const> irqs;              // one IrqBridge per spec.irqs[i]
    IInterruptSource*                  irq{nullptr};       // alias for irqs[0] (or nullptr)
    ICharacterDevice*                  chardev{nullptr};   // when chardev_index is set
    IScheduler*                        scheduler{nullptr}; // future-event queue
    ILogger*                           logger{nullptr};    // diagnostic sink
    ITimeSource*                       time{nullptr};      // read current sim time
    IEmulatorObserver*                 observer{nullptr};  // optional event hooks; null = no-op
    std::uint32_t                      num_cores{1};       // EmulatorConfig::num_cores
    std::uint64_t                      ns_per_cycle{20};   // sim-ns per system-clock cycle
};
Field Who populates it Typical consumer
bus Always set (emulator.cpp:1408). DMA masters: ctx_.bus->dma_read/dma_write (demo-dma).
irqs One IrqBridge per spec.irqs[i]; backing storage owned by the Emulator (peripheral_irq_views_). Multi-line drivers (GRGPIO drives ctx.irqs[bit-1]).
irq irqs.empty() ? nullptr : irqs[0] (emulator.cpp:1429). Every single-IRQ peripheral (GPTimer, APBUART).
chardev From cfg.character_devices[*chardev_index], with set_uart_character_device override for apbuart{N}. APBUART TX/RX.
scheduler &scheduler_. One-shot / irregular timed events.
logger logger_.get(). Diagnostics (never std::cout).
time &time_source_. Read now() for scheduling.
observer observer_.get() (may be null). IRQ/trace hooks.
num_cores cfg.num_cores. IRQMP/IRQAMP size their per-core banks.
ns_per_cycle ns_per_cycle(cfg.cpu_clock_hz) (emulator.cpp:1389). GPTimer prescaler period (frequency-driven timing).

The irqs span and the irq alias are both safe. New peripherals that drive multiple IRQs read ctx.irqs[k]->raise(); legacy single-line code reading ctx.irq continues to work. A peripheral typically copies the whole struct:

void attach(const PeripheralContext& ctx) override { ctx_ = ctx; }

IRQ wiring

A peripheral never knows the global IRQ line number. It calls raise()/lower() on the injected IInterruptSource; an IrqBridge (src/runtime/include/lince/runtime/emulator.hpp:55) translates that into the controller's external_assert/external_clear with the precomputed bit mask:

class IrqBridge final : public IInterruptSource {
    void raise() override { irqc_.external_assert(mask_); }   // mask_ = 1u << irq_line
    void lower() override { irqc_.external_clear(mask_); }
};
flowchart LR
    P["Peripheral<br/>(e.g. GPTimer)"] -->|"ctx.irq->raise()"| B["IrqBridge<br/>mask = 1<<8"]
    B -->|"external_assert(0x100)"| C["IRQMP / IRQAMP<br/>pending |= mask"]
    C -->|"pending_mask(core)"| S["Emulator::sample_interrupts"]
    S -->|"level > PSR.PIL && PSR.ET"| T["enter_trap(tt = 0x10 + level)"]
    S -->|"on trap delivery"| A["controller.acknowledge(core, 1<<level)<br/>auto-clears pending/force bit"]

The bridge is SoC-agnostic — it talks to whichever controller variant (IRQMP for GR712RC, IRQAMP for GR740) the Emulator wired at init. The full delivery path (pending_mask → level select → PSR.PIL gate → enter_trapacknowledge) lives in Emulator::sample_interrupts (emulator.cpp:1285); see Traps and interrupts and the IRQMP page for the level / extended-IRQ details.

DMA bus-master wiring

DMA-capable peripherals receive an IBusMaster* in ctx.bus (src/interfaces/include/lince/ibus_master.hpp). It is a write-/read-port into the same SystemBus the CPU uses — accesses look like ordinary bus traffic with a different originator, and reach RAM or another peripheral's MMIO:

virtual Result<void> dma_read (PhysAddr addr, std::span<std::byte> out) = 0;
virtual Result<void> dma_write(PhysAddr addr, std::span<const std::byte> in) = 0;

DMA payloads are raw std::byte; SPARC is big-endian, so re-composing a word requires explicit byte ordering. The Demo DMA device shows the host-endianness-safe byte-wise pattern.

MMIO dispatch

The SystemBus (src/bus/src/system_bus.cpp) owns address dispatch. On a CPU access it finds the RAM region first, then the MMIO region whose range contains(addr). For MMIO it distinguishes two shapes:

flowchart TD
    A["bus access at addr"] --> R{"RAM region?"}
    R -->|yes| RAM["read/write backing store"]
    R -->|no| M{"MMIO region?"}
    M -->|no| BE["BusError"]
    M -->|yes| S{"CPU-shaped?<br/>1/2/4 byte · aligned"}
    S -->|yes| MM["peripheral.mmio_read / mmio_write<br/>(side effects preserved)"]
    S -->|no| C{"peripheral.memory_region()<br/>!= nullptr ?"}
    C -->|yes| MR["bounds-checked read_at / write_at<br/>(bulk, side-effect free)"]
    C -->|no| MM2["fall back to mmio_*_bytes<br/>(rejects non-pow2 / misaligned)"]
  • CPU-shaped path (is_cpu_shaped): ½/4-byte, naturally aligned accesses go through mmio_read/mmio_write so every side effect is preserved (UART RX FIFO pop, IRQMP clear-on-read, W1C bits). The bus byte-swaps to/from big-endian (encode_be/decode_be).
  • Bulk path (memory_region()): spans that are not CPU-shaped (debugger reads, descriptor/DMA windows) go to the peripheral's IMemoryRegion after the bus bounds-checks the offset. Peripherals with side-effectful registers must not expose a memory region.
  • Word-only policy: all built-in GRLIB peripherals reject sub-word accesses with ErrorCode::AlignmentError via peripherals::is_word_access (src/peripherals/include/lince/peripherals/mmio_access.hpp). This matches the GRLIB APB bus (sub-word APB control-register accesses fault). Custom peripherals may relax this, but the convention is recommended.
  • Per-region lock: each MMIO region carries a GatedMutex engaged only under ExecutionMode::MultiThread; in single-thread it is a no-op (emulator.cpp:373). See ADR-001.

IMemoryRegion — bulk access capability

src/interfaces/include/lince/imemory_region.hpp. Implemented by peripherals that own a side-effect-free memory bank (ROMs, descriptor areas, scratch RAM, packet buffers) — anywhere reads are idempotent and writes only store bytes. The PROM (src/peripherals/include/lince/peripherals/prom.hpp) is the only built-in user today: it dynamic-mixes IPeripheral + IMemoryRegion and returns this from memory_region().

class IMemoryRegion {
    virtual Result<void> read_at (uint32_t offset, std::span<std::byte> out) = 0;
    virtual Result<void> write_at(uint32_t offset, std::span<const std::byte> in) = 0;
    virtual bool         writable() const noexcept = 0;   // PROM: false
};

The bus pre-validates offset against mmio_range().size before calling, so implementations only copy. writable() == false lets the bus reject bulk writes (debugger pokes into PROM) with a clean BusError instead of the peripheral logging per access.

Port system

Peripherals expose named typed ports for peer wiring without going through MMIO — the model for board-level signals (GPIO pins, a custom IRQ source, a ready/valid line). Defined in src/interfaces/include/lince/iport.hpp.

class IPort {                                  // tag-only base
    virtual std::string_view type_id() const noexcept = 0;
};

class ISignalPort : public IPort {             // the only port shape in Phase 9.3
    static constexpr std::string_view PortTypeId = "lince::ISignalPort";
    virtual void set(bool level) = 0;          // fires callbacks iff level != get()
    virtual bool get() const noexcept = 0;
    virtual void on_change(std::function<void(bool)> cb) = 0;  // appended, fan-out in order
};

class IPortResolver {                          // handed to connect_ports()
    virtual IPort* resolve(std::string_view peer,
                           std::string_view peer_slot) noexcept = 0;
};
  • Ports are typed via type_id() so the runtime can refuse mismatched wiring without RTTI on the consumer side.
  • peripherals::SignalPort (src/peripherals/include/lince/peripherals/signal_port.hpp) is the reusable concrete ISignalPort: set() fires on_change callbacks only on a real transition (a self-edge set(get()) is ignored), and callbacks may call set() on other ports (fan-out chains). GRGPIO uses one per pin.

Declarative wiring (Pass 1)

Wiring is declared in the consumer's spec:

cfg.peripherals.push_back({
    .instance_name = "counter",
    .factory       = [...]{ return std::make_unique<EdgeCounter>(); },
    .connections   = {{.from_slot       = "in",        // a port on "counter"
                       .peer = "pulser",    // another spec
                       .peer_slot     = "out"}},      // a port on "pulser"
});

connect_peripheral_ports Pass 1 (emulator.cpp:1561) resolves both ports, verifies both are ISignalPort, and wires peer_signal->on_change([my_signal](bool v){ my_signal->set(v); }). A missing port or a non-signal port type fails with ErrorCode::InvalidConfig.

Custom wiring (Pass 2)

For anything beyond signal→signal (read a peer's instantaneous value, fan into several sinks conditionally, wire a future non-signal port type), override IPeripheral::connect_ports(IPortResolver&). Pass 2 (emulator.cpp:1609) calls it on every peripheral after Pass 1, handing the resolver so the peripheral can resolve(instance, port) by name.

Validation rules

validate_emulator_config(cfg, logger) (src/runtime/src/config_validate.cpp) runs inside Emulator::create, before any factory is invoked. It is side-effect free, returns the first violation as ErrorCode::InvalidConfig, and logs a precise diagnostic under the "config_validate" component when a logger is supplied.

# Rule Source
1 Every instance_name is non-empty. config_validate.cpp:34
2 Every factory is non-null. config_validate.cpp:40
3 All instance_names are unique (case-sensitive). config_validate.cpp:47
4 Every irqs[i] is in [1, 31] (multi-IRQ accepted). config_validate.cpp:57
5 Every chardev_index, if set, is < character_devices.size(). config_validate.cpp:68
6 Every Connection has non-empty from_slot/peer/peer_slot. config_validate.cpp:84
7 Every peer matches some instance_name (self-references allowed). config_validate.cpp:104

What the validator does not check (enforced elsewhere):

  • Named ports exist — verified at connect_peripheral_ports time, since port discovery requires the peripheral object.
  • MMIO overlap — peripheral addresses only exist after the factory runs; overlap is reported by SystemBus::map_peripheral (surfacing as ErrorCode::InvalidConfig from initialize()).
  • Controller ordering — IRQ-declaring specs must follow the controller spec; reported by create_peripherals_from_specs.

Adding a custom peripheral — two forms

Pattern A — declarative PeripheralSpec (preferred)

auto cfg = lince::runtime::gr712rc_config();
cfg.peripherals.push_back({
    .instance_name = "my_dma",
    .factory       = [](const lince::PeripheralContext&) {
        return std::make_unique<MyDma>();
    },
    .irqs          = {lince::IrqLine{10}},
});
auto emu = lince::runtime::Emulator::create(std::move(cfg));

The Emulator invokes the factory during initialize(), allocates one IrqBridge per spec.irqs entry, calls attach() with a fully-wired context, runs port resolution, and maps the MMIO range on the bus.

Pattern B — add_peripheral sugar (post-initialize)

auto emu = lince::runtime::Emulator::create(lince::runtime::gr712rc_config());
(*emu)->initialize();
(*emu)->add_peripheral(std::make_unique<MyDma>(), lince::IrqLine{10});

Emulator::add_peripheral (emulator.cpp:1738) builds a single-IRQ, no-chardev, no-connections equivalent: it constructs the context by hand, allocates one IrqBridge when irq > 0 and a controller exists, calls attach(), then bus_.map_peripheral. Pass IrqLine{0} for no-IRQ devices. Prefer Pattern A for statically-assembled configs; Pattern B is for tests and REPL exploration.

See the custom-peripheral walkthrough for the full step-by-step, and Demo DMA for a worked example.

Recipes

gr712rc_config() / gr740_config() populate peripherals with the silicon device set. They are pure functions returning an EmulatorConfig you mutate before create:

GR712RC (gr712rc_config()) GR740 (gr740_config())
irqmp @ 0x80000200 (2 CPUs) irqamp @ 0xFF904000 (4 CPUs)
memctrl @ 0x80000000 memctrl @ 0xFFE00000
gptimer0 @ 0x80000300, IRQ 8 gptimer0 @ 0xFF908000, IRQ 1
apbuart0 @ 0x80000100, IRQ 2 (console) apbuart0 @ 0xFF900000, IRQ 29 (console)
apbuart1..5 @ 0x801001xx, IRQ 17..21 apbuart1 @ 0xFF901000, IRQ 30
grgpio0 @ 0x80000900, grgpio1 @ 0x80000A00 (IRQs 1..15 each)
auto cfg = lince::runtime::gr712rc_config();
cfg.num_cores = 1;                                       // override
cfg.peripherals.erase(cfg.peripherals.end() - 7,
                      cfg.peripherals.end());            // drop UART1..5 + GPIOs
cfg.peripherals.push_back({/* custom spec */});
auto emu = lince::runtime::Emulator::create(std::move(cfg));

*_uniprocessor_config() variants are the same recipe with num_cores = 1.

AMBA Plug and Play

Peripherals do not implement PnP registers themselves. The runtime fabricates the AMBA PnP scratch areas during initialize() via write_pnp_entries (src/runtime/src/pnp_table.cpp), so RTEMS' ambapp_scan discovers the bus. The builder reads IRQ numbers back from the specs by instance-name convention ("apbuart0".."apbuart5", "gptimer0"); the layout (slot positions, bridge count, master device ID Leon3 vs Leon4) is chosen by soc_family. See src/runtime/src/pnp_table.cpp and the memctrl page for the FTMCTRL descriptor.

Legacy / compatibility

  • Emulator::set_character_device(unique_ptr) and set_uart_character_device(i, unique_ptr) — still supported. The Emulator owns the chardev and wires it into the apbuart{i} spec's context, overriding any pointer in cfg.character_devices[i] (emulator.cpp:1443).
  • Emulator::add_peripheral(IPeripheral, IrqLine) — still supported (Pattern B above).
  • EmulatorConfig::uarts, gptimer_irq, UartInstanceremoved in Tanda 4. Migrate to PeripheralSpec with the canonical apbuart{N} / gptimer0 instance names.

See also