# Tick ownership & preemption-integration policy *Resolves [`arch-interface.md`](arch-interface.md) findings F5 (arch-shaped board glue) and F6 (timekeeping mis-filed under the switch contract). Complements [`timekeeping.md`](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//kernel_arch.c` plus the board glue in `boards//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`](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`](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`](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):** ```c 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):** ```c 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`):** ```c 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`](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`](timekeeping.md) §7.3 — out of scope for the tick contract itself.