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 + |
arch/kernel/board |
|
2 |
|
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 |
|
|---|---|---|---|
nrf52832_pca10040 ( |
RTC1 @ 32.768 kHz LFCLK (internal LFRC by default, no crystal wait) |
|
|
rp2040_pico ( |
SysTick @ |
|
|
qemu_riscv32_virt ( |
CLINT |
|
|
host ( |
OS |
no tick ISR at all — |
delegates straight to |
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 — calltk_kernel_now_ms()ortk_time_ms().msp430andtricoreare not boards at root (boards/has only the 4 above), so they don’t appear in this table — seearch-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 toarch_timer_now_ms()underARCH_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 insidekernel_arch.h’s Tier 1 section — seearch-interface.mdF6), 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()snapshotstk_kernel_now_ms()into the instance’sk->time_base;tk_time_ms()subtracts it;tk_time_us()is the coarsetk_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¶
Init — EXPLICIT
tk_time_init(), not folded intotk_board_init(). As designed:hal/tk_board.hdeclares onlytk_board_init(); nothing there starts the clock. Keeps the explicit-boot contract and leaves the timer a resource the app controls.Kernel name —
tk_kernel_now_ms()stays a second name. As designed: both names read the samek->ticks_ms/arch_timer_now_ms()value; the kernel tick path is unchanged bytk_time.h’s existence.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.Order — MSP430 first, then kernel boards, then µs. Did not happen this way — see §7.2.
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.