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 (the how-to for adding a port) and ADR 0001 (which axes of foreignness the contract absorbs vs quarantines).

Method. Read every arch/<cpu>/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 #defines 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/<cpu>/arch_irq.h (universal; kernel-independent, HAL may use alone)

typedef <uint> 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/<cpu>/kernel_arch.h (mandatory for any scheduling port)

#define ARCH_CONTEXT_SWITCH_SUPPORTED {0|1}
#define ARCH_PREEMPTION_SUPPORTED     {0|1}
#define ARCH_MIN_STACK_SIZE           <cells>   /* 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.

Tier 2 — SMP (only if #define ARCH_HAS_SMP 1; else defaulted no-op single-core)

#define ARCH_CORE_COUNT <n>
typedef <atomic> 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 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)

#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).

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.


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.

  • 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.

  • 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 §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 (“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.