Fourth stage of the preemptive context-switching plan, and the biggest. LithosAnanke now genuinely, continuously preempts between Hera, Hermes, and Artemis -- timer-driven, running live for the entire remainder of every boot once the Tripod fleet registers, not a bounded probe. A real design fork was resolved before writing code: the naive approach (the timer ISR calling Stage 2's sk_vm_context_switch() directly) is broken -- Stage 0's trap frame lives on whatever stack was active at interrupt time, and jumping to a different stack via Stage 2's own independent swap mid-handler would abandon that trap frame unresumed, guaranteed corruption on the first tick. Chose the safer of two named options: the ISR only ever sets a flag and returns completely normally through its own full epilogue; the actual switch happens moments later, via Stage 2's already-proven mechanism, at a safe cooperative checkpoint on the mainline (execute_colon_word()'s per-word dispatch loop, checked on literally every word, not throttled to the existing 256-word heartbeat-tuning cadence) -- confirmed with the user that word-level granularity is fine-grained enough given the eventual Zynq FPGA target where a word is a mnemonic. New capsule_vm_switch_signal.c/.h: a purpose-built run-readiness signal, deliberately separate from capsule_vm_physics.c's execution-heat engine (that one's own header documents itself as never touched from interrupt context, by design). Slot table sized with headroom (8) rather than hardcoded to today's 3 participants, so extending participation later is another register() call, not a redesign -- per direct request to leave room for swapping the participant set. Simple linear accumulate-then- threshold for this first cut; a fancier law can replace it later without touching the mechanism around it. heartbeat_tick() gains its one deliberate, documented amendment to this file's own top-half/bottom-half discipline -- the first time this codebase reaches into VM-scheduling state from real ISR context. Registration happens only after all three VMs are fully born, right before the REPL starts -- no critical-section protection yet against being switched away mid-birth-setup. Known, flagged rough edge (not reconciled this pass): MSG-TICK's own idle-pump and this new mechanism can still independently move control between the same VMs; not observed to interact badly in verification, but not fully unified either. Verified interactively at the console on all 3 architectures with continuous background preemption running throughout -- amd64 computed `1 1 + .` -> 2, aarch64 computed `1 1 + dup DUP * . CR` -> 4, both correct, REPL fully responsive, zero fault indicators over sustained runtime. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UNhH1mhi52i6Qihh7ZV5S
263 lines
8.4 KiB
C
263 lines
8.4 KiB
C
/*
|
||
StarForth — Steady-State Virtual Machine Runtime
|
||
|
||
Copyright (c) 2023–2025 Robert A. James
|
||
All rights reserved.
|
||
|
||
Licensed under the StarForth License, Version 1.0
|
||
*/
|
||
|
||
/**
|
||
* heartbeat.c - Shared M5 heartbeat / TIME-TRUST engine (punch-list item 0.8)
|
||
*
|
||
* One implementation of heartbeat_init()/heartbeat_ticks()/heartbeat_trust()/
|
||
* heartbeat_state(), shared by all three architectures. Each architecture's
|
||
* timer.c contributes only heartbeat_read_counter() — the one thing that is
|
||
* genuinely per-ISA (rdtsc / rdtime / CNTPCT_EL0).
|
||
*
|
||
* Top half / bottom half split (FABRIC-0.md §25.1 item 0.8, per the GAP-A1
|
||
* ruling in §16.4/§18.4): heartbeat_tick() is called from interrupt context
|
||
* and does nothing but read the counter, bump TIME-TICKS, and latch a
|
||
* pending sample. heartbeat_service() runs on the mainline (the REPL idle
|
||
* loop) and does the window/variance/trust work. Neither one feeds patron
|
||
* state — the engine stays on the virtual tick per §18.4, unchanged by this
|
||
* file.
|
||
*
|
||
* Adaptive re-arm period (FABRIC-0.md §26, ruled 2026-08-03): Loop #7
|
||
* (vm_runtime.c) computes an execution-derived stable/volatile signal and
|
||
* calls heartbeat_set_adaptive_period_ns() with it, rescaled to this file's
|
||
* kernel-appropriate base (10 ms, matching the 100 Hz rate item 0.1-0.7
|
||
* configured) rather than the hosted 10 µs HEARTBEAT_TICK_NS base. Each
|
||
* architecture's re-arm function reads heartbeat_next_period_ns() and
|
||
* converts it to raw counter units instead of using a fixed constant.
|
||
* Single writer (mainline, via vm_tick()'s Loop #7 site), single reader
|
||
* (the ISR's re-arm call) — no lock needed, per §21.1's finding that
|
||
* nothing here is actually concurrent on one hart.
|
||
*/
|
||
|
||
#include "starkernel/timer.h"
|
||
#include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.md §XXVIII Stage 3 */
|
||
|
||
#if defined(ARCH_AMD64)
|
||
#define HEARTBEAT_HAS_VARIANCE 1
|
||
#else
|
||
#define HEARTBEAT_HAS_VARIANCE 0
|
||
#endif
|
||
|
||
/* Kernel-side adaptive-period base: 10 ms, matching the 100 Hz hardware
|
||
* rate established throughout items 0.1-0.7. Deliberately NOT
|
||
* HEARTBEAT_TICK_NS (include/starforth_config.h) -- that constant is the
|
||
* hosted pthread-worker's 10 µs base and is three orders of magnitude too
|
||
* fast for a bare-metal ISR period (FABRIC-0.md §26.3). Loop #7's decision
|
||
* logic is reused unmodified; only the base it scales differs. */
|
||
#define HEARTBEAT_BASE_PERIOD_NS 10000000ULL
|
||
|
||
static TimeTrustState g_heartbeat;
|
||
|
||
/* Top/bottom half handoff. */
|
||
static volatile uint64_t g_pending_counter;
|
||
static volatile int g_pending_valid;
|
||
|
||
/* Adaptive re-arm period, clamped to the same [1/4x, 4x] band Loop #7
|
||
* itself enforces on tick_target_ns, so a caller cannot runaway the ISR
|
||
* rate even if it forwarded an unclamped value. */
|
||
static volatile uint64_t g_adaptive_period_ns = HEARTBEAT_BASE_PERIOD_NS;
|
||
|
||
#if HEARTBEAT_HAS_VARIANCE
|
||
|
||
/**
|
||
* @brief Push a signed delta value into the heartbeat rolling window.
|
||
*/
|
||
static void window_push(TimeWindow *w, int64_t delta)
|
||
{
|
||
w->deltas[w->pos] = delta;
|
||
w->pos = (w->pos + 1) % TIME_WINDOW_SIZE;
|
||
if (w->count < TIME_WINDOW_SIZE) {
|
||
w->count++;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @brief Compute the relative variance of heartbeat deltas as a Q48.16 value.
|
||
*/
|
||
static q48_16_t window_variance_q48(const TimeWindow *w, uint64_t expected_delta)
|
||
{
|
||
if (w->count < 2 || expected_delta == 0) {
|
||
return 0;
|
||
}
|
||
|
||
int64_t sum = 0;
|
||
for (uint32_t i = 0; i < w->count; i++) {
|
||
sum += w->deltas[i];
|
||
}
|
||
int64_t mean = sum / (int64_t)w->count;
|
||
|
||
uint64_t sum_sq = 0;
|
||
for (uint32_t i = 0; i < w->count; i++) {
|
||
int64_t diff = w->deltas[i] - mean;
|
||
if (diff > 0x7FFFFFFF) diff = 0x7FFFFFFF;
|
||
if (diff < -0x7FFFFFFF) diff = -0x7FFFFFFF;
|
||
sum_sq += (uint64_t)(diff * diff);
|
||
}
|
||
uint64_t var_tsc = sum_sq / w->count;
|
||
|
||
uint64_t exp_sq = expected_delta;
|
||
if (exp_sq > 0xFFFFFFFF) {
|
||
var_tsc >>= 16;
|
||
exp_sq >>= 8;
|
||
}
|
||
exp_sq = exp_sq * exp_sq;
|
||
if (exp_sq == 0) return 0;
|
||
|
||
if (var_tsc > 0x0000FFFFFFFFFFFFULL) {
|
||
var_tsc = 0x0000FFFFFFFFFFFFULL;
|
||
}
|
||
|
||
return (var_tsc << 16) / exp_sq;
|
||
}
|
||
|
||
/**
|
||
* @brief Derive TIME-TRUST from a Q48.16 relative variance value.
|
||
*/
|
||
static q48_16_t variance_to_trust(q48_16_t variance)
|
||
{
|
||
q48_16_t denom = q48_add(Q48_ONE, variance);
|
||
if (denom == 0) {
|
||
return Q48_ONE;
|
||
}
|
||
return q48_div(Q48_ONE, denom);
|
||
}
|
||
|
||
#endif /* HEARTBEAT_HAS_VARIANCE */
|
||
|
||
/**
|
||
* @brief Initialise the M5 heartbeat / TIME-TRUST subsystem.
|
||
*
|
||
* Shared across all three architectures -- the fallback and calibration
|
||
* formula never differed between them, only what heartbeat_service() does
|
||
* with the resulting window did.
|
||
*/
|
||
void heartbeat_init(uint64_t tsc_hz, uint64_t tick_hz)
|
||
{
|
||
g_heartbeat.ticks = 0;
|
||
g_heartbeat.last_tsc = 0;
|
||
g_heartbeat.total_samples = 0;
|
||
g_heartbeat.variance = 0;
|
||
g_heartbeat.trust = Q48_ONE;
|
||
|
||
g_heartbeat.window.pos = 0;
|
||
g_heartbeat.window.count = 0;
|
||
for (int i = 0; i < TIME_WINDOW_SIZE; i++) {
|
||
g_heartbeat.window.deltas[i] = 0;
|
||
}
|
||
|
||
g_heartbeat.expected_delta = (tick_hz > 0 && tsc_hz > 0)
|
||
? (tsc_hz / tick_hz) : 10000000ULL;
|
||
|
||
g_pending_valid = 0;
|
||
g_adaptive_period_ns = HEARTBEAT_BASE_PERIOD_NS;
|
||
}
|
||
|
||
void heartbeat_tick(void)
|
||
{
|
||
g_pending_counter = heartbeat_read_counter();
|
||
g_heartbeat.ticks++;
|
||
g_pending_valid = 1;
|
||
|
||
/* FABRIC-3.md §XXVIII, Stage 3 (2026-09-13): the one deliberate,
|
||
* documented amendment to this file's own top-half/bottom-half
|
||
* discipline above. sk_vm_switch_signal_tick() reaches into VM
|
||
* scheduling state from real ISR context -- something this file's
|
||
* header comment previously said nothing here did. It stays cheap
|
||
* (bounded iteration over a small slot table, no locking needed for
|
||
* the same reason nothing else here needs it: single hart, nothing
|
||
* concurrent with this ISR while it runs) and it never itself
|
||
* switches anything -- it only ever sets a flag, consumed later at a
|
||
* safe cooperative checkpoint on the mainline
|
||
* (execute_colon_word()). The GAP-A1 split itself is not overturned:
|
||
* everything else in this function is exactly as minimal as before. */
|
||
sk_vm_switch_signal_tick();
|
||
}
|
||
|
||
void heartbeat_service(void)
|
||
{
|
||
if (!g_pending_valid) {
|
||
return;
|
||
}
|
||
uint64_t now = g_pending_counter;
|
||
g_pending_valid = 0;
|
||
|
||
TimeTrustState *s = &g_heartbeat;
|
||
s->total_samples++;
|
||
|
||
if (s->total_samples == 1) {
|
||
s->last_tsc = now;
|
||
return;
|
||
}
|
||
|
||
uint64_t actual_delta = now - s->last_tsc;
|
||
s->last_tsc = now;
|
||
int64_t deviation = (int64_t)actual_delta - (int64_t)s->expected_delta;
|
||
|
||
#if HEARTBEAT_HAS_VARIANCE
|
||
window_push(&s->window, deviation);
|
||
s->variance = window_variance_q48(&s->window, s->expected_delta);
|
||
s->trust = variance_to_trust(s->variance);
|
||
#else
|
||
/* RISC-V `time` / AArch64 CNTPCT_EL0 are architecturally invariant
|
||
* counters -- no statistical quality estimate needed (matches the
|
||
* per-arch rationale this file replaces). */
|
||
(void)deviation;
|
||
s->trust = Q48_ONE;
|
||
#endif
|
||
}
|
||
|
||
uint64_t heartbeat_ticks(void)
|
||
{
|
||
return g_heartbeat.ticks;
|
||
}
|
||
|
||
time_trust_t heartbeat_trust(void)
|
||
{
|
||
return g_heartbeat.trust;
|
||
}
|
||
|
||
const TimeTrustState *heartbeat_state(void)
|
||
{
|
||
return &g_heartbeat;
|
||
}
|
||
|
||
/**
|
||
* @brief Set the adaptive re-arm period (FABRIC-0.md §26).
|
||
*
|
||
* Called from vm_runtime.c's Loop #7 site on the mainline execution path
|
||
* (never interrupt context) with a value already rescaled to this file's
|
||
* HEARTBEAT_BASE_PERIOD_NS. Clamped defensively to the same [1/4x, 4x]
|
||
* band Loop #7 itself enforces around its own base, so a caller forwarding
|
||
* an unclamped or wrongly-scaled value cannot run the ISR away.
|
||
*
|
||
* @param ns Desired period in nanoseconds for the next re-arm.
|
||
*/
|
||
void heartbeat_set_adaptive_period_ns(uint64_t ns)
|
||
{
|
||
uint64_t lo = HEARTBEAT_BASE_PERIOD_NS / 4;
|
||
uint64_t hi = HEARTBEAT_BASE_PERIOD_NS * 4;
|
||
if (ns < lo) ns = lo;
|
||
if (ns > hi) ns = hi;
|
||
g_adaptive_period_ns = ns;
|
||
}
|
||
|
||
/**
|
||
* @brief Read the period the next hardware re-arm should use.
|
||
*
|
||
* Called from interrupt context by each architecture's re-arm function
|
||
* (apic_timer_rearm(), riscv64_timer_rearm(), the AArch64 equivalent) in
|
||
* place of the fixed constant those functions used before item 0.8.
|
||
*
|
||
* @return Current adaptive period in nanoseconds.
|
||
*/
|
||
uint64_t heartbeat_next_period_ns(void)
|
||
{
|
||
return g_adaptive_period_ns;
|
||
}
|