Skip to content

0.8.0 —— trap 组补上一个动作,第四台机器实现了它 - #8

Merged
Sunrisepeak merged 5 commits into
mainfrom
feat/partial-backends
Sep 4, 2026
Merged

0.8.0 —— trap 组补上一个动作,第四台机器实现了它#8
Sunrisepeak merged 5 commits into
mainfrom
feat/partial-backends

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

0.8.0 — the trap group gains an action, and the fourth machine implements it

The primitive that was missing, and what showed it

arch_trap_set_handler lets a kernel SEE a trap; arch_trap_enable_interrupts
lets it MASK one. Neither can change what the trap returns to — and that is the
whole of preemption, which is the principal reason to use this layer on a
microcontroller at all.

void arch_trap_switch(arch_trap_frame* f, void* from, void* to);

Called from a handler. The interrupted context is saved through from, the trap
resumes to, and the call returns normally: the switch happens when the trap
does. It sits beside arch_context_switch, takes the same storage and the same
arch_context_init, and differs only in WHEN it takes effect.

examples/preempt found the gap. Its first version called arch_context_switch
from PendSV: it built, booted, and reported that neither of two tasks had ever
observed the other. Nothing failed — the tasks were simply never interleaved,
and only a counter neither task advanced itself told that apart from success.

⭐⭐ Four machines, and they do not implement it the same way

how the trap resumes elsewhere
riscv64 a cooperative switch inside the dispatcher, mepc saved across it
aarch64 the same, ELR_EL1 and SPSR_EL1 saved across it
x86_64 the same, nothing to save — the iret frame travels with the stack
Cortex-M not that at all: pend PendSV, taken by the hardware at exception exit

The first three work because the trap runs on the interrupted context's own
stack. M-profile's does not — the handler is on MSP while the task is on PSP —
so a cooperative swap there swaps the HANDLER's state and the exception return
unstacks a frame belonging to nobody. That difference is exactly what an
interface function earns its place by hiding.

⭐ And the mechanism M-profile does offer turns out to be the interface's own
sentence about the two primitives: PendSV pended from thread mode is taken AT
ONCE, pended from a handler it is taken WHEN THE HANDLER EXITS. So
arch_context_switch and arch_trap_switch on that backend are one instruction
sequence and the hardware supplies the difference.

⚠️ That forced the Cortex-M context layout to change. Two layouts — one for the
cooperative path, one for the preempted one — would let a kernel that mixed a
yield with a timer corrupt a context by resuming it through the other door,
silently, because both are just words. One mechanism, one layout, and the
example's probe resumes a trap-saved context with the cooperative call to say so.

The first partial backend

openarch-cortex-m declares openarch-backend and openarch:preemption, and
NOT openarch:address-space or openarch:percpu-register: M-profile has a
region-based MPU with no page-table entry to construct, and no TPIDR-class
register. A kernel that needs either is refused by name at resolution rather
than by a wall of undefined reference to arch_pte_* at link time. The three
application-class backends declare all four.

Splitting the capability is what made admitting the machine possible, and
openarch:preemption is what made it worth doing.

What is asserted, and where

  • examples/switch gains a preemption probe on all three application machines:
    a breakpoint whose handler switches, and a counter the OTHER context advanced.
    A synchronous trap rather than a timer, so the probe stays one piece of code.
    Measured: a backend whose arch_trap_switch does nothing prints steps=0 and
    reaches every other assertion.
  • examples/preempt runs in its own CI job on mps2-an385, because the gate
    matrix runs examples/switch, which needs the two capabilities this machine
    does not have. A gate with a branch in it stops being one.
  • CI greps examples/preempt/src/main.cpp for assembly and fails if it returns.
    Thirty lines of hand-written PendSV are gone; if they come back, the primitive
    has stopped carrying its weight.

Also

  • [xlings] deps[xlings.workspace] in every example and the template.
  • The engine pin moves to 2026.9.4.1, which is the release carrying the
    Cortex-M target rows. The OLDEST version this repository needs, not the
    newest that exists: pinning further ahead would make the repository
    unbuildable between a merge here and a release there.

Measured: 3/3 host tests; examples/switch on riscv64, aarch64 and x86_64;
examples/preempt on thumbv7m under xim:qemu-arm@9.2.4-1.

… on it

## Capabilities are split, because one machine cannot do everything

`openarch-backend` says a backend is present. It does not say what the machine
can do, and until now it did not need to: riscv64, aarch64 and x86_64 are all
application-class machines with a memory management unit.

M-profile is not. Its MPU describes regions by base and limit; there is no entry
naming a physical page, so `arch_pte_make_leaf` — one of the two primitives this
layer's viability was decided on — has nothing to construct. The interface
either refuses that machine or admits a partial backend, and refusing it would
exclude the class of device this layer is most useful on.

