SPEC-003 - Time Source, Timeouts, and Software Timers

Implementation and release scope: Project status records the implemented subset; roadmap defines release gates. Acceptance of this design does not establish implementation, verification, or support. Reconciled 2026-10-14.

Status: Accepted 2026-10-07 by the project owner, with the defaults of §15. Specification work item 3 of the roadmap (07 §3). Requirements: docs/requirements/KRN-TIM.md (KRN-TIM-001 to 016 restated; new from 017). Builds on: SPEC-001 §7 (time types, conversions, blocking bounds); SPEC-002 (timer interrupt level, critical sections, preemption points); 03 §4; v0.1 §7; ADR-010, ADR-017, ADR-019. Toolchain constraints applied: 09 §6 (no 64-bit atomics on 32-bit targets; byte atomics only on AVR), 09 §7 (division by constant is a library call on AVR).


1. Scope and terms

Term

Meaning

Kernel clock

The monotonic time base of scheduling, timeouts, and software timers; read with emb_time_now()

Tick

The unit of the kernel clock, CONFIG_EMB_TICK_NS nanoseconds

Periodic tick mode

The hardware timer interrupts every tick and the kernel counts

Tickless mode

The hardware timer interrupts only at the next deadline; the kernel reads elapsed time from a free-running counter

Timeout

A deadline attached to a blocked thread, after which its wait ends with TIMEOUT

Software timer

A kernel object that runs a callback once or periodically

Timer source

The hardware timer the architecture or SoC port drives the kernel clock from

Next deadline

The earliest instant at which the kernel has anything to do

Cycle counter

A high-resolution counter for measurement, not for scheduling

Wall clock

Calendar time, optional, never used for timeouts

2. Time domains

  emb_time_now()      kernel monotonic clock   ticks, 64-bit (32-bit tiny profile)   scheduling, timeouts, timers
  emb_cycles_now()    cycle counter            hardware cycles, 32 or 64 bits        measurement only
  emb_wallclock_*()   calendar time            ns since the Unix epoch, int64        logs, filesystems, protocols; optional subsystem

Wall clock is monotonic clock plus an offset maintained by a time-synchronization service or an RTC driver; adjusting it moves no deadline (KRN-TIM-002, KRN-TIM-031). The cycle counter may wrap quickly and is never compared across sleeps.

3. Kernel clock representation

  • emb_tick_t is uint64_t, or uint32_t when CONFIG_EMB_TICK_32BIT=y (tiny profile).

  • CONFIG_EMB_TICK_NS is the tick length; default 1 000 000 (1 ms). Any value from 1 000 (1 µs) to 100 000 000 (100 ms) is accepted; the port states which values its timer source can produce exactly.

  • The clock starts at zero at kernel start and never moves backward (KRN-TIM-001).

3.1 Reading a 64-bit clock on a 32-bit core

EmbCC refuses 8-byte atomics on 32-bit targets (09 §6), and on AVR only byte accesses are atomic. emb_time_now() therefore reads the clock in one of two ways, chosen per architecture at build time:

Mechanism

Where

How

Sequence lock

Cortex-M, RISC-V, native

The writer increments a sequence word, writes the clock, and increments again; a reader accepts an even, unchanged sequence. Writer serialization, memory ordering, and reader progress must be established per port; see §3.2

Critical section

AVR

in SREG; cli; read 4 bytes; out SREG. A few cycles, and interrupts are already short on this core

Both expose @ctx thread isr prekernel; a strict O(1) claim for the sequence-lock form requires a demonstrated retry bound, not an assumed typical case. In tickless mode the reader adds the elapsed hardware count since the last clock update, converted to ticks (§4.2), so emb_time_now() has hardware resolution without an interrupt per tick.

3.2 Reader progress and memory ordering

An ISR reader must never interrupt a writer while the sequence is odd and then spin waiting for that same writer. All writers must exclude every kernel-aware reader that could preempt them, or the port must use a supported non-spinning read mechanism. Kernel-independent handlers may not read the kernel clock. Changing timer priority alone is not proof: tickless writes can also occur in thread context.

For the current UP implementation, clock writes execute with the timeout lock domain held, masking kernel-aware readers. The native gate serializes execution. Future SMP needs a C-memory-model-safe implementation with explicit ordering of the sequence and payload accesses; compiler barriers and a sequence counter alone do not establish freedom from C data races. Port validation must interrupt at writer boundaries, test nested ISR reads, and report retry limits. This is a validation obligation, not a claim that asynchronous or SMP clock access has passed.

