Custom peripheral — step-by-step¶
Audience — Developer Manual
Write a new peripheral end-to-end: implement IPeripheral, wire its
IRQs / DMA / ports / chardev, register it via PeripheralSpec (or the
add_peripheral sugar), and pass the validator. One C++ class plus one
entry in EmulatorConfig::peripherals — no CMake change, no rebuild of
tero_runtime, no global registry.
For the framework rationale (lifecycle, MMIO dispatch, validation) read
Peripheral system first. For worked code see
Demo DMA device (DMA + IRQ) or examples/custom-board/main.cpp
(multi-IRQ + ISignalPort peer wiring + from-scratch board).
Step 1 — implement IPeripheral¶
Every peripheral implements tero::IPeripheral
(src/interfaces/include/tero/iperipheral.hpp):
class IPeripheral {
public:
virtual std::string_view device_class() const = 0;
// name() is final: the registry identity (the spec's instance_name,
// injected by the runtime), falling back to device_class().
virtual AddressRange mmio_range() const = 0;
virtual void attach(const PeripheralContext& ctx) = 0;
virtual void reset() = 0;
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;
virtual void publish(IPublisher& pub) = 0;
// optional capabilities (default nullptr / no-op):
virtual IMemoryRegion* memory_region() noexcept { return nullptr; }
virtual IPort* find_port(std::string_view) noexcept { return nullptr; }
virtual void connect_ports(IPortResolver&) {}
virtual ~IPeripheral() = default;
};
| Method | What you must do |
|---|---|
device_class() |
Return the IP-core kind ("my_device"). name() (final) returns the instance name from your PeripheralSpec, so find_entity(x)->name() == x. |
mmio_range() |
Return {base_, size}; the bus uses it for dispatch and overlap detection. size must be > 0. |
attach() |
Cache the service pointers you need from ctx. Called once by the runtime during initialize() (or by add_peripheral), before reset(). |
reset() |
Return all internal state to power-on defaults. Lower any IRQ you hold. |
mmio_read/mmio_write |
Decode addr - base_; return BusError for unknown offsets, AlignmentError for unsupported sizes. |
tick() |
Advance time-dependent state; may be empty. |
publish() |
Register observable fields (optional in practice; may be empty). |
Step 2 — read the injected services¶
attach() receives a PeripheralContext
(src/interfaces/include/tero/peripheral_context.hpp):
struct PeripheralContext {
IBusMaster* bus{nullptr}; // DMA into physical memory
std::span<IInterruptSource* const> irqs; // one bridge 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}; // current sim time
IEmulatorObserver* observer{nullptr}; // optional hooks
std::uint32_t num_cores{1};
std::uint64_t ns_per_cycle{20}; // sim-ns per system-clock cycle
};
The pointers stay valid for the peripheral's lifetime, so the canonical
attach just copies the struct:
ctx.bus, ctx.logger, ctx.scheduler, ctx.time, ctx.num_cores, and
ctx.ns_per_cycle are always populated. ctx.irqs/ctx.irq are populated only
when the spec declares IRQs; ctx.chardev only when chardev_index is set;
ctx.observer may be null.
Step 3 — handle MMIO¶
The bus calls mmio_read/mmio_write only for addresses inside mmio_range(),
with naturally aligned AccessSize. addr is absolute — subtract the base.
The built-in GRLIB devices are word-only (the recommended convention); the
shared helper is peripherals::is_word_access
(src/peripherals/include/tero/peripherals/mmio_access.hpp):
Result<uint32_t> mmio_read(PhysAddr addr, AccessSize size) override {
if (size != AccessSize::Word)
return make_error(ErrorCode::AlignmentError);
const auto offset = to_underlying(addr) - to_underlying(base_);
switch (offset) {
case 0x00: return status_;
case 0x04: return data_;
default: return make_error(ErrorCode::BusError);
}
}
Side effects (FIFO pop, write-1-clear status bits, IRQ acknowledge) belong in these methods. If your storage is genuinely side-effect-free and you want the bus to serve bulk spans, see Step 7.
Step 4 — raise and lower interrupts¶
If your peripheral drives an IRQ line, call raise()/lower() on the injected
IInterruptSource. You never need the global line number — the runtime's
IrqBridge maps your calls to the controller's external_assert/external_clear
with the right bit:
if (ctx_.irq) ctx_.irq->raise(); // something happened
if (ctx_.irq) ctx_.irq->lower(); // condition cleared
For multiple IRQ lines, declare them in spec.irqs and index the span:
(examples/custom-board's PulseGenerator drives ctx.irqs[0] on a rising
edge and ctx.irqs[1] on a falling edge.)
Level vs pulse
The IRQMP auto-clears its pending bit when the CPU takes the trap. A
level-sensitive device (demo-dma) calls raise() and later lower() when
the condition clears; a periodic pulse source (GPTIMER) calls raise() on
every event because the controller drops the bit on acknowledge.
Step 5 — DMA via IBusMaster¶
A peripheral with ctx_.bus can read/write any physical address — RAM or
another peripheral's MMIO — through dma_read/dma_write
(src/interfaces/include/tero/ibus_master.hpp):
std::array<std::byte, 16> buf{};
if (auto r = ctx_.bus->dma_read(PhysAddr{src}, buf); !r) { /* bus error */ }
// ... mutate buf ...
if (auto w = ctx_.bus->dma_write(PhysAddr{dst}, buf); !w) { /* bus error */ }
Endianness in DMA payloads¶
dma_read/dma_write move raw std::byte. SPARC is big-endian, so composing a
word from bytes uses the standard shift-and-or pattern — or operate byte-by-byte
to stay host-endianness-independent, as the Demo DMA device does:
uint32_t word =
(uint32_t(std::to_integer<uint8_t>(buf[0])) << 24)
| (uint32_t(std::to_integer<uint8_t>(buf[1])) << 16)
| (uint32_t(std::to_integer<uint8_t>(buf[2])) << 8)
| uint32_t(std::to_integer<uint8_t>(buf[3]));
Step 6 — character device (UART-like)¶
A byte-stream device reads/writes through ctx.chardev
(src/interfaces/include/tero/icharacter_device.hpp):
if (ctx_.chardev) ctx_.chardev->write_char(byte); // TX
if (ctx_.chardev && ctx_.chardev->has_input())
auto c = ctx_.chardev->read_char(); // RX (std::optional<char>)
Bind a chardev to your peripheral by setting chardev_index in the spec (an
index into cfg.character_devices). The runtime injects the corresponding
pointer into ctx.chardev before attach().
Step 7 — optional: bulk memory (IMemoryRegion)¶
If your peripheral owns a side-effect-free memory bank (ROM, descriptor RAM,
packet buffers), mix in IMemoryRegion
(src/interfaces/include/tero/imemory_region.hpp) and return this from
memory_region(). The bus then serves non-CPU-shaped spans (debugger reads,
DMA windows) directly, after bounds-checking the offset for you:
class MyRom final : public IPeripheral, public IMemoryRegion {
public:
IMemoryRegion* memory_region() noexcept override { return this; }
Result<void> read_at (uint32_t off, std::span<std::byte> out) override { /* copy */ }
Result<void> write_at(uint32_t off, std::span<const std::byte> in) override { /* copy */ }
bool writable() const noexcept override { return false; } // read-only ROM
};
Peripherals with side-effectful registers (UART, IRQMP, timers) must not
implement this — their mmio_read/mmio_write are the only legal access path.
peripherals::Rom is the built-in reference user.
Step 8 — optional: peer wiring with ports¶
To consume a signal driven by another peripheral (a GPIO edge, a custom IRQ
source, a ready/valid line) without going through MMIO, expose a
tero::ISignalPort via find_port and declare a Connection in the
consumer's spec:
[[nodiscard]] IPort* find_port(std::string_view name) noexcept override {
return name == "in" ? &in_ : nullptr; // in_ is a peripherals::SignalPort member
}
cfg.peripherals.push_back({
.instance_name = "my_listener",
.factory = [](const tero::PeripheralContext&) {
return std::make_unique<MyListener>();
},
.connections = {{.from_slot = "in",
.peer = "my_driver",
.peer_slot = "out"}},
});
During connect_ports Pass 1 the runtime wires
peer.on_change([my](bool v){ my->set(v); }) automatically. For wiring beyond
signal→signal (read a peer's instantaneous value, fan out conditionally),
override connect_ports(IPortResolver&) and use resolver.resolve(instance,
port). See Peripheral system § Port system.
Step 9 — register the peripheral¶
Preferred — declarative PeripheralSpec¶
Push a spec into cfg.peripherals before Emulator::create. The runtime
invokes the factory during initialize(), allocates one IrqBridge per
spec.irqs entry, calls attach() with a fully-wired context, resolves ports,
and maps the MMIO range:
auto cfg = tero::compose::gr712rc_config();
cfg.peripherals.push_back({
.instance_name = "my_dev", // unique, descriptive
.factory = [](const tero::PeripheralContext&) {
return std::make_unique<MyDevice>(tero::PhysAddr{0x80000800});
},
.irqs = {tero::IrqLine{10}}, // omit/empty for no-IRQ
// .chardev_index = 0, // optional
// .connections = {{...}}, // optional
});
auto emu = tero::runtime::Emulator::create(std::move(cfg));
(*emu)->initialize();
Multi-IRQ devices declare more entries (.irqs = {IrqLine{10}, IrqLine{11}})
and read ctx.irqs[k]->raise().
Order matters
A spec that declares IRQs must appear after the interrupt-controller
spec (the IRQMP). The runtime reports ErrorCode::InvalidConfig otherwise.
The recipes place the controller first; keep your additions after it.
Sugar — add_peripheral (post-initialize)¶
For tests / REPL usage that insert a peripheral after initialize():
auto r = emu->add_peripheral(std::make_unique<MyDevice>(PhysAddr{0x80000800}),
IrqLine{10});
if (!r) { /* InvalidConfig / BusError */ }
This builds a single-IRQ, no-chardev, no-connections equivalent and maps it
immediately. Pass IrqLine{0} for no-IRQ devices.
Step 10 — what the validator checks¶
validate_emulator_config runs inside Emulator::create. Your spec must
satisfy: non-empty instance_name, non-null factory, unique instance_name,
each irqs[i] ∈ [1, 31], in-range chardev_index, and well-formed
Connections whose peer resolves. MMIO-range overlap is caught
later by SystemBus::map_peripheral. Full rule list:
Peripheral system § Validation rules.
Optional — scheduling one-shot events¶
For irregular / one-shot timing (a transfer timeout, a delayed completion), use
the scheduler instead of tick():
class TimeoutEvent : public IEvent { void fire() override { /* ... */ } };
ctx_.scheduler->schedule_event(
SimTimeNs{to_underlying(ctx_.time->now()) + 1'000'000}, // 1 ms later
&my_event);
tick() is simpler for periodic state machines; the scheduler is better for
one-shot or irregular events.
Full skeleton¶
#include "tero/iperipheral.hpp"
#include "tero/peripheral_context.hpp"
#include "tero/iinterrupt_source.hpp"
#include "tero/types.hpp"
class MyDevice final : public tero::IPeripheral {
public:
explicit MyDevice(tero::PhysAddr base) : base_{base} {}
std::string_view device_class() const override { return "my_device"; }
tero::AddressRange mmio_range() const override { return {base_, 0x10}; }
void attach(const tero::PeripheralContext& ctx) override { ctx_ = ctx; }
void reset() override { if (ctx_.irq) ctx_.irq->lower(); reg_ = 0; }
tero::Result<uint32_t> mmio_read(tero::PhysAddr addr,
tero::AccessSize size) override {
if (size != tero::AccessSize::Word)
return tero::make_error(tero::ErrorCode::AlignmentError);
const auto off = tero::to_underlying(addr) - tero::to_underlying(base_);
if (off == 0x00) return reg_;
return tero::make_error(tero::ErrorCode::BusError);
}
tero::Result<void> mmio_write(tero::PhysAddr addr, tero::AccessSize size,
uint32_t value) override {
if (size != tero::AccessSize::Word)
return tero::make_error(tero::ErrorCode::AlignmentError);
const auto off = tero::to_underlying(addr) - tero::to_underlying(base_);
if (off == 0x00) { reg_ = value; if (ctx_.irq) ctx_.irq->raise(); return {}; }
return tero::make_error(tero::ErrorCode::BusError);
}
void tick(tero::SimTimeNs) override {}
void publish(tero::IPublisher&) override {}
private:
tero::PhysAddr base_;
tero::PeripheralContext ctx_{};
uint32_t reg_{0};
};
That is the whole device. One file, one PeripheralSpec (or add_peripheral
call), no rebuild of tero_runtime.
See also¶
- Peripheral system — framework reference.
- Demo DMA device — DMA + IRQ worked example.
examples/custom-board/main.cpp— multi-IRQ +ISignalPortpeer wiring + a from-scratch board built without any recipe.- Traps and interrupts — how your
raise()becomes a CPU trap.