Skip to content

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:

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

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:

if (ctx_.irqs.size() >= 2) ctx_.irqs[1]->raise();   // second declared line

(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 + ISignalPort peer wiring + a from-scratch board built without any recipe.
  • Traps and interrupts — how your raise() becomes a CPU trap.