FreeRTOS xTaskDelayUntil: Periodic Tasks Without Slow Timing Drift

2026-05-26 · Davide Carrese
FreeRTOS · RTOS · Timing

Periodic firmware tasks often look simple: read a sensor every 10 ms, run a control loop every 1 ms, publish telemetry every second. The bug is usually hidden in the word “every”. A relative delay creates period drift; an absolute delay creates a schedule. FreeRTOS V11.3.0 also explicitly documents the catch-up behavior of xTaskDelayUntil(), which is a useful reminder that fixed-frequency code still needs an overrun policy.

The practical problem

On STM32, ESP32, and similar MCU products, many timing faults are not hard crashes. They are slow degradations: a sampling phase walks away from an ADC trigger, a CAN transmit task bunches messages after a busy interval, an LED status task seems harmless but hides scheduler pressure, or a control loop occasionally executes twice back-to-back after being blocked. These faults are easy to miss on a desk and painful in the field.

The common beginner pattern is do_work(); vTaskDelay(period);. That means the next wake-up is scheduled relative to the time at which the task finally calls the delay function. If do_work() takes 2 ms and the delay is 10 ms, the period is approximately 12 ms, plus jitter from interrupts and higher-priority work. Over hours, that is not a fixed 100 Hz loop. It is a loop whose frequency depends on execution time.

xTaskDelayUntil() solves a different problem. It schedules the next unblock time relative to a previous absolute tick value. The task can therefore target a stable cadence even when each iteration has modest execution-time variation. That is exactly what you want for periodic housekeeping, acquisition state machines, watchdog progress checks, and many protocol time bases.

Absolute time, not “sleep after work”

The FreeRTOS API keeps the state in a TickType_t variable owned by the task. Initialize it with xTaskGetTickCount(), then pass it by pointer on each iteration. FreeRTOS updates it to the next scheduled release time. The important detail is architectural: the period is measured from release to release, not from the end of one iteration to the start of the next delay.

This also handles tick wrap correctly when used as intended. Do not replace it with hand-written unsigned comparisons scattered through the application unless you have a very specific reason and tests around wrap-around behavior. The API exists because this is a common source of off-by-one and overflow bugs.

Catch-up behavior is not an overrun strategy

The V11.3.0 FreeRTOS kernel release notes mention documentation updates around xTaskDelayUntil() catch-up behavior. The practical meaning is simple: if the task is already late when it calls the API, it may not block for a full period. That is correct for an absolute schedule; the task is catching up to the timeline it promised to follow.

But catching up is not always safe. For a temperature logger, running one iteration immediately after a long flash write might be acceptable. For a motor-control supervisor, draining several stale iterations can make the system less deterministic. For a communications gateway, a burst of “catch-up” work can starve lower-priority tasks and create the next latency problem.

Design rule: use xTaskDelayUntil() to remove drift, but separately decide what happens when one iteration misses its deadline. Late work should be measured, reported, and sometimes skipped.

A bounded periodic task pattern

The following C pattern is deliberately small. It keeps the stable FreeRTOS cadence, records overruns, and prevents unlimited catch-up work by resynchronizing after a severe miss. The exact thresholds belong to the product, not to the RTOS.

#include "FreeRTOS.h"
#include "task.h"
#include <stdint.h>
#include <stdbool.h>

#define SENSOR_PERIOD_MS        10u
#define MAX_ACCEPTABLE_LATE_MS  20u

static void publish_timing_fault(uint32_t late_ms);
static bool sensor_sample_and_filter(void);

static void sensor_task(void *arg)
{
    const TickType_t period = pdMS_TO_TICKS(SENSOR_PERIOD_MS);
    const TickType_t max_late = pdMS_TO_TICKS(MAX_ACCEPTABLE_LATE_MS);
    TickType_t next_release = xTaskGetTickCount();

    for (;;) {
        TickType_t now = xTaskGetTickCount();
        TickType_t late = now - next_release;   /* valid with unsigned tick math */

        if (late > max_late) {
            publish_timing_fault(late * portTICK_PERIOD_MS);

            /* Resync instead of executing a burst of stale iterations. */
            next_release = now;
        }

        if (sensor_sample_and_filter()) {
            /* Store a progress breadcrumb or feed a task watchdog here,
               after useful work, not before a blocking peripheral call. */
        }

        xTaskDelayUntil(&next_release, period);
    }
}

There are two subtleties here. First, the “late” metric is checked before doing work. That tells you whether the task was released late or blocked by something outside its normal body. Second, the resynchronization is explicit. Without that policy, a task that was delayed by a long flash erase, radio coexistence interval, disabled interrupt section, or priority inversion may immediately run again and again until the absolute schedule is back in the future.

Practical example: a battery telemetry product

Imagine a battery-powered industrial telemetry node built around an STM32 or ESP32. It samples current and voltage at 100 Hz, computes rolling energy counters, sends a compact packet over CAN or Wi-Fi, and spends as much time as possible in a low-power state. The 100 Hz task should not be written as sample(); vTaskDelay(10 ms);, because every ADC conversion delay, filtering branch, and storage update becomes part of the period.

I would use xTaskDelayUntil() for the acquisition task, store the worst observed lateness in a retained diagnostic block, and define a product-level rule: if the task is late by less than two periods, record it and continue; if it is late by more, mark the sample window invalid, resynchronize, and avoid reporting false precision. The customer gets a product that is honest about missing timing instead of one that silently changes its sampling frequency.

The same logic applies to ESP32 Wi-Fi products where radio and flash activity can introduce surprising latency. The RTOS API gives you a stable target; the firmware architecture still has to decide how much lateness is tolerable for the physical process being measured.

Practical checklist

How I would approach this on a client project

I would first list every task that claims a frequency: control, sampling, communication windows, watchdog supervision, UI refresh, storage flushing. For each one I would ask whether the frequency is a hard requirement, a soft requirement, or just a convenience. Hard and soft periodic work get explicit timing diagnostics; convenience tasks do not deserve high priority.

Then I would convert drift-sensitive loops to xTaskDelayUntil(), add lightweight overrun counters, and review priority and blocking behavior. On STM32 this often exposes HAL calls that wait too long in a task that should only trigger DMA and return. On ESP32 it often exposes flash, logging, or connectivity paths that were accidentally placed on a timing-critical path. Finally, I would put the lateness counters into a field-readable diagnostic report so the first customer failure produces data, not guesses.

Sources consulted

Comments

Have a concrete firmware timing problem or a note about this article? Send a short email and include the article title.