Skip to content

Custom peripheral walkthrough

Adding a custom peripheral to Lince is one C++ class plus a single add_peripheral() call. No CMake changes, no recompilation of the runtime library, no global registry.

This page walks through the full contract. For a concrete, annotated example see the Demo DMA device.

The IPeripheral contract

Every peripheral implements lince::IPeripheral:

class IPeripheral {
public:
    virtual std::string_view name() const = 0;
    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) {}
    virtual ~IPeripheral() = default;
};
Method Purpose
name() Short identifier used in logs and SMP2 field paths.
mmio_range() The physical-address window this device occupies. The bus uses it for dispatch.
attach() Called once by Emulator::add_peripheral before reset(). Cache any service pointers you need from ctx.
reset() Power-on reset: return all internal state to defaults.
mmio_read / mmio_write CPU-initiated loads and stores. addr is relative to mmio_range().base.
tick() Advance time-dependent state. Called once per scheduling round; may be empty.
publish() Register observable fields for debug / SMP2 publication.

PeripheralContext — injected services

attach() receives an aggregate struct with every service a peripheral might need:

struct PeripheralContext {
    IBusMaster*        bus{nullptr};      // DMA path into physical memory
    IInterruptSource*  irq{nullptr};      // Your assigned IRQ line
    ICharacterDevice*  chardev{nullptr};  // Console I/O (APBUart only)
    IScheduler*        scheduler{nullptr};// Future-event queue
    ILogger*           logger{nullptr};   // Diagnostic sink
    ITimeSource*       time{nullptr};     // Current simulated time
};

Important: Emulator::add_peripheral always wires ctx.bus = &bus_ (Decision 34). Previously this was left null for user-defined peripherals, which silently broke DMA. The other pointers are wired according to the emulator's current configuration.

A minimal attach() implementation simply copies the struct:

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

MMIO handling

The bus guarantees that mmio_read / mmio_write are called only for addresses inside mmio_range(), with naturally aligned AccessSize. However, Lince's MVP policy is word-only for all built-in peripherals (Decision 20). Your custom peripheral may choose to support byte and half-word accesses, but if you follow the same strict policy the implementation is trivial:

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_;
        // ...
        default:   return make_error(ErrorCode::BusError);
    }
}

Raising and lowering interrupts

If your peripheral drives an IRQ line, call raise() and lower() on the injected IInterruptSource:

// Something happened — notify the CPU
if (ctx_.irq) ctx_.irq->raise();

// Condition cleared — drop the line
if (ctx_.irq) ctx_.irq->lower();

The runtime converts these calls into IrqMP::external_assert / external_clear on the correct bit, so you do not need to know the global IRQ line number inside your peripheral.

DMA via IBusMaster

A peripheral with ctx_.bus wired can read from or write to any physical address — RAM or another peripheral's MMIO:

std::array<std::byte, 16> buf{};
auto r = ctx_.bus->dma_read(PhysAddr{src}, buf);
if (!r) { /* handle error */ }

// ... mutate buf ...

auto w = ctx_.bus->dma_write(PhysAddr{dst}, buf);
if (!w) { /* handle error */ }

Endianness in DMA payloads

dma_read / dma_write move raw std::byte. Because SPARC is big-endian, re-composing a uint32_t requires the standard shift-and-or pattern:

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]));

The Demo DMA device shows a more robust pattern that avoids host-endianness assumptions entirely by operating byte-by-byte.

Registering the peripheral

Pick a base address outside the GR712RC default APB space and an unused IRQ line, then call:

auto my_dev = std::make_unique<MyDevice>(PhysAddr{0x80000800});
auto r = emu->add_peripheral(std::move(my_dev), IrqLine{10});
if (!r) { /* handle error */ }

Emulator takes ownership, constructs an IrqBridge, calls attach(), registers the MMIO range with SystemBus, and wires all services.

Pass IrqLine{0} for devices that do not generate interrupts.

Scheduling timed events

For peripherals that need to fire after a delay (e.g. a SPI transfer timeout), use the scheduler instead of tick():

class TimeoutEvent : public IEvent {
public:
    void fire() override { /* called at the scheduled time */ }
};

// Inside the peripheral:
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 "lince/iperipheral.hpp"
#include "lince/peripheral_context.hpp"
#include "lince/types.hpp"

class MyDevice final : public lince::IPeripheral {
public:
    explicit MyDevice(lince::PhysAddr base) : base_{base} {}

    std::string_view name() const override { return "my_device"; }

    lince::AddressRange mmio_range() const override {
        return {base_, 0x10}; // 16 bytes
    }

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

    void reset() override {
        if (ctx_.irq) ctx_.irq->lower();
        reg_ = 0;
    }

    lince::Result<uint32_t> mmio_read(lince::PhysAddr addr,
                                      lince::AccessSize size) override {
        if (size != lince::AccessSize::Word)
            return lince::make_error(lince::ErrorCode::AlignmentError);
        // ... map offset to registers ...
        return reg_;
    }

    lince::Result<void> mmio_write(lince::PhysAddr addr,
                                   lince::AccessSize size,
                                   uint32_t value) override {
        if (size != lince::AccessSize::Word)
            return lince::make_error(lince::ErrorCode::AlignmentError);
        // ... handle write ...
        return {};
    }

    void tick(lince::SimTimeNs now) override { (void)now; }
    void publish(lince::IPublisher& pub) override { (void)pub; }

private:
    lince::PhysAddr base_;
    lince::PeripheralContext ctx_{};
    uint32_t reg_{0};
};

That's it. One file, one add_peripheral() call, no rebuild of lince_runtime.