Oberon RTK

ECS for Control — Lessons Learned

What building the EcsControlBase test program variants surfaced, at the concept and the implementation level.

Overview

The concepts document says what the architecture is; the variants show it running. This document records what building them taught – the part that neither claims nor code carry on their own.

The lessons come at two levels: concept-level – about the architecture, independent of language and target – and implementation-level – about realising it in Oberon with Astrobe on the RP2350. Each lesson is written to stand on its own; read what is relevant, in any order.

Concept-Level Lessons

Choose by Data Shape

The central finding of the whole evolution: the carrier of a crossing must match the shape of the data, not a notion of progress. A variable-length stream of uniform elements (diagnostic text) wants a byte ring; a fixed-size record of mixed content (a sensor reading, a command, an event) wants a message ring. The evolution demonstrated both the match and the mismatch on purpose: v3-v5 pushed variable-length text through the message form and paid in accounting; v7 gave the message form the shape it fits and the progress accounting machinery all but disappeared; v6 gave the stream its natural carrier and the asynchronous drain collapsed to a character pump.

The same rule reaches further than the carrier: the asynchronous structure follows the data shape too. A message needs per-message set-up, so its interrupt-driven drain is a hybrid – a scheduled step prepares, a handler streams (v5). A byte stream has no set-up, so the handler can do everything (v6).

Corollary: the variants are options, not a ladder. v6 and v7 are peers, one per data shape; nothing about a message ring is "more advanced" than a byte ring. When designing a crossing, reach for the fitting carrier.

A Small Set of Invariants Does the Architectural Work

I did not expect this when starting out: firmly sticking to a small set of rules – all state in Components, one producer per Component type, Systems run to completion and never wait, structure fixed at creation – produced clarity far beyond what the rules seem to say. Two mechanisms appear to be behind it.

First, the invariants are not independent rules but one property seen from different angles: the store you can trust. Because of that, design questions kept resolving by derivation rather than by new rulings – when an edge case appeared (hardware state on the synchronous path, an RTOS as host, what may be locked), the answer was already implied. A rule set with that property turns design decisions into lookups.

Second, every invariant is a prohibition, and every prohibition is a proof obligation removed. No hidden state to hunt for, no blocking chains to analyse, no second writers to trace. What looks like a cost in expressiveness returns as reasoning power.

And there is evidence the rules are not arbitrary discipline: the exploration started from one demand – a valid picture of the control system's state to recover from – held the invariants firmly, and landed, without aiming there, squarely inside established practice: the cyclic executive, the synchronous languages, the partitioned architectures of avionics. Independent traditions converging on the same small rule set suggests the rules are the shape of the problem, found rather than decreed.

Decoupling You Can Test by Perturbation

The store's decoupling claims are not just plausible – they were tested, accidentally at first, by ordinary refactoring.

Renaming a Systems module (UartDrainSystem to DrainSystem) touched exactly one other module per program: the main program, where the System is registered. Eight variants, eight times the same result. That is the predicted structural signature made observable: a Systems module has a fan-in of exactly one, because Systems meet only in the store – nothing else knows a System by name.

Adding a System is the symmetric test: it touches the two designated growth points – the Components module (its new Component types and instances) and the main program (registration) – plus its own new module, and nothing else. No existing System requires changes.[1] This is extension without modification, achieved structurally: the store mediates, so no extension points need designing in advance.

The general reading: refactoring blast radius is a coupling measurement. In this architecture the dependency graph is exactly what the store declares, and a perturbation proves it. The orthogonality has a precise limit, and even that is informative: an addition is orthogonal exactly while it respects single-producer – a new System modifying an existing Component type is not a ripple through the code but a rejected design, caught at construction.

Crossing Visibility Is a Design Decision, Made per Interaction

v3 and v4 are the same program with one decision taken both ways: is the progress of the report's crossing part of the observable state, or not?

v3 keeps the drain's per-message progress in the store. For a blinker's report that buys little – but read the message as a telemetry dump from a spacecraft, and the transmit status is exactly what recovery needs: how far the dump got, where to restart mid-stream after a failure. v4 encapsulates the same machinery below the store: the store stays lean, the hand-over is all that remains visible, and the crossing's progress is no longer recoverable – by choice.

The lesson is that neither form is "the right one". What the architecture fixes is that the choice exists and is made deliberately, per interaction, with the recovery consequences on the table – rather than falling out of whatever the transport code happened to do.

The Asynchronous Seam Is Smaller Than Feared

Going from cooperative to interrupt-driven output was expected to be the hard step. It was not – provided the data shape was right. v6's complete asynchronous machinery is: a ring both sides act on, a handler that moves one byte per interrupt, a latched wake to start from idle, and one register write to quiesce. The seam's discipline – each side owns its own ring index, values cross whole – is small and local.

