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
Emulatorcore never branches onsoc_familyto decide which peripherals exist. It iteratescfg.peripheralsand instantiates each spec through its factory. - A config is inspectable, mutable, and serialisable as plain data: the
only opaque part is the
factorylambda. 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_nameis the stable key: it appears in observer events, debug logs, and is the target ofConnection::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.factoryis the only place where peripheral-class knowledge lives. It receives a skeletonPeripheralContext(services already wired, no peers attached yet); the factory should construct the object but not stash the reference — canonical wiring happens throughattach()immediately after. Returning a nullunique_ptris anErrorCode::InvalidConfigatcreate_peripherals_from_specstime (emulator.cpp:1459).irqs— each entry becomes oneIrqBridgewith mask1u << irq(emulator.cpp:1423). Multi-IRQ is allowed (Tanda 2). The peripheral readsctx.irqs[k]->raise(); single-IRQ devices read thectx.irqalias.chardev_index— when set, the runtime injectscfg.character_devices[*chardev_index]intoctx.chardevbeforeattach().connections— declarativeISignalPort → ISignalPortedges, resolved during theconnect_portslifecycle 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:352–409) 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:
- Controller first. A spec that declares IRQs must come after the spec
that produces an
IInterruptController(the IRQMP/IRQAMP). The loop reportsErrorCode::InvalidConfigotherwise (emulator.cpp:1413). The single controller is discovered bydynamic_cast<IInterruptController*>during the spec loop (emulator.cpp:1466); a second controller is rejected. attachbeforeconnect_ports. Every peripheral's factory has run andattach()has been called before Pass 1 of port resolution, so all ports are live even when two peripherals reference each other.connect_portsbefore bus mapping. Port wiring is side-effect free with respect to MMIO; mapping happens last (emulator.cpp:362).- GPTimer auto-discovery. The first
peripherals::GPTimeris cached (emulator.cpp:1484) soinitialize()can write the boot prescaler scaler (emulator.cpp:394) without re-scanning the list. reset()is invoked on every peripheral viaEmulator::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:
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_trap →
acknowledge) 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 throughmmio_read/mmio_writeso 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'sIMemoryRegionafter 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::AlignmentErrorviaperipherals::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
GatedMutexengaged only underExecutionMode::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 concreteISignalPort:set()fireson_changecallbacks only on a real transition (a self-edgeset(get())is ignored), and callbacks may callset()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_portstime, 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 asErrorCode::InvalidConfigfrominitialize()). - 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)andset_uart_character_device(i, unique_ptr)— still supported. TheEmulatorowns the chardev and wires it into theapbuart{i}spec's context, overriding any pointer incfg.character_devices[i](emulator.cpp:1443).Emulator::add_peripheral(IPeripheral, IrqLine)— still supported (Pattern B above).EmulatorConfig::uarts,gptimer_irq,UartInstance— removed in Tanda 4. Migrate toPeripheralSpecwith the canonicalapbuart{N}/gptimer0instance names.
See also¶
- Peripheral device pages — register maps for each device.
- Custom-peripheral walkthrough — write one end to end.
- Memory and bus — address dispatch internals.
- Traps and interrupts — how an asserted IRQ becomes a trap.
- Configuration guide — building / mutating an
EmulatorConfig. examples/custom-board/— from-scratch board (multi-IRQ +ISignalPortwiring).examples/demo-dma/— DMA + IRQ peripheral, both spec andadd_peripheralforms.