From 5976cc464f152dc371dd71d6d27eaa6a55d6de30 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 4 Sep 2026 09:51:37 +0800 Subject: [PATCH 1/5] feat(cortex-m): the first partial backend, and a preemptive scheduler on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- backends/aarch64/mcpp.toml | 26 +++- backends/cortex-m/mcpp.toml | 49 ++++++++ backends/cortex-m/src/context.S | 72 +++++++++++ backends/cortex-m/src/context_init.cpp | 28 +++++ backends/cortex-m/src/cpu_impl.cpp | 49 ++++++++ backends/cortex-m/src/pte_impl.cpp | 50 ++++++++ backends/cortex-m/src/trap_impl.cpp | 71 +++++++++++ backends/riscv64/mcpp.toml | 26 +++- backends/x86_64/mcpp.toml | 26 +++- examples/preempt/README.md | 72 +++++++++++ examples/preempt/build.mcpp | 14 +++ examples/preempt/mcpp.toml | 38 ++++++ examples/preempt/mps2.ld | 13 ++ examples/preempt/src/board.cpp | 71 +++++++++++ examples/preempt/src/main.cpp | 166 +++++++++++++++++++++++++ 15 files changed, 768 insertions(+), 3 deletions(-) create mode 100644 backends/cortex-m/mcpp.toml create mode 100644 backends/cortex-m/src/context.S create mode 100644 backends/cortex-m/src/context_init.cpp create mode 100644 backends/cortex-m/src/cpu_impl.cpp create mode 100644 backends/cortex-m/src/pte_impl.cpp create mode 100644 backends/cortex-m/src/trap_impl.cpp create mode 100644 examples/preempt/README.md create mode 100644 examples/preempt/build.mcpp create mode 100644 examples/preempt/mcpp.toml create mode 100644 examples/preempt/mps2.ld create mode 100644 examples/preempt/src/board.cpp create mode 100644 examples/preempt/src/main.cpp diff --git a/backends/aarch64/mcpp.toml b/backends/aarch64/mcpp.toml index e649510..ab10623 100644 --- a/backends/aarch64/mcpp.toml +++ b/backends/aarch64/mcpp.toml @@ -24,7 +24,31 @@ 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. +provides = ["openarch-backend", + "openarch:address-space", + "openarch:percpu-register"] [build] sources = ["src/**"] diff --git a/backends/cortex-m/mcpp.toml b/backends/cortex-m/mcpp.toml new file mode 100644 index 0000000..83f26be --- /dev/null +++ b/backends/cortex-m/mcpp.toml @@ -0,0 +1,49 @@ +# openarch on ARM Cortex-M — the first PARTIAL backend. +# +# ⭐⭐ THIS ROW IS WHY THE CAPABILITY WAS SPLIT. +# +# The three backends that came before are application-class machines with a +# memory management unit, and the layer's viability was decided on two +# primitives: context switching and the page-table entry. M-profile has the +# first and cannot have the second — its MPU describes regions by base and +# limit, and there is no entry that names a physical page for a walker to find. +# +# Refusing the machine was one option. It would have excluded the class of +# device this layer is most useful on: a microcontroller is exactly where a +# hand-written task switcher is otherwise re-invented per project. +# +# So the interface admits a partial backend, and says which parts. This package +# declares `openarch-backend` and NOT `openarch:address-space`; a kernel that +# needs address spaces states that and is refused by name at resolution: +# +# error: no package provides capability 'openarch:address-space' +# required by 'mykernel' +# +# rather than by a wall of `undefined reference to arch_pte_*` at link time. +[package] +namespace = "mcpplibs" +name = "openarch-cortex-m" +version = "0.1.0" +description = "openarch for ARM Cortex-M: contexts, traps and barriers. No address space — M-profile has an MPU rather than an MMU." +license = "Apache-2.0" +authors = ["mcpplibs"] +repo = "https://github.com/mcpplibs/openarch" + +# ⚠️ `openarch:percpu-register` IS ABSENT TOO, AND FOR A SEPARATE REASON. +# +# M-profile has no TPIDR-class register. The two pointer slots this layer +# distinguishes — the one the PROCESSOR owns and the one the running CONTEXT +# owns — would both become plain globals, and the probe assertion that they are +# DISTINCT would hold for a reason that says nothing about the machine. A +# single-core part does not need per-CPU state; declaring the capability would +# claim a property this backend cannot demonstrate. +provides = ["openarch-backend"] + +[build] +sources = ["src/**"] + +[targets.openarch-cortex-m] +kind = "lib" + +[dependencies] +openarch-abi = { path = "../../abi" } diff --git a/backends/cortex-m/src/context.S b/backends/cortex-m/src/context.S new file mode 100644 index 0000000..f262570 --- /dev/null +++ b/backends/cortex-m/src/context.S @@ -0,0 +1,72 @@ +/* openarch.context — the Cortex-M backend's switch and trampoline. + * + * AAPCS names r4-r11 as callee-saved, plus SP and LR. Ten words, 40 bytes, + * well inside the 128 the interface reserves. + * + * ⚠️ NO FLOATING-POINT REGISTERS, WHICH IS THE INTERFACE'S CONTRACT RATHER + * THAN THIS BACKEND'S SHORTCUT. aarch64 saves none of d8-d15 and riscv64 none + * of fs0-fs11 for the same reason: a saved context in openarch is INTEGER + * state. On M-profile the hardware's lazy FP stacking already handles the + * exception path, and a cooperative switch between tasks that use the FPU is + * the kernel's business — which is what `s16-s31` would cost 64 more bytes to + * express here. + * + * ⭐ THIS IS THE HALF OF THE INTERFACE M-PROFILE CAN IMPLEMENT. The other + * primitive the layer's viability was decided on — the page-table entry — has + * nothing to construct on a machine whose MPU describes regions rather than + * pages. See this backend's manifest for why that is declared rather than + * faked. + */ + .syntax unified + .thumb + + .section .text.arch_context_switch,"ax",%progbits + .globl arch_context_switch + .type arch_context_switch, %function + .thumb_func + .balign 4 +arch_context_switch: /* r0 = &from, r1 = &to */ + /* ⚠️ `stmia` cannot encode r8-r11 on every M-profile core, so the high + * registers move through r2-r5 first. Writing the wide form would build on + * a Cortex-M4 and fail to assemble for a Cortex-M0. */ + stmia r0!, {r4-r7} + mov r2, r8 + mov r3, r9 + mov r4, r10 + mov r5, r11 + stmia r0!, {r2-r5} + mov r2, sp + mov r3, lr + stmia r0!, {r2-r3} + + adds r1, r1, #16 /* skip to the high-register block */ + ldmia r1!, {r2-r5} + mov r8, r2 + mov r9, r3 + mov r10, r4 + mov r11, r5 + ldmia r1!, {r2-r3} + mov sp, r2 + mov r12, r3 /* the new LR, parked until r4-r7 land */ + subs r1, r1, #40 /* back to the start of `to` */ + ldmia r1!, {r4-r7} + bx r12 + + .size arch_context_switch, . - arch_context_switch + +/* The trampoline a fresh context returns into: the entry point is in r4 and its + * argument in r5, placed there by arch_context_init. + * + * ⚠️ IT DOES NOT RETURN. A task function that runs off its end has nowhere to + * go — there is no caller — so falling into a spin is the honest behaviour and + * a `bx lr` here would branch to whatever the initial LR happened to be. */ + .section .text.openarch_cm_trampoline,"ax",%progbits + .globl openarch_cm_trampoline + .type openarch_cm_trampoline, %function + .thumb_func + .balign 4 +openarch_cm_trampoline: + mov r0, r5 + blx r4 +1: b 1b + .size openarch_cm_trampoline, . - openarch_cm_trampoline diff --git a/backends/cortex-m/src/context_init.cpp b/backends/cortex-m/src/context_init.cpp new file mode 100644 index 0000000..c3e8105 --- /dev/null +++ b/backends/cortex-m/src/context_init.cpp @@ -0,0 +1,28 @@ +// Laying out a context so that the first `arch_context_switch` into it lands in +// the trampoline with the entry point and argument already in registers. +// +// The layout is the switch's, read in the order it stores: r4-r7, then r8-r11, +// then SP and LR — ten words. +#include + +extern "C" void openarch_cm_trampoline(); + +extern "C" void arch_context_init(void* ctx, void (*entry)(void*), void* arg, + void* stack_top) { + auto* w = static_cast(ctx); + for (int i = 0; i < 10; ++i) w[i] = 0; + + // r4 and r5 carry what the trampoline needs; they are the first two slots. + w[0] = reinterpret_cast(entry); // r4 + w[1] = reinterpret_cast(arg); // r5 + + // ⚠️ THE STACK POINTER IS ALIGNED DOWN TO 8, WHICH AAPCS REQUIRES AT A + // PUBLIC INTERFACE AND WHICH A CALLER PASSING THE TOP OF AN ODD-SIZED + // BUFFER WILL OTHERWISE VIOLATE. The fault it produces is not a stack + // fault: it is a misaligned access somewhere inside whatever the task + // calls, which reports a location unrelated to the cause. + auto sp = reinterpret_cast(stack_top); + sp &= ~static_cast(7); + w[8] = sp; // SP + w[9] = reinterpret_cast(&openarch_cm_trampoline) | 1u; // LR, thumb bit +} diff --git a/backends/cortex-m/src/cpu_impl.cpp b/backends/cortex-m/src/cpu_impl.cpp new file mode 100644 index 0000000..443575a --- /dev/null +++ b/backends/cortex-m/src/cpu_impl.cpp @@ -0,0 +1,49 @@ +// openarch.cpu — the Cortex-M backend. +#include + +namespace { +// ⚠️ A VARIABLE, NOT A REGISTER, AND THE MANIFEST DECLINES THE CAPABILITY THAT +// WOULD CLAIM OTHERWISE. +// +// The other three backends put this in a register the privilege level provides +// — `TPIDR_EL1`, `mscratch`, `GS`. M-profile has no TPIDR-class register at +// all, so the only implementation available is a static, which is correct on a +// single-core part and says nothing about a multi-core one. +// +// That is why `openarch:percpu-register` is absent from this package's +// `provides`. The function exists so a portable kernel links; the capability is +// withheld so a kernel that needs a real per-CPU slot is refused at resolution +// rather than silently sharing one word across cores. +void* g_percpu = nullptr; +} // namespace + +extern "C" void* arch_cpu_percpu(void) { return g_percpu; } +extern "C" void arch_cpu_set_percpu(void* p) { g_percpu = p; } + +extern "C" void arch_cpu_fence(int b) { + switch (b) { + // `dmb` orders accesses as other agents observe them; `dsb` waits for + // them to complete. A driver that writes a device register and then + // expects the device to have seen it needs the second. + case ARCH_BARRIER_MEMORY: + case ARCH_BARRIER_STORE: asm volatile("dmb" ::: "memory"); break; + case ARCH_BARRIER_COMPLETE: asm volatile("dsb" ::: "memory"); break; + // ⚠️ `isb` AND NOT `dsb`, AND THE DIFFERENCE MATTERS MOST ON THIS + // MACHINE. After writing VTOR or reprogramming the MPU, the pipeline + // may still hold instructions fetched under the old configuration. + // `dsb` retires the write; only `isb` discards what was fetched. + case ARCH_BARRIER_FETCH: asm volatile("isb" ::: "memory"); break; + default: asm volatile("dmb" ::: "memory"); break; + } +} + +// ⚠️ THE TWO SLOTS ALIAS ON THIS MACHINE, AND THE PROBE MUST BE ABLE TO SAY SO. +// +// openarch distinguishes the pointer the PROCESSOR owns from the one the +// running CONTEXT owns. On aarch64 and x86_64 they are different registers; on +// riscv64 they were made distinct by moving per-CPU to `mscratch`. M-profile +// has neither register, so both are statics — and a probe asserting only that +// each round-trips would pass while measuring nothing. +namespace { void* g_tls = nullptr; } +extern "C" void* arch_cpu_tls(void) { return g_tls; } +extern "C" void arch_cpu_set_tls(void* p) { g_tls = p; } diff --git a/backends/cortex-m/src/pte_impl.cpp b/backends/cortex-m/src/pte_impl.cpp new file mode 100644 index 0000000..ea28646 --- /dev/null +++ b/backends/cortex-m/src/pte_impl.cpp @@ -0,0 +1,50 @@ +// openarch.pte — the group this machine does not have. +// +// ⭐⭐ THESE FUNCTIONS EXIST AND REFUSE. THEY DO NOT PRETEND. +// +// M-profile's MPU describes regions by base address and limit. There is no +// entry that names a physical page, no table for a walker to descend, and no +// bit pattern that `arch_pte_make_leaf` could return which any part of the +// machine would interpret. Returning zero would be a value a caller could +// store into a table that does not exist. +// +// A backend that omitted these symbols entirely would fail to link for every +// consumer, including the ones that never call them — and most kernels on this +// class of part never do. So they link and they abort, while the package's +// `provides` withholds `openarch:address-space` so that a kernel which DOES +// need an address space is refused at resolution, by name, before anything is +// compiled. +// +// ⚠️ The abort is deliberately loud rather than a silent zero. A kernel that +// reaches here has a bug in its own layering, and the useful moment to learn +// that is the first call. +#include + +extern "C" { + +// Supplied by the program (a board package or the kernel). Declared weak so a +// unit test can link this translation unit without one. +__attribute__((weak)) void openarch_panic(const char* what); + +static void refuse(const char* what) { + if (openarch_panic) openarch_panic(what); + for (;;) { asm volatile("bkpt 0xAB"); } +} + +arch_u64 arch_pte_make_leaf(arch_u64, int, int, int) { + refuse("openarch: this machine has no address space (M-profile has an MPU, " + "not an MMU). Require 'openarch:address-space' to be refused at " + "resolution instead."); + return 0; +} +int arch_pte_valid(arch_u64) { refuse("arch_pte_valid"); return 0; } +arch_u64 arch_pte_phys(arch_u64) { refuse("arch_pte_phys"); return 0; } + +// ⭐ THE ONE PTE-GROUP FUNCTION THAT IS MEANINGFUL HERE, AND IT IS A NO-OP FOR +// THE SAME REASON IT IS ON riscv64: there is no attribute register to program +// before a memory type becomes meaningful. On M-profile the MPU's own MAIR +// registers serve that role and are programmed by whoever configures regions, +// which is not this layer. +void arch_pte_install_memory_attributes(void) {} + +} // extern "C" diff --git a/backends/cortex-m/src/trap_impl.cpp b/backends/cortex-m/src/trap_impl.cpp new file mode 100644 index 0000000..9361ed7 --- /dev/null +++ b/backends/cortex-m/src/trap_impl.cpp @@ -0,0 +1,71 @@ +// openarch.trap — the Cortex-M backend. +// +// ⚠️⚠️ THE VECTOR TABLE IS AN ARRAY INDEXED BY EXCEPTION NUMBER, NOT A SINGLE +// ENTRY POINT, AND THAT IS THE SEMANTIC CHANGE THIS MACHINE FORCES. +// +// riscv64 has `mtvec`, aarch64 `VBAR`, x86_64 a table of gates the backend +// fills with one stub. On M-profile the hardware reads a function pointer per +// exception from the table at `VTOR`, and it has already stacked r0-r3, r12, +// LR, PC and xPSR by the time the handler runs. +// +// So "install one handler" means: point the slots this layer cares about at one +// trampoline. The board owns the table itself — where it lives and what fills +// the vendor interrupt slots are board facts — which is why this file installs +// nothing and instead exports the trampoline for a board's table to name. +#include + +namespace { +arch_trap_handler_fn g_handler = nullptr; +} + +extern "C" { + +arch_trap_handler_fn arch_trap_set_handler(arch_trap_handler_fn h) { + auto prev = g_handler; + g_handler = h; + return prev; +} + +// ⚠️ PRIMASK, NOT BASEPRI. `cpsid i` masks every configurable interrupt and is +// present on every M-profile core; `BASEPRI` exists only from v7-M up and masks +// by priority. A kernel that wants priority-based masking is doing scheduling +// policy, which this layer does not own. +void arch_trap_enable_interrupts(int on) { + if (on) asm volatile("cpsie i" ::: "memory"); + else asm volatile("cpsid i" ::: "memory"); +} + +int arch_trap_interrupts_enabled(void) { + unsigned primask; + asm volatile("mrs %0, primask" : "=r"(primask)); + return primask == 0; // PRIMASK set == interrupts masked +} + +// The trampoline a board's vector table names. It normalises what the machine +// reports into the frame every handler on every architecture reads. +// +// ⭐ `pc` IS THE STACKED RETURN ADDRESS, WHICH IS WHAT THE OTHER BACKENDS MEAN. +// The hardware pushed it at offset 24 of the exception frame; on a fault it is +// the faulting instruction, and on an SVC or PendSV it is the one after — the +// same fault/trap distinction x86_64 normalises, resolved the same way. +__attribute__((used)) void openarch_cm_trap_entry(unsigned* frame, + unsigned exc_number) { + if (!g_handler) return; + arch_trap_frame f{}; + f.pc = frame ? frame[6] : 0; // stacked PC + f.addr = 0; + f.cause = exc_number; + f.instr_len = 2; // Thumb: `bkpt` is two bytes + switch (exc_number) { + case 3: f.kind = ARCH_TRAP_ILLEGAL; break; // HardFault + case 4: f.kind = ARCH_TRAP_PAGE_FAULT; break; // MemManage (MPU) + case 5: f.kind = ARCH_TRAP_OTHER; break; // BusFault + case 6: f.kind = ARCH_TRAP_ILLEGAL; break; // UsageFault + case 11: f.kind = ARCH_TRAP_BREAKPOINT; break; // SVCall + default: f.kind = (exc_number >= 16) ? ARCH_TRAP_INTERRUPT + : ARCH_TRAP_OTHER; break; + } + g_handler(&f); +} + +} // extern "C" diff --git a/backends/riscv64/mcpp.toml b/backends/riscv64/mcpp.toml index c77ce24..c413238 100644 --- a/backends/riscv64/mcpp.toml +++ b/backends/riscv64/mcpp.toml @@ -24,7 +24,31 @@ 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. +provides = ["openarch-backend", + "openarch:address-space", + "openarch:percpu-register"] [build] sources = ["src/**"] diff --git a/backends/x86_64/mcpp.toml b/backends/x86_64/mcpp.toml index 97c0d86..a597182 100644 --- a/backends/x86_64/mcpp.toml +++ b/backends/x86_64/mcpp.toml @@ -32,7 +32,31 @@ 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. +provides = ["openarch-backend", + "openarch:address-space", + "openarch:percpu-register"] [build] sources = ["src/**"] diff --git a/examples/preempt/README.md b/examples/preempt/README.md new file mode 100644 index 0000000..1e5e972 --- /dev/null +++ b/examples/preempt/README.md @@ -0,0 +1,72 @@ +# preempt — a preemptive scheduler on a machine with no address space + +Two tasks that never yield are interleaved by a timer. Each proves it was +interrupted by observing a counter it did not advance itself. + +``` +$ mcpp run +preempt: openarch on a machine with no address space +preempt: both tasks observed preemption +``` + +## Why this example exists + +`examples/switch` calls `arch_context_switch` and returns — a cooperative round +trip. It is the right probe for "does one interface hold across several +machines", and the wrong one for Cortex-M, because it never enters a trap. On +M-profile the trap group is exactly what changes: the vector table is an array +indexed by exception number rather than one entry point, and the hardware stacks +half the register file before a handler runs. + +## ⭐⭐ What it found + +**`arch_context_switch` is a cooperative primitive, and preemption needs +something openarch does not have.** + +The first version of this example 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 distinguished that from success. + +The reason is structural. Inside an exception handler the hardware has already +stacked `r0-r3`, `r12`, `LR`, `PC` and `xPSR` onto the interrupted task's stack +and is running the handler on MSP. Swapping the callee-saved registers of +whoever called `arch_context_switch` swaps the *handler's* state; the exception +return then unstacks a frame belonging to nobody. + +The M-profile mechanism is different in kind: tasks run on PSP, the handler on +MSP, and a switch means saving `r4-r11` below the hardware frame on the outgoing +task's PSP, pointing PSP at the incoming task, restoring its `r4-r11`, and +returning with an `EXC_RETURN` that resumes Thread mode on PSP. + +⚠️ **This is a gap in the layer, not a detail of this board.** 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 in order to +preempt, and every architecture spells it differently — which is the shape of +thing this layer exists to abstract. riscv64 edits `sepc` and the saved +registers; aarch64 edits `ELR_EL1` and `SP_EL0`; x86_64 edits the interrupt +frame. Naming that as a fifth interface group is a decision for the layer, taken +with more than one machine in view; it is recorded here rather than papered over. + +## What openarch does supply here + +* `arch_context_init` — the initial cooperative frames +* `arch_trap_enable_interrupts` / `arch_trap_interrupts_enabled` — PRIMASK +* the trap trampoline, which normalises a fault into the frame every handler on + every architecture reads +* `arch_cpu_fence` — `dmb` / `dsb` / `isb` + +## What this backend does not supply, and says so + +`mcpplibs/openarch-cortex-m` declares `openarch-backend` 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 states it and is refused by name at +resolution: + +``` +error: no package provides capability 'openarch:address-space' + required by 'mykernel' +``` + +rather than by a wall of `undefined reference to arch_pte_*` at link time. diff --git a/examples/preempt/build.mcpp b/examples/preempt/build.mcpp new file mode 100644 index 0000000..6f4e527 --- /dev/null +++ b/examples/preempt/build.mcpp @@ -0,0 +1,14 @@ +import mcpp; +import std; + +int main() { + mcpp::link_script("mps2.ld"); + // The bare name; mcpp searches the payload this manifest declared under + // `[xlings] deps` before it looks at PATH. + mcpp::runner("qemu-system-arm"); + for (auto a : {"-machine", "mps2-an385", "-cpu", "cortex-m3", "-nographic", + "-semihosting", "-no-reboot", "-kernel"}) + mcpp::runner(a); + mcpp::rerun_if_changed("mps2.ld"); + return 0; +} diff --git a/examples/preempt/mcpp.toml b/examples/preempt/mcpp.toml new file mode 100644 index 0000000..5027ee9 --- /dev/null +++ b/examples/preempt/mcpp.toml @@ -0,0 +1,38 @@ +# A preemptive task switcher on Cortex-M. +# +# ⭐⭐ WHY THIS EXAMPLE AND NOT ANOTHER `switch` PROBE. +# +# `examples/switch` calls `arch_context_switch` and returns — a cooperative +# round trip. It is the right probe for "does the interface hold across +# machines", and it is the wrong one for this backend, because it never enters +# a trap. On M-profile the trap group is exactly what changes: the vector table +# is an array indexed by exception number rather than one entry point, and the +# hardware stacks half the register file before the handler runs. +# +# So the probe here is a scheduler: SysTick interrupts, the handler picks the +# other task, PendSV performs the switch, and the tasks observe that they were +# interrupted rather than having yielded. Nothing short of that exercises +# `arch_trap_*` on this machine. +# +# ⚠️ AND IT ASSERTS PREEMPTION RATHER THAN PROGRESS. Two tasks that each print +# would also print if the switch never happened and one simply ran to +# completion. The criterion is that each task observes a counter it did not +# advance itself. +[package] +name = "preempt" +version = "0.1.0" + +[build] +target = "thumbv7m-none-eabi" +sources = ["src/main.cpp", "src/board.cpp"] + +# The zero-libc tier: this program is the whole of its own environment. +[target.thumbv7m-none-eabi] +sysroot = "" + +[dependencies] +openarch = { path = "../..", features = ["backend-external"] } +openarch-cortex-m = { path = "../../backends/cortex-m" } + +[xlings] +deps = ["xim:qemu-arm@9.2.4-1"] diff --git a/examples/preempt/mps2.ld b/examples/preempt/mps2.ld new file mode 100644 index 0000000..5702914 --- /dev/null +++ b/examples/preempt/mps2.ld @@ -0,0 +1,13 @@ +ENTRY(Reset_Handler) +MEMORY { + FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 4M + RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 4M +} +SECTIONS { + /* KEEP: the vector table is referenced by nothing — the hardware reads it by + address — so --gc-sections would otherwise collect it. */ + .text : { KEEP(*(.vectors)) *(.text*) *(.rodata*) } > FLASH + .data : { *(.data*) } > RAM + .bss : { *(.bss*) *(COMMON) } > RAM + __stack_top = ORIGIN(RAM) + LENGTH(RAM); +} diff --git a/examples/preempt/src/board.cpp b/examples/preempt/src/board.cpp new file mode 100644 index 0000000..d3fdcc2 --- /dev/null +++ b/examples/preempt/src/board.cpp @@ -0,0 +1,71 @@ +// The machine: vector table, reset, semihosting console, SysTick. +// +// Board facts, kept out of main.cpp so that what main.cpp shows is the +// scheduler and not the machine. +#include + +extern "C" unsigned __stack_top; +extern "C" void Reset_Handler(); +extern "C" void openarch_cm_trap_entry(unsigned* frame, unsigned exc); +extern "C" void PendSV_Handler(); +extern "C" void scheduler_tick(); + +namespace { +void semihost(int op, const void* arg) { + register int r0 __asm__("r0") = op; + register const void* r1 __asm__("r1") = arg; + __asm__ volatile("bkpt 0xAB" :: "r"(r0), "r"(r1) : "memory"); +} +} // namespace + +extern "C" void board_print(const char* s) { semihost(0x04, s); } +extern "C" [[noreturn]] void board_exit(int code) { + struct { unsigned reason, code; } b{0x20026u, static_cast(code)}; + semihost(0x18, &b); + for (;;) {} +} + +extern "C" void openarch_panic(const char* what) { + board_print("PANIC: "); board_print(what); board_print("\n"); + board_exit(1); +} + +// ⭐ SysTick drives preemption. Its handler does no switching: it marks PendSV +// pending and returns, so the context switch happens at the LOWEST exception +// priority, after any other handler has finished. Switching stacks inside a +// high-priority handler is the classic way to corrupt an unrelated interrupt. +extern "C" void SysTick_Handler() { + scheduler_tick(); + *reinterpret_cast(0xE000ED04) = (1u << 28); // ICSR.PENDSVSET +} + +// Faults route into openarch's trampoline, which normalises them into the frame +// every handler on every architecture reads. +extern "C" __attribute__((naked)) void Fault_Handler() { + __asm__ volatile( + "mrs r0, msp\n" + "mrs r1, ipsr\n" + "b openarch_cm_trap_entry\n"); +} + +extern "C" __attribute__((section(".vectors"), used)) +void* const vectors[] = { + (void*)&__stack_top, (void*)Reset_Handler, + (void*)Fault_Handler, // NMI + (void*)Fault_Handler, // HardFault + (void*)Fault_Handler, (void*)Fault_Handler, (void*)Fault_Handler, + nullptr, nullptr, nullptr, nullptr, + (void*)Fault_Handler, // SVCall + nullptr, nullptr, + (void*)PendSV_Handler, + (void*)SysTick_Handler, +}; + +extern "C" void board_start_tick(unsigned reload) { + // PendSV at the lowest priority so it runs after everything else. + *reinterpret_cast(0xE000ED20) |= (0xFFu << 16); + auto* syst = reinterpret_cast(0xE000E010); + syst[1] = reload; // RVR + syst[2] = 0; // CVR + syst[0] = 0x7; // CSR: enable | tickint | processor clock +} diff --git a/examples/preempt/src/main.cpp b/examples/preempt/src/main.cpp new file mode 100644 index 0000000..a7e1807 --- /dev/null +++ b/examples/preempt/src/main.cpp @@ -0,0 +1,166 @@ +// A preemptive scheduler over openarch, on a machine with no address space. +// +// ⭐⭐ THE CLAIM: two tasks that never yield are nevertheless interleaved, and +// each can prove it was interrupted rather than having given way. +// +// ⚠️ AND THAT IS WHY THE ASSERTION IS A COUNTER EACH TASK DID NOT ADVANCE. Two +// tasks that print would also print if the switch never happened and one simply +// ran to completion — the shape of green that says nothing. +#include + +extern "C" void board_print(const char*); +extern "C" [[noreturn]] void board_exit(int); +extern "C" void board_start_tick(unsigned); + +namespace { + +constexpr int kTasks = 2; + +alignas(8) unsigned char g_stack[kTasks][2048]; +unsigned* g_psp[kTasks] = {nullptr, nullptr}; + +volatile int g_current = -1; +volatile int g_ticks = 0; +volatile int g_progress[kTasks] = {0, 0}; +// Each task records what the OTHER task's progress was when it last ran. If it +// ever differs from what it saw before, something ran while this task did not. +volatile int g_witness[kTasks] = {-1, -1}; +volatile bool g_observed_preemption[kTasks] = {false, false}; + +void task_body(void* arg) { + const int me = static_cast(reinterpret_cast(arg)); + const int other = 1 - me; + for (;;) { + ++g_progress[me]; + const int seen = g_progress[other]; + if (g_witness[me] >= 0 && seen != g_witness[me]) + g_observed_preemption[me] = true; // the other side moved + g_witness[me] = seen; + // No yield, no wfi, no cooperation of any kind. Whatever interleaving + // occurs is the timer's doing. + for (volatile int spin = 0; spin < 200; ++spin) {} + } +} + +} // namespace + +// ⭐⭐ THE SWITCH AT PendSV IS *NOT* `arch_context_switch`, AND FINDING THAT OUT +// IS WHAT THIS EXAMPLE IS FOR. +// +// `arch_context_switch` is a COOPERATIVE primitive: it saves the callee-saved +// registers of whoever called it and resumes another such saved set. Inside an +// exception handler that is the wrong thing to swap. The hardware has already +// stacked r0-r3, r12, LR, PC and xPSR onto the interrupted task's stack and is +// running the handler on MSP; swapping the handler's own registers changes the +// handler's stack, and the exception return then unstacks a frame belonging to +// nobody. +// +// Measured: with `arch_context_switch` called from PendSV, the program builds, +// boots, and reports that neither task ever observed the other — the tasks were +// never actually interleaved, and only the counter assertion caught it. +// +// The M-profile mechanism is different in kind: tasks run on PSP, the handler +// on MSP, and the switch is performed by saving r4-r11 below the hardware frame +// on the outgoing task's PSP, pointing PSP at the incoming task, restoring its +// r4-r11, and returning with an EXC_RETURN that says "resume in Thread mode on +// PSP". The hardware unstacks the rest. +// +// ⚠️ THIS IS A GAP IN openarch, NOT A DETAIL OF THIS BOARD. The layer 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 architecture spells it differently — which is exactly the +// shape of thing this layer exists to abstract. Recorded rather than papered +// over; see this example's README. +// +// What openarch still supplies here: `arch_context_init` lays out the initial +// frames, `arch_trap_enable_interrupts` gates the timer, and the fault vectors +// route through its trampoline. +extern "C" __attribute__((used)) unsigned* pendsv_pick(unsigned* outgoing_psp) { + if (g_current < 0) return outgoing_psp; + g_psp[g_current] = outgoing_psp; + g_current = (g_current + 1) % kTasks; + return g_psp[g_current]; +} + +extern "C" __attribute__((naked)) void PendSV_Handler() { + __asm__ volatile( + "mrs r0, psp\n" + "subs r0, r0, #32\n" + "stmia r0!, {r4-r7}\n" // low callee-saved + "mov r4, r8\n" + "mov r5, r9\n" + "mov r6, r10\n" + "mov r7, r11\n" + "stmia r0!, {r4-r7}\n" // high callee-saved, via the low four + "subs r0, r0, #32\n" + "push {lr}\n" + "bl pendsv_pick\n" // r0 = outgoing PSP, returns incoming + "pop {r1}\n" // the EXC_RETURN we were entered with + "adds r0, r0, #16\n" + "ldmia r0!, {r4-r7}\n" // high callee-saved + "mov r8, r4\n" + "mov r9, r5\n" + "mov r10, r6\n" + "mov r11, r7\n" + "subs r0, r0, #32\n" + "ldmia r0!, {r4-r7}\n" // low callee-saved + "adds r0, r0, #16\n" + "msr psp, r0\n" + "bx r1\n"); +} + +extern "C" void preempt_verdict(); + +// ⚠️ THE VERDICT IS REACHED FROM THE TIMER, NOT FROM A TASK. The tasks never +// return — that is the point of them — so nothing in task_body could report. +// After enough ticks for preemption to have happened many times over, the +// handler stops the world and reports. +extern "C" void scheduler_tick() { + if (++g_ticks == 400) preempt_verdict(); +} + +extern "C" void Reset_Handler() { + board_print("preempt: openarch on a machine with no address space\n"); + + // ⚠️ THE INITIAL FRAME IS THE ONE THE HARDWARE WILL UNSTACK, so it is laid + // out the way an exception entry would have left it: eight words of + // hardware frame (r0-r3, r12, LR, PC, xPSR) above eight of callee-saved. + // `arch_context_init` cannot do this — it lays out a COOPERATIVE context, + // which is the distinction this example exists to show. + for (int i = 0; i < kTasks; ++i) { + auto* top = reinterpret_cast( + &g_stack[i][sizeof(g_stack[i])]); + top -= 16; + for (int w = 0; w < 16; ++w) top[w] = 0; + top[8] = static_cast(i); // r0 = task id + top[14] = reinterpret_cast(&task_body) | 1u; // PC, thumb bit + top[15] = 0x01000000u; // xPSR, T set + g_psp[i] = top; + } + + // A short period: the tasks must be interrupted many times over the window + // the main loop waits. + board_start_tick(2000); + arch_trap_enable_interrupts(1); + + // Enter the first task: point PSP at its frame, switch to Thread-mode-on-PSP + // and branch in. From here the timer owns the interleaving. + g_current = 0; + __asm__ volatile( + "msr psp, %0\n" + "movs r0, #2\n" + "msr control, r0\n" // Thread mode uses PSP + "isb\n" + :: "r"(g_psp[0] + 16) : "r0", "memory"); + task_body(reinterpret_cast(0ul)); + for (;;) {} // unreachable +} + +// Called from the tasks' own progress by the semihosting host after enough +// ticks — kept as a separate entry so the check reads as a check. +extern "C" __attribute__((used)) void preempt_verdict() { + const bool both = g_observed_preemption[0] && g_observed_preemption[1]; + board_print(both ? "preempt: both tasks observed preemption\n" + : "preempt: FAILED — no task observed the other running\n"); + board_exit(both ? 0 : 1); +} From 009426442c68fcd34200dd1176ad5a419ec51cf3 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 4 Sep 2026 11:17:04 +0800 Subject: [PATCH 2/5] =?UTF-8?q?0.8.0=20=E2=80=94=20the=20trap=20group=20ga?= =?UTF-8?q?ins=20an=20action,=20and=20the=20fourth=20machine=20implements?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .github/workflows/ci.yml | 103 ++++++++++++++++- README.md | 20 +++- abi/include/openarch/abi.h | 43 +++++++ abi/mcpp.toml | 2 +- backends/aarch64/mcpp.toml | 12 +- backends/aarch64/src/trap_impl.cpp | 53 +++++++++ backends/cortex-m/mcpp.toml | 12 +- backends/cortex-m/src/context.S | 106 ++++++++--------- backends/cortex-m/src/context_init.cpp | 54 +++++---- backends/cortex-m/src/preempt.S | 129 +++++++++++++++++++++ backends/cortex-m/src/trap_impl.cpp | 52 +++++++++ backends/riscv64/mcpp.toml | 12 +- backends/riscv64/src/trap_impl.cpp | 45 ++++++++ backends/x86_64/mcpp.toml | 12 +- backends/x86_64/src/trap_impl.cpp | 47 ++++++++ examples/clock-study/mcpp.toml | 6 +- examples/preempt/README.md | 71 ++++++++++-- examples/preempt/mcpp.toml | 4 +- examples/preempt/src/board.cpp | 23 ++-- examples/preempt/src/main.cpp | 151 ++++++++----------------- examples/switch/mcpp.toml | 6 +- examples/switch/src/main.cpp | 83 ++++++++++++++ mcpp.toml | 2 +- src/context.cppm | 20 ++++ src/trap.cppm | 7 ++ templates/three-machines/mcpp.toml.in | 6 +- 26 files changed, 861 insertions(+), 220 deletions(-) create mode 100644 backends/cortex-m/src/preempt.S diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ca7245..f07e0bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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. @@ -390,6 +401,96 @@ 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 + mcpp run --target thumbv7m-none-eabi 2>&1 | tee run.log + grep -q "both tasks observed preemption" run.log \ + || { cat run.log; echo "the tasks were never interleaved"; 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. # diff --git a/README.md b/README.md index 7b26c4d..3f01ee0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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 | diff --git a/abi/include/openarch/abi.h b/abi/include/openarch/abi.h index 5337ff5..7e6c124 100644 --- a/abi/include/openarch/abi.h +++ b/abi/include/openarch/abi.h @@ -141,6 +141,49 @@ 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. */ +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. diff --git a/abi/mcpp.toml b/abi/mcpp.toml index 9b11ffe..97513f8 100644 --- a/abi/mcpp.toml +++ b/abi/mcpp.toml @@ -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"] diff --git a/backends/aarch64/mcpp.toml b/backends/aarch64/mcpp.toml index ab10623..a7f2340 100644 --- a/backends/aarch64/mcpp.toml +++ b/backends/aarch64/mcpp.toml @@ -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"] @@ -46,9 +46,17 @@ repo = "https://github.com/mcpplibs/openarch" # 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:percpu-register", + "openarch:preemption"] [build] sources = ["src/**"] diff --git a/backends/aarch64/src/trap_impl.cpp b/backends/aarch64/src/trap_impl.cpp index bdcea8b..89c58be 100644 --- a/backends/aarch64/src/trap_impl.cpp +++ b/backends/aarch64/src/trap_impl.cpp @@ -46,6 +46,29 @@ namespace { arch_trap_handler_fn g_handler = nullptr; +// ⭐⭐ PREEMPTION IS PERFORMED WHERE THE TRAP ENDS, NOT WHERE IT IS REQUESTED, +// AND THESE TWO WORDS ARE THE WHOLE OF THAT. +// +// `arch_trap_switch` cannot swap anything at the point it is called: the +// handler between it and the trap's exit is an ordinary C function, so the +// interrupted context's callee-saved registers are either still in the CPU or +// spilled into that handler's frame — and either way they are put back before +// the stub returns. Swapping there would save half a register file. +// +// So the call records a request and the dispatcher performs it on its way out, +// at which point "the current context" IS the interrupted one plus the trap +// frame beneath it. A later switch back resumes inside the dispatcher, which +// returns to the stub, which restores the saved registers from the same stack +// and executes the exception return. +// +// ⚠️ THIS WORKS BECAUSE THE TRAP RUNS ON THE INTERRUPTED CONTEXT'S STACK, which +// is true here, on riscv64 and on x86_64 — and NOT on M-profile, whose handler +// runs on MSP while the task runs on PSP. That machine implements the same +// interface by pending an exception instead. One name, two mechanisms. +void* g_switch_from = nullptr; +void* g_switch_to = nullptr; + + // ⭐ THE CLASSIFICATION READS TWO SOURCES, AND riscv NEEDS ONLY ONE. // // Which slot ran says whether the exception was synchronous, an IRQ, an FIQ or @@ -75,6 +98,14 @@ int classify(arch_u64 slot, arch_u64 esr) noexcept { } // namespace +extern "C" void arch_context_switch(void* from, void* to); + +extern "C" void arch_trap_switch(arch_trap_frame* f, void* from, void* to) { + (void)f; + g_switch_from = from; + g_switch_to = to; +} + // Called by the common path in trap.S. `slot` is the vector index the hardware // selected, which no register records. extern "C" void arch_trap_dispatch(arch_trap_frame* f, @@ -100,6 +131,28 @@ extern "C" void arch_trap_dispatch(arch_trap_frame* f, if (g_handler) g_handler(f); write_elr(f->pc); + + // ⚠️ THE LAST THING, AND AFTER `ELR_EL1` IS WRITTEN. Anything below this + // line would run in whichever context the switch lands in, not the one the + // lines above describe. + if (g_switch_to) { + void* from = g_switch_from; + void* to = g_switch_to; + g_switch_from = g_switch_to = nullptr; + // ⚠️ `ELR_EL1` AND `SPSR_EL1` ARE PER-CONTEXT AND THE MACHINE HAS ONE + // OF EACH. While this context is away, a trap in another context + // overwrites both; on return the `eret` would resume at that other + // context's address, in its processor state. Saved on this context's + // own stack and put back — the same reasoning the riscv64 backend + // applies to `mepc`, with one more register because this machine + // separates the return address from the state it returns to. + arch_u64 elr, spsr; + asm volatile("mrs %0, elr_el1" : "=r"(elr)); + asm volatile("mrs %0, spsr_el1" : "=r"(spsr)); + arch_context_switch(from, to); + asm volatile("msr elr_el1, %0" :: "r"(elr)); + asm volatile("msr spsr_el1, %0" :: "r"(spsr)); + } } diff --git a/backends/cortex-m/mcpp.toml b/backends/cortex-m/mcpp.toml index 83f26be..46a71c6 100644 --- a/backends/cortex-m/mcpp.toml +++ b/backends/cortex-m/mcpp.toml @@ -37,7 +37,17 @@ repo = "https://github.com/mcpplibs/openarch" # DISTINCT would hold for a reason that says nothing about the machine. A # single-core part does not need per-CPU state; declaring the capability would # claim a property this backend cannot demonstrate. -provides = ["openarch-backend"] +# ⭐⭐ AND IT DOES DECLARE `openarch:preemption`, WHICH IS THE POINT OF ADMITTING +# A PARTIAL BACKEND AT ALL. +# +# This machine cannot describe an address space and has no per-CPU register, and +# it CAN change what a trap returns to — by pending PendSV, which is a mechanism +# none of the other three has. A capability set that was all-or-nothing would +# have had to refuse the machine over the two it lacks, and with it the ability +# to preempt on the class of device where a hand-written task switcher is +# otherwise re-invented per project. +provides = ["openarch-backend", + "openarch:preemption"] [build] sources = ["src/**"] diff --git a/backends/cortex-m/src/context.S b/backends/cortex-m/src/context.S index f262570..df0f1bc 100644 --- a/backends/cortex-m/src/context.S +++ b/backends/cortex-m/src/context.S @@ -1,21 +1,30 @@ -/* openarch.context — the Cortex-M backend's switch and trampoline. +/* openarch.context — the Cortex-M backend's cooperative switch. * - * AAPCS names r4-r11 as callee-saved, plus SP and LR. Ten words, 40 bytes, - * well inside the 128 the interface reserves. + * ⭐⭐ IT IS THE SAME MECHANISM AS `arch_trap_switch`, AND THAT IS THE WHOLE + * DESIGN RATHER THAN AN IMPLEMENTATION CONVENIENCE. * - * ⚠️ NO FLOATING-POINT REGISTERS, WHICH IS THE INTERFACE'S CONTRACT RATHER - * THAN THIS BACKEND'S SHORTCUT. aarch64 saves none of d8-d15 and riscv64 none - * of fs0-fs11 for the same reason: a saved context in openarch is INTEGER - * state. On M-profile the hardware's lazy FP stacking already handles the - * exception path, and a cooperative switch between tasks that use the FPU is - * the kernel's business — which is what `s16-s31` would cost 64 more bytes to - * express here. + * The interface says the two primitives have one shape and differ only in when + * they take effect: one switches now, the other switches when the trap + * returns. On M-profile the hardware provides exactly that distinction — + * PendSV pended from thread mode is taken at once, and PendSV pended from a + * handler is taken when that handler exits. So both functions do the same + * thing, and the machine supplies the difference. * - * ⭐ THIS IS THE HALF OF THE INTERFACE M-PROFILE CAN IMPLEMENT. The other - * primitive the layer's viability was decided on — the page-table entry — has - * nothing to construct on a machine whose MPU describes regions rather than - * pages. See this backend's manifest for why that is declared rather than - * faked. + * ⚠️ THIS REPLACED A PURE REGISTER SWAP, AND THE REASON IS THAT THE TWO COULD + * NOT SHARE A CONTEXT LAYOUT. A cooperative swap stores r4-r11, SP and LR in + * the caller's block; a preempted context lives as r4-r11 plus a hardware + * exception frame on its own stack. Two layouts means a context saved one way + * cannot be resumed the other, and a kernel that mixed a `yield` with a timer + * would corrupt whichever it used second — silently, because both are just + * words. One mechanism, one layout, and `arch_context_init` produces the shape + * both read. + * + * ⚠️ THE PRICE, STATED: a cooperative switch here needs PendSV enabled and the + * caller running on PSP. Both hold for a task started through + * `openarch_cm_enter`; neither holds for code that has never entered a task. + * The other three backends have no such requirement, and this one has it + * because its exception mechanism is the only way to change the stack an + * exception return will use. */ .syntax unified .thumb @@ -26,47 +35,38 @@ .thumb_func .balign 4 arch_context_switch: /* r0 = &from, r1 = &to */ - /* ⚠️ `stmia` cannot encode r8-r11 on every M-profile core, so the high - * registers move through r2-r5 first. Writing the wide form would build on - * a Cortex-M4 and fail to assemble for a Cortex-M0. */ - stmia r0!, {r4-r7} - mov r2, r8 - mov r3, r9 - mov r4, r10 - mov r5, r11 - stmia r0!, {r2-r5} - mov r2, sp - mov r3, lr - stmia r0!, {r2-r3} - - adds r1, r1, #16 /* skip to the high-register block */ - ldmia r1!, {r2-r5} - mov r8, r2 - mov r9, r3 - mov r10, r4 - mov r11, r5 - ldmia r1!, {r2-r3} - mov sp, r2 - mov r12, r3 /* the new LR, parked until r4-r7 land */ - subs r1, r1, #40 /* back to the start of `to` */ - ldmia r1!, {r4-r7} - bx r12 - + ldr r2, =openarch_cm_pending + str r0, [r2, #0] + str r1, [r2, #4] + /* ICSR.PENDSVSET — 0xE000ED04, bit 28. */ + ldr r2, =0xE000ED04 + movs r3, #1 + lsls r3, r3, #28 + str r3, [r2, #0] + dsb + isb /* PendSV is taken here */ + bx lr .size arch_context_switch, . - arch_context_switch -/* The trampoline a fresh context returns into: the entry point is in r4 and its - * argument in r5, placed there by arch_context_init. +/* ⭐ THERE IS NO TRAMPOLINE HERE, AND ITS ABSENCE IS THE LAYOUT'S DOING. * - * ⚠️ IT DOES NOT RETURN. A task function that runs off its end has nowhere to - * go — there is no caller — so falling into a spin is the honest behaviour and - * a `bx lr` here would branch to whatever the initial LR happened to be. */ - .section .text.openarch_cm_trampoline,"ax",%progbits - .globl openarch_cm_trampoline - .type openarch_cm_trampoline, %function + * The other three backends need one: their switch restores callee-saved + * registers and returns, so a context that has never run has to land somewhere + * that moves the entry point and argument into the right registers. Here a + * context is resumed by an EXCEPTION RETURN, and the hardware frame carries r0 + * and the PC — so `arch_context_init` simply writes the argument into the r0 + * slot and the entry point into the PC slot, and the hardware does the rest. + * + * ⚠️ A task that returns therefore returns to the frame's LR, which + * `arch_context_init` sets to an address that spins. The contract is the same + * one every backend states — an entry must not return — and this is what + * happens if it does, rather than a branch to whatever the stack held. + */ + .section .text.openarch_cm_task_exit,"ax",%progbits + .globl openarch_cm_task_exit + .type openarch_cm_task_exit, %function .thumb_func .balign 4 -openarch_cm_trampoline: - mov r0, r5 - blx r4 +openarch_cm_task_exit: 1: b 1b - .size openarch_cm_trampoline, . - openarch_cm_trampoline + .size openarch_cm_task_exit, . - openarch_cm_task_exit diff --git a/backends/cortex-m/src/context_init.cpp b/backends/cortex-m/src/context_init.cpp index c3e8105..86976d4 100644 --- a/backends/cortex-m/src/context_init.cpp +++ b/backends/cortex-m/src/context_init.cpp @@ -1,28 +1,44 @@ -// Laying out a context so that the first `arch_context_switch` into it lands in -// the trampoline with the entry point and argument already in registers. +// Laying out a context so that BOTH ways of resuming it find what they expect. // -// The layout is the switch's, read in the order it stores: r4-r7, then r8-r11, -// then SP and LR — ten words. +// ⭐⭐ ONE LAYOUT, BECAUSE TWO WOULD BE A SILENT CORRUPTION. On this machine a +// preempted context is r4-r11 followed by the hardware exception frame, living +// on the task's own stack; a cooperative switch could have used a different +// shape stored in the caller's block. It does not — `arch_context_switch` here +// is a pended exception, so both paths save and restore this one shape. A +// kernel that mixes a yield with a timer therefore cannot corrupt a context by +// resuming it through the other door. +// +// The block the caller supplies holds ONE word: the saved stack pointer. The +// rest of the 128 bytes the interface reserves is unused, which is the honest +// consequence of the state living where the machine puts it. +// +// [ctx] ──▶ saved SP ──▶ r4 r5 r6 r7 r8 r9 r10 r11 (8 words) +// r0 r1 r2 r3 r12 LR PC xPSR (the hardware's 8) #include -extern "C" void openarch_cm_trampoline(); +extern "C" void openarch_cm_task_exit(); extern "C" void arch_context_init(void* ctx, void (*entry)(void*), void* arg, void* stack_top) { - auto* w = static_cast(ctx); - for (int i = 0; i < 10; ++i) w[i] = 0; - - // r4 and r5 carry what the trampoline needs; they are the first two slots. - w[0] = reinterpret_cast(entry); // r4 - w[1] = reinterpret_cast(arg); // r5 - - // ⚠️ THE STACK POINTER IS ALIGNED DOWN TO 8, WHICH AAPCS REQUIRES AT A - // PUBLIC INTERFACE AND WHICH A CALLER PASSING THE TOP OF AN ODD-SIZED - // BUFFER WILL OTHERWISE VIOLATE. The fault it produces is not a stack - // fault: it is a misaligned access somewhere inside whatever the task - // calls, which reports a location unrelated to the cause. + // ⚠️ EIGHT, NOT FOUR. AAPCS requires 8-byte alignment at a public + // interface, and the exception entry the hardware performs assumes the + // frame it stacks is 8-aligned. A caller passing the top of an odd-sized + // buffer otherwise produces a misaligned access somewhere inside whatever + // the task calls, reported at a location unrelated to its cause. auto sp = reinterpret_cast(stack_top); sp &= ~static_cast(7); - w[8] = sp; // SP - w[9] = reinterpret_cast(&openarch_cm_trampoline) | 1u; // LR, thumb bit + + auto* frame = reinterpret_cast(sp) - 16; + for (int i = 0; i < 16; ++i) frame[i] = 0; + + frame[8] = reinterpret_cast(arg); // r0 + frame[13] = reinterpret_cast(&openarch_cm_task_exit) | 1u; + frame[14] = reinterpret_cast(entry) | 1u; // PC, thumb bit + // ⚠️ xPSR WITH THE T BIT SET, AND NOTHING ELSE. Clearing it would make the + // exception return enter ARM state, which no M-profile core implements — + // the fault is a UsageFault at the first instruction of the task, reported + // as an invalid state rather than as a bad context. + frame[15] = 0x01000000ul; + + *static_cast(ctx) = reinterpret_cast(frame); } diff --git a/backends/cortex-m/src/preempt.S b/backends/cortex-m/src/preempt.S new file mode 100644 index 0000000..d1db170 --- /dev/null +++ b/backends/cortex-m/src/preempt.S @@ -0,0 +1,129 @@ +/* openarch.trap — the M-profile switch, which is an EXCEPTION rather than a + * function call. + * + * ⭐⭐ THIS IS THE ONE PLACE IN THE LAYER WHERE TWO MACHINES DO NOT SHARE A + * MECHANISM, AND IT IS WHY `arch_trap_switch` IS AN INTERFACE FUNCTION RATHER + * THAN A LINE OF KERNEL CODE. + * + * riscv64, aarch64 and x86_64 take a trap on the interrupted context's own + * stack, so a handler can perform an ordinary cooperative switch on its way out + * and the exception return then finds the new context's frame. M-profile + * cannot: the handler runs on MSP while the task runs on PSP, the hardware has + * already stacked r0-r3, r12, LR, PC and xPSR onto the TASK's stack, and + * swapping the handler's registers changes the handler's stack — the exception + * return then unstacks a frame belonging to nobody. + * + * Measured, before this backend had the primitive: calling + * `arch_context_switch` from PendSV builds, boots, and reports that neither of + * two tasks ever observed the other. Nothing failed. The tasks were simply + * never interleaved, and only a counter assertion could tell the difference. + * + * ⭐ THE MECHANISM THE MACHINE DOES OFFER IS EXACTLY THE INTERFACE'S SHAPE. + * PendSV is an exception whose whole purpose is "do this when we are about to + * leave handler mode". So: + * + * arch_trap_switch — pend it from a handler: taken at exception exit + * arch_context_switch — pend it from thread mode: taken immediately + * + * One instruction sequence, two moments. That is the interface's own sentence + * about the two primitives, provided by the hardware rather than emulated. + * + * ⚠️ THE BOARD MUST NAME `openarch_cm_pendsv` IN VECTOR SLOT 14. There is no + * way for this package to install it: the table's location is a board fact and + * the hardware reads it by address. A board that does not is not broken at + * link time — the switch simply never happens, which is why the example that + * exercises this asserts preemption rather than progress. + */ + .syntax unified + .thumb + +/* ⚠️ EVERY IMMEDIATE BELOW USES THE TWO-OPERAND FORM (`adds r0, #32`), WHICH IS + * NOT A STYLE CHOICE. On v6-M the three-operand `adds Rd, Rn, #imm` encodes + * only imm3 — 0 to 7 — while `adds Rdn, #imm8` reaches 255. Writing the + * three-operand form assembles for a Cortex-M4 and fails for a Cortex-M0, which + * is the same hazard the `stmia`/high-register dance below exists for. + * + * The saved layout, at the address a context block holds: + * + * +0 r4 r5 r6 r7 the low callee-saved four + * +16 r8 r9 r10 r11 the high four, moved through the low ones + * +32 r0 r1 r2 r3 r12 LR PC xPSR the hardware's own frame + */ + + .section .text.openarch_cm_pendsv,"ax",%progbits + .globl openarch_cm_pendsv + .type openarch_cm_pendsv, %function + .thumb_func + .balign 4 +openarch_cm_pendsv: + /* r4-r11 still hold the OUTGOING context's values: the hardware stacked + * only the caller-saved half, and any handler between then and now restored + * the rest in its own epilogue. */ + mrs r0, psp + subs r0, #32 + stmia r0!, {r4-r7} + mov r4, r8 + mov r5, r9 + mov r6, r10 + mov r7, r11 + stmia r0!, {r4-r7} + subs r0, #32 /* r0 = the outgoing saved SP */ + + push {lr} /* the EXC_RETURN we were entered with */ + bl openarch_cm_pendsv_pick /* r0 in = outgoing, r0 out = incoming */ + pop {r1} + + adds r0, #16 + ldmia r0!, {r4-r7} /* the high four, via the low ones */ + mov r8, r4 + mov r9, r5 + mov r10, r6 + mov r11, r7 + subs r0, #32 + ldmia r0!, {r4-r7} /* the low four */ + adds r0, #16 /* past both blocks: the hardware frame */ + msr psp, r0 + bx r1 + .size openarch_cm_pendsv, . - openarch_cm_pendsv + +/* Entering the FIRST task. There is no outgoing context, so this cannot go + * through the switch above. + * + * ⚠️ IT IS ONLY VALID ON A CONTEXT THAT HAS NEVER RUN, and that is what lets it + * be this short: a fresh frame from `arch_context_init` holds zero in r1-r3 and + * r12, so only the argument and the entry point have to be delivered. A running + * context carries live values in all of them and must be resumed by an + * exception return, which is what PendSV above does. + * + * ⚠️ AND IT SWITCHES THE THREAD STACK POINTER MID-FUNCTION, so nothing after + * the `msr control` may touch this function's own stack. Everything below it is + * register-to-register for that reason. + * + * It does not return. */ + .section .text.openarch_cm_enter,"ax",%progbits + .globl openarch_cm_enter + .type openarch_cm_enter, %function + .thumb_func + .balign 4 +openarch_cm_enter: /* r0 = the context block */ + ldr r0, [r0, #0] /* the saved SP it holds */ + adds r0, #16 + ldmia r0!, {r4-r7} + mov r8, r4 + mov r9, r5 + mov r10, r6 + mov r11, r7 + subs r0, #32 + ldmia r0!, {r4-r7} + adds r0, #16 /* r0 = the hardware frame */ + msr psp, r0 + movs r1, #2 + msr control, r1 /* Thread mode uses PSP from here */ + isb + ldr r1, [r0, #24] /* the frame's PC */ + ldr r2, [r0, #0] /* the frame's r0 — the task argument */ + adds r0, #32 /* drop the frame we just consumed */ + msr psp, r0 + mov r0, r2 + bx r1 + .size openarch_cm_enter, . - openarch_cm_enter diff --git a/backends/cortex-m/src/trap_impl.cpp b/backends/cortex-m/src/trap_impl.cpp index 9361ed7..d532a89 100644 --- a/backends/cortex-m/src/trap_impl.cpp +++ b/backends/cortex-m/src/trap_impl.cpp @@ -69,3 +69,55 @@ __attribute__((used)) void openarch_cm_trap_entry(unsigned* frame, } } // extern "C" + +// ── Preemption: pending the switch rather than performing it ──────────────── +// +// ⭐⭐ THE ONE PLACE IN THIS LAYER WHERE TWO MACHINES DO NOT SHARE A MECHANISM. +// +// riscv64, aarch64 and x86_64 take a trap on the interrupted context's own +// stack, so each performs `arch_trap_switch` as a cooperative switch on the way +// out of its dispatcher and the exception return then finds the new context's +// frame. M-profile cannot: the handler runs on MSP while the task runs on PSP, +// so swapping the handler's registers changes the handler's stack and the +// exception return unstacks a frame belonging to nobody. +// +// Measured, before this backend had the primitive: calling +// `arch_context_switch` from PendSV builds, boots, and reports that neither of +// two tasks ever observed the other. Nothing failed — the tasks were simply +// never interleaved, and only a counter assertion could tell the difference. +// +// What the machine offers instead is exactly the interface's own sentence about +// the two primitives: PendSV pended from thread mode is taken AT ONCE, and +// pended from a handler is taken WHEN THAT HANDLER EXITS. So both functions +// pend it, and the hardware supplies the difference. +extern "C" { + +// Read by `arch_context_switch` and by the PendSV stub. Two words, and the +// spelling is an array rather than a struct because the assembly indexes it. +__attribute__((used)) void* openarch_cm_pending[2] = { nullptr, nullptr }; + +void arch_trap_switch(arch_trap_frame* f, void* from, void* to) { + (void)f; + openarch_cm_pending[0] = from; + openarch_cm_pending[1] = to; + // ICSR.PENDSVSET. Taken when this handler returns — tail-chained, so the + // hardware does not unstack and re-stack the task's frame in between. + *reinterpret_cast(0xE000ED04u) = 1u << 28; +} + +// Called by the PendSV stub with the outgoing context's saved stack pointer, +// and answering with the incoming one. +// +// ⚠️ AN UNREQUESTED PendSV IS A NO-OP RATHER THAN AN ERROR. A board may pend it +// for its own reasons, and returning the outgoing pointer unchanged saves and +// restores the same context — which is what "nothing was requested" means. +__attribute__((used)) void* openarch_cm_pendsv_pick(void* outgoing) { + void* from = openarch_cm_pending[0]; + void* to = openarch_cm_pending[1]; + if (!to) return outgoing; + openarch_cm_pending[0] = openarch_cm_pending[1] = nullptr; + if (from) *static_cast(from) = outgoing; + return *static_cast(to); +} + +} // extern "C" diff --git a/backends/riscv64/mcpp.toml b/backends/riscv64/mcpp.toml index c413238..bf5ad33 100644 --- a/backends/riscv64/mcpp.toml +++ b/backends/riscv64/mcpp.toml @@ -14,7 +14,7 @@ [package] namespace = "mcpplibs" name = "openarch-riscv64" -version = "0.6.0" +version = "0.7.0" description = "openarch's riscv64 backend: the instructions behind the ABI" license = "Apache-2.0" authors = ["mcpplibs"] @@ -46,9 +46,17 @@ repo = "https://github.com/mcpplibs/openarch" # 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:percpu-register", + "openarch:preemption"] [build] sources = ["src/**"] diff --git a/backends/riscv64/src/trap_impl.cpp b/backends/riscv64/src/trap_impl.cpp index 2a23701..71a3fc5 100644 --- a/backends/riscv64/src/trap_impl.cpp +++ b/backends/riscv64/src/trap_impl.cpp @@ -48,11 +48,34 @@ inline arch_u64 read_mtval() noexcept { extern "C" void arch_trap_entry(); // the stub in trap.S +extern "C" void arch_context_switch(void* from, void* to); namespace { arch_trap_handler_fn g_handler = nullptr; +// ⭐⭐ PREEMPTION IS PERFORMED WHERE THE TRAP ENDS, NOT WHERE IT IS REQUESTED, +// AND THESE TWO WORDS ARE THE WHOLE OF THAT. +// +// `arch_trap_switch` cannot swap anything at the point it is called: the +// handler between it and the trap's exit is an ordinary C function, so the +// interrupted context's callee-saved registers are either still in the CPU or +// spilled into that handler's frame — and either way they are put back before +// the stub returns. Swapping there would save half a register file. +// +// So the call records a request and the dispatcher performs it on its way out, +// at which point "the current context" IS the interrupted one plus the trap +// frame beneath it. A later switch back resumes inside the dispatcher, which +// returns to the stub, which restores the caller-saved registers from the same +// stack and executes `mret`. +// +// ⚠️ THIS WORKS BECAUSE THE TRAP RUNS ON THE INTERRUPTED CONTEXT'S STACK, which +// is true here, on aarch64 and on x86_64 — and NOT on M-profile, whose handler +// runs on MSP while the task runs on PSP. That machine implements the same +// interface by pending an exception instead. One name, two mechanisms. +void* g_switch_from = nullptr; +void* g_switch_to = nullptr; + int classify(arch_u64 cause) noexcept { if (cause & kCauseInterrupt) return 4; switch (cause) { @@ -70,6 +93,12 @@ int classify(arch_u64 cause) noexcept { } // namespace +extern "C" void arch_trap_switch(arch_trap_frame* f, void* from, void* to) { + (void)f; + g_switch_from = from; + g_switch_to = to; +} + // Called by trap.S with a pointer to 32 bytes of stack for the frame. extern "C" void arch_trap_dispatch(arch_trap_frame* f) { static_assert(sizeof(arch_trap_frame) == 32, @@ -101,6 +130,22 @@ extern "C" void arch_trap_dispatch(arch_trap_frame* f) { // resuming has to honour that. Writing it back unconditionally is simpler // than asking whether it changed, and identical when it did not. write_mepc(f->pc); + + // ⚠️ THE LAST THING, AND AFTER `mepc` IS WRITTEN. Anything below this line + // would run in whichever context the switch lands in, which is not the one + // the lines above describe. + if (g_switch_to) { + void* from = g_switch_from; + void* to = g_switch_to; + g_switch_from = g_switch_to = nullptr; + // ⚠️ `mepc` IS PER-CONTEXT AND THE MACHINE HAS ONE. While this context + // is away, another trap in another context overwrites it; on return + // the `mret` below would resume at that other context's address. Saved + // in this frame — which is this context's stack — and put back. + const auto epc = read_mepc(); + arch_context_switch(from, to); + write_mepc(epc); + } } diff --git a/backends/x86_64/mcpp.toml b/backends/x86_64/mcpp.toml index a597182..3f77daf 100644 --- a/backends/x86_64/mcpp.toml +++ b/backends/x86_64/mcpp.toml @@ -22,7 +22,7 @@ [package] namespace = "mcpplibs" name = "openarch-x86-64" -version = "0.6.0" +version = "0.7.0" description = "openarch's x86_64 backend: the instructions behind the ABI" license = "Apache-2.0" authors = ["mcpplibs"] @@ -54,9 +54,17 @@ repo = "https://github.com/mcpplibs/openarch" # 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:percpu-register", + "openarch:preemption"] [build] sources = ["src/**"] diff --git a/backends/x86_64/src/trap_impl.cpp b/backends/x86_64/src/trap_impl.cpp index f795db7..918c709 100644 --- a/backends/x86_64/src/trap_impl.cpp +++ b/backends/x86_64/src/trap_impl.cpp @@ -33,6 +33,29 @@ namespace { arch_trap_handler_fn g_handler = nullptr; +// ⭐⭐ PREEMPTION IS PERFORMED WHERE THE TRAP ENDS, NOT WHERE IT IS REQUESTED, +// AND THESE TWO WORDS ARE THE WHOLE OF THAT. +// +// `arch_trap_switch` cannot swap anything at the point it is called: the +// handler between it and the trap's exit is an ordinary C function, so the +// interrupted context's callee-saved registers are either still in the CPU or +// spilled into that handler's frame — and either way they are put back before +// the stub returns. Swapping there would save half a register file. +// +// So the call records a request and the dispatcher performs it on its way out, +// at which point "the current context" IS the interrupted one plus the trap +// frame beneath it. A later switch back resumes inside the dispatcher, which +// returns to the stub, which restores the saved registers from the same stack +// and executes the exception return. +// +// ⚠️ THIS WORKS BECAUSE THE TRAP RUNS ON THE INTERRUPTED CONTEXT'S STACK, which +// is true here, on riscv64 and on x86_64 — and NOT on M-profile, whose handler +// runs on MSP while the task runs on PSP. That machine implements the same +// interface by pending an exception instead. One name, two mechanisms. +void* g_switch_from = nullptr; +void* g_switch_to = nullptr; + + // ── The interrupt descriptor table ───────────────────────────────────────── // // ⚠️ SIXTEEN BYTES PER GATE, WITH THE HANDLER'S ADDRESS SPLIT ACROSS THREE @@ -160,6 +183,14 @@ struct Raw { } // namespace +extern "C" void arch_context_switch(void* from, void* to); + +extern "C" void arch_trap_switch(arch_trap_frame* f, void* from, void* to) { + (void)f; + g_switch_from = from; + g_switch_to = to; +} + extern "C" void arch_trap_dispatch(arch_trap_frame* f, Raw* raw) { static_assert(sizeof(arch_trap_frame) == 32, "trap.S reserves 32 bytes below the saved registers"); @@ -203,6 +234,22 @@ extern "C" void arch_trap_dispatch(arch_trap_frame* f, Raw* raw) { // same contract the other two backends have — they write `mepc` and // `ELR_EL1`, and this one writes the `iret` frame. raw->rip = f->pc; + + // ⚠️ THE LAST THING, AND AFTER THE `iret` FRAME IS WRITTEN. Anything below + // this line would run in whichever context the switch lands in. + if (g_switch_to) { + void* from = g_switch_from; + void* to = g_switch_to; + g_switch_from = g_switch_to = nullptr; + // ⭐ NOTHING TO SAVE AND RESTORE AROUND IT, WHICH IS THIS MACHINE'S ONE + // SIMPLIFICATION HERE. The other two keep their return address in a + // system register that the whole machine shares, so a trap taken while + // this context is away would overwrite it. x86_64 keeps RIP, CS, + // RFLAGS, RSP and SS on the interrupted stack, which travels with the + // context — so `iretq` finds this context's frame however many traps + // happened elsewhere in between. + arch_context_switch(from, to); + } } extern "C" arch_trap_handler_fn arch_trap_set_handler(arch_trap_handler_fn h) { diff --git a/examples/clock-study/mcpp.toml b/examples/clock-study/mcpp.toml index 2d298c0..9c9e7d1 100644 --- a/examples/clock-study/mcpp.toml +++ b/examples/clock-study/mcpp.toml @@ -60,5 +60,7 @@ sources = ["src/machine_x86_64.cpp", "src/boot_x86_64.S"] # Both, on every build: which one is used is decided by the target, and # declaring only one would make the manifest depend on the target the same way # a `runner` key would. -[xlings] -deps = ["qemu-riscv", "qemu-arm", "qemu-x86"] +[xlings.workspace] +qemu-riscv = "" +qemu-arm = "" +qemu-x86 = "" diff --git a/examples/preempt/README.md b/examples/preempt/README.md index 1e5e972..76e549b 100644 --- a/examples/preempt/README.md +++ b/examples/preempt/README.md @@ -39,27 +39,76 @@ MSP, and a switch means saving `r4-r11` below the hardware frame on the outgoing task's PSP, pointing PSP at the incoming task, restoring its `r4-r11`, and returning with an `EXC_RETURN` that resumes Thread mode on PSP. -⚠️ **This is a gap in the layer, not a detail of this board.** 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 in order to -preempt, and every architecture spells it differently — which is the shape of -thing this layer exists to abstract. riscv64 edits `sepc` and the saved -registers; aarch64 edits `ELR_EL1` and `SP_EL0`; x86_64 edits the interrupt -frame. Naming that as a fifth interface group is a decision for the layer, taken -with more than one machine in view; it is recorded here rather than papered over. +⚠️ **That was a gap in the layer, not a detail of this board — and it is closed +in 0.8.0.** openarch had a primitive for *switch to another saved context* and +none for *switch the context this trap will return to*. Every architecture needs +the second in order to preempt, and every architecture spells it differently, +which is the shape of thing this layer exists to abstract. + +```c +void arch_trap_switch(arch_trap_frame* f, void* from, void* to); +``` + +Call it from a handler. It returns to the handler normally; the switch happens +when the trap does. + +⭐⭐ **And the four machines do not implement it the same way, which is why it is +an interface function rather than a line of kernel code.** + +| | How the trap resumes somewhere else | +|---|---| +| riscv64 | a cooperative switch inside the dispatcher, with `mepc` saved across it | +| aarch64 | the same, with `ELR_EL1` and `SPSR_EL1` saved across it | +| x86_64 | the same, and nothing to save — the `iret` frame travels with the stack | +| Cortex-M | **not that at all**: pend PendSV, which the hardware takes at exception exit | + +The first three work because the trap runs on the interrupted context's own +stack. M-profile's does not — that is the whole of the finding above — so it +uses the mechanism the machine does offer. ⭐ And that mechanism turns out to be +the interface's own sentence about the two primitives: **PendSV pended from +thread mode is taken at once, and pended from a handler is taken when the +handler exits.** `arch_context_switch` and `arch_trap_switch` on this backend +are the same instruction sequence; the hardware supplies the difference. + +## ⭐ What that did to this example + +Thirty lines of hand-written PendSV assembly are gone. What is left is forty +lines of scheduling *policy*: + +```c++ +void on_tick(arch_trap_frame* f) { + if (++g_ticks == 400) verdict(); + const int prev = g_current; + g_current = (prev + 1) % kTasks; + arch_trap_switch(f, g_ctx[prev], g_ctx[g_current]); +} +``` + +CI asserts the absence: a `grep` for `asm` in `src/main.cpp` fails the build. If +that code comes back, the primitive has stopped carrying its weight. + +⚠️ **The board must still name `openarch_cm_pendsv` in vector slot 14.** The +table's location is a board fact and the hardware reads it by address, so the +package that implements the switch cannot install itself. A board that forgets +is not broken at link time — the switch simply never happens, which is why this +example asserts preemption rather than progress. ## What openarch does supply here -* `arch_context_init` — the initial cooperative frames +* `arch_context_init` — the initial frames, in the one layout both the + cooperative and the preemptive path read * `arch_trap_enable_interrupts` / `arch_trap_interrupts_enabled` — PRIMASK * the trap trampoline, which normalises a fault into the frame every handler on every architecture reads * `arch_cpu_fence` — `dmb` / `dsb` / `isb` +* `arch_trap_switch` and `openarch_cm_enter` — the switch itself, and the way + into the first task ## What this backend does not supply, and says so -`mcpplibs/openarch-cortex-m` declares `openarch-backend` and **not** -`openarch:address-space` or `openarch:percpu-register`. M-profile has a +`mcpplibs/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 states it and is refused by name at resolution: diff --git a/examples/preempt/mcpp.toml b/examples/preempt/mcpp.toml index 5027ee9..509f13a 100644 --- a/examples/preempt/mcpp.toml +++ b/examples/preempt/mcpp.toml @@ -34,5 +34,5 @@ sysroot = "" openarch = { path = "../..", features = ["backend-external"] } openarch-cortex-m = { path = "../../backends/cortex-m" } -[xlings] -deps = ["xim:qemu-arm@9.2.4-1"] +[xlings.workspace] +"xim:qemu-arm" = "9.2.4-1" diff --git a/examples/preempt/src/board.cpp b/examples/preempt/src/board.cpp index d3fdcc2..3ede549 100644 --- a/examples/preempt/src/board.cpp +++ b/examples/preempt/src/board.cpp @@ -7,7 +7,12 @@ extern "C" unsigned __stack_top; extern "C" void Reset_Handler(); extern "C" void openarch_cm_trap_entry(unsigned* frame, unsigned exc); -extern "C" void PendSV_Handler(); +// ⚠️ THE BOARD NAMES openarch'S HANDLER IN SLOT 14, AND NOTHING BUT THE BOARD +// CAN. The table's location is a board fact and the hardware reads it by +// address, so the package that implements the switch cannot install itself. A +// board that forgets is not broken at link time — the switch simply never +// happens, which is why this example asserts preemption rather than progress. +extern "C" void openarch_cm_pendsv(); extern "C" void scheduler_tick(); namespace { @@ -30,14 +35,12 @@ extern "C" void openarch_panic(const char* what) { board_exit(1); } -// ⭐ SysTick drives preemption. Its handler does no switching: it marks PendSV -// pending and returns, so the context switch happens at the LOWEST exception -// priority, after any other handler has finished. Switching stacks inside a -// high-priority handler is the classic way to corrupt an unrelated interrupt. -extern "C" void SysTick_Handler() { - scheduler_tick(); - *reinterpret_cast(0xE000ED04) = (1u << 28); // ICSR.PENDSVSET -} +// ⭐ SysTick drives preemption, and it does no switching of its own: it calls +// the scheduler, which calls `arch_trap_switch`, which pends PendSV. The switch +// therefore happens at the LOWEST exception priority, after every other handler +// has finished — switching stacks inside a high-priority handler is the classic +// way to corrupt an unrelated interrupt. +extern "C" void SysTick_Handler() { scheduler_tick(); } // Faults route into openarch's trampoline, which normalises them into the frame // every handler on every architecture reads. @@ -57,7 +60,7 @@ void* const vectors[] = { nullptr, nullptr, nullptr, nullptr, (void*)Fault_Handler, // SVCall nullptr, nullptr, - (void*)PendSV_Handler, + (void*)openarch_cm_pendsv, (void*)SysTick_Handler, }; diff --git a/examples/preempt/src/main.cpp b/examples/preempt/src/main.cpp index a7e1807..109d3b2 100644 --- a/examples/preempt/src/main.cpp +++ b/examples/preempt/src/main.cpp @@ -6,20 +6,34 @@ // ⚠️ AND THAT IS WHY THE ASSERTION IS A COUNTER EACH TASK DID NOT ADVANCE. Two // tasks that print would also print if the switch never happened and one simply // ran to completion — the shape of green that says nothing. +// +// ⭐ THERE IS NO ASSEMBLY IN THIS FILE, AND THERE USED TO BE THIRTY LINES OF IT. +// +// The first version of this example hand-wrote a PendSV handler: save r4-r11 +// below the hardware frame on the outgoing task's PSP, swap the pointer, +// restore, return with an EXC_RETURN that resumes Thread mode on PSP. It had to, +// because openarch had a primitive for "switch to another saved context" and +// none for "switch the context this trap will return to" — and on M-profile the +// first is not the second. +// +// That gap is `arch_trap_switch` now, so what remains here is forty lines of +// scheduling POLICY: pick a task, ask the layer to resume it. Which is what an +// example of this layer should have looked like from the start. #include extern "C" void board_print(const char*); extern "C" [[noreturn]] void board_exit(int); extern "C" void board_start_tick(unsigned); +extern "C" void openarch_cm_enter(void* ctx); namespace { constexpr int kTasks = 2; +alignas(16) unsigned char g_ctx[kTasks][128]; alignas(8) unsigned char g_stack[kTasks][2048]; -unsigned* g_psp[kTasks] = {nullptr, nullptr}; -volatile int g_current = -1; +volatile int g_current = 0; volatile int g_ticks = 0; volatile int g_progress[kTasks] = {0, 0}; // Each task records what the OTHER task's progress was when it last ran. If it @@ -42,125 +56,52 @@ void task_body(void* arg) { } } -} // namespace - -// ⭐⭐ THE SWITCH AT PendSV IS *NOT* `arch_context_switch`, AND FINDING THAT OUT -// IS WHAT THIS EXAMPLE IS FOR. -// -// `arch_context_switch` is a COOPERATIVE primitive: it saves the callee-saved -// registers of whoever called it and resumes another such saved set. Inside an -// exception handler that is the wrong thing to swap. The hardware has already -// stacked r0-r3, r12, LR, PC and xPSR onto the interrupted task's stack and is -// running the handler on MSP; swapping the handler's own registers changes the -// handler's stack, and the exception return then unstacks a frame belonging to -// nobody. -// -// Measured: with `arch_context_switch` called from PendSV, the program builds, -// boots, and reports that neither task ever observed the other — the tasks were -// never actually interleaved, and only the counter assertion caught it. -// -// The M-profile mechanism is different in kind: tasks run on PSP, the handler -// on MSP, and the switch is performed by saving r4-r11 below the hardware frame -// on the outgoing task's PSP, pointing PSP at the incoming task, restoring its -// r4-r11, and returning with an EXC_RETURN that says "resume in Thread mode on -// PSP". The hardware unstacks the rest. -// -// ⚠️ THIS IS A GAP IN openarch, NOT A DETAIL OF THIS BOARD. The layer 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 architecture spells it differently — which is exactly the -// shape of thing this layer exists to abstract. Recorded rather than papered -// over; see this example's README. -// -// What openarch still supplies here: `arch_context_init` lays out the initial -// frames, `arch_trap_enable_interrupts` gates the timer, and the fault vectors -// route through its trampoline. -extern "C" __attribute__((used)) unsigned* pendsv_pick(unsigned* outgoing_psp) { - if (g_current < 0) return outgoing_psp; - g_psp[g_current] = outgoing_psp; - g_current = (g_current + 1) % kTasks; - return g_psp[g_current]; +void verdict() { + const bool both = g_observed_preemption[0] && g_observed_preemption[1]; + board_print(both ? "preempt: both tasks observed preemption\n" + : "preempt: FAILED - no task observed the other running\n"); + board_exit(both ? 0 : 1); } -extern "C" __attribute__((naked)) void PendSV_Handler() { - __asm__ volatile( - "mrs r0, psp\n" - "subs r0, r0, #32\n" - "stmia r0!, {r4-r7}\n" // low callee-saved - "mov r4, r8\n" - "mov r5, r9\n" - "mov r6, r10\n" - "mov r7, r11\n" - "stmia r0!, {r4-r7}\n" // high callee-saved, via the low four - "subs r0, r0, #32\n" - "push {lr}\n" - "bl pendsv_pick\n" // r0 = outgoing PSP, returns incoming - "pop {r1}\n" // the EXC_RETURN we were entered with - "adds r0, r0, #16\n" - "ldmia r0!, {r4-r7}\n" // high callee-saved - "mov r8, r4\n" - "mov r9, r5\n" - "mov r10, r6\n" - "mov r11, r7\n" - "subs r0, r0, #32\n" - "ldmia r0!, {r4-r7}\n" // low callee-saved - "adds r0, r0, #16\n" - "msr psp, r0\n" - "bx r1\n"); +// The whole of the scheduler. It runs in the timer's handler, picks the next +// task, and hands both contexts to the layer; the switch happens when the trap +// returns, not here. +void on_tick(arch_trap_frame* f) { + if (++g_ticks == 400) verdict(); + const int prev = g_current; + const int next = (prev + 1) % kTasks; + g_current = next; + arch_trap_switch(f, g_ctx[prev], g_ctx[next]); } -extern "C" void preempt_verdict(); +} // namespace // ⚠️ THE VERDICT IS REACHED FROM THE TIMER, NOT FROM A TASK. The tasks never -// return — that is the point of them — so nothing in task_body could report. -// After enough ticks for preemption to have happened many times over, the -// handler stops the world and reports. +// return — that is the point of them — so nothing in `task_body` could report. extern "C" void scheduler_tick() { - if (++g_ticks == 400) preempt_verdict(); + // The frame is the handler's, and `arch_trap_switch` is documented to take + // the one its handler received. This board routes SysTick straight here + // rather than through openarch's trampoline, so it builds the one field + // the layer reads on this machine. + arch_trap_frame f{}; + f.kind = ARCH_TRAP_INTERRUPT; + on_tick(&f); } extern "C" void Reset_Handler() { board_print("preempt: openarch on a machine with no address space\n"); - // ⚠️ THE INITIAL FRAME IS THE ONE THE HARDWARE WILL UNSTACK, so it is laid - // out the way an exception entry would have left it: eight words of - // hardware frame (r0-r3, r12, LR, PC, xPSR) above eight of callee-saved. - // `arch_context_init` cannot do this — it lays out a COOPERATIVE context, - // which is the distinction this example exists to show. - for (int i = 0; i < kTasks; ++i) { - auto* top = reinterpret_cast( - &g_stack[i][sizeof(g_stack[i])]); - top -= 16; - for (int w = 0; w < 16; ++w) top[w] = 0; - top[8] = static_cast(i); // r0 = task id - top[14] = reinterpret_cast(&task_body) | 1u; // PC, thumb bit - top[15] = 0x01000000u; // xPSR, T set - g_psp[i] = top; - } + for (int i = 0; i < kTasks; ++i) + arch_context_init(g_ctx[i], &task_body, + reinterpret_cast(static_cast(i)), + &g_stack[i][sizeof(g_stack[i])]); // A short period: the tasks must be interrupted many times over the window - // the main loop waits. + // the verdict waits for. board_start_tick(2000); arch_trap_enable_interrupts(1); - // Enter the first task: point PSP at its frame, switch to Thread-mode-on-PSP - // and branch in. From here the timer owns the interleaving. g_current = 0; - __asm__ volatile( - "msr psp, %0\n" - "movs r0, #2\n" - "msr control, r0\n" // Thread mode uses PSP - "isb\n" - :: "r"(g_psp[0] + 16) : "r0", "memory"); - task_body(reinterpret_cast(0ul)); - for (;;) {} // unreachable -} - -// Called from the tasks' own progress by the semihosting host after enough -// ticks — kept as a separate entry so the check reads as a check. -extern "C" __attribute__((used)) void preempt_verdict() { - const bool both = g_observed_preemption[0] && g_observed_preemption[1]; - board_print(both ? "preempt: both tasks observed preemption\n" - : "preempt: FAILED — no task observed the other running\n"); - board_exit(both ? 0 : 1); + openarch_cm_enter(g_ctx[0]); // does not return + for (;;) {} } diff --git a/examples/switch/mcpp.toml b/examples/switch/mcpp.toml index 36a33c4..60e83b8 100644 --- a/examples/switch/mcpp.toml +++ b/examples/switch/mcpp.toml @@ -58,5 +58,7 @@ openarch = { path = "../.." } # Both, on every build: which one is used is decided by the target, and # declaring only one would make the manifest depend on the target the same way # a `runner` key would. -[xlings] -deps = ["qemu-riscv", "qemu-arm", "qemu-x86"] +[xlings.workspace] +qemu-riscv = "" +qemu-arm = "" +qemu-x86 = "" diff --git a/examples/switch/src/main.cpp b/examples/switch/src/main.cpp index 1b14490..0183945 100644 --- a/examples/switch/src/main.cpp +++ b/examples/switch/src/main.cpp @@ -106,6 +106,83 @@ void probe_trap() { machine::putc('\n'); } +// ── Preemption: the trap RESUMES SOMEWHERE ELSE ───────────────────────────── +// +// ⭐⭐ THE ASSERTION THAT NEEDED A FOURTH MACHINE TO EXIST AT ALL. +// +// `set_handler` lets a kernel see a trap and `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 device. +// `arch::trap_switch` is that action, and this probe is what says it works. +// +// ⚠️ IT USES A SYNCHRONOUS TRAP RATHER THAN A TIMER, DELIBERATELY. A timer +// would drag a per-machine device into a probe whose whole value is being one +// piece of code — three interrupt controllers, three frequency sources, and an +// assertion that could then fail for reasons having nothing to do with the +// interface. A breakpoint is a trap on every machine here, and the property +// under test is identical: the handler runs, calls `trap_switch`, returns, and +// execution continues IN THE OTHER CONTEXT. +// +// ⚠️ AND THE ASSERTION IS THAT EACH SIDE SAW THE OTHER ADVANCE, not that both +// printed. A `trap_switch` that did nothing at all would leave the first task +// running, and a probe that only checked for output would report success. +namespace { + +arch::context g_pre_main; +arch::context g_pre_task; +alignas(16) unsigned char g_pre_stack[4096]; + +volatile int g_pre_steps = 0; // advanced only by the preempted task +volatile int g_pre_resumed = 0; // set only after the trap returned elsewhere + +inline void raise_breakpoint() { +#if defined(__riscv) + asm volatile("ebreak"); +#elif defined(__aarch64__) + asm volatile("brk #0"); +#elif defined(__x86_64__) + asm volatile("int3"); +#else +# error "the probe has no breakpoint instruction for this architecture" +#endif +} + +void on_preempt(arch::trap_frame* f) { + if (arch::kind_of(*f) != arch::trap_kind::breakpoint) return; + f->pc += f->instr_len; + // Once: the second breakpoint (raised by the task) returns normally, which + // is what lets the task reach its own `context_switch` back. + if (g_pre_resumed) return; + g_pre_resumed = 1; + arch::trap_switch(f, g_pre_main, g_pre_task); +} + +[[noreturn]] void preempted_task(void*) { + g_pre_steps = 1; + // ⚠️ NOT A `trap_switch` BACK. Returning by the cooperative primitive is + // what shows the two are interchangeable: a context saved by the trap path + // is resumed by the ordinary one, which is only true if they share a + // layout. Two layouts would corrupt whichever was used second, silently. + arch::context_switch(g_pre_task, g_pre_main); + for (;;) { } +} + +void probe_preempt() { + arch::set_handler(&on_preempt); + arch::context_init(g_pre_task, &preempted_task, nullptr, + g_pre_stack + sizeof(g_pre_stack)); + machine::print("preempt: trapping\n"); + // The trap below does not return here. The handler switches to the task, + // the task switches back, and THAT resumes this function — after the + // breakpoint, because the handler advanced `pc` before switching away. + raise_breakpoint(); + machine::print("preempt: back, steps="); + machine::print_int(g_pre_steps); + machine::putc('\n'); +} + +} // namespace + // ── The per-CPU pointer and the barriers ─────────────────────────────────── // // The pointer is a round trip: what a kernel stores is what it reads back, and @@ -168,9 +245,15 @@ extern "C" int probe_main() { machine::putc('\n'); probe_trap(); + probe_preempt(); probe_cpu(); + // ⚠️ `g_pre_steps == 1` IS THE ONE THAT CATCHES A `trap_switch` THAT DID + // NOTHING. Every line above it is printed by whichever context is running; + // only a counter the OTHER context advanced says the trap resumed + // elsewhere. const bool ok = (g_witness == 7 && before == 1234 && g_trapped == 1 + && g_pre_steps == 1 && g_pre_resumed == 1 && arch::percpu() == &g_percpu_area); machine::print(ok ? "switch ok\n" : "switch FAILED\n"); return ok ? 0 : 1; diff --git a/mcpp.toml b/mcpp.toml index a338470..a584b07 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -33,7 +33,7 @@ [package] namespace = "mcpplibs" name = "openarch" -version = "0.7.0" +version = "0.8.0" description = "openarch: the architecture-mechanism layer — execution contexts, traps, per-CPU state and address spaces, as one interface over several instruction sets" license = "Apache-2.0" authors = ["mcpplibs"] diff --git a/src/context.cppm b/src/context.cppm index 8bc1e57..3760d89 100644 --- a/src/context.cppm +++ b/src/context.cppm @@ -98,6 +98,26 @@ inline void context_switch(context& from, context& to) noexcept { ::arch_context_switch(&from, &to); } +// The same operation, performed when the current trap returns rather than now. +// +// ⭐⭐ CALLED FROM INSIDE A HANDLER. The context the trap interrupted is saved +// into `from` and the trap resumes `to` instead; this function RETURNS +// NORMALLY to the handler, and the switch happens when the handler does. That +// is the whole of preemption, and it is the one thing `arch::set_handler` and +// `arch::enable_interrupts` together cannot express — they let a kernel see a +// trap and mask one, never act on one. +// +// ⭐ It sits beside `context_switch` because it takes the same storage and the +// same `context_init` lays it out: a task may be resumed by either. The +// difference is only when. +// +// ⚠️ Only backends declaring `openarch:preemption` provide it. A kernel that +// preempts requires that capability and is refused by name at resolution on a +// machine that cannot, rather than by `undefined reference` at link time. +inline void trap_switch(::arch_trap_frame* f, context& from, context& to) noexcept { + ::arch_trap_switch(f, &from, &to); +} + // Prepares `ctx` so that switching to it begins executing `entry(arg)` on the // stack whose highest address is `stack_top`. // diff --git a/src/trap.cppm b/src/trap.cppm index a059323..b2ed439 100644 --- a/src/trap.cppm +++ b/src/trap.cppm @@ -138,4 +138,11 @@ inline trap_handler set_handler(trap_handler h) noexcept { return ::arch_trap_se inline void enable_interrupts(bool on) noexcept { ::arch_trap_enable_interrupts(on ? 1 : 0); } inline bool interrupts_enabled() noexcept { return ::arch_trap_interrupts_enabled() != 0; } +// ⭐ `trap_switch` IS NOT HERE. It belongs beside `context_switch`, in +// `openarch.context`: the two are the same operation performed at different +// moments, and they take the same storage. Declaring it here would have made +// this module depend on that one to name the type, or have made it take +// `void*` — which is the ABI's spelling and not this face's. + + } // namespace arch diff --git a/templates/three-machines/mcpp.toml.in b/templates/three-machines/mcpp.toml.in index 6e9d5d3..7c3386c 100644 --- a/templates/three-machines/mcpp.toml.in +++ b/templates/three-machines/mcpp.toml.in @@ -61,5 +61,7 @@ openarch = "{{self.version}}" # Both, on every build: which one is used is decided by the target, and # declaring only one would make the manifest depend on the target the same way # a `runner` key would. -[xlings] -deps = ["qemu-riscv", "qemu-arm", "qemu-x86"] +[xlings.workspace] +qemu-riscv = "" +qemu-arm = "" +qemu-x86 = "" From 12c61231b2fc7722e8495b058351f3f376c2f25d Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 4 Sep 2026 11:35:31 +0800 Subject: [PATCH 3/5] fix(cortex-m): three ways a deferred switch loses a context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- abi/include/openarch/abi.h | 18 ++++++++++- backends/cortex-m/src/preempt.S | 47 +++++++++++++++++++++++++++++ backends/cortex-m/src/trap_impl.cpp | 21 ++++++++++++- examples/preempt/README.md | 20 ++++++++++++ examples/preempt/src/main.cpp | 38 ++++++++++++++++++++--- 5 files changed, 137 insertions(+), 7 deletions(-) diff --git a/abi/include/openarch/abi.h b/abi/include/openarch/abi.h index 7e6c124..090e628 100644 --- a/abi/include/openarch/abi.h +++ b/abi/include/openarch/abi.h @@ -181,7 +181,23 @@ int arch_trap_interrupts_enabled(void); * * ⚠️ `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. */ + * 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 ────────────────────────────────────────────────────────── diff --git a/backends/cortex-m/src/preempt.S b/backends/cortex-m/src/preempt.S index d1db170..121d11e 100644 --- a/backends/cortex-m/src/preempt.S +++ b/backends/cortex-m/src/preempt.S @@ -56,6 +56,33 @@ .thumb_func .balign 4 openarch_cm_pendsv: + /* ⚠️⚠️ MASKED FOR THE WHOLE SWITCH, AND THIS IS THE FIX FOR A DEFECT THAT + * PRESENTED AS A FLAKE. + * + * PendSV is the LOWEST priority, which is what makes it run after every + * other handler — and it is also what lets the tick that REQUESTED the + * switch preempt the switch itself. The tick handler then calls + * `arch_trap_switch` again, overwriting the pending pair, and this stub + * resumes holding an outgoing pointer for one context and a `from` naming + * another. It writes the first into the second, and both contexts are lost. + * + * Measured on `mps2-an385`: six of twelve runs of an identical image + * reported `no task observed the other` — the same output a backend with no + * `arch_trap_switch` at all produces. The counters said what the message + * could not: `pendsv=2997` switches performed, and both tasks stopped at a + * handful of iterations, spinning in the task-exit trap because their saved + * stack pointers no longer described a frame. + * + * ⭐ SAFE TO UNMASK UNCONDITIONALLY AT THE END. PRIMASK masks every + * configurable-priority exception, so a context that had interrupts masked + * could not have been interrupted into PendSV in the first place: on entry + * here PRIMASK is necessarily clear. + * + * ⚠️ It is NOT enough to make the pair atomic. The outgoing pointer is read + * from PSP at entry and the pair from memory after the call; making only + * the second indivisible leaves them describing different moments. What has + * to be indivisible is the switch. */ + cpsid i /* r4-r11 still hold the OUTGOING context's values: the hardware stacked * only the caller-saved half, and any handler between then and now restored * the rest in its own epilogue. */ @@ -83,6 +110,7 @@ openarch_cm_pendsv: ldmia r0!, {r4-r7} /* the low four */ adds r0, #16 /* past both blocks: the hardware frame */ msr psp, r0 + cpsie i bx r1 .size openarch_cm_pendsv, . - openarch_cm_pendsv @@ -99,6 +127,24 @@ openarch_cm_pendsv: * the `msr control` may touch this function's own stack. Everything below it is * register-to-register for that reason. * + * ⚠️⚠️ IT UNMASKS INTERRUPTS ITSELF, AND THAT IS A FIX RATHER THAN A SERVICE. + * + * CALL IT WITH INTERRUPTS MASKED. Between arming a timer and arriving here + * there is no valid context to preempt: PSP still holds whatever reset left, + * and a tick in that window pends PendSV, which saves eight registers below + * that address and writes the result into the outgoing context — destroying the + * one this function is about to enter. + * + * Measured. With the example unmasking before calling this, the program did not + * fault: it ran one task forever and reported `no task observed the other`, + * about one run in three. A pass rate is not a result, and the fix is to close + * the window rather than to widen the timer — so the unmask is the last + * instruction before the branch, where no window remains. + * + * ⭐ An interrupt taken between the `cpsie` and the `bx` is harmless: PSP is + * valid by then, so the hardware stacks this function's own r0-r3 and PC onto + * the task's stack and PendSV resumes it correctly. + * * It does not return. */ .section .text.openarch_cm_enter,"ax",%progbits .globl openarch_cm_enter @@ -125,5 +171,6 @@ openarch_cm_enter: /* r0 = the context block */ adds r0, #32 /* drop the frame we just consumed */ msr psp, r0 mov r0, r2 + cpsie i /* see the note above: HERE, not sooner */ bx r1 .size openarch_cm_enter, . - openarch_cm_enter diff --git a/backends/cortex-m/src/trap_impl.cpp b/backends/cortex-m/src/trap_impl.cpp index d532a89..1a19bca 100644 --- a/backends/cortex-m/src/trap_impl.cpp +++ b/backends/cortex-m/src/trap_impl.cpp @@ -98,7 +98,26 @@ __attribute__((used)) void* openarch_cm_pending[2] = { nullptr, nullptr }; void arch_trap_switch(arch_trap_frame* f, void* from, void* to) { (void)f; - openarch_cm_pending[0] = from; + // ⚠️⚠️ THE FIRST `from` WINS AND THE LAST `to` WINS, AND THAT ASYMMETRY IS + // A FIX RATHER THAN A CHOICE. + // + // PendSV is the lowest priority, so it runs only when no handler is active + // — and TWO ticks can therefore arrive before one switch is performed. With + // a single slot overwritten by each, the pair that PendSV eventually read + // named the second request's `from` while the stack pointer it had already + // taken belonged to the first request's context. It wrote one task's stack + // pointer into the other task's context, and both were then lost. + // + // Measured on `mps2-an385`: `pendsv=2998` switches performed, `p1=0` — the + // second task never ran at all — and the addresses said why: the two + // contexts held stack pointers 32 bytes apart, on one stack. + // + // The interrupted context is the one the FIRST call in this window saw as + // current, so that is the `from` to honour; the context to resume is + // whatever the LAST call asked for. A scheduler that ticked twice is then + // consistent either way — with two tasks and two ticks the net effect is + // no switch, which is exactly what it asked for. + if (!openarch_cm_pending[0]) openarch_cm_pending[0] = from; openarch_cm_pending[1] = to; // ICSR.PENDSVSET. Taken when this handler returns — tail-chained, so the // hardware does not unstack and re-stack the task's frame in between. diff --git a/examples/preempt/README.md b/examples/preempt/README.md index 76e549b..65cf19b 100644 --- a/examples/preempt/README.md +++ b/examples/preempt/README.md @@ -87,6 +87,26 @@ void on_tick(arch_trap_frame* f) { CI asserts the absence: a `grep` for `asm` in `src/main.cpp` fails the build. If that code comes back, the primitive has stopped carrying its weight. +## ⚠️⚠️ Three defects the example found after the primitive existed + +Each one produced the same output — `no task observed the other` — which is also +what a backend with no `arch_trap_switch` at all produces. A message cannot tell +them apart; the counters could. + +| | What happened | Fix | +|---|---|---| +| the entry window | The timer was armed before the first context existed. A tick there switched away from a context that was not yet valid. Failed about one run in three | `openarch_cm_enter` unmasks interrupts itself, as its last instruction | +| the tick preempts the switch | PendSV is the lowest priority, so the tick that requested a switch can interrupt the switch and request another. The stub then held one context's stack pointer and another context's `from` | PendSV masks interrupts for the whole switch. Safe to unmask at the end: a context with interrupts masked could not have been interrupted into PendSV | +| two ticks, one switch | PendSV runs only when no handler is active, so two ticks can arrive before one switch. A single overwritten slot crossed the contexts: measured, the two held stack pointers **32 bytes apart on one stack** | The **first** `from` and the **last** `to` win. The interrupted context is the one the first call saw as current; the context to resume is what the last call asked for | + +⭐ The last is now part of the interface's contract rather than this backend's +detail, because the window it describes exists on any machine whose switch is +deferred to a lower-priority exception. + +⚠️ **And the criterion is 15 consecutive runs, not one.** The first two fixes +each raised the pass rate without reaching 1; a single green run would have +retired either of them prematurely. + ⚠️ **The board must still name `openarch_cm_pendsv` in vector slot 14.** The table's location is a board fact and the hardware reads it by address, so the package that implements the switch cannot install itself. A board that forgets diff --git a/examples/preempt/src/main.cpp b/examples/preempt/src/main.cpp index 109d3b2..b63815d 100644 --- a/examples/preempt/src/main.cpp +++ b/examples/preempt/src/main.cpp @@ -66,8 +66,29 @@ void verdict() { // The whole of the scheduler. It runs in the timer's handler, picks the next // task, and hands both contexts to the layer; the switch happens when the trap // returns, not here. +// +// ⚠️⚠️ THE DEADLINE IS NOT COUNTED IN TICKS, AND COUNTING IT THAT WAY MADE THIS +// EXAMPLE FAIL HALF THE TIME. +// +// The first version reported its verdict at tick 400. Under an emulator the +// timer runs on the HOST's clock while the tasks run at whatever rate the host +// can emulate — so on a loaded machine the ticks arrive fast and the tasks +// execute almost nothing between them. Ticks are exactly the quantity that goes +// UP when the thing being measured goes DOWN, so a deadline denominated in them +// measures the host. +// +// Measured: twelve runs of the same image, six passing and six reporting +// `no task observed the other` — which is the same output a backend with no +// `arch_trap_switch` at all produces. A flake indistinguishable from the defect +// the example exists to detect. +// +// ⭐ So success is reported the moment it is OBSERVED, and the tick budget +// exists only to bound the failure. A run that never preempts still terminates; +// a run that does terminates as soon as it can say so. void on_tick(arch_trap_frame* f) { - if (++g_ticks == 400) verdict(); + ++g_ticks; + if (g_observed_preemption[0] && g_observed_preemption[1]) verdict(); + if (g_ticks > 200000) verdict(); const int prev = g_current; const int next = (prev + 1) % kTasks; g_current = next; @@ -96,12 +117,19 @@ extern "C" void Reset_Handler() { reinterpret_cast(static_cast(i)), &g_stack[i][sizeof(g_stack[i])]); - // A short period: the tasks must be interrupted many times over the window - // the verdict waits for. + // ⚠️⚠️ THE TIMER IS ARMED WITH INTERRUPTS STILL MASKED, AND + // `openarch_cm_enter` IS WHAT UNMASKS THEM. + // + // Between arming and entering there is no valid context to preempt: PSP + // still holds whatever reset left, and a tick in that window switches away + // from a context that does not exist. Unmasking here made the program fail + // about one run in three, reporting `no task observed the other` — which is + // the same output a backend with no `arch_trap_switch` at all produces, so + // the flake was indistinguishable from the defect this example detects. + arch_trap_enable_interrupts(0); board_start_tick(2000); - arch_trap_enable_interrupts(1); g_current = 0; - openarch_cm_enter(g_ctx[0]); // does not return + openarch_cm_enter(g_ctx[0]); // unmasks, and does not return for (;;) {} } From b58292eb3f750d1c33f1dcbb6d5b76ea284e1d2d Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 4 Sep 2026 11:59:56 +0800 Subject: [PATCH 4/5] fix(ci): the probe keeps its ONE architecture conditional, and the pins agree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️⚠️ 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. --- .github/workflows/ci.yml | 4 ++-- examples/switch/src/main.cpp | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f07e0bd..2961c1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -511,7 +511,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: @@ -601,7 +601,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: diff --git a/examples/switch/src/main.cpp b/examples/switch/src/main.cpp index 0183945..1013855 100644 --- a/examples/switch/src/main.cpp +++ b/examples/switch/src/main.cpp @@ -82,9 +82,15 @@ void on_trap(arch::trap_frame* f) { } } -void probe_trap() { - arch::set_handler(&on_trap); - machine::print("trap: raising\n"); +// ⭐⭐ THE ONE ARCHITECTURE CONDITIONAL IN THIS FILE, AND IT IS A FUNCTION SO +// THAT IT CAN STAY THE ONE. +// +// CI counts `#if defined(__` in this file and requires exactly one: what the +// gate claims is that the probe is not two programs, and the trap instruction +// is the single thing no portable spelling exists for. The preemption probe +// below needs the same instruction, and writing a second conditional for it +// would have been two programs by the letter as well as by the check. +inline void raise_breakpoint() { #if defined(__riscv) asm volatile("ebreak"); #elif defined(__aarch64__) @@ -101,6 +107,12 @@ void probe_trap() { #else # error "the probe has no breakpoint instruction for this architecture" #endif +} + +void probe_trap() { + arch::set_handler(&on_trap); + machine::print("trap: raising\n"); + raise_breakpoint(); machine::print("trap: back, witness="); machine::print_int(g_trapped); machine::putc('\n'); @@ -135,18 +147,6 @@ alignas(16) unsigned char g_pre_stack[4096]; volatile int g_pre_steps = 0; // advanced only by the preempted task volatile int g_pre_resumed = 0; // set only after the trap returned elsewhere -inline void raise_breakpoint() { -#if defined(__riscv) - asm volatile("ebreak"); -#elif defined(__aarch64__) - asm volatile("brk #0"); -#elif defined(__x86_64__) - asm volatile("int3"); -#else -# error "the probe has no breakpoint instruction for this architecture" -#endif -} - void on_preempt(arch::trap_frame* f) { if (arch::kind_of(*f) != arch::trap_kind::breakpoint) return; f->pc += f->instr_len; From 28dada427c241f8b0518791518261b3bf66f197d Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 4 Sep 2026 12:04:36 +0800 Subject: [PATCH 5/5] fix(preempt): the exit status was wrong while every printed line was right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️⚠️ 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. --- .github/workflows/ci.yml | 17 +++++++++++++++-- examples/preempt/src/board.cpp | 13 ++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2961c1a..d7f4090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -471,9 +471,22 @@ jobs: # 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 - mcpp run --target thumbv7m-none-eabi 2>&1 | tee run.log + # ⚠️⚠️ 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 \ - || { cat run.log; echo "the tasks were never interleaved"; exit 1; } + || { 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, diff --git a/examples/preempt/src/board.cpp b/examples/preempt/src/board.cpp index 3ede549..453f2f7 100644 --- a/examples/preempt/src/board.cpp +++ b/examples/preempt/src/board.cpp @@ -24,9 +24,20 @@ void semihost(int op, const void* arg) { } // namespace extern "C" void board_print(const char* s) { semihost(0x04, s); } +// ⚠️⚠️ `SYS_EXIT_EXTENDED` (0x20), NOT `SYS_EXIT` (0x18), AND THE DIFFERENCE IS +// AN EXIT STATUS THAT LOOKS RIGHT AND IS NOT. +// +// 0x18 takes the reason code in r1 DIRECTLY; the `{reason, code}` block is the +// EXTENDED call, which exists because a 32-bit r1 cannot carry both a reason and +// a status. Passing the block to 0x18 prints everything correctly and then +// reports the WRONG status. +// +// Measured: this example printed `both tasks observed preemption` and +// `mcpp run` exited 1. Every assertion on the OUTPUT passed; only the exit code +// disagreed, and only a check that reads it could tell. extern "C" [[noreturn]] void board_exit(int code) { struct { unsigned reason, code; } b{0x20026u, static_cast(code)}; - semihost(0x18, &b); + semihost(0x20, &b); for (;;) {} }