Home › Embedded, industrial & energy › Firmware & peripherals
algorithm low

Fixed-rate control loop

Problem A sampled controller (motor loop, balancing, filters) on a microcontroller or real-time OS silently changes behaviour when its loop timing drifts or jitters. This keeps the loop running at a steady rate.

Run a controller at an exact period using absolute deadlines and a fixed dt, so jitter never accumulates and the math stays valid.

O(1) per tick; timing error bounded by interrupt latency, not by loop history; one timer interrupt per period
firmwarereal-timecontroltimingjitter
Overview

A digital controller assumes samples arrive every h seconds. sleep(h) after the work gives h + work + latency: the rate drifts and the gains silently change. Anchor every iteration to a grid t0 + n·h (hardware timer or absolute-deadline sleep), sample and actuate at fixed points of it, use a constant dt, and decide in advance what a missed tick does.

Key facts

Use when

  • Any sampled controller (PID, motor/current loops, balancing, filters) on an MCU, RTOS or real-time Linux.
  • Robot controllers: the ros2_control controller manager runs its update loop at update_rate (default 100) and asks for minimal jitter (docs).
  • Flight controllers: ArduPilot's main loop rate SCHED_LOOP_RATE is 50 to 400 Hz (parameters).
  • Industrial control: a PLC executes its program in a repeating scan cycle (read inputs, run program, write outputs) (overview).
  • Loop rates from 100 Hz up to tens of kHz where timing error matters relative to the period.

Avoid when

  • Control is event-driven by nature (PLC-style scan cycle is fine; a UI loop is not a control loop).
  • The OS gives no timing guarantees (general-purpose desktop OS): jitter of milliseconds is normal there.

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.

  1. 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.
  2. 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.
  3. The ISR (or a task released by it) reads the latest sample, runs u = f(setpoint, pv) with constant dt, and writes u to a shadow register; it is applied at the next update event, giving a constant one-period delay instead of a variable one.
  4. The pacer computes the next deadline from the previous one. If now is 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.
  5. 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::sleep to 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 Pi state is 20 bytes. At 1 kHz on a 100+ MHz core, well under 1 % CPU. Bound: wake-up jitter, not compute. Measured here: absolute-deadline thread::sleep at 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, no malloc or 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 becomes period + 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 a timerfd with a periodic expiry, a SCHED_FIFO thread, locked memory (mlockall) and pre-faulted stack; real-time Linux covers the rest. timerfd reports missed expirations in its read value.
  • On an RTOS, use the "delay until" call (FreeRTOS xTaskDelayUntil keeps previous wake time + increment) rather than a relative delay.
  • On bare metal compare a free-running counter with wrapping_sub as in due; never now >= deadline on 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 N on-time ticks.

Pitfalls

  • sleep(period) after the work: the period becomes period + work + latency; the loop runs slow and its rate depends on load.
  • Measuring dt with a clock and feeding it to the derivative: quantisation and jitter in dt show up as noise on D. Use the nominal dt, 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

Event-triggered / sporadic controlupdate only when the error exceeds a threshold, saves energy, harder to analyse.
Multirate loopsinner current loop at kHz, outer speed loop at a divided tick of the same grid.
Time-triggered execution (time-triggered scheduling)the whole system is a static schedule on one grid.
Jitter-compensated controldesign the controller for a measured delay distribution (see Cervin et al.), when jitter cannot be removed.

In production

  • Real-time kernels expose drift-free periodic primitives (FreeRTOS xTaskDelayUntil, Zephyr periodic k_timer (docs), Linux TIMER_ABSTIME sleeps and timerfd), 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

namelangrepoinstallverdictlicencehealthas_ofnotes
embassy-timeRustembassy-rs/embassycargo add embassy-time————Ticker::every(..): periodic stream, docs
RTICRustrtic-rs/rticcargo add rtic————interrupt-driven tasks with priorities and timer-queue scheduling
ZephyrCzephyrproject-rtos/zephyrwest init————k_timer periodic timers, timer docs
FreeRTOS-KernelCFreeRTOS/FreeRTOS-Kernelvendor 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

Builds on

Used by

automatic generation control · plannedLoad-frequency control: area control error integrated and dispatched to generators
field oriented control · plannedFOC of BLDC/PMSM motors: Clarke/Park transforms, current PI loops, space-vector PWM
impedance control · plannedMake a robot behave like a spring-damper at the contact point for safe interaction
interlocking logic · plannedRailway route setting, locking, flank protection and release as verified boolean logic
Model predictive controlA controller for a robot, vehicle or process must choose actions that respect hard limits and coupled inputs and outputs, and exploit a known future reference, where a simple feedback loop falls short.
movement authority braking curves · plannedTrain supervision: compute braking curves from movement authority and enforce speed limits
plc scan cycle · plannedCyclic read-inputs, execute-logic, write-outputs execution with IEC 61131-3 programs
power oversubscription capping · plannedOversubscribe datacenter power with telemetry and fast capping (RAPL, priority-based throttling)
whole body control · plannedPrioritized task-space control solved as a QP over joint torques and contact forces

Systems (29):

Alternatives and related