What made v5 intricate was not the seam but the message model dragged through it: per-message set-up state that must persist across interrupts. Placed side by side, v5 and v6 separate the two costs cleanly – the boundary is cheap; carrying the wrong data shape across it is what costs.

Overflow Is a Disturbance, Not an Error

A System never waits – so when a crossing is unexpectedly full, output is clipped: dropped, never blocking the loop, never corrupting the logic. The dropped amount is not swallowed, though: the guarded append yields the exact count, and that count is ordinary control data – a Component the control logic can observe and weigh, like a disturbance arriving from the plant.

This reframing ran through every variant and never failed: what would conventionally be an error path (buffer overflow) becomes an input. Transient overrun decays and is tolerated; sustained overrun is a signal to act on. No exceptional control flow appears anywhere in the programs.

Implementation-Level Lessons

Memory Barriers: Fewer Than Feared, and a Different One Than Expected

The barrier story on this target has a surprising shape. First, the Astrobe compiler emits memory accesses in source order – there is no compiler reordering, hence no need for anything like C's volatile in the role of a compiler barrier. Data barriers address a different layer entirely: the hardware making writes visible to the reader out of order.

Second, on a single core, a thread and its own interrupt handler are one observer – the core observes its own accesses in program order, even across pre-emption. So the SPSC ring needs no data-ordering barrier (DMB) between a cooperative producer and a same-core ISR consumer: the publish discipline – single writer per index, advance the index last – is enforced by program order alone. The DMB spots marked in the ring are the right spots, but they become load-bearing only when one end moves to a second core or to DMA (where the consumer needs an acquire barrier as well as a release – a control dependency orders a following store, but not a following load).

The barrier that actually matters is a different one: the DSB where a quiescing register write must have landed before the handler returns. Clearing or masking the transmit interrupt is a write that may still be buffered at exception return; if the interrupt line is still asserted at that moment, the NVIC re-pends and the handler re-enters spuriously. The DSB forces the write to complete first. One barrier, guarding completion on the same core – not ordering across cores.

Interrupt-Driven Transmit: the Interrupt Fires on a Transition, Never on a State

One hardware fact organises the whole interrupt-driven output design: the RP2350's UART's transmit interrupt fires on a transition, not on a state. With the FIFO enabled it fires when the fill level crosses the watermark going down – there is no FIFO-empty event, and the lowest watermark is four entries, so a short message never fires at all. That is why the FIFO stays disabled for the per-byte pump: without it, the interrupt fires on the transition to empty – one interrupt per completed byte, each transmitted byte pulling the next.

Two consequences follow. From idle there is no edge, so draining cannot start itself: it needs a kick – priming a first byte (v5) or pending the interrupt in the NVIC (v6). And when the ring drains, the asserted source must be silenced, or the handler spins.

For that last problem, two designs were tried: 1) toggling the interrupt mask (armed by the producer, disarmed by the handler), and 2) leaving the mask permanently enabled with the handler clearing the raw status on empty. The second is the more robust: the mask stops being a control bit written by two parties, the wake becomes a latched NVIC pend that cannot be dropped – the classic lost-wakeup window disappears entirely – and the producer shrinks to "put data, pend". Even a slow status-clear self-resolves in bounded, harmless re-entries. The handler gates its sends on "ring not empty and transmit register free", never on the masked interrupt status – a software pend carries none.

Guard, Don't Ask Afterwards

The ring buffer's API settled on one idiom: Empty/Full as pure queries, Put/Get as unconditional operations, and the caller guarding – WHILE (i < n) & ~Full(ring) DO .... The rejected alternative, Put returning a success flag, reports failure only after the operation already failed; the guard prevents instead of reacting. The guarded loop also yields the better diagnostic for free: n - i is the exact number of characters clipped – the disturbance count – where a flag says only "it failed".

Internally, Put and Get still no-op safely when full or empty. That is deliberate corruption insurance: a forgotten guard then costs a silent drop, never a corrupted ring. For an SPSC ring the guards are also monotone-safe across the seam – the other side's progress can only relax your guard, never invalidate it – so the idiom carries unchanged from cooperative pairs to the ISR consumer.

World Creation

One of the architectural invariants concerns what the store must contain, and when. The store holds the control system's state – the control program's state it is, the hardware's and the controlled system's it represents – and that obligation reaches back to the initial conditions, before the first tick. So at the end of World creation:

  • the store is completely initialised, both as regards structure and values;

  • the hardware is completely initialised and configured in agreement with the store's values.