4. Clock modes

Both modes have identical semantics and pass identical tests (KRN-TIM-017); they differ only in cost and in resolution.

4.1 Periodic tick

The timer source interrupts every tick. The timer ISR (kernel-aware, highest kernel-aware level by default, SPEC-002 §11):

  1. ticks += 1 under the sequence lock or critical section;

  2. expire every timeout and timer whose deadline is at or before ticks (§5.3);

  3. charge the quantum of the running thread if time slicing applies to its level (§8);

  4. set reschedule_pending if anything became READY or the quantum expired.

Resolution is one tick. Cost is one interrupt per tick whether or not anything is due, which is what tickless removes.

4.2 Tickless

The timer source is a free-running counter with a compare register (or an equivalent the port can emulate). The kernel programs the compare register for the next deadline and lets the core idle until then.

next_deadline = min( earliest timeout,
                     earliest software timer,
                     quantum expiry of the running thread if slicing applies,
                     next budget replenishment            (temporal protection, when enabled),
                     now + MAX_INTERVAL )                 (hardware counter range guard)
  • MAX_INTERVAL is the longest sleep the counter can represent without ambiguity, declared by the port (emb_arch_timer_max_ticks()); a deadline beyond it is reached in several hops, each costing one interrupt.

  • On any wake (timer interrupt or any other interrupt), the first kernel action is to update the clock: read the counter, compute elapsed ticks since the last update, add them, and process expiries before any scheduling decision (KRN-TIM-019). This keeps emb_time_now() correct even when the wake was not the timer.

  • The next deadline is recomputed and reprogrammed whenever the set of deadlines changes: a timeout is armed or removed, a timer started or stopped, the running thread changes (quantum), or on wake. Reprogramming is O(1) given the ordered structures of §5.

  • When no deadline exists (EMB_WAIT_FOREVER only), the compare interrupt is disabled and the clock is updated purely on wake; idle may enter the deepest state the power policy allows (04 §4).

Tickless requires a port whose counter keeps running in the chosen sleep state; the port declares which states keep it. The resolution of the kernel clock is still CONFIG_EMB_TICK_NS; a hardware period finer than the tick gives emb_time_now() sub-tick accuracy only in the sense that updates are exact, not that the API unit changes (KRN-TIM-009).

4.3 Architecture timer contract

emb_status_t emb_arch_timer_init(void);                     /* configure the source for the selected mode */
emb_arch_timer_raw_t emb_arch_timer_now_raw(void);          /* free-running counter, tickless mode */
void         emb_arch_timer_set_deadline_raw(emb_arch_timer_raw_t when);   /* program the compare */
void         emb_arch_timer_cancel(void);                   /* no deadline */
uint32_t     emb_arch_timer_hz(void);                       /* counter frequency */
emb_tick_t   emb_arch_timer_max_ticks(void);                /* MAX_INTERVAL */
/* the port's timer ISR calls: */
void         embk_time_timer_isr(void);                      /* periodic: tick; tickless: deadline reached */
void         embk_time_on_wake(void);                        /* tickless: called at the outermost exit of any interrupt that ended idle */

Conversion between raw counts and ticks is exact when one period divides the other, which the configuration system checks; otherwise the port provides a scaled conversion with a documented maximum error per hop, and the error never accumulates because the clock is re-based from the counter at each update.

4.4 Choosing a mode

Profile

Default

Reason

tiny (ATmega328P)

periodic tick, 1 ms, Timer1 compare

Simplest, 16-bit counter limits tickless gains; tickless is a target design, refused by the current AVR manifest

base

tickless when the SoC has a 32-bit timer that runs in sleep; else periodic

Power

isolated, multicore

tickless

Power, deadline precision

native

tickless over a virtual clock

Deterministic tests: the clock jumps to the next deadline when all simulated CPUs idle

5. Timeouts

5.1 Node and structure

Every thread control block carries one intrusive timeout node; software timers will carry one when implemented. Base uses a doubly linked intrusive deadline list. Tiny uses a singly linked list bounded by the configured thread table: next == NULL means unarmed and the tail points to itself (A15). Its removal is O(armed nodes), not O(1). The current UP node omits a wait generation because removal and wait completion occur under the same critical section; future SMP adds an in-flight generation and superseded cancellation protocol. Base costs are:

Operation

Cost

Notes

arm (insert)

O(n) over armed nodes

walks from the head; the common case (a new deadline later than most) can walk from the tail

