Tick ownership & preemption-integration policy

Resolves arch-interface.md findings F5 (arch-shaped board glue) and F6 (timekeeping mis-filed under the switch contract). Complements timekeeping.md, which audits every timer source in the tree; this document is scoped to the one timer that drives the scheduler — who owns it, who calls into the kernel, and in what shape.

Method. Read the tick core in kernel/tk_kernel.c (tk_kernel_tick, tk_kernel_tick_preempt, tk_kernel_preempt_select, tk_tick, tk_tick_advance), the Tier B port contract in kernel/tk_kernel_port.h, the Tier A time contract in kernel/tk_time.h, and every arch’s tick wiring in arch/<cpu>/kernel_arch.c plus the board glue in boards/<board>/tk_tick_*.c. (These “Tier A/B” letters are the kernel-module layering from tk_kernel_port.h/tk_time.h, unrelated to the arch capability “Tier 0-3” numbering in arch-interface.md — both schemes are named “Tier” by convention, in different documents, for different axes.)


The three actors (this is the whole model)

A tick involves three roles the tree keeps implicitly separate:

  1. The tick OWNER — owns the ~1 ms hardware source and its ISR. This is the board (SysTick / RTC1 / CLINT / CPU-timer) or a vendor SDK sharing its own timer (CubeHAL’s SysTick, an SDK’s timer). The kernel is never the owner.

  2. The kernel — provides a tick entry the owner’s ISR calls, and consumes a monotonic ms base. It programs no timer and reads no timer register.

  3. The time base — a small monotonic-ms service the owner advances and the kernel (and the app) read. At root this is per-instance k->ticks_ms in kernel/tk_kernel.c (advanced by each instance’s ticker_core; review C4 — was one global g_ticks_ms), exposed as tk_kernel_now_ms() and, one layer up, tk_time_ms() (Tier A) — see timekeeping.md.


Policy resolutions

P1 — The kernel never owns the tick; it consumes it

  • Board-owned: the board programs the timer and its ISR calls the kernel entry (§ below). arch_timer_init() is declared in kernel_arch.h but the header comments admit it: “provided by the BOARD” on the ARM ports (RTC1 on nRF52 because SysTick can’t survive its sleep mode; plain SysTick elsewhere) — a naming smell still live in the tree; see P5.

  • SDK-owned: the SDK already runs the timer + ISR; the integrator adds one call (tk_tick()) into the SDK’s existing ISR — e.g. CubeHAL SysTick_Handler → HAL_IncTick() + tk_tick(). The kernel programs nothing.

→ The kernel’s tick pre-requirement is “a ~1 ms interrupt that calls the kernel entry” — satisfiable by owning OR sharing, identically.

P2 — Two tick entries, split by ENTRY SHAPE, not by hardware mechanism (F3)

This is easy to mis-read as “PendSV vs no PendSV.” It isn’t — c2000 has no PendSV and still uses the “deferred” entry (below). The real split is whether the port has an ordinary callable arch_context_switch(from,to) or not:

  • Deferred-entry ports (arm_v6m, arm_v7m, arm_v8m, and c2000): the owner’s ISR calls tk_kernel_tick_preempt() (void in, void out). The kernel computes from/to and calls the port’s arch_context_switch(from,to). On the ARM family this swap is deferred into PendSV (lowest priority) — the classic reading. On c2000 there is no deferral hardware at all: arch/c2000/kernel_arch.c calls tk_tick() → tk_kernel_tick_preempt() → arch_context_switch() synchronously, inside the same ISR — it lands in this bucket purely because C28x’s switch is a plain callable function on two arch_context*, not because the switch is deferred in time.

  • Trap-frame-entry ports (risc_v, confirmed in arch/risc_v/kernel_arch.c; and tricore per its header, body not yet grafted — see arch-interface.md §7): the tick trap has already saved the FULL frame on the thread’s own stack at a raw SP. It calls tk_kernel_preempt_select(sp), gets back the SP to resume, and the trap’s own return path restores it. There is no arch_context_switch call anywhere in this path — the trap-frame save/restore is the switch.

Both funnel into the shared tk_kernel_tick() core in kernel/tk_kernel.c (wake due sleepers, refresh quantum, pick next). This split is intrinsic to whether the port’s switch is a callable function or a trap convention, and stays.

P3 — The kernel entry is pre-launch-safe on THREE fronts → an SDK-shared tick needs NO board gate

Verified in kernel/tk_kernel.c, both entries bail out before touching an invalid or in-flight launch:

  1. active_kernel == NULL (kernel not started yet).

  2. current_thread[cpu] < 0 (this core has no thread committed yet).

  3. g_sched_entering[cpu] != 0 (a launch is mid-flight on this core: the thread is chosen but its context isn’t valid to resume from yet — the SMP re-entry guard from smp-model.md, reused here for the single-core pre-launch window too).

