# Timekeeping in k2 — timers, time sources, and the system clock Status: **descriptive audit of the 4 boards in the tree, plus the `tk_time_ms()` / `tk_time_us()` contract — implemented, not a proposal.** Sections 1–5 document what the code does today (verified against every board backend at root). Section 6 records the architectural gap that motivated `tk_time.h`; section 7 is what shipped. Companion to [`tick-policy.md`](tick-policy.md), which is scoped to the one timer that drives the scheduler — this document covers all three timer roles. --- ## 1. The three kinds of "timer" in k2 — keep them distinct | # | Thing | Purpose | Who owns it | Public API | |---|---|---|---|---| | 1 | **Kernel scheduler tick** | drive preemption + `sleep`/timeouts; maintain a monotonic ms base | arch/kernel/board | `tk_kernel_now_ms()`, `tk_time_ms()` | | 2 | **`tk_gpt`-style app timers** | application one-shot/periodic timers with callbacks | HAL (a board HW timer) | not yet ported to root — see §3 | | 3 | **Busy-wait delay** | crude blocking spin for early boot / bit-bang timing | board | not yet ported to root — see §4 | They are *separate hardware* (where hardware is involved) and serve different roles. The system "clock" people reach for is #1. --- ## 2. The kernel tick / system time — per board (audited, 4 real boards) The shared pattern: each **kernel instance** owns a software counter `k->ticks_ms` in its `struct tk_kernel` (review C4 — was a single global `g_ticks_ms` before per-instance ticks), incremented once per ms by the instance's `ticker_core` (its lowest owned core) when a board/SDK ISR calls the kernel's tick front door (`tk_tick()` → `tk_tick_advance()` — see `tick-policy.md`), and `tk_kernel_now_ms()` returns the current core's instance counter. The **hardware** that produces the 1 ms tick differs per board; the **abstraction is uniform**, with one real exception (host, which owns its own timebase instead of ticking). A single-instance build (the default) has `ticker_core == 0`, so only core 0 advances — byte-identical to the old single global. | Board / arch | 1 ms hardware source | Mechanism | `tk_kernel_now_ms()` | |---|---|---|---| | **nrf52832_pca10040** (`arm_v7m`) | **RTC1** @ 32.768 kHz LFCLK (internal LFRC by default, no crystal wait) | `RTC1_IRQHandler` → `arch_kernel_tick()` → `tk_tick()` → `tk_tick_advance()` → `k->ticks_ms++` (fractional accumulator: 32/33 counts → exact 1 ms average, ~30 µs jitter; the compare shadow **re-anchors to the live counter if a >1-tick interrupts-off section made it fall behind** — else it would stall until the 24-bit RTC wrapped, ~8.5 min — `boards/nrf52832_pca10040/tk_tick_nrf52.c`) | `k->ticks_ms` (plain load — `kernel/tk_kernel.c`) | | **rp2040_pico** (`arm_v6m`) | **SysTick** @ `clk_sys` (12 MHz XOSC, crystal-accurate) | `SysTick_Handler` → `arch_kernel_tick()` → `tk_tick_advance()` (SMP: every core; only the instance's `ticker_core` advances) + PendSV pend (`boards/rp2040_pico/tk_tick_rp2040.c`) | `k->ticks_ms` | | **qemu_riscv32_virt** (`risc_v`) | **CLINT `mtime`/`mtimecmp`** @ `TK_RISCV_MTIME_HZ` (10 MHz on qemu `virt`) | `mtvec` timer trap → `tk_tick_advance()` on **every** hart (each advances only if it is its instance's `ticker_core`, so the clock advances once per ms per instance, not once per hart) + `tk_kernel_preempt_select()` (`arch/risc_v/kernel_arch.c`) | `k->ticks_ms` | | **host** (`pthread`) | OS `CLOCK_MONOTONIC` | no tick ISR at all — `ARCH_OWNS_TIMEBASE 1`; `arch_timer_now_ms()` calls `clock_gettime` directly, ms-truncated (`arch/pthread/kernel_arch.c`) | delegates straight to `arch_timer_now_ms()` (`#if ARCH_OWNS_TIMEBASE` in `tk_kernel.c`) | Reload math derives the tick from `tk_mcu_clock_hz()` (`hal/tk_mcu.h`, implemented per board — e.g. `boards/nrf52832_pca10040/tk_mcu_nrf52.c`); never hardcoded. **Key takeaways** - On every board in the tree, the system ms clock is real and app-readable via `tk_kernel_now_ms()` / `tk_time_ms()` (§7) — there is no board today with a dead or zero clock (unlike the pre-reshape tree, where rp2040 had no tick yet and MSP430 had no kernel at all; see §6 for why that mattered). - `arch_timer_now_ms()` is a *contract* ("return monotonic ms"), implemented per-arch — a software counter driven by an ISR on the three MCU boards, the OS clock on host. It is **not** for application code — call `tk_kernel_now_ms()` or `tk_time_ms()`. - `msp430` and `tricore` are **not boards** at root (`boards/` has only the 4 above), so they don't appear in this table — see `arch-interface.md` §7 for their status. --- ## 3. `tk_gpt` — application countdown timers (not yet ported to root) The legacy tree had a HAL/MCAL service (`hal/tk_gpt_core.c` + per-board backends) for per-channel one-shot/periodic countdowns with callbacks, using a **different** timer from the kernel tick. **This has not been carried into the reshaped root `hal/`** — `hal/` at root today is `tk_board.h`, `tk_console.*`, `tk_dio*.h`, `tk_mcu.h`, `tk_port.h`, `tk_status.h`, `tk_uart.h`; there is no `tk_gpt` anywhere outside `_legacy/`. Kept here as a placeholder for the taxonomy (role #2 above stays distinct from the kernel tick and from busy-wait), not as a description of present functionality. When it is ported, the legacy design's constraint still applies: use a HW timer the kernel tick doesn't already own (e.g. nRF52 TIMER1, since the kernel tick owns RTC1). --- ## 4. Busy-wait delays (not yet ported to root) The legacy tree had `tk_board_delay_ms/us` — uncalibrated spin loops for pre-kernel boot blinks and bit-bang device timing. **`hal/tk_board.h` at root declares only `tk_board_init()`** — no delay API exists yet. Placeholder entry for the same reason as §3. --- ## 5. Public API surface today - `tk_kernel_now_ms()` — **public**, monotonic ms since the kernel started. Thin wrapper that either reads the calling core's instance counter (`this_kernel()->ticks_ms`) or delegates to `arch_timer_now_ms()` under `ARCH_OWNS_TIMEBASE` (`kernel/tk_kernel.c`). - `tk_time_init()` / `tk_time_ms()` / `tk_time_us()` — **public**, the HAL-tier monotonic clock (Tier A). **Shipped** — see §7. - `tk_mcu_clock_hz()` — **public**, the reference frequency ticks derive from (`hal/tk_mcu.h`). - `arch_timer_now_ms()` / `arch_timer_init()` — **internal** arch↔kernel contract (declared inside `kernel_arch.h`'s Tier 1 section — see `arch-interface.md` F6), not for apps. - `tk_gpt_*` / `tk_board_delay_*` — **not present at root** (§3, §4). --- ## 6. The architectural gap that motivated `tk_time.h` (history) **Monotonic time used to be a KERNEL-layer concept, not a HAL one.** The ms base (`g_ticks_ms`) was maintained by the kernel's scheduler tick and exposed only as `tk_kernel_now_ms()`. The HAL itself had no "what time is it" service. This was invisible until a HAL-only board with **no kernel at all** entered the picture (`msp430`, still true at root today — Tier-0-only, no `boards/msp430*` entry, see `arch-interface.md` §7): with no kernel there is no kernel tick, so there is no `tk_kernel_now_ms()`, so an application that "knows nothing about the kernel" has no system clock on that target. The gap was always there — every board that runs the kernel happened to get the clock for free — and the kernel-less target is simply the first context to expose it. The fix (AUTOSAR-shaped: an MCAL/BSW time service the OS reads) is the inverse: a monotonic time base is a HAL-tier service the kernel *consumes*, not something the kernel owns and the HAL borrows. --- ## 7. `tk_time_ms()` / `tk_time_us()` — implemented `kernel/tk_time.h` (Tier A) ships exactly the contract below, and it is the app-facing system clock on every board in the tree today: ```c /* kernel/tk_time.h */ void tk_time_init(void); /* start/zero the clock */ uint32_t tk_time_ms(void); /* ms since tk_time_init(); monotonic */ uint32_t tk_time_us(void); /* µs since tk_time_init(); monotonic (see 7.3) */ ``` - **Monotonic**, non-decreasing. - **Starts at 0** at `tk_time_init()`; advances only once interrupts are enabled (the tick ISR drives it on an MCU board). - **Resolution** = the board tick — 1 ms on every board in the tree today (§2). - **Wrap** at 2³² ms ≈ 49.7 days; compare wrap-safe: `(int32_t)(tk_time_ms() - t0) >= d`. - **Implementation is a single 3-line body in `kernel/tk_kernel.c`** (not a per-board backend as the original proposal's §7.2 mapping table anticipated): `tk_time_init()` snapshots `tk_kernel_now_ms()` into the instance's `k->time_base`; `tk_time_ms()` subtracts it; `tk_time_us()` is the coarse `tk_time_ms()*1000` (§7.3). There is no separate HAL-only-board code path today, because every board at root runs the kernel (§2) — the kernel-less branch the original proposal designed for (MSP430) has no board entry to exercise it. ### 7.1 What did NOT ship: the read-before-init guard The original decision said the "forgot to init" risk would be "handled by docs + a loud failure (reads 0, and a `TK_DET` in dev builds if read before init)." **The shipped `tk_time_init`/`tk_time_ms`/`tk_time_us` in `kernel/tk_kernel.c` have no `TK_DET` guard** — they are plain arithmetic (`return tk_kernel_now_ms() - k->time_base`) with no init-tracking flag. Read-before-init is not even consistently "reads 0": on the three MCU boards `k->ticks_ms` starts at 0, so a pre-init read of `tk_time_ms()` happens to be 0; on **host**, `ARCH_OWNS_TIMEBASE` reads the live OS `CLOCK_MONOTONIC` regardless of init state, so a pre-init read returns the *process's* elapsed-since-boot ms, not 0. Not a correctness bug in practice (every board calls `tk_time_init()` once, early), but the "reads 0, loudly, everywhere" guarantee the design called for is not what is on disk. Open, not fixed here. ### 7.2 Order — as-built The order the original decisions specified (MSP430 first, then kernel boards, then µs) did not play out that way: MSP430 has no board/kernel integration at root at all (§3, §6), so the kernel-board path (§2) is the only one that shipped. ### 7.3 `tk_time_us()` — coarse everywhere at root (implemented, not board-tuned yet) `k->ticks_ms` is millisecond-resolution; true µs needs a **sub-tick hardware counter**: `us = ms_base*1000 + hw_count`. At root, **no board wires this** — `kernel/tk_kernel.c` defines `tk_time_us()` unconditionally as `tk_time_ms() * 1000u`, with no per-board override point. This holds even on **host**, where `clock_gettime(CLOCK_MONOTONIC, &ts)` already has nanosecond resolution in `ts` — `arch_timer_now_ms()` truncates it to ms before `tk_time_us()` ever sees it, so the extra precision is discarded, not surfaced. A board earning real µs (host `ts.tv_nsec`; ARM SysTick `VAL`/DWT `CYCCNT`; RISC-V `mtime` low bits) is a real, un-started TODO, not a partially-done feature. - **Wrap**: `tk_time_us()` (uint32 µs) wraps at ~71.6 minutes on every board — far sooner than ms. Use the wrap-safe compare. ### 7.4 Decisions — as-built status 1. **Init — EXPLICIT `tk_time_init()`**, not folded into `tk_board_init()`. **As designed**: `hal/tk_board.h` declares only `tk_board_init()`; nothing there starts the clock. Keeps the explicit-boot contract and leaves the timer a resource the app controls. 2. **Kernel name — `tk_kernel_now_ms()` stays a second name.** **As designed**: both names read the same `k->ticks_ms` / `arch_timer_now_ms()` value; the kernel tick path is unchanged by `tk_time.h`'s existence. 3. **`tk_time_us()` — separate, board-by-board.** **Not as designed** — see §7.3: it shipped as one unconditional coarse implementation, not a per-board sub-tick surface. 4. **Order — MSP430 first, then kernel boards, then µs.** **Did not happen this way** — see §7.2. 5. **rp2040 — no tick yet (Stage 1b), waits on it.** **Superseded**: rp2040_pico has a real SysTick tick at root today (§2); this was the Stage-1b work landing.