disarm (remove)

O(1)

doubly linked

peek next deadline

O(1)

head

expire at now

O(k) for k expired

pops from the head while deadline <= now

Insertion and expiry costs must include wake hooks, inheritance propagation, ready-set scans, and all nodes due together, as described in Timing and evidence.

The interface (embk_timeout_arm, embk_timeout_disarm, embk_timeout_next, embk_timeout_expire) hides the structure; a hierarchical timing wheel is a drop-in alternative for a profile whose measurements show the list walk on the critical path (KRN-TIM-021). On SMP the structure is per CPU (FUTURE; the interface already takes the CPU).

5.2 Deadlines and wrap

  • A deadline is an emb_instant_t. Arming with a duration computes now + duration saturating at EMB_TICK_MAX; a saturated deadline is treated as forever.

  • 32-bit profile: comparisons use the signed difference (int32_t)(a - b), which is correct as long as every armed deadline is within 2^31 - 1 ticks of now. EMB_TIMEOUT() and the arming path therefore clamp a finite timeout to EMB_TIMEOUT_MAX_TICKS = 2^31 - 1 (about 24.8 days at 1 ms), and an absolute value outside that half-range is indistinguishable from a past instant and takes the past-deadline path; EMB_EOVERFLOW is not reachable from that comparison (KRN-TIM-022, A2). Callers must keep absolute deadlines within the half-range. Instant addition wraps in this profile. The 64-bit profile has no such limit in practice (584 years at 1 ns).

  • A deadline at or before now is the EMB_NO_WAIT path (API-024).

5.3 Expiry

Expiry runs in the timer interrupt (periodic) or in the deadline interrupt and on wake (tickless), always with the timeout structure protected by a critical section, and always in deadline order:

expire(now):
  while head and head.deadline <= now:
      node = pop(head)
      if node is a thread timeout:  embk_wait_wake(thread, TIMEOUT)      -- the wait protocol (SPEC-004) decides the race
      else:                          embk_timer_expired(timer)           -- §7.4
  reprogram next deadline (tickless)

Expiry never calls application code directly except for ISR_CONTEXT timers (§7.4). Two nodes with the same deadline expire in arming order.

6. Sleep and yield

emb_status_t emb_thread_sleep(emb_duration_t d);          /* @ctx thread  @blocks timeout  O(timeouts) arm */
emb_status_t emb_thread_sleep_until(emb_instant_t t);     /* @ctx thread  @blocks timeout */
void         emb_thread_yield(void);                      /* @ctx thread  @blocks no  O(1) */
  • Sleep blocks the caller with reason SLEEP until the deadline (KRN-THR-009); the result is EMB_OK on expiry, EMB_ECANCELED if canceled. There is no object, so no DESTROYED.

  • A zero duration, or a deadline in the past, behaves exactly as emb_thread_yield(): the caller moves behind equal-priority READY threads and returns EMB_OK (KRN-SCH-008 applies: never a lower-priority thread).

  • Periodic loops use sleep_until with a running deadline (SPEC-001 §13); the kernel does not offer a “sleep for period minus elapsed” helper because sleep_until is the correct primitive.

7. Software timers

7.1 Object

EMB_DECLARE_HANDLE(emb_timer_t);

typedef void (*emb_timer_fn_t)(emb_timer_t timer, void *arg);

typedef struct emb_timer_attr {
    const char    *name;
    emb_timer_fn_t fn;            /* required */
    void          *arg;
    emb_workq_t    workq;         /* EMB_HANDLE_NULL = system work queue */
    uint8_t        flags;         /* EMB_TIMER_PERIODIC | EMB_TIMER_ISR_CONTEXT */
} emb_timer_attr_t;

The object holds the timeout node, the period, the expiry count, the overrun count, and an embedded work item (03 §6.2) used to run the callback in thread context.

7.2 API

Function

@ctx

@blocks

@time

Notes

emb_timer_init(storage, attr, out)

prekernel thread

no

O(1)

emb_timer_destroy(t)

thread

no

O(1)

misuse if a callback is pending or running (§7.5)

emb_timer_start(t, emb_duration_t first)

thread isr

no

O(timeouts)

one-shot, or first expiry of a periodic timer whose period was set by start_periodic

emb_timer_start_at(t, emb_instant_t first)

thread isr

no

O(timeouts)

absolute form

emb_timer_start_periodic(t, emb_duration_t first, emb_duration_t period)

thread isr

no

