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, 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:

/* 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.