So the groups are named. A backend declares what it implements, a kernel
requires what it needs, and a mismatch is reported by name at RESOLUTION rather
than as a wall of `undefined reference to arch_pte_*` at link time. The three
existing backends declare `openarch:address-space` and
`openarch:percpu-register`; `openarch-cortex-m` declares neither, and says why
in each case.

⭐ This is the mechanism openarch already used for backend selection, applied one
level finer — and the same mechanism mcpp uses for target-side layers and for
named runners. Three uses, one idea.

## ⭐⭐ And the example found a gap in the layer

`examples/preempt` interleaves two tasks that never yield, and asserts that each
observed a counter it did not advance — because two tasks that merely print
would also print if the switch never happened.

Its first version called `arch_context_switch` from PendSV. It compiled, linked,
booted, and reported that neither task had ever observed the other: nothing
failed, the tasks were simply never interleaved, and only the counter assertion
told the difference.

Inside an exception handler the hardware has already stacked half the register
file onto the interrupted task's stack and is running on MSP. Swapping the
callee-saved registers of whoever called `arch_context_switch` swaps the
HANDLER's state, and the exception return unstacks a frame belonging to nobody.

⚠️ openarch has a primitive for "switch to another saved context" and none for
"switch the context this trap will return to". Every architecture needs the
second to preempt and every one spells it differently — riscv64 edits `sepc`,
aarch64 `ELR_EL1`, x86_64 the interrupt frame — which is precisely the shape of
thing this layer exists to abstract. Recorded in the example's README rather
than papered over; naming it as a fifth interface group is a decision to take
with more than one machine in view.

`examples/switch` could not have found this. It never enters a trap.

Measured on qemu `mps2-an385`: both tasks observe preemption, 1235 bytes of text.
…ents it

## The primitive that was missing, and what showed it

`arch_trap_set_handler` lets a kernel SEE a trap; `arch_trap_enable_interrupts`
lets it MASK one. Neither can change what the trap returns to — and that is the
whole of preemption, which is the principal reason to use this layer on a
microcontroller at all.

    void arch_trap_switch(arch_trap_frame* f, void* from, void* to);

Called from a handler. The interrupted context is saved through `from`, the trap
resumes `to`, and the call returns normally: the switch happens when the trap
does. It sits beside `arch_context_switch`, takes the same storage and the same
`arch_context_init`, and differs only in WHEN it takes effect.

`examples/preempt` found the gap. Its first version called `arch_context_switch`
from PendSV: it built, booted, and reported that neither of two tasks had ever
observed the other. Nothing failed — the tasks were simply never interleaved,
and only a counter neither task advanced itself told that apart from success.

## ⭐⭐ Four machines, and they do not implement it the same way

| | how the trap resumes elsewhere |
|---|---|
| riscv64 | a cooperative switch inside the dispatcher, `mepc` saved across it |
| aarch64 | the same, `ELR_EL1` and `SPSR_EL1` saved across it |
| x86_64 | the same, nothing to save — the `iret` frame travels with the stack |
| Cortex-M | not that at all: pend PendSV, taken by the hardware at exception exit |

The first three work because the trap runs on the interrupted context's own
stack. M-profile's does not — the handler is on MSP while the task is on PSP —
so a cooperative swap there swaps the HANDLER's state and the exception return
unstacks a frame belonging to nobody. That difference is exactly what an
interface function earns its place by hiding.

⭐ And the mechanism M-profile does offer turns out to be the interface's own
sentence about the two primitives: PendSV pended from thread mode is taken AT
ONCE, pended from a handler it is taken WHEN THE HANDLER EXITS. So
`arch_context_switch` and `arch_trap_switch` on that backend are one instruction
sequence and the hardware supplies the difference.

⚠️ That forced the Cortex-M context layout to change. Two layouts — one for the
cooperative path, one for the preempted one — would let a kernel that mixed a
yield with a timer corrupt a context by resuming it through the other door,
silently, because both are just words. One mechanism, one layout, and the
example's probe resumes a trap-saved context with the cooperative call to say so.

## The first partial backend

`openarch-cortex-m` declares `openarch-backend` and `openarch:preemption`, and
NOT `openarch:address-space` or `openarch:percpu-register`: M-profile has a
region-based MPU with no page-table entry to construct, and no TPIDR-class
register. A kernel that needs either is refused by name at resolution rather
than by a wall of `undefined reference to arch_pte_*` at link time. The three
application-class backends declare all four.

Splitting the capability is what made admitting the machine possible, and
`openarch:preemption` is what made it worth doing.

## What is asserted, and where

