# The arch interface: common-denominator analysis *What a CPU port owns, what the kernel provides, and why the surface tiers the way it does. See also [`arch-porting.md`](../guides/arch-porting.md) (the how-to for adding a port) and [ADR 0001](../decisions/0001-foreign-architecture-policy.md) (which axes of foreignness the contract absorbs vs quarantines).* **Method.** Read every `arch//kernel_arch.h` in the tree (9 ports) plus the shared `arch/arch_defaults.h`, and diffed the declared surface element-by-element. The port header is ground truth; `arch_defaults.h` is the idealized contract ("a port owns context switch, the interrupt-state primitive, and the timer tick — everything else is opt-in"). Below is what the ports actually declare, the tiering that falls out, and the findings that shaped it. Legend: **R** = real implementation · **∘** = no-op / trivial default (from `arch_defaults.h`) · **–** = absent (not provided by that port) · **(hdr)** = declared in the port header but not implemented at root (no `kernel_arch.c`, no board). --- ## 1. The observed surface (interface element × port) The 9 root ports: `arm_v7m arm_v6m arm_v8m risc_v c2000 pthread flat msp430 tricore`. `msp430` and `tricore` ship **no `kernel_arch.c`** — see §7. | Element | v7m | v6m | v8m | riscv | c2000 | pthread | flat | msp430 | tricore | |---|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:| | **IRQ state** `arch_irq_state_t` | R | R | R | R | R | ∘ | ∘ | **R** | R(hdr) | | `arch_irq_disable/enable` | R | R | R | R | R | ∘ | ∘ | **R** | R(hdr) | | `arch_irq_save/restore` | R | R | R | R | R | ∘ | ∘ | **R** | R(hdr) | | `ARCH_CONTEXT_SWITCH_SUPPORTED` | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 1(hdr) | | `ARCH_PREEMPTION_SUPPORTED` | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 1(hdr) | | `ARCH_MIN_STACK_SIZE` | R | R | R | R | R | R | R | – | R(hdr) | | **Context** `struct arch_context` | R | R | R | R | R | R | R | – | R(hdr) | | `arch_context_clear` | R | R | R | R | R | R | R | – | R(hdr) | | `arch_context_init(ctx,stk,cells,fn,arg)` | R | R | R | R | R | R | R | – | R(hdr) | | `arch_context_switch(from,to)` | R | R | R | R | R | R | ∘ | – | R(hdr) | | `arch_context_start(to)` | R | R | R | R | R | R | **–** | – | R(hdr) | | `arch_timer_init` / `_now_ms` | board | board | board | R | R | R (OS clock) | ∘ | – | R(hdr) | | `arch_idle` | R | R | R | R | R | R | **–** | – | R(hdr) | | **SMP** `ARCH_CORE_COUNT` | 1 (fixed) | cfg | cfg | cfg | 1 (fixed) | 1 | 1 | – | cfg(hdr) | | `arch_spinlock_t`, `arch_core_id`, `arch_spin_*` | ∘ | ∘/R | ∘/R | ∘/R | ∘ | ∘ | ∘ | – | ∘/R(hdr) | | `arch_ipi`, `arch_smp_start` | ∘ | ∘/R | ∘/R | ∘/R | ∘ | ∘ | ∘ | – | ∘/R(hdr) | | `arch_sched_switch` (SMP coop) | **–** | R¹ | R¹ | R¹ | – | – | – | – | R¹(hdr) | | **Safety** `arch_mpu_init`, `arch_stack_guard_*` | **R** | ∘ | **R** | **R** | ∘ | ∘ | ∘ | – | ∘ | | `arch_thread_region_set`, `arch_stack_isolatable` | **R** | ∘ | **R** | **R** | ∘ | ∘ | ∘ | – | ∘ | | `arch_context_set_privilege`, `_is_unprivileged` | **R** | ∘ | **R** | **R** | ∘ | ∘ | ∘ | – | ∘ | | `arch_syscall_set_return` | **R** | ∘ | **R** | **R** | ∘ | ∘ | ∘ | – | ∘ | | `arch_context_reclaim` (`ARCH_HAS_CONTEXT_RECLAIM`) | – | – | – | – | – | – | – | – | R(hdr) | | `arch_context_prepare` (`ARCH_HAS_LAZY_CONTEXT`, SMP only) | – | – | – | – | – | – | – | – | R(hdr) | ¹ only when built `TK_SMP_CORES>1`. Port-private extras (not part of the kernel-facing contract): `arch_sched_init` + `arch_kernel_tick` (the ARM family's board-tick glue — see §6, still live and **un-renamed**); `arch_trap_init` + `riscv_external_irq` (riscv); `ARCH_HAS_STACK_LIMIT` (v8m's PSPLIM register, replaces the v7m MPU-region stack guard); `ARCH_CONTEXT_START_CLEARS_SCHED_ENTERING` (arm single-core, c2000, tricore: the launch path clears `g_sched_entering` itself once the context is valid, so a tick from an already-running SDK timer can never preempt onto a not-yet-launched stack — see `tk_kernel_start_this_core` in `kernel/tk_kernel.c`); `tk_fault_report` (v7m/v8m). **Core identity rule (SMP).** `arch_core_id()` is **boot-established, never discovered at runtime.** A core's id is fixed by which entry/vector table it comes up on and read back through an *architectural* anchor — `mhartid` (RISC-V), the banked `VTOR` (Cortex-M with no per-core id register — `arm_v6m`/`arm_v8m` map `VTOR` → id), or a board's own register. No kernel or arch code probes a chip peripheral to ask "who am I?"; the question is answered once per arch/board and never re-raised. Cross-core *locks* still use a chip atomic (RP2040's SIO hardware spinlock — ARMv6-M has no LDREX/STREX), and that lives entirely in `arch_spin_*`. --- ## 2. The tiering that falls out The surface separates cleanly into four concentric tiers — this is the structural finding the tree is built on. `arch/arch_defaults.h`, included at the end of every `kernel_arch.h`, supplies a central `#ifndef ARCH_HAS_*`-guarded no-op for each tier a port doesn't declare, so an absent feature costs nothing and is never hand-stubbed: - **Tier 0 — `arch_irq.h` (truly universal).** The only thing **all 9** ports provide, including the HAL-only `msp430`, which has *no kernel at all*: `arch_irq_state_t`, `arch_irq_save/restore`, `arch_irq_disable/enable`. Independent of the scheduler — the HAL/MCAL needs it on its own — so it lives in its own tiny header, includable without the kernel contract. - **Tier 1 — scheduler core (`kernel_arch.h`, every switching port).** `struct arch_context` (+ `_clear`/`_init`/`_switch`/`_start`), `arch_idle`, the two capability macros, `ARCH_MIN_STACK_SIZE`, and ONE preempt-integration hook (§4). Plus two Tier-1 *properties*, each defaulted in `arch_defaults.h` and overridden only where true: `ARCH_STACK_GROWS_UP` (default 0; c2000 sets 1) and `ARCH_OWNS_TIMEBASE` (default 0 — the kernel's tick counter is authoritative; `pthread` sets 1 and reads `CLOCK_MONOTONIC` instead). `flat` is the degenerate member (`ARCH_CONTEXT_SWITCH_SUPPORTED 0`, no `_start`/`_idle`); `msp430` has no Tier 1 at all. - **Tier 2 — SMP (opt-in, `TK_SMP_CORES>1`).** `ARCH_CORE_COUNT`, `arch_spinlock_t` + `arch_spin_*`, `arch_core_id`, `arch_ipi`, `arch_smp_start`, `arch_sched_switch`. Real on `arm_v6m` (RP2040, 2×M0+), `arm_v8m` (M33 target), `risc_v` (multi-hart), and declared-but-unimplemented on `tricore` (AURIX, 3-core — see §7); **absent on `arm_v7m` and `c2000`** (neither target has multi-core silicon in this tree); no-op single-core default everywhere else. - **Tier 3 — safety/security (opt-in).** MPU/stack-guard, per-thread isolation, privilege/unpriv, syscall-return. **Real on `arm_v7m`, `arm_v8m`, and `risc_v`** (`ARCH_HAS_MPU` + `ARCH_HAS_UNPRIV`, the latter two further gated at the `.c` level on `TK_CONFIG_UNPRIV_THREADS`); no-op everywhere else, including `tricore`, whose header says outright: *"No MPU / no unprivileged mode in this port yet."* --- ## 3. The interface as built (tiered, defaulted) The port author writes **Tier 0 + Tier 1 only**; Tiers 2–3 default to no-ops unless the port declares a capability flag. A port that implements a tier `#define`s its `ARCH_HAS_*` flag and provides the real prototypes; the matching default group in `arch_defaults.h` then compiles out — real and default definitions never collide. ### Tier 0 — `arch//arch_irq.h` (universal; kernel-independent, HAL may use alone) ```c typedef arch_irq_state_t; static inline arch_irq_state_t arch_irq_save(void); /* nesting-safe: returns prior, masks */ static inline void arch_irq_restore(arch_irq_state_t); /* restore exact prior state */ static inline void arch_irq_disable(void); /* flat mask (kernel cooperative yield) */ static inline void arch_irq_enable(void); /* unconditional enable */ ``` ### Tier 1 — `arch//kernel_arch.h` (mandatory for any scheduling port) ```c #define ARCH_CONTEXT_SWITCH_SUPPORTED {0|1} #define ARCH_PREEMPTION_SUPPORTED {0|1} #define ARCH_MIN_STACK_SIZE /* addressable cells, not bytes — ADR 0001 */ struct arch_context { uintptr_t stack_pointer; /* SP or opaque token; MUST be first word (F2) */ }; static inline void arch_context_clear(struct arch_context*); void arch_context_init (struct arch_context*, void* stack, size_t cells, void* (*fn)(void*), void* arg); void arch_context_switch(struct arch_context* from, struct arch_context* to); /* cooperative */ void arch_context_start (struct arch_context* to); /* launch first */ void arch_idle(void); /* wait-for-irq */ /* preempt integration — the port wires its tick to exactly ONE (§4): */ void tk_kernel_tick_preempt(void); /* deferred-switch ports (kernel-provided) */ uintptr_t tk_kernel_preempt_select(uintptr_t); /* trap-frame ports (kernel-provided) */ void arch_timer_init(void); /* owner-provided ms base; see docs/architecture/tick-policy.md */ uint32_t arch_timer_now_ms(void); ``` `arch_timer_init`/`_now_ms` are still declared *inside* `kernel_arch.h` at root — the Q2-era idea of splitting them into a standalone time-contract header (§6, F6) was not carried out as a file split. What *did* ship is the layering it was chasing: the app never calls `arch_timer_now_ms` directly, it reads `kernel/tk_time.h`'s `tk_time_ms()` / `tk_kernel_now_ms()` (Tier A, a consumed service) — see [`timekeeping.md`](timekeeping.md). ### Tier 2 — SMP (only if `#define ARCH_HAS_SMP 1`; else defaulted no-op single-core) ```c #define ARCH_CORE_COUNT typedef arch_spinlock_t; uint32_t arch_core_id(void); void arch_spin_init/lock/unlock(arch_spinlock_t*); void arch_ipi(uint32_t core); void arch_smp_start(void); void arch_sched_switch(void); /* SMP-safe cooperative switch (select-in-trap) */ ``` See [`smp-model.md`](smp-model.md) for the scheduler-side half of this contract (the giant lock, affinity, the `g_sched_entering` re-entry guard). ### Tier 3 — safety/security (each behind its own capability flag) ```c #define ARCH_HAS_MPU 1 -> arch_mpu_init, arch_stack_guard_{set,active,suspend,resume}, arch_stack_isolatable, arch_thread_region_set #define ARCH_HAS_UNPRIV 1 -> arch_context_set_privilege, arch_context_is_unprivileged, arch_syscall_set_return #define ARCH_HAS_CONTEXT_RECLAIM 1 -> arch_context_reclaim(token) (tricore CSA) #define ARCH_HAS_LAZY_CONTEXT 1 -> arch_context_prepare(ctx) (tricore SMP) #define ARCH_HAS_SUPERVISOR_FENCE 1 -> arch_instance_fence_apply(deny) (arm_v8m, arm_v7m, risc_v S-mode, tricore; ADR 0003) ``` Everything not declared is a central default no-op in `arch/arch_defaults.h` — **the port file never mentions it.** `ARCH_HAS_SUPERVISOR_FENCE` is the per-instance memory fence that binds even the kernel (C3-B): a fence-capable port implements the mechanism-neutral deny-list seam and an app requesting `TK_CONFIG_ENABLE_INSTANCE_ISOLATION` on an arch that lacks it fails loud ([`0003-supervisor-fence-seam.md`](../decisions/0003-supervisor-fence-seam.md)). ### Minimal new-port checklist Tier 0 (4 fns + type) + Tier 1 (`arch_context` + init/switch/start/clear, idle, one preempt hook, timer, 3 macros). **~10 functions.** SMP and safety are added later, in isolation, by flipping a capability flag — never by editing the core or stubbing unrelated features. Walked step by step in [`arch-porting.md`](../guides/arch-porting.md). --- ## 4. Findings — what's proven and what's still open - **F1 — the "stub tax" is solved.** Without `arch_defaults.h`, every port would hand-copy ~15-20 no-op stubs for tiers it doesn't have. Measured reduction on the two ports checked in at the time: `arm_v7m` `kernel_arch.h` 192→96 lines, `arm_v6m` 150→73, with Tier 0 split into a 38–45-line `arch_irq.h`. `msp430`'s single-core probe reduces to the four Tier-0 inlines with zero scheduler symbols — proof Tier 0 truly stands alone. - **F2 — the `stack_pointer`-first convention is load-bearing and stays implicit.** Every `struct arch_context` at root keeps `uintptr_t stack_pointer` as its first member — even `tricore` (a PCXI *token*, not an SP) and `pthread` (unused, kept only so the shared preempt path compiles) — specifically so the kernel/asm can treat `&ctx` as `&saved_sp`. Still enforced only by convention + comments, not a static assert. - **F3 — two preempt-integration models, both real, both stay — split by entry point, not by hardware mechanism.** *Deferred-switch* ports call the void entry `tk_kernel_tick_preempt()` (or the friendlier `tk_tick()` front door) from their tick ISR; the kernel computes from/to and the port's `arch_context_switch(from,to)` does the swap. *Trap-frame* ports call `tk_kernel_preempt_select(sp)` instead: the trap already saved the full frame at a raw SP, the kernel picks next and returns the SP to resume, and the trap's own return path restores it — there is no ordinary `arch_context_switch` call in this path. The two are usually read as "PendSV vs no PendSV," but `c2000` breaks that reading: it has **no PendSV** (the C28x switch is an immediate, synchronous `ctx_switch` call with no deferral hardware at all) and yet `arch/c2000/kernel_arch.c` wires its tick ISR through `tk_tick()` → `tk_kernel_tick_preempt()` → `arch_context_switch()`, the *deferred*-family entry — because C28x's switch is an ordinary callable function on two `arch_context*`, not a raw-SP trap-return convention. So the real split is **deferred-entry** (`arm_v6m`/`arm_v7m`/`arm_v8m`/`c2000` — has a callable `arch_context_switch`) vs **trap-frame-entry** (`risc_v`, and `tricore` per its header, unconfirmed body — the switch is baked into the trap's SP save/restore). Both funnel into the same `tk_kernel_tick()` core in `kernel/tk_kernel.c`. See [`tick-policy.md`](tick-policy.md). - **F4 — cooperative vs preemptive switch are separate arch entry points, and the globals-vs-explicit-args question is settled.** Single-core cooperative switch uses `arch_context_switch(from,to)` (explicit args); SMP cooperative switch uses `arch_sched_switch()` (select-in-trap, because the from/to globals aren't SMP-safe). Resolved in full in [`smp-model.md`](smp-model.md). - **F5 — `arch_sched_init`/`arch_kernel_tick` are ARM-family board glue, still arch-shaped.** They exist because the ARM tick lives on the *board* (nRF52 RTC1 vs generic SysTick), not the arch. A rename to a clearly board-glue name (`armv7m_tick_isr`/`armv7m_sched_init`) was proposed in `tick-policy.md` P5 but **not adopted** — `arch_sched_init`/`arch_kernel_tick` are still the live names in `arch/arm_v6m|arm_v7m|arm_v8m/kernel_arch.h` today. Open, not blocking. - **F6 — `arch_timer_now_ms` still lives in the Tier-1 switch contract.** The intent (time as a *consumed* service, not part of the switch contract) shipped as `kernel/tk_time.h`; the mechanical split of `arch_timer_now_ms`/`arch_timer_init` out of `kernel_arch.h` into their own header did not happen. Not a correctness problem — every port already treats it as "the owner provides a ms counter" — just an unfinished file move. See [`timekeeping.md`](timekeeping.md) §5–6. - **F7 — naming stays consistent.** `arch_*` = the port implements it; `tk_kernel_*` / `tk_*` = kernel-provided hook the port calls (`tk_kernel_tick_preempt`, `tk_kernel_preempt_select`, `tk_tick`, `tk_tick_advance`). Confirmed unchanged at root. - **F8 — `fn` is pthread-shaped** (`void* (*fn)(void*)`) throughout the arch layer, including on ports (`c2000`, `tricore`) where the return value is unused. Consistent across all 9 ports. --- ## 5. The absorbed-axis ports (ADR 0001) `c2000` is the first port that isn't GCC + 8-bit-byte + downward-stack — the silent assumption baked into the other 8. Per [ADR 0001](../decisions/0001-foreign-architecture-policy.md) ("draw the line by axis"), the two *cheap* axes it hits are absorbed into the shared contract rather than special-cased: - **`CHAR_BIT == 16`, no `uint8_t`.** The stack contract is `void*` + a `size_t` count of *addressable cells* (`ARCH_MIN_STACK_SIZE` is a cell count, not a byte count), and `arch_defaults.h`'s no-op spinlock is typed `unsigned char`, never `uint8_t`. - **Stack grows up.** `#define ARCH_STACK_GROWS_UP 1` (default 0); read only by the stack-guard/high-water scan — `arch_context_init` builds its own frame in its own direction regardless. - **Non-GCC vendor compiler.** `c2000/arch_irq.h` uses cl2000's `DINT`/`EINT`/ `__disable/enable/restore_interrupts`, not GCC inline asm — proof the gate idiom is compiler-agnostic. The CHAR_BIT absorption didn't stop at the arch headers: ADR 0001's addenda record it completing into the kernel **core** (`kernel/tk_kernel.c`, `tk_sync.c`) once C2000 actually had to compile the scheduler, not just the contract, and the final settled spelling (kind-1 values are plain `unsigned char`, no project typedef). Full history in the ADR. --- ## 6. Board-owned time and tick glue (arm family) `arch_timer_init()` is declared in `kernel_arch.h` but is *provided by the board*: RTC1 on nRF52 (SysTick can't survive its sleep mode), plain SysTick on a generic Cortex-M. Its ISR calls `arch_sched_init()` once, then `arch_kernel_tick()` per tick — both are the port-private glue named in F5, not part of the cross-arch contract a new port needs to know about. `risc_v`, `c2000`, and `tricore` fold the equivalent into their trap handlers directly and never declare these two names. --- ## 7. `msp430` and `tricore`: two different kinds of "header-only" Both ports ship `arch_irq.h` + `kernel_arch.h` and **no `kernel_arch.c`** — but for opposite reasons, and conflating them is a mistake: - **`msp430` is Tier-0-only by design.** `ARCH_CONTEXT_SWITCH_SUPPORTED` and `ARCH_PREEMPTION_SUPPORTED` are both `0`; there is no `struct arch_context`, no switch, no tick. This is the proof that Tier 0 is genuinely separable: an HAL/MCAL superloop with no kernel at all can include just `arch_irq.h`. A kernel build never selects this arch. - **`tricore` declares the FULL Tier 1 contract (plus `ARCH_HAS_CONTEXT_RECLAIM`, and `ARCH_HAS_LAZY_CONTEXT`/`ARCH_HAS_SMP` under `TK_SMP_CORES>1`) but has no `kernel_arch.c` body and no `boards/` entry at root.** The header is real — `struct arch_context` holds a PCXI *token* (not an SP; the first-word convention still holds), and the comments describe a working CSA-based context switch — but the implementation exists only in isolated bring-up sketches (AURIX TC275, 3-core SMP + AMP-island proofs), not yet grafted into this tree. This port is a **prepared, unimplemented precedent**, not a working target: selecting `-DTK_ARCH_TRICORE` and linking will fail on every undefined `arch_*` symbol. Tracked as a graft, alongside the un-globaling in [ADR 0002](../decisions/0002-multi-instance-kernel.md).