The SMP model¶
Resolves arch-interface.md finding F4 (cooperative-switch args)
and generalizes per-core state, affinity, and the cross-core lock across the SMP
proofs. The legacy ancestor _legacy/docs/smp-design.md (giant-lock model, Phases 0-2)
stays in _legacy/ — superseded by this document and by
ADR 0002.
Method. Read _legacy/docs/smp-design.md and the SMP glue in kernel/tk_kernel.c
(g_sched_lock, g_sched_entering[], tk_thread_affine_here,
tk_kernel_start_this_core, arch_sched_switch vs arch_context_switch),
cross-referenced with the SMP proofs behind the ports in arch/: RISC-V qemu SMP (tree,
risc_v), AURIX TC275 3-core (CSA-heavy, tricore — declared but not yet grafted, see
arch-interface.md §7), RP2040 2-core (SIO-light, arm_v6m), plus the approach-2
sketches.
The settled SMP model (verified against kernel/tk_kernel.c)¶
Giant lock: one
struct tk_kernel s_kernel(accessed through the singleton pointeractive_kernel), oneg_sched_lock. Shared: TCB pool, ready selection, sched policy, wait queues. The lock makes select → mark RUNNING atomic across cores; the selector only considers READY, so two cores never pick the same thread.What is per-core (arrays sized
ARCH_CORE_COUNT, = 1 on uniprocessor so single-core is byte-identical):current_thread[],idle_index[](one idle thread per core),g_quantum_remaining[], and the re-entry guardg_sched_entering[]— all confirmed present intk_kernel.c/tk_kernel_internal.h.The kernel lock = IRQ-off THEN spinlock (order matters — lock-then-IRQ risks taking an interrupt while holding the spinlock → deadlock):
arch_irq_state_t s = arch_irq_save(); /* keep an ISR on THIS core out */ arch_spin_lock(&g_sched_lock); /* keep OTHER cores out */ ... scheduler critical section ... arch_spin_unlock(&g_sched_lock); arch_irq_restore(s);
This is
tk_sched_enter()/tk_sched_exit()verbatim intk_kernel.c. On uniprocessor the spinlock is a no-op (arch_defaults.h), so this collapses to plainarch_irq_save/restore— the single-core path pays nothing.
F4 — the cooperative-switch reconcile¶
Three switch shapes existed across the evidence; the tree keeps two and dropped one:
Path |
Mechanism |
Lock convention |
|---|---|---|
single-core cooperative |
|
hold-across-switch is harmless (spinlock is a no-op) |
SMP cooperative |
|
lock released inside the trap, before resume |
preempt (both) |
|
lock held only inside |
~~sketch~~ |
~~ |
superseded — a preempt-only shortcut |
The invariant (the hard-won part, still enforced verbatim in tk_kernel.c): the
scheduler spinlock is held only inside the *_select functions — NEVER across a
context switch, an mret/rfe, or a thread’s run. Why it must be so:
A freshly created thread enters straight at its function and never reaches an unlock — if the lock were held across the switch, the other core deadlocks. Holding it only inside
*_selectmeans a new thread starts with the lock already free → no entry trampoline needed.tk_kernel_wait()keeps the lock from enqueue → mark BLOCKED → save-in-trap on the SMP path (arch_sched_switch(), called with the lock held), which closes the lost-wakeup race (another core can’t ready+run the thread before it’s saved). On single-core the lock is dropped before the yield — there is no other core to race.
So F4 is resolved: the sketches’ g_current/g_next globals were the single-core
“hold-across” convention in disguise; the general model uses explicit from/to for
single-core cooperative and select-in-trap for SMP, both gated on
ARCH_CORE_COUNT > 1 (#if ARCH_CORE_COUNT > 1 in tk_kernel.c and
tk_kernel_port.h), single-core byte-for-byte unchanged.
Two hard-won SMP details to carry forward (do not re-derive)¶
The
tk_kernel_start_this_coredeadlock + the re-entry guard. A core enters its per-core start holdingg_sched_lockwith interrupts on (it’s about to launch its first thread); a tick/IPI landing there would re-enterpreempt_selectand self-deadlock on the held lock. Fixed with a per-core flagg_sched_entering[cpu]thattk_kernel_preempt_selectchecks and bails on — not by masking IRQs (masking only moved when the tick landed and inverted a real 2-core/3-core failure). HW-proven on AURIX 3-core; the guard array and both check sites are live intk_kernel.ctoday (tk_kernel_tick_preempt,tk_kernel_preempt_select,tk_kernel_start_this_core).The spinlock is TTAS-shaped, fairness deferred. A fair ticket lock (
cmpswap.wfetch-add) was sketch-sound and clean on AURIX smp5/smp7 but wedged smp6 on hardware (cause not root-caused). Shipped = test-and-test-and-set —risc_v’sarch_spin_lock(arch/risc_v/kernel_arch.h) is a literal TTAS (__sync_lock_test_and_setthen spin read-only); RP2040’s software lock word is made atomic by a SIO hardware meta-spinlock in the same acquire/retry shape (boards/rp2040_pico/tk_smp_rp2040.c). Starvation is bounded at the source byarch_idle()dwelling. Revisit fairness only after reproducing the smp6 wedge under a debugger.
Affinity (proven — keep as first-class, config-gated)¶
Per-TCB affinity bitmask, default 0xFFu (any core) — set in
tk_thread_create_core. tk_thread_affine_here(k,i) = (affinity >> arch_core_id()) & 1; tk_thread_set_affinity(t, mask) / tk_thread_pin(t, core). Off → the pick loop is
identical to uniprocessor. This is the single field that makes the giant lock subsume
the pinned model (above). At root this is TK_FEAT_AFFINITY, compiled in only when
TK_CONFIG_ENABLE_AFFINITY is set and ARCH_CORE_COUNT > 1 (kernel/tk_config.h)
— on a single-core build the field and the check both compile out.
ARCH_HAS_LAZY_CONTEXTimplies mandatory single-core affinity. On register- window / CSA archs (TriCore) a thread’s context must be materialized from the pool of the core that runs it, so the thread cannot migrate. Such a port pins each thread to its home core and materializes on first dispatch (arch_context_prepare, gated by the capability flag — seetk_kernel_preempt_select’s#if defined(ARCH_HAS_LAZY_CONTEXT)block). This coupling is explicit: lazy-context ⇒ affinity is forced, not optional.
Round-robin fairness across cores (phase-offset first slice)¶
The per-core round-robin (tk_kernel_next_ready_thread scans from THIS core’s current
is provably fair — verified by a host simulation of the exact selection logic: equal work per thread at every tick phase, on equal-speed cores. There is exactly one degenerate case: when the cores’ quantum expiries land on the same tick (their per-core SysTicks in phase), both cores switch together and the round-robin settles into a stable per-core partition — threads stop migrating, each pinned to its initial core. That is fine on a HOMOGENEOUS SMP (fair, and cache-local), but on HETEROGENEOUS cores — e.g. a core that boots Non-secure and runs measurably slower — it pins some threads to the slow core and skews their work share (HW-observed ~27% on the bring-up board that surfaced this).
Fix (HW-verified, live in tk_kernel_start_this_core): phase-offset each core’s
FIRST slice so expiries interleave rather than coincide — core c starts at
q·(1 − c/N) (core0 = full quantum, core1 = half, …). The switch points de-sync, every
thread cycles across all cores, and work is fair even when the cores differ in speed
(skew 27% → <0.5% measured). Negligible cost on Cortex-M (no L1 D-cache). Single-core
(cpu==0) is unchanged. It is a one-time phase establishment; a dynamically re-phasing
workload (a periodically-migrating thread) can transiently re-partition, but the
cumulative distribution stays even. The heterogeneous core-speed difference itself is
a HW property (a security-state execution penalty), not a fairness bug; the EXC_RETURN
security reconciliation in arch/arm_v8m is what makes such a split safe at all.
The arch Tier-2 contract (from arch-interface.md)¶
ARCH_CORE_COUNT, arch_core_id, arch_spinlock_t + arch_spin_{init,lock,unlock},
arch_ipi, arch_smp_start, arch_sched_switch. The atomic primitive behind the
spinlock is the only per-arch variance, and the proofs span the range:
Arch |
core id |
spinlock atomic |
IPI |
secondary start |
|---|---|---|---|---|
RISC-V ( |
|
AMO |
CLINT MSIP |
park-in-startup, release |
AURIX ( |
CORE_ID CSFR |
|
GPSR service req |
|
RP2040 ( |
SIO CPUID |
SIO hardware spinlock (M0+ has no LDREX) |
SIO FIFO |
|
All fit the same Tier-2 surface; single-core inherits the no-op defaults from
arch/arch_defaults.h. The AURIX-heavy vs RP2040-light gap reduces to exactly one
bit: ARCH_HAS_LAZY_CONTEXT (CSA materialization) — everything else is the same
contract with a different atomic.
Separate-kernel AMP: in scope (ADR 0002)¶
The approach-2 experiments referenced here were labelled “SMP+AMP,” but what they
actually built was never separate kernels: one SMP kernel scheduling both cores
(per-core ticks, both preempt-scheduling), plus an application-level cross-core
handoff — a producer thread on one core handing jobs to a consumer on another over an
inter-core FIFO. That is SMP with pinned threads and a cross-core message primitive, not
a second scheduling model, and it is still a reasonable way to describe a lightweight
cross-core mailbox layered on arch_ipi (an IPC concern, not a scheduler concern).
Separate-kernel AMP — multiple independent struct tk_kernel instances, each owning a
disjoint subset of cores — was an open question when this document was first written.
It is no longer out of scope: it was HW-proven twice (RP2040 two-instance 1+1, and
AURIX TC275 “2 SMP + 1 aside” — one instance owning a real 2-core SMP domain beside a
disjoint 1-core AMP island), and
ADR 0002 generalizes the singleton above
into per-instance kernels resolved per physical core — the same scheduler model,
replicated N times, not a new one. Every mechanic this document settles (the giant
lock, per-core current/idle/quantum/entering, select-in-trap with the lock held only
inside *_select, the g_sched_entering re-entry guard, phase-offset first slice,
TTAS-not-ticket lock, ARCH_HAS_LAZY_CONTEXT ⇒ mandatory affinity) is preserved
per instance; ADR 0002 only lifts the singleton / single-scheduling-domain scope
limit. The graft into kernel/tk_kernel.c is tracked there, not here — this document
describes the mechanics one instance uses, which do not change.
What this settles¶
Settled: giant-lock + first-class affinity (subsumes the sketches’ per-core pinned model); per-core
current/idle/quantum/entering; the kernel-lock pair (IRQ-off then spinlock, no-op single-core); F4 — explicitfrom/tosingle-core vs select-in-trap SMP, lock held only inside*_select; theg_sched_enteringre-entry guard and TTAS-not-ticket lock (both HW-hardened); affinity forced underARCH_HAS_LAZY_CONTEXT.The whole AURIX↔RP2040 SMP delta =
ARCH_HAS_LAZY_CONTEXTplus the spinlock’s atomic; the rest of the model is identical.Separate-kernel AMP is in scope via ADR 0002 — see that document for the un-globaling plan and current status.
See also:
arch-interface.md(the Tier-2 contract this model consumes) andtick-policy.md(how the tick feedstk_kernel_tick, shared by every core).