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:
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.