Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 117 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ jobs:
# carries it, and the row is now the same shape as the other two.
- { arch: x86_64, triple: x86_64-none-elf, qemu: 'xim:qemu-x86' }
env:
MCPP_VERSION: 2026.8.21.2
# ⚠️ THE OLDEST ENGINE THIS REPOSITORY NEEDS, NOT THE NEWEST THAT EXISTS.
# 2026.9.4.1 is the release carrying the Cortex-M target rows, which the
# fourth backend's job below builds against. Pinning something newer would
# make this repository unbuildable for the window between a merge here and
# a release there, and would say a dependency exists that does not.
MCPP_VERSION: 2026.9.4.1
XLINGS_VERSION: v2026.8.17.2
XLINGS_NON_INTERACTIVE: '1'
steps:
Expand Down Expand Up @@ -176,6 +181,12 @@ jobs:
grep -q "witness=7 before=1234" run.log
grep -q "switch ok" run.log
grep -q "trap: back, witness=1" run.log
# ⚠️ `steps=1` AND NOT MERELY THE LINE. Every other assertion here is
# printed by whichever context is running; only a counter the OTHER
# context advanced says the trap resumed somewhere else. Measured: a
# backend whose `arch_trap_switch` does nothing prints
# `preempt: back, steps=0` and reaches every line above.
grep -q "preempt: back, steps=1" run.log
grep -q "cpu: percpu round-trips" run.log

# ⚠️ THE TEMPLATE IS RENDERED BY HAND HERE, AND IT HAS TO BE.
Expand Down Expand Up @@ -390,6 +401,109 @@ jobs:
grep -q 'export module mcpplibs.openarch;' src/openarch.cppm
echo "one package, two faces, three backends"

# ---------------------------------------------------------------------------
# The FOURTH machine, and the only one whose backend is partial.
#
# ⭐⭐ IT IS A SEPARATE JOB BECAUSE IT CANNOT JOIN THE MATRIX ABOVE. Every row
# there runs `examples/switch`, which needs an address space and a per-CPU
# register; this machine has neither and says so in its manifest. Adding a row
# would have meant a conditional inside the gate — and a gate with a branch in
# it stops being one.
#
# What it does share is the primitive the other three grew for it:
# `arch_trap_switch`. Three machines exercise it with a synchronous trap in
# `examples/switch`; this one exercises it with a TIMER, which is the case the
# primitive exists for and the only one that shows an interrupted context
# being resumed elsewhere.
#
# ⚠️ AND THE ASSERTION IS PREEMPTION, NOT PROGRESS. Two tasks that print would
# also print if the switch never happened and one simply ran to completion.
# Measured before the primitive existed: calling `arch_context_switch` from
# PendSV built, booted, and reported that neither task ever observed the
# other. Only a counter neither task advanced itself tells them apart.
cortex-m:
name: the partial backend preempts (thumbv7m)
runs-on: ubuntu-24.04
timeout-minutes: 40
env:
MCPP_VERSION: 2026.9.4.1
XLINGS_VERSION: v2026.8.17.2
XLINGS_NON_INTERACTIVE: '1'
steps:
- uses: actions/checkout@v4

- name: Install xlings
run: |
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh \
| bash -s "$XLINGS_VERSION"
echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH"

- name: Install mcpp
run: |
# The same wait the gate job performs, and for the same reason: a
# version bump's index pointer propagates asynchronously, and a single
# `xlings update` can return a stale index while reporting freshness.
for attempt in 1 2 3 4 5 6; do
xlings update > /dev/null 2>&1 || true
if xlings install "mcpp@$MCPP_VERSION" -y -g; then break; fi
if [ "$attempt" = 6 ]; then
echo "::error::mcpp@$MCPP_VERSION never appeared in the index"
exit 1
fi
echo "the index has not caught up yet (attempt $attempt of 6); waiting 60s"
sleep 60
done
mcpp --version
mcpp self config --mirror GLOBAL