O(timeouts)

period must be nonzero, EMB_EINVAL otherwise

emb_timer_stop(t, emb_timer_state_t *out_state)

thread isr

no

O(1)

disarms; cancels a pending work item; reports IDLE, PENDING, or RUNNING

emb_timer_stop_sync(t, emb_timeout_t)

thread

timeout

O(1) + wait

stops and waits for a running callback to finish; misuse from the callback itself

emb_timer_restart(t)

thread isr

no

O(timeouts)

re-arms with the last duration or period from now

emb_timer_is_running(t)

thread isr

no

O(1)

emb_timer_remaining(t, emb_duration_t *out)

thread isr

no

O(1)

zero when not running

emb_timer_get_counts(t, uint32_t *out_expiries, uint32_t *out_overruns)

thread isr

no

O(1)

Starting a running timer re-arms it (the previous deadline is dropped); the counts are not reset. All ISR-safe operations preserve the caller’s mask (KRN-IRQ-033).

7.3 Execution context

Default: the callback runs on a work queue in thread context (ADR-019, KRN-TIM-013). At expiry the kernel submits the timer’s embedded work item to the configured queue, which is O(1) and ISR-safe; the queue’s thread runs the callback later, at the queue’s priority. Callback latency is therefore the work queue’s latency, which the application controls by choosing the queue and its priority (03 §6.2). A callback may block, with the documented consequence that it delays every other item on that queue.

With EMB_TIMER_ISR_CONTEXT the callback runs directly in the expiry path, in interrupt context, with the timeout structure’s critical section released but kernel-aware interrupts still masked by the timer interrupt’s level. It is bounded, may call only ISR-safe API, and may not block (KRN-TIM-014). Checked builds fault on a blocking call from it. This flag exists for the few callbacks that must have interrupt-level latency (toggle a pin, kick a DMA); everything else uses the default.

7.4 Expiry, periodic re-arm, and overrun

embk_timer_expired(timer):
  timer.expiries += 1
  if timer.period != 0:
      timer.deadline += timer.period            -- from the PREVIOUS deadline: no drift (KRN-TIM-015)
      while timer.deadline <= now:               -- missed whole periods (system was asleep or overloaded)
          timer.deadline += timer.period
          timer.overruns += 1
      arm(timer)                                 -- before the callback, so stop() inside the callback works
  if ISR_CONTEXT:  timer.fn(timer, timer.arg)
  else:            submitted = emb_work_submit(timer.work)   -- idempotent: if the previous callback is still
                                                              -- queued, this expiry coalesces into it
                   if not submitted: timer.overruns += 1

Overruns are therefore counted in two cases: whole periods missed because the deadline was already past at expiry time, and callbacks coalesced because the previous one had not run. Both are visible through emb_timer_get_counts() and the trace point timer_overrun. A periodic callback that wants to know how many periods it represents reads the counts.

7.5 Lifecycle rules

  • emb_timer_destroy() while the callback is pending on a queue or running is misuse: a kernel fault in checked builds and EMB_EBUSY in release builds (KRN-TIM-028). The correct sequence is emb_timer_stop_sync() then destroy, or emb_timer_stop() and checking the returned state.

  • emb_timer_stop() from within the timer’s own callback returns RUNNING and is the normal way a periodic timer ends itself; stop_sync from within the callback is misuse (it would wait for itself).

  • A timer’s arg and fn are fixed at init; changing them means destroy and init.

8. Time slicing hook

When CONFIG_EMB_TIME_SLICING=y and the running thread’s priority level has a nonzero quantum (KRN-SCH-037), the per-CPU state holds quantum_deadline, set at dispatch to now + quantum. It is not a timer object: it is one more input to the next-deadline computation (tickless) or one more comparison in the tick ISR (periodic). At expiry, if another READY thread exists at the same effective priority, the running thread is moved to the tail of its class and reschedule_pending is set (v0.1 §5.8); otherwise the quantum is simply renewed. Temporal-protection budgets (03 §2.3) will plug into the same place when their specification lands.

9. Cycle counter

emb_cycles_t emb_cycles_now(void);        /* @ctx thread isr prekernel  O(1); 32 or 64 bits per port */
uint32_t     emb_cycles_hz(void);
uint64_t     emb_cycles_to_ns(emb_cycles_t c);