* `examples/switch` gains a preemption probe on all three application machines:
  a breakpoint whose handler switches, and a counter the OTHER context advanced.
  A synchronous trap rather than a timer, so the probe stays one piece of code.
  Measured: a backend whose `arch_trap_switch` does nothing prints `steps=0` and
  reaches every other assertion.
* `examples/preempt` runs in its own CI job on `mps2-an385`, because the gate
  matrix runs `examples/switch`, which needs the two capabilities this machine
  does not have. A gate with a branch in it stops being one.
* CI greps `examples/preempt/src/main.cpp` for assembly and fails if it returns.
  Thirty lines of hand-written PendSV are gone; if they come back, the primitive
  has stopped carrying its weight.

## Also

* `[xlings] deps` → `[xlings.workspace]` in every example and the template.
* The engine pin moves to 2026.9.4.1, which is the release carrying the
  Cortex-M target rows. The OLDEST version this repository needs, not the
  newest that exists: pinning further ahead would make the repository
  unbuildable between a merge here and a release there.

Measured: 3/3 host tests; `examples/switch` on riscv64, aarch64 and x86_64;
`examples/preempt` on thumbv7m under xim:qemu-arm@9.2.4-1.
Each produced the same sentence — `no task observed the other` — which is also
what a backend with no `arch_trap_switch` at all produces. The message could not
tell them apart; instrumenting the counters and the addresses could.

1. THE ENTRY WINDOW. The timer was armed before the first context existed, so a
   tick there switched away from a context that was not yet valid. Failed about
   one run in three. `openarch_cm_enter` now unmasks interrupts itself, as its
   last instruction, where no window remains.

2. THE TICK PREEMPTS THE SWITCH. PendSV is the lowest priority — which is what
   makes it run after every other handler, and also what lets the tick that
   requested a switch interrupt the switch and request another. The stub then
   held one context's stack pointer and another context's `from`. PendSV now
   masks interrupts for the whole switch; unmasking at the end is safe because a
   context with interrupts masked could not have been interrupted into PendSV.

3. ⚠️⚠️ TWO TICKS, ONE SWITCH. PendSV runs only once no handler is active, so
   two ticks can arrive before one switch is performed. A single overwritten
   slot then crossed the contexts — measured: `pendsv=2998` switches performed,
   the second task never ran, and the two contexts held stack pointers 32 bytes
   apart on one stack.

   The FIRST `from` and the LAST `to` win. The context being saved is the one
   that was interrupted, and only the first call in a trap window can name it;
   the context to resume is whatever the caller last asked for. This is in
   `abi.h` rather than in this backend, because the window exists on any machine
   whose switch is deferred to a lower-priority exception.

⚠️ The criterion is 15 CONSECUTIVE runs. Fixes 1 and 2 each raised the pass rate
without reaching 1, and a single green run would have retired either of them
prematurely.

Measured: 15/15 `examples/preempt` on thumbv7m; `examples/switch` still green on
riscv64, aarch64 and x86_64.
…ns agree

⚠️⚠️ THE GATE CAUGHT WHAT IT EXISTS FOR. Adding the preemption probe added a
second `#if defined(__` — the same trap instruction, written out again — and CI
counts them: the claim the gate makes is that the probe is not TWO PROGRAMS, and
a second conditional is that by the letter as well as by the check.

The instruction is now a function both probes call, so the file has exactly one
conditional and nothing but instructions inside it.

⚠️ And the two jobs still pinned 2026.8.21.2 move to 2026.9.4.1 with the rest.
`host-encoders (ubuntu-24.04)` failed on that pin with

    selected RuntimeBinding glibc@2.44 requires payload '…/xim-x-glibc/2.44',
    but it is not installed

on a fresh MCPP_HOME — a bootstrap path later releases fix. A repository whose
jobs pin two different engines is also measuring two different things.

Measured: examples/switch on riscv64, aarch64 and x86_64, steps=1 on each.
…right

⚠️⚠️ THE EXAMPLE SUCCEEDED AND `mcpp run` EXITED 1.

`SYS_EXIT` (0x18) takes its reason code in r1 DIRECTLY; the `{reason, code}`
block is `SYS_EXIT_EXTENDED` (0x20), which exists because a 32-bit r1 cannot
carry both a reason and a status. This board passed the block to 0x18, so it
printed `both tasks observed preemption` and then reported failure.

Every assertion on the OUTPUT passed. Only the exit code disagreed — and the CI
step added in this branch is what read it.

⚠️ AND THE STEP HAD TO STOP PIPING INTO `tee` TO SEE IT. `$?` after a pipeline
is the last command's status, so `mcpp run | tee` would have read tee's 0 and
the check would have been vacuous in exactly the way the defect needed.

Measured: 5/5 runs print the success line AND exit 0.
@Sunrisepeak
Sunrisepeak merged commit edec008 into main Sep 4, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant