diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ca7245..d7f4090 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,109 @@ jobs: grep -q 'export module mcpplibs.openarch;' src/openarch.cppm echo "one package, two faces, three backends" + # --------------------------------------------------------------------------- + # The FOURTH machine, and the only one whose backend is partial. + # + # ⭐⭐ IT IS A SEPARATE JOB BECAUSE IT CANNOT JOIN THE MATRIX ABOVE. Every row + # there runs `examples/switch`, which needs an address space and a per-CPU + # register; this machine has neither and says so in its manifest. Adding a row + # would have meant a conditional inside the gate — and a gate with a branch in + # it stops being one. + # + # What it does share is the primitive the other three grew for it: + # `arch_trap_switch`. Three machines exercise it with a synchronous trap in + # `examples/switch`; this one exercises it with a TIMER, which is the case the + # primitive exists for and the only one that shows an interrupted context + # being resumed elsewhere. + # + # ⚠️ AND THE ASSERTION IS PREEMPTION, NOT PROGRESS. Two tasks that print would + # also print if the switch never happened and one simply ran to completion. + # Measured before the primitive existed: calling `arch_context_switch` from + # PendSV built, booted, and reported that neither task ever observed the + # other. Only a counter neither task advanced itself tells them apart. + cortex-m: + name: the partial backend preempts (thumbv7m) + runs-on: ubuntu-24.04 + timeout-minutes: 40 + env: + MCPP_VERSION: 2026.9.4.1 + XLINGS_VERSION: v2026.8.17.2 + XLINGS_NON_INTERACTIVE: '1' + steps: + - uses: actions/checkout@v4 + + - name: Install xlings + run: | + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh \ + | bash -s "$XLINGS_VERSION" + echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" + + - name: Install mcpp + run: | + # The same wait the gate job performs, and for the same reason: a + # version bump's index pointer propagates asynchronously, and a single + # `xlings update` can return a stale index while reporting freshness. + for attempt in 1 2 3 4 5 6; do + xlings update > /dev/null 2>&1 || true + if xlings install "mcpp@$MCPP_VERSION" -y -g; then break; fi + if [ "$attempt" = 6 ]; then + echo "::error::mcpp@$MCPP_VERSION never appeared in the index" + exit 1 + fi + echo "the index has not caught up yet (attempt $attempt of 6); waiting 60s" + sleep 60 + done + mcpp --version + mcpp self config --mirror GLOBAL + + # ⚠️ BOTH HOMES. The shim on PATH dispatches against whichever home owns + # it, while `mcpp run` starts the runner through mcpp's own. + - name: Install the emulator + run: | + xlings install xim:qemu-arm -y + XLINGS_HOME="$HOME/.mcpp/registry" xlings install xim:qemu-arm -y + + - name: The scheduler preempts on mps2-an385 + working-directory: examples/preempt + run: | + set -euo pipefail + # Twice, the first allowed to fail: the toolchain payload is installed + # during a build, so the first build on a machine that has never + # targeted this triple is the one that installs it. + mcpp build --target thumbv7m-none-eabi > /dev/null 2>&1 || true + # ⚠️⚠️ NOT `mcpp run | tee`, AND THE PIPELINE IS THE POINT. `$?` after a + # pipeline is the LAST command's status, so `| tee` would read tee's 0 + # and the exit check below would be vacuous — which is how this job + # found the defect it now guards: the example printed + # `both tasks observed preemption` and `mcpp run` exited 1, because + # the board passed `SYS_EXIT_EXTENDED`'s block to `SYS_EXIT`. Every + # assertion on the OUTPUT passed. + set +e + mcpp run --target thumbv7m-none-eabi > run.log 2>&1 + rc=$? + set -e + cat run.log + grep -q "both tasks observed preemption" run.log \ + || { echo "the tasks were never interleaved"; exit 1; } + [ "$rc" = "0" ] \ + || { echo "the scheduler reported success and exited $rc — check the semihosting exit call"; exit 1; } + + # ⭐ THE EXAMPLE CONTAINS NO ASSEMBLY, AND THAT IS AN ASSERTION RATHER + # THAN A REMARK. Its first version hand-wrote thirty lines of PendSV, + # because the layer had no primitive for "switch the context this trap + # will return to". If that code comes back, the primitive has stopped + # carrying its weight and this check is where it is noticed. + - name: The example is scheduling policy, not machine code + run: | + set -euo pipefail + if grep -nE '__asm__|asm volatile' examples/preempt/src/main.cpp; then + echo "the scheduler has grown assembly; arch_trap_switch should have made it unnecessary" + exit 1 + fi + grep -q 'arch_trap_switch' examples/preempt/src/main.cpp \ + || { echo "the example no longer exercises arch_trap_switch"; exit 1; } + echo "the scheduler is forty lines of policy" + # --------------------------------------------------------------------------- # The half no emulator can check. # @@ -410,7 +524,7 @@ jobs: run: shell: bash env: - MCPP_VERSION: 2026.8.21.2 + MCPP_VERSION: 2026.9.4.1 XLINGS_VERSION: v2026.8.17.2 XLINGS_NON_INTERACTIVE: '1' steps: @@ -500,7 +614,7 @@ jobs: run: shell: bash env: - MCPP_VERSION: 2026.8.21.2 + MCPP_VERSION: 2026.9.4.1 XLINGS_VERSION: v2026.8.17.2 XLINGS_NON_INTERACTIVE: '1' steps: 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..090e628 100644 --- a/abi/include/openarch/abi.h +++ b/abi/include/openarch/abi.h @@ -141,6 +141,65 @@ arch_trap_handler_fn arch_trap_set_handler(arch_trap_handler_fn h); void arch_trap_enable_interrupts(int on); int arch_trap_interrupts_enabled(void); +/* ⭐⭐ THE ACTION THE TRAP GROUP WAS MISSING: ACTING ON A TRAP RATHER THAN + * OBSERVING ONE. + * + * `arch_trap_set_handler` lets a kernel SEE a trap and + * `arch_trap_enable_interrupts` lets it MASK one. Neither lets it change what + * the trap returns to — and that is the whole of preemption, which is the + * principal reason to use this layer on a microcontroller at all. + * + * Call it from inside a handler. The context that was interrupted is saved and + * a handle to it is written through `from`; the trap then resumes `to` instead. + * It RETURNS NORMALLY to the handler: the switch happens when the trap does, + * not at the call. + * + * void tick(arch_trap_frame* f) { + * int next = pick(); + * if (next != current) { + * int prev = current; current = next; + * arch_trap_switch(f, &ctx[prev], &ctx[next]); + * } + * } // ← the switch happens after this + * + * ⭐ THE SAME SHAPE AS `arch_context_switch`, DIFFERING ONLY IN WHEN IT TAKES + * EFFECT. One is "switch now"; this one is "switch on the way out". `from` and + * `to` are the same 128-byte, 16-aligned storage, laid out by the same + * `arch_context_init`, so a task can be resumed by either. + * + * ⚠️ EVERY MACHINE NEEDS IT AND EVERY MACHINE SPELLS IT DIFFERENTLY, WHICH IS + * WHY IT IS HERE. riscv64 edits `mepc`, aarch64 `ELR_EL1`, x86_64 the interrupt + * frame's `RIP`/`RSP` — and M-profile none of those, because its handler runs + * on a different stack from the task and the switch has to be performed by a + * pended exception. Three of the four implement it as a cooperative switch + * taken inside the trap; the fourth cannot, and that difference is exactly the + * thing an abstraction earns its place by hiding. + * + * Backends that implement it declare the capability `openarch:preemption`. A + * kernel that preempts requires it, and a machine that cannot is refused by + * name at resolution rather than at link time. + * + * ⚠️ `f` IS THE FRAME THE HANDLER RECEIVED. Passing a frame from a different + * trap, or a null pointer, is undefined: a backend may read the machine state + * the frame describes. + * + * ⚠️⚠️ CALLED MORE THAN ONCE BEFORE THE TRAP RETURNS, THE FIRST `from` AND THE + * LAST `to` ARE THE ONES THAT APPLY. This is not a convenience; it is the only + * consistent answer, and getting it wrong cost this layer a defect that + * presented as a flake. + * + * The context being saved is the one that was interrupted, and only the FIRST + * call in a trap window can name it — by the second, the caller's idea of + * "current" has already moved. The context to resume is whatever the caller + * last asked for. A backend that simply overwrote both would write one task's + * saved stack pointer into another task's storage, losing both. + * + * It is not a theoretical window. On M-profile the switch is performed by an + * exception at the LOWEST priority, so it runs only once no handler is active + * — and two timer ticks can arrive first. Measured: the second task never ran, + * and the two contexts held stack pointers 32 bytes apart on one stack. */ +void arch_trap_switch(arch_trap_frame* f, void* from, void* to); + /* ── openarch.cpu ────────────────────────────────────────────────────────── * * `barrier` is an `arch_barrier` — the four orderings both machines can state. 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 e649510..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"] @@ -24,7 +24,39 @@ repo = "https://github.com/mcpplibs/openarch" # package's `backend` feature requires `openarch-backend` and names nobody; the # resolver binds the one provider in the graph. A consumer's own implementation # of `openarch/abi.h` declares the same line and is selected the same way. -provides = ["openarch-backend"] + +# ⭐⭐ AND WHICH GROUPS OF THAT INTERFACE THIS BACKEND IMPLEMENTS. +# +# `openarch-backend` says "there is a backend here". It does not say what the +# machine can do, and until every backend could do everything it did not need +# to: riscv64, aarch64 and x86_64 are all application-class machines with a +# memory management unit. +# +# ⚠️ A Cortex-M is not. M-profile has a region-based MPU and no page table, so +# `arch_pte_make_leaf` — one of the two primitives this layer's viability was +# decided on — has nothing to construct. The interface either refuses that +# machine or admits a PARTIAL backend, and refusing it would exclude the class +# of device this layer is most useful on. +# +# So the groups are named. A backend declares what it implements; a kernel +# requires what it needs; the resolver reports a mismatch by name at RESOLUTION +# rather than as a wall of `undefined reference to arch_pte_*` at link time. +# +# ⭐ This is the mechanism openarch already used, applied one level finer. It is +# also the mechanism mcpp uses for target-side layers (docs/14) and for named +# runners: a capability is data, declared by a package, and the engine knows +# only that capabilities exist. Three uses, one mechanism. +# ⭐ `openarch:preemption` — the trap group's ACTION, not only its observation. +# +# `arch_trap_switch` makes the trap resume a different context. Every machine +# needs it to preempt and every machine spells it differently, which is the +# shape of thing this layer exists to hide; a backend that cannot provide it — +# and one could exist, on a machine with no way to change what an exception +# returns to — is refused by name at resolution rather than at link time. +provides = ["openarch-backend", + "openarch:address-space", + "openarch:percpu-register", + "openarch:preemption"] [build] sources = ["src/**"] 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 new file mode 100644 index 0000000..46a71c6 --- /dev/null +++ b/backends/cortex-m/mcpp.toml @@ -0,0 +1,59 @@ +# 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. +# ⭐⭐ 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/**"] + +[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..df0f1bc --- /dev/null +++ b/backends/cortex-m/src/context.S @@ -0,0 +1,72 @@ +/* openarch.context — the Cortex-M backend's cooperative switch. + * + * ⭐⭐ IT IS THE SAME MECHANISM AS `arch_trap_switch`, AND THAT IS THE WHOLE + * DESIGN RATHER THAN AN IMPLEMENTATION CONVENIENCE. + * + * 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 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 + + .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 */ + 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 + +/* ⭐ THERE IS NO TRAMPOLINE HERE, AND ITS ABSENCE IS THE LAYOUT'S DOING. + * + * 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_task_exit: +1: b 1b + .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 new file mode 100644 index 0000000..86976d4 --- /dev/null +++ b/backends/cortex-m/src/context_init.cpp @@ -0,0 +1,44 @@ +// Laying out a context so that BOTH ways of resuming it find what they expect. +// +// ⭐⭐ 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_task_exit(); + +extern "C" void arch_context_init(void* ctx, void (*entry)(void*), void* arg, + void* stack_top) { + // ⚠️ 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); + + 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/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/preempt.S b/backends/cortex-m/src/preempt.S new file mode 100644 index 0000000..121d11e --- /dev/null +++ b/backends/cortex-m/src/preempt.S @@ -0,0 +1,176 @@ +/* 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: + /* ⚠️⚠️ 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. */ + 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 + cpsie i + 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 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 + .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 + 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/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..1a19bca --- /dev/null +++ b/backends/cortex-m/src/trap_impl.cpp @@ -0,0 +1,142 @@ +// 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" + +// ── 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; + // ⚠️⚠️ 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. + *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 c77ce24..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"] @@ -24,7 +24,39 @@ repo = "https://github.com/mcpplibs/openarch" # package's `backend` feature requires `openarch-backend` and names nobody; the # resolver binds the one provider in the graph. A consumer's own implementation # of `openarch/abi.h` declares the same line and is selected the same way. -provides = ["openarch-backend"] + +# ⭐⭐ AND WHICH GROUPS OF THAT INTERFACE THIS BACKEND IMPLEMENTS. +# +# `openarch-backend` says "there is a backend here". It does not say what the +# machine can do, and until every backend could do everything it did not need +# to: riscv64, aarch64 and x86_64 are all application-class machines with a +# memory management unit. +# +# ⚠️ A Cortex-M is not. M-profile has a region-based MPU and no page table, so +# `arch_pte_make_leaf` — one of the two primitives this layer's viability was +# decided on — has nothing to construct. The interface either refuses that +# machine or admits a PARTIAL backend, and refusing it would exclude the class +# of device this layer is most useful on. +# +# So the groups are named. A backend declares what it implements; a kernel +# requires what it needs; the resolver reports a mismatch by name at RESOLUTION +# rather than as a wall of `undefined reference to arch_pte_*` at link time. +# +# ⭐ This is the mechanism openarch already used, applied one level finer. It is +# also the mechanism mcpp uses for target-side layers (docs/14) and for named +# runners: a capability is data, declared by a package, and the engine knows +# only that capabilities exist. Three uses, one mechanism. +# ⭐ `openarch:preemption` — the trap group's ACTION, not only its observation. +# +# `arch_trap_switch` makes the trap resume a different context. Every machine +# needs it to preempt and every machine spells it differently, which is the +# shape of thing this layer exists to hide; a backend that cannot provide it — +# and one could exist, on a machine with no way to change what an exception +# returns to — is refused by name at resolution rather than at link time. +provides = ["openarch-backend", + "openarch:address-space", + "openarch:percpu-register", + "openarch:preemption"] [build] sources = ["src/**"] 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 97c0d86..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"] @@ -32,7 +32,39 @@ repo = "https://github.com/mcpplibs/openarch" # package's `backend` feature requires `openarch-backend` and names nobody; the # resolver binds the one provider in the graph. A consumer's own implementation # of `openarch/abi.h` declares the same line and is selected the same way. -provides = ["openarch-backend"] + +# ⭐⭐ AND WHICH GROUPS OF THAT INTERFACE THIS BACKEND IMPLEMENTS. +# +# `openarch-backend` says "there is a backend here". It does not say what the +# machine can do, and until every backend could do everything it did not need +# to: riscv64, aarch64 and x86_64 are all application-class machines with a +# memory management unit. +# +# ⚠️ A Cortex-M is not. M-profile has a region-based MPU and no page table, so +# `arch_pte_make_leaf` — one of the two primitives this layer's viability was +# decided on — has nothing to construct. The interface either refuses that +# machine or admits a PARTIAL backend, and refusing it would exclude the class +# of device this layer is most useful on. +# +# So the groups are named. A backend declares what it implements; a kernel +# requires what it needs; the resolver reports a mismatch by name at RESOLUTION +# rather than as a wall of `undefined reference to arch_pte_*` at link time. +# +# ⭐ This is the mechanism openarch already used, applied one level finer. It is +# also the mechanism mcpp uses for target-side layers (docs/14) and for named +# runners: a capability is data, declared by a package, and the engine knows +# only that capabilities exist. Three uses, one mechanism. +# ⭐ `openarch:preemption` — the trap group's ACTION, not only its observation. +# +# `arch_trap_switch` makes the trap resume a different context. Every machine +# needs it to preempt and every machine spells it differently, which is the +# shape of thing this layer exists to hide; a backend that cannot provide it — +# and one could exist, on a machine with no way to change what an exception +# returns to — is refused by name at resolution rather than at link time. +provides = ["openarch-backend", + "openarch:address-space", + "openarch:percpu-register", + "openarch:preemption"] [build] sources = ["src/**"] 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 new file mode 100644 index 0000000..65cf19b --- /dev/null +++ b/examples/preempt/README.md @@ -0,0 +1,141 @@ +# 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. + +⚠️ **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. + +## ⚠️⚠️ 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 +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 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 +`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: + +``` +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..509f13a --- /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.workspace] +"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..453f2f7 --- /dev/null +++ b/examples/preempt/src/board.cpp @@ -0,0 +1,85 @@ +// 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); +// ⚠️ 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 { +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); } +// ⚠️⚠️ `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(0x20, &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, 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. +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*)openarch_cm_pendsv, + (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..b63815d --- /dev/null +++ b/examples/preempt/src/main.cpp @@ -0,0 +1,135 @@ +// 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. +// +// ⭐ 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]; + +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 +// 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) {} + } +} + +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); +} + +// 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) { + ++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; + arch_trap_switch(f, g_ctx[prev], g_ctx[next]); +} + +} // 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. +extern "C" void scheduler_tick() { + // 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"); + + 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])]); + + // ⚠️⚠️ 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); + + g_current = 0; + openarch_cm_enter(g_ctx[0]); // unmasks, and 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..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,11 +107,82 @@ 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'); } +// ── 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 + +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 = ""