For measurement (benchmarks, trace timestamps, critical-section statistics). At least 1 MHz where the hardware allows. Sources: DWT_CYCCNT on Cortex-M3 and up, a free-running timer on Cortex-M0+ (declared by the SoC), mcycle on RISC-V, Timer1 on the ATmega328P, the host’s monotonic clock on native. It is never the scheduling clock and never used for timeouts (KRN-TIM-012).

10. Wall clock (optional subsystem)

emb_status_t emb_wallclock_get(int64_t *out_ns_since_epoch);     /* CONFIG_EMB_WALLCLOCK */
emb_status_t emb_wallclock_set(int64_t ns_since_epoch);          /* privileged; sets the offset */
emb_status_t emb_wallclock_adjust(int64_t delta_ns);             /* slew or step, per attribute */

Wall clock is monotonic + offset. Setting or adjusting it changes the offset only; no timeout, timer, or deadline moves (KRN-TIM-031). An RTC driver may seed it at boot and a synchronization service may discipline it; both are middleware. Nothing in the kernel reads it.

11. Conversions and documentation

The macros and rules of SPEC-001 §7.3 apply. In addition:

  • On AVR, integer division by a non-power-of-two constant is a library call (09 §7), so the tiny profile’s default CONFIG_EMB_TICK_NS and timer prescaler are chosen so that millisecond and microsecond conversions are shifts or small multiplications; the port documents which conversions are cheap.

  • Every port documents, in its docs/ports/<arch>.md time section: timer source, counter width, frequency and the tick values it produces exactly, MAX_INTERVAL, tickless capability and the sleep states in which the counter runs, wake latency from each state, and cycle counter source (KRN-TIM-034).

12. Checked-build diagnostics

Condition

Detection

Arming a timeout node that is already armed

node state check in embk_timeout_arm

32-bit absolute deadline outside the half-range

cannot distinguish it from a past instant; caller constraint, not an overflow diagnostic

Blocking call from an ISR_CONTEXT timer callback

context check at the blocking entry

emb_timer_destroy with a pending or running callback

state check

emb_timer_stop_sync from the timer’s own callback

identity check against the running work item

Clock read that observes the sequence lock odd for more than N iterations

debug counter, indicates a stuck writer

13. Per-architecture timer sources

Cortex-M

RISC-V

AVR (ATmega328P)

native

Periodic source

SysTick (24-bit down counter)

mtime/mtimecmp (64-bit) or SoC timer

Timer1 CTC compare, prescaler chosen for exact 1 ms

virtual clock

Tickless source

SoC 32-bit timer or low-power timer that runs in sleep; SysTick only where no better source exists (24 bits, stops in deep sleep)

mtime/mtimecmp

Timer1 compare, 16-bit: MAX_INTERVAL about 4 s at 16 MHz with prescaler 1024

virtual clock

Counter in deep sleep

SoC-dependent, declared in the hardware description

platform-dependent

Timer1 stops in power-down; tickless limited to idle and ADC-noise-reduction modes

n/a

Cycle counter

DWT_CYCCNT (M3 and up); SoC timer on M0+

mcycle, mcycleh on RV32

Timer1

host monotonic

Clock read

sequence lock

sequence lock

critical section

sequence lock

14. Reference model

The timeout structure, the expiry ordering, periodic re-arm with overrun counting, and the next-deadline computation are modeled alongside the wait protocol in work item 4. The model exposes advance_to(deadline) and fire_timer() operations and an oracle for the set and order of expired nodes; differential tests then compare the native port’s behavior against it under randomized arm, disarm, start, stop, and advance sequences.

15. Decisions taken at acceptance (2026-10-07)

  1. A zero-duration sleep or a past deadline behaves exactly as emb_thread_yield().

  2. Cortex-M tickless uses a SoC 32-bit timer declared in the hardware description; SysTick is the fallback only.

  3. The 32-bit tick profile caps finite timeouts at 2^31 - 1 ticks; absolute deadlines must remain within that half-range because out-of-range future instants alias past instants (A2).

  4. emb_timer_stop_sync is part of 1.0.

16. Pending timer implementation constraints

Software timers remain unimplemented. Periodic catch-up must compute missed periods with checked arithmetic rather than loop once per missed period; the §7.4 loop expresses semantics, not an acceptable unbounded ISR algorithm. State the maximum number of due callbacks and any deferred batch policy, preserving expiry order. Arming-overhead compensation must define clock resolution, calibration uncertainty, and already-due deadlines; no accuracy guarantee exists until measured on hardware. stop_sync and delayed-work cancellation need tests for pending, running, resubmitted, and timed-out callbacks.