Consequently, Systems must not be used to initialise and configure: they implement the steady state of the control system, and are not part of World creation. This also avoids any question regarding the correctness of the store and the hardware configuration stemming from which code runs first, not least as this may change when the control system evolves, and the scheduler executes the Systems in a different order based on their Component access attributions.

The implementation of World creation to meet the invariant is not formalised, and will probably never be part of any RTK framework. Sloppily: "do whatever your program needs". The concepts document frames the same latitude as contracts: each sub-phase is defined by what must hold when it completes, the mechanics left to the program.

These programs take the standard RTK program start-up as World creation's first sub-phase – clocks, power, and memory brought up and locked – and add nothing to it: it is infrastructure, not ECS-specific. What the architecture adds is the store, and the experience so far suggests the following concrete discipline for building and valuing it. It realises the concepts document's build, value, and propagate sub-phases; the intervening acquire step – giving each measured Component an admissible first value – has no work here, since these programs keep no measured Component across ticks (StateSystem reads the pins fresh each tick).

  • buildallocate all Components defined in the store, together with the data structures they refer to (device records, buffer memory), and wire them: bind Components to peripheral devices and crossing elements such as buffers, initialise buffers and their indices, and configure the hardware as far as it does not depend on the Component values. Memory allocation belongs to build – directly, or through construction calls that set up an ADT's own internals – and to build alone;

  • value – set all Components to valid initial values; no memory is allocated any more;

  • propagate – write the store's initial values out to the hardware.

The propagation step ensures that the control hardware's initial state is derived from the store, never stated and set independently. One set point – the store – and a mechanical write-out. (The same propagation is what a future recovery needs after re-valuing the store, which is a further reason to keep it an explicit step.)

Encapsulation Technique Follows the Data

Two techniques for letting a consumer at a producer's data proved right in different places: bulk-cross once – copy the whole content out into owned data in one call, then work on the copy (the copy-out drain of v0/v1/v3) – and character-wise in place – hold the container and read incrementally (v4 onward). The choice interlocks with the visibility decision above (copy-out returns the message to its pool at once; in-place holds it for the whole transmission) – another case of technique following data and design, not habit.

Two structural findings ride along. A shared transport belongs to neither end: it is created at World creation and injected into both, not owned and hidden by one. And a message that carries formattable text has a text device – it is not one: single inheritance cannot make a type both a ring element and a formatting target, and composition turns out to be the cleaner statement anyway.

The Formatted-Output Layer, Untouched

Across all eight variants, the standard formatted-output modules – Texts, TextIO, and their kin – were used unchanged: zero library modifications. Everything the evolution varied happened below the hand-over: formatting targets a memory buffer or a message payload through an ordinary TextIO.Writer, and from there on down only raw characters move.

This confirmed the two-layer output design the exploration started from – formatting above, rate-matching below, meeting at a device interface – and, more generally, that library modules with no policy in them serve radically different program architectures without adaptation. The policy split also held: control-loop output is non-blocking and drops on overflow; the error console keeps its blocking, guaranteed delivery – the loop is being abandoned there, so waiting is no longer forbidden.

The Alias Workaround – an Unsatisfying Corner

One aspect of the implementation I consider murky, and worth stating plainly because it touches exactly the clarity the architecture promises.

The chain that leads to it: in Oberon, an exported variable is read-only in importing modules. Making Components pointers does not open a write path by itself, since the rule is enforced through a pointer as well: a System cannot write through an imported Component pointer directly. What remains is aliasing – copying the pointer into a writable variable of one's own, and writing through the copy.

A first attempt made the aliases systematic: each System caches the Components it produces in module variables at initialisation. That looks like a declared write set, but it is not one, and it resolves nothing – an alias can equally be created by passing a Component pointer as a procedure argument, a write path that never appears as a write to anything imported. In the programs as they stand, each System simply creates aliases as needed: for write access, but also to avoid repeatedly reaching values through array indexing, with its run-time cost. Necessity and convenience use the same construct, side by side.

So there is, as of now, no clear implementation rule for aliasing, and the code shows that inconsistency. This is future work: how the store is represented and how Systems are granted access – in a form the compiler can check rather than trust – is under examination; the outlook document takes it up.

From Lessons to Outlook

Several lessons end at the same edge: the access roles are annotations rather than declarations, the run order is written by the programmer rather than derived, the alias workaround stands in for enforceable access, and the store's storage model is the simplest thing that worked rather than a settled choice. Those are the opening items of the outlook document – the work this evaluation feeds next.


  1. Unless an existing System interacts with the new System via the Component store, ie. if we have a functional extension of the control system. ↩︎

Last updated: 13 July 2026