Deep dive
How it works
In: timer tick events, setpoint, latest sample · Out: one actuator command per period, on the grid, plus an overrun count · State: next deadline, controller state (integrator), missed-tick counter · Cost: O(1) per tick.
- A timer interrupt (e.g. SysTick, a 24-bit down-counter on Cortex-M, or a general timer) fires every
h; the reload value is fixed, the counter free-runs, so the ISR's own latency does not shift the next tick. - Hardware does the time-critical edges: the timer triggers the ADC (optionally via DMA) and the output stage (PWM) latches new duty on its update event.
- The ISR (or a task released by it) reads the latest sample, runs
u = f(setpoint, pv)with constantdt, and writesuto a shadow register; it is applied at the next update event, giving a constant one-period delay instead of a variable one. - The pacer computes the next deadline from the previous one. If
nowis already past it, a policy applies: skip the lost ticks and rejoin the grid, or run back-to-back until caught up, with a bound. - Overruns are counted; too many in a window means the system is overloaded and should go to a safe state.
tick: 0 1 2 3 4 5
grid: |------|------|------|------|------|
sample: s0 s1 s2 (stall...) s4 s5 <- timer-triggered, not code-triggered
compute: [c0] [c1] [c2 ======>] [c4]
output: ..u0 u1 u2 (u2 held; ticks 3 lost, counted) u4
Code
Pacer (host std::time, but the logic is the same on a free-running tick counter), a wrap-safe u32 tick compare for MCUs, and a fixed-dt PI with anti-windup:
use std::time::{Duration, Instant};
/// What to do when a period was overrun.
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Overrun { SkipToGrid, CatchUp }
/// Absolute-deadline pacer: deadlines are start + n*period, never now + period,
/// so jitter and execution time do not accumulate as drift.
pub struct Pacer { next: Instant, period: Duration, policy: Overrun, pub missed: u64 }
impl Pacer {
pub fn new(start: Instant, period: Duration, policy: Overrun) -> Self {
Pacer { next: start + period, period, policy, missed: 0 }
}
/// Call at the end of an iteration. Returns how long to sleep (zero if late).
pub fn after_step(&mut self, now: Instant) -> Duration {
if now <= self.next {
let wait = self.next - now;
self.next += self.period;
return wait;
}
let late = now - self.next;
let skipped = (late.as_nanos() / self.period.as_nanos()) as u32;
self.missed += 1 + skipped as u64;
match self.policy {
// Drop the ticks we cannot serve; stay on the original grid.
Overrun::SkipToGrid => self.next += self.period * (skipped + 1),
// Run back-to-back until caught up (bursts; bounded by caller).
Overrun::CatchUp => self.next += self.period,
}
Duration::ZERO
}
}
/// Wrap-safe deadline test for a free-running u32 tick counter (valid while
/// the distance is < 2^31 ticks).
pub fn due(now: u32, deadline: u32) -> bool { (now.wrapping_sub(deadline) as i32) >= 0 }
/// PI controller with fixed dt: the period is a compile-time constant, not measured.
pub struct Pi { kp: f32, ki_dt: f32, i: f32, lo: f32, hi: f32 }
impl Pi {
pub fn new(kp: f32, ki: f32, dt: f32, lo: f32, hi: f32) -> Self {
Pi { kp, ki_dt: ki * dt, i: 0.0, lo, hi }
}
pub fn step(&mut self, sp: f32, pv: f32) -> f32 {
let e = sp - pv;
let u = self.kp * e + self.i + self.ki_dt * e;
let uc = u.clamp(self.lo, self.hi);
if uc == u { self.i += self.ki_dt * e; } // anti-windup: integrate only unsaturated
uc
}
}
Tested on a fake clock (stall of 3.5 periods, both policies, counter wrap) and on a simulated first-order plant at 1 kHz that settles to within 2 % of the setpoint.
Complexity and constants
- Per tick: one timer interrupt, one compare, one controller step (PI: a few float ops; with precomputed
ki·dt, no division in the loop). - Interrupt entry on Cortex-M is of order tens of cycles (order of 12 cycles on Cortex-M3/M4, from memory of Arm's documentation), so at 1 kHz and 100+ MHz the overhead is far below 1 % of the period.
- Budget rule of thumb: worst-case execution time well under the period (order of 50 % or less); the worst case, not the average, decides overruns.
- Measured by me:
thread::sleepto absolute deadlines at 1 kHz on a loaded macOS laptop missed 31 of 2000 ticks (worst wake-up 7.6 ms, one run, order-of only). A hardware timer ISR on an MCU is typically microseconds; Linux with PREEMPT_RT and a real-time priority is typically tens of microseconds (order of).
Efficiency
- Cost: one tick is one timer interrupt (entry order of 12 cycles on Cortex-M3/M4) plus one PI step of a few float ops; the
Pistate is 20 bytes. At 1 kHz on a 100+ MHz core, well under 1 % CPU. Bound: wake-up jitter, not compute. Measured here: absolute-deadlinethread::sleepat 1 kHz on a loaded macOS laptop missed 31 of 2000 ticks (worst 7.6 ms, one run). - Levers:
- timer-triggered ADC and PWM update events — sampling and actuation jitter removed without CPU work — timer-linked peripherals
WFI/idle between ticks — core idle most of the period (duty cycling) — battery loops- lowest sufficient rate — wake-ups scale linearly with rate; Åström and Wittenmark suggest several samples per rise time — any new loop
- precomputed
ki·dt, nomallocor formatting in the tick — short, constant worst case — hard deadlines SCHED_FIFO, PREEMPT_RT — tens of µs jitter (order of) — Linux
- Naive → standard → state of the art:
sleep(period)after the work · period becomesperiod + work + latency, rate drifts with load- absolute-deadline sleep or "delay until" · no drift; jitter is OS wake-up latency (measured above)
- timer ISR with timer-triggered ADC and shadow-register PWM · jitter of order of µs, CPU only computes
Implementation notes
- On Linux use
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, ...)or atimerfdwith a periodic expiry, aSCHED_FIFOthread, locked memory (mlockall) and pre-faulted stack; real-time Linux covers the rest.timerfdreports missed expirations in its read value. - On an RTOS, use the "delay until" call (FreeRTOS
xTaskDelayUntilkeepsprevious wake time + increment) rather than a relative delay. - On bare metal compare a free-running counter with
wrapping_subas indue; nevernow >= deadlineon a wrapping counter. - Give the control task the highest priority among tasks that share the CPU, or use time-triggered slots; under priority preemption, account its blocking time.
- Apply setpoint changes at the start of the tick via an atomic or double buffer, so one step never sees half an update.
- Clamp the integrator on saturation (anti-windup) and reset it when re-entering the loop after a fault.
- Kick the watchdog only when the loop has completed
Non-time ticks.
Pitfalls
sleep(period)after the work: the period becomesperiod + work + latency; the loop runs slow and its rate depends on load.- Measuring
dtwith a clock and feeding it to the derivative: quantisation and jitter indtshow up as noise onD. Use the nominaldt, and fix the jitter instead. - Variable computation-to-output delay: outputting as soon as the compute finishes moves the actuation point around the tick; latch on a timer event.
- Catch-up bursts: after a stall, replaying missed ticks back-to-back feeds the controller stale data at the wrong cadence; skipping is usually safer.
- Logging,
printf, flash writes or blocking bus transactions inside the loop can hold up the tick for milliseconds. - Counter wrap: a 32-bit microsecond counter wraps after about 71.6 minutes (2^32 µs); naive comparison fails then.
- ISR-driven kicks of the watchdog hide a hung control task.
Variants and alternatives
In production
- Real-time kernels expose drift-free periodic primitives (FreeRTOS
xTaskDelayUntil, Zephyr periodick_timer(docs), LinuxTIMER_ABSTIMEsleeps andtimerfd), and motor-control and flight-control firmware builds its current and attitude loops on timer-driven ticks. - Robotics: ros2_control's controller manager (
update_rate, default 100). Aerospace: ArduPilot (SCHED_LOOP_RATE, 50 to 400 Hz). - Industrial: PLCs run a cyclic scan. Automotive ECUs use OSEK/AUTOSAR cyclic alarms.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| embassy-time | Rust | embassy-rs/embassy | cargo add embassy-time | — | — | — | — | Ticker::every(..): periodic stream, docs |
| RTIC | Rust | rtic-rs/rtic | cargo add rtic | — | — | — | — | interrupt-driven tasks with priorities and timer-queue scheduling |
| Zephyr | C | zephyrproject-rtos/zephyr | west init | — | — | — | — | k_timer periodic timers, timer docs |
| FreeRTOS-Kernel | C | FreeRTOS/FreeRTOS-Kernel | vendor the source | — | — | — | — | xTaskDelayUntil for drift-free periodic tasks |
Sources
- Anton Cervin et al., How does control timing affect performance? Analysis and simulation of timing using Jitterbug and TrueTime, IEEE Control Systems Magazine 23(3), 2003, pp. 16-30. paper
- Karl J. Åström, Björn Wittenmark, Computer-Controlled Systems: Theory and Design, 3rd ed., Prentice Hall 1997.
- Linux man-pages, clock_nanosleep(2) (
TIMER_ABSTIME). man page - ros2_control documentation, Controller Manager (
update_rate, determinism). docs - ArduPilot documentation, Copter parameters,
SCHED_LOOP_RATE. docs