tk_kernel_preempt_select returns the SP unchanged on (1)/(2)/(3); tk_kernel_tick_preempt returns early the same way. So the kernel tick entry no-ops before the first thread is launched or mid-launch. Consequence: a board wires the entry call unconditionally into an SDK’s existing ISR — an early tick fired before the kernel starts is a safe no-op. No board-side “first launched” gate is needed or present in the tree.

P4 — Timekeeping is a consumed service, not part of the switch contract (F6)

  • Three timer roles stay distinct: (1) kernel scheduler tick + monotonic ms base, (2) tk_gpt-style application countdown timers (separate HW, board-provided), (3) busy-wait delay. See timekeeping.md for the full audit.

  • The monotonic ms base is a consumed service, not something the switch layer owns for its own sake: kernel/tk_time.h (Tier A) is shipped — tk_time_init() / tk_time_ms() / tk_time_us() / tk_kernel_now_ms() all exist and are what the app and the kernel’s own sleep/timeout/EDF math read. This is implemented, not proposed — see timekeeping.md for the flip from “proposal” to “shipped.”

  • What did NOT happen: arch_timer_now_ms() / arch_timer_init() were meant to leave kernel_arch.h for their own small time-contract header (the file-split half of F6). At root they are still declared inside kernel_arch.h’s Tier 1 section on every port. The layering F6 was chasing shipped (the app never touches arch_timer_now_ms — it reads tk_time_ms()/tk_kernel_now_ms()); the file split did not. Tracked as open in arch-interface.md F6.

P5 — The ARM board glue was NOT de-arched (F5, still open)

arch_kernel_tick(irq) and arch_sched_init() are ARM-family board glue, not the portable contract: arch_kernel_tick is the shared tick-ISR body reused by the ARM tick boards, and arch_sched_init sets PendSV priority. Non-ARM ports do the equivalent inside their trap and never declare these names. A rename to a clearly board-glue name (armv7m_tick_isr / armv7m_sched_init) was proposed here but was not adopted — arch/arm_v6m|arm_v7m|arm_v8m/kernel_arch.h still declare arch_sched_init(void) / arch_kernel_tick(uint8_t irq) verbatim today, and boards/nrf52832_pca10040/tk_tick_nrf52.c still calls them by those names. What DID happen: arch_kernel_tick() on every ARM port is now a one-line shim that delegates to tk_tick() (arch/arm_v7m/kernel_arch.c: void arch_kernel_tick(uint8_t irq) { (void)irq; tk_tick(); }), kept only so existing board files link unchanged — new integrations are told to call tk_tick() directly and never see the ARM-only names. Status: open, not blocking — a new non-ARM port never has to know these two names exist.


The board tick contract (what an integrator writes)

Deferred-entry, board owns the timer (ARM family, direct):

void SysTick_Handler(void) {   /* board-owned ISR */
    tk_tick();                 /* kernel front door: ms base++ AND tk_kernel_tick_preempt() (pre-launch-safe, P3) */
}

Deferred-entry, SDK shares its own timer (CubeHAL SysTick):

void SysTick_Handler(void) {   /* SDK-owned ISR; integrator adds one line */
    HAL_IncTick();              /* SDK keeps its own timebase */
    tk_tick();                  /* + kernel entry — unconditional, no gate (P3) */
}

Deferred-entry, immediate hardware switch, no PendSV (c2000):

interrupt void cpu_timer0_isr(void) {
    /* C28x auto-saves the caller-saved set on IRQ entry (hardware context save) */
    tk_tick();   /* -> tk_kernel_tick_preempt() -> arch_context_switch(), INLINE, same ISR */
    /* board re-arms IER — the C28x hardware clears it on IRQ entry */
}

Trap-frame-entry (RISC-V): the port’s timer trap (mtvec) advances the base via tk_tick_advance() (boot hart only, so the one global ms clock advances once per ms, not once per hart — see tk_kernel_port.h) and switches via next_sp = tk_kernel_preempt_select(current_sp) — the ISR shape lives entirely in arch/risc_v/kernel_arch.c, not board C; there is no arch_context_switch call to write.


What this settles

  • The kernel never owns the tick (own-vs-shared is just who owns it, P1); two entries stay, split by whether the switch is a callable function or a trap convention — not by whether PendSV exists (P2, corrects the historical “deferred = PendSV” shorthand using c2000 as the counter-example); the entry is pre-launch-safe on three checks so a shared-timer SDK needs no board gate (P3); timekeeping is a consumed, shipped service (P4, kernel/tk_time.h); the ARM tick/sched glue is still un-renamed — P5 is open, not a blocker, and does not leak into new ports.

  • → SMP: the tick core already indexes per-core state (current_thread[cpu], g_quantum_remaining[cpu], arch_core_id()), and tk_kernel_preempt_select reuses the SMP re-entry guard (g_sched_entering[cpu]) for the single-core pre-launch case too. See smp-model.md for the full per-core model and the cooperative-switch counterpart of this split.

  • → time µs / sub-tick (tk_time_us) is board-by-board, audited in timekeeping.md §7.3 — out of scope for the tick contract itself.