# ⚠️ BOTH HOMES. The shim on PATH dispatches against whichever home owns
# it, while `mcpp run` starts the runner through mcpp's own.
- name: Install the emulator
run: |
xlings install xim:qemu-arm -y
XLINGS_HOME="$HOME/.mcpp/registry" xlings install xim:qemu-arm -y

- name: The scheduler preempts on mps2-an385
working-directory: examples/preempt
run: |
set -euo pipefail
# Twice, the first allowed to fail: the toolchain payload is installed
# during a build, so the first build on a machine that has never
# targeted this triple is the one that installs it.
mcpp build --target thumbv7m-none-eabi > /dev/null 2>&1 || true
# ⚠️⚠️ NOT `mcpp run | tee`, AND THE PIPELINE IS THE POINT. `$?` after a
# pipeline is the LAST command's status, so `| tee` would read tee's 0
# and the exit check below would be vacuous — which is how this job
# found the defect it now guards: the example printed
# `both tasks observed preemption` and `mcpp run` exited 1, because
# the board passed `SYS_EXIT_EXTENDED`'s block to `SYS_EXIT`. Every
# assertion on the OUTPUT passed.
set +e
mcpp run --target thumbv7m-none-eabi > run.log 2>&1
rc=$?
set -e
cat run.log
grep -q "both tasks observed preemption" run.log \
|| { echo "the tasks were never interleaved"; exit 1; }
[ "$rc" = "0" ] \
|| { echo "the scheduler reported success and exited $rc — check the semihosting exit call"; exit 1; }

# ⭐ THE EXAMPLE CONTAINS NO ASSEMBLY, AND THAT IS AN ASSERTION RATHER
# THAN A REMARK. Its first version hand-wrote thirty lines of PendSV,
# because the layer had no primitive for "switch the context this trap
# will return to". If that code comes back, the primitive has stopped
# carrying its weight and this check is where it is noticed.
- name: The example is scheduling policy, not machine code
run: |
set -euo pipefail
if grep -nE '__asm__|asm volatile' examples/preempt/src/main.cpp; then
echo "the scheduler has grown assembly; arch_trap_switch should have made it unnecessary"
exit 1
fi
grep -q 'arch_trap_switch' examples/preempt/src/main.cpp \
|| { echo "the example no longer exercises arch_trap_switch"; exit 1; }
echo "the scheduler is forty lines of policy"

# ---------------------------------------------------------------------------
# The half no emulator can check.
#
Expand All @@ -410,7 +524,7 @@ jobs:
run:
shell: bash
env:
MCPP_VERSION: 2026.8.21.2
MCPP_VERSION: 2026.9.4.1
XLINGS_VERSION: v2026.8.17.2
XLINGS_NON_INTERACTIVE: '1'
steps:
Expand Down Expand Up @@ -500,7 +614,7 @@ jobs:
run:
shell: bash
env:
MCPP_VERSION: 2026.8.21.2
MCPP_VERSION: 2026.9.4.1
XLINGS_VERSION: v2026.8.17.2
XLINGS_NON_INTERACTIVE: '1'
steps:
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@
The architecture-mechanism layer: execution contexts, traps and address spaces,
as one interface over several instruction sets.

**Status: 0.4.0.** Four interfaces — contexts, page-table entries, traps,
per-CPU state and barriers — over **three** instruction sets: riscv64, aarch64
and x86_64. One probe source builds and runs on all three and produces
byte-identical output.
**Status: 0.8.0.** Four interfaces — contexts, page-table entries, traps,
per-CPU state and barriers — over **four** instruction sets: riscv64, aarch64,
x86_64 and ARM Cortex-M. One probe source builds and runs on the first three and
produces byte-identical output.

The fourth is the first **partial** backend. M-profile has no memory management
unit and no per-CPU register, so `openarch-cortex-m` declares neither
`openarch:address-space` nor `openarch:percpu-register` — and a kernel that
needs either is refused by name at resolution rather than by a wall of
`undefined reference` at link time. It does declare `openarch:preemption`, which
is the capability that made admitting a partial backend worth doing: a
microcontroller is exactly where a hand-written task switcher is otherwise
re-invented per project.

## What this is, and what it is not

Expand Down Expand Up @@ -150,6 +159,8 @@ answer as `MAIR_EL1`, arrived at for a different reason.
| | Checked by |
|---|---|
| The switch reaches, returns and preserves; traps classify; per-CPU round-trips; four barriers are accepted | One probe source, three emulators, in CI |
| A trap resumes a **different** context | The same probe, on all three; the assertion is a counter the *other* context advanced, not that both printed |
| The partial backend preempts | `examples/preempt` on `mps2-an385`, in its own job: two tasks that never yield, each proving it was interrupted |
| The entry encodings | A host unit test that holds **all three** encoders at once |
| The two faces declare one library | A host test of `static_assert`s, on a machine with no backend at all |
| The ABI's frozen layout | `tests/abi_shape.cpp`, in byte offsets rather than in `sizeof` of another member |
Expand Down Expand Up @@ -334,6 +345,7 @@ loop that decides whether the layer is viable.

| | Status |
|---|---|
| A 32-bit machine with an address space | Not yet. Cortex-M is 32-bit and has no page-table entry at all, so `arch_pte_make_leaf` returning `arch_u64` has never been asked what a 32-bit entry looks like. ARMv7-A would ask it — short descriptors are 32 bits, long (LPAE) ones 64 — and mcpp carries the target rows for it since 2026.9.4.2 |
| Timer ticks | **Answered, not implemented.** `examples/clock-study` reads a counter on all three machines directly and `FINDING.md` records the result: all three provide a monotonic counter with one address-free instruction, and only aarch64 reports how fast it runs. So `counter()` belongs here and `frequency()` and `set_deadline()` do not — the interface is narrower than the one that would have been written first |
| Page-table **walking** | Out of scope. Building an entry is mechanism; deciding where entries go is policy, and belongs to the kernel |
| A second backend for one ISA | The arrangement now supports it — `backend-riscv64` names a backend rather than an architecture — and riscv will want it: this backend traps into M-mode, and a kernel under SBI traps into S-mode |
59 changes: 59 additions & 0 deletions abi/include/openarch/abi.h
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,65 @@ arch_trap_handler_fn arch_trap_set_handler(arch_trap_handler_fn h);
void arch_trap_enable_interrupts(int on);
int arch_trap_interrupts_enabled(void);

/* ⭐⭐ THE ACTION THE TRAP GROUP WAS MISSING: ACTING ON A TRAP RATHER THAN
* OBSERVING ONE.
*
* `arch_trap_set_handler` lets a kernel SEE a trap and
* `arch_trap_enable_interrupts` lets it MASK one. Neither lets it 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.
*
* Call it from inside a handler. The context that was interrupted is saved and
* a handle to it is written through `from`; the trap then resumes `to` instead.
* It RETURNS NORMALLY to the handler: the switch happens when the trap does,
* not at the call.
*
* void tick(arch_trap_frame* f) {
* int next = pick();
* if (next != current) {
* int prev = current; current = next;
* arch_trap_switch(f, &ctx[prev], &ctx[next]);
* }
* } // ← the switch happens after this
*
* ⭐ THE SAME SHAPE AS `arch_context_switch`, DIFFERING ONLY IN WHEN IT TAKES
* EFFECT. One is "switch now"; this one is "switch on the way out". `from` and
* `to` are the same 128-byte, 16-aligned storage, laid out by the same
* `arch_context_init`, so a task can be resumed by either.
*
* ⚠️ EVERY MACHINE NEEDS IT AND EVERY MACHINE SPELLS IT DIFFERENTLY, WHICH IS
* WHY IT IS HERE. riscv64 edits `mepc`, aarch64 `ELR_EL1`, x86_64 the interrupt
* frame's `RIP`/`RSP` — and M-profile none of those, because its handler runs
* on a different stack from the task and the switch has to be performed by a
* pended exception. Three of the four implement it as a cooperative switch
* taken inside the trap; the fourth cannot, and that difference is exactly the
* thing an abstraction earns its place by hiding.
*
* Backends that implement it declare the capability `openarch:preemption`. A
* kernel that preempts requires it, and a machine that cannot is refused by
* name at resolution rather than at link time.
*
* ⚠️ `f` IS THE FRAME THE HANDLER RECEIVED. Passing a frame from a different
* trap, or a null pointer, is undefined: a backend may read the machine state
* the frame describes.
*
* ⚠️⚠️ CALLED MORE THAN ONCE BEFORE THE TRAP RETURNS, THE FIRST `from` AND THE
* LAST `to` ARE THE ONES THAT APPLY. This is not a convenience; it is the only
* consistent answer, and getting it wrong cost this layer a defect that
* presented as a flake.
*
* The context being saved is the one that was interrupted, and only the FIRST
* call in a trap window can name it — by the second, the caller's idea of
* "current" has already moved. The context to resume is whatever the caller
* last asked for. A backend that simply overwrote both would write one task's
* saved stack pointer into another task's storage, losing both.
*
* It is not a theoretical window. On M-profile the switch is performed by an
* exception at the LOWEST priority, so it runs only once no handler is active
* — and two timer ticks can arrive first. Measured: the second task never ran,
* and the two contexts held stack pointers 32 bytes apart on one stack. */
void arch_trap_switch(arch_trap_frame* f, void* from, void* to);

/* ── openarch.cpu ──────────────────────────────────────────────────────────
*
* `barrier` is an `arch_barrier` — the four orderings both machines can state.
Expand Down
2 changes: 1 addition & 1 deletion abi/mcpp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
[package]
namespace = "mcpplibs"
name = "openarch-abi"
version = "0.6.0"
version = "0.7.0"
description = "openarch's C ABI: the contract between the interface and an instruction set's backend"
license = "Apache-2.0"
authors = ["mcpplibs"]
Expand Down
36 changes: 34 additions & 2 deletions backends/aarch64/mcpp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
[package]
namespace = "mcpplibs"
name = "openarch-aarch64"
version = "0.6.0"
version = "0.7.0"
description = "openarch's aarch64 backend: the instructions behind the ABI"
license = "Apache-2.0"
authors = ["mcpplibs"]
Expand All @@ -24,7 +24,39 @@ repo = "https://github.com/mcpplibs/openarch"
# package's `backend` feature requires `openarch-backend` and names nobody; the
# resolver binds the one provider in the graph. A consumer's own implementation
# of `openarch/abi.h` declares the same line and is selected the same way.
provides = ["openarch-backend"]

# ⭐⭐ AND WHICH GROUPS OF THAT INTERFACE THIS BACKEND IMPLEMENTS.
#
# `openarch-backend` says "there is a backend here". It does not say what the
# machine can do, and until every backend could do everything it did not need
# to: riscv64, aarch64 and x86_64 are all application-class machines with a
# memory management unit.
#
# ⚠️ A Cortex-M is not. M-profile has a region-based MPU and no page table, 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; the resolver reports a mismatch by name at RESOLUTION
# rather than as a wall of `undefined reference to arch_pte_*` at link time.
#
# ⭐ This is the mechanism openarch already used, applied one level finer. It is
# also the mechanism mcpp uses for target-side layers (docs/14) and for named
# runners: a capability is data, declared by a package, and the engine knows
# only that capabilities exist. Three uses, one mechanism.
# ⭐ `openarch:preemption` — the trap group's ACTION, not only its observation.
#
# `arch_trap_switch` makes the trap resume a different context. Every machine
# needs it to preempt and every machine spells it differently, which is the
# shape of thing this layer exists to hide; a backend that cannot provide it —
# and one could exist, on a machine with no way to change what an exception
# returns to — is refused by name at resolution rather than at link time.
provides = ["openarch-backend",
"openarch:address-space",
"openarch:percpu-register",
"openarch:preemption"]

[build]
sources = ["src/**"]
Expand Down
Loading
Loading