
When a FreeRTOS software timer starts firing late, the instinct is to blame the tick interrupt or the timer period arithmetic. The real culprit is stated in one sentence of the kernel documentation: every callback executes in the context of a single timer service task, one after another, no matter how many timers the application created. One callback that blocks for ten milliseconds pushes every subsequent expiry in that wakeup window outward, and because auto-reload timers reschedule themselves, the delay repeats on every period for as long as the overload lasts.
This article dissects the failure signature, explains exactly what the daemon does between callbacks, and gives three fixes with their trade-offs: move blocking work to a worker task, route results through task notifications and queues so callbacks stay short, and recognize when the problem needs a different mechanism entirely. It closes with a latency probe you can drop into any project to prove the fix held over a multi-day soak.
The field report usually reads like this. A datalogger samples a sensor through a 50 ms auto-reload timer and flushes an SD card log from a separate 500 ms timer. Bench testing looks flawless. On hardware with real flash wear, samples begin arriving at 69 ms intervals, then 88 ms, occasionally skipping periods entirely, while no timer period was ever changed. A logic analyzer on the SPI chip-select line shows the SD write overlapping the moment the sample was due.
Three observations distinguish a timer-service overrun from an ordinary priority problem:
Any one of these points away from scheduling arithmetic and toward the daemon architecture itself.
At scheduler start, FreeRTOS creates the timer service task, often called the timer daemon, at the priority from configTIMER_TASK_PRIORITY. Applications never call timer callbacks directly. Instead, xTimerStart(), xTimerStop(), xTimerReset(), and xTimerChangePeriod() post commands to a single queue. The daemon drains that queue, walks its expiry list, and invokes every matured callback in sequence, all inside its own context.
+==========================================================================+| ONE DAEMON PASS: ALL CALLBACKS SHARE THE SAME WINDOW |+==========================================================================+| ||[1] log flush matures t=10 BLOCKS 12 ms in f_sync() ||[2] sensor poll matures t=15 waits behind [1], fires late ||[3] led toggle matures t=20 waits behind [1], fires late || || ^ ^ ^ ^ ^ || t=0 10 15 20 22 40 || || daemon blocked inside [1]; [2] dispatched 7 ms late |+--------------------------------------------------------------------------+
Follow one pass through the diagram. The log flush callback matures at t=10 and calls a flash sync that blocks for 12 ms on the storage device. The sensor poll matures at t=15 and sits ready the whole time but cannot run, because the daemon is inside somebody else’s callback. When the sync returns at t=22, the daemon dispatches the sensor poll 7 ms late and finds the LED expiry also matured behind it. At 168 MHz those 12 ms are about two million cycles no other timer could use. The utilization discipline matches rate-monotonic analysis: total runtime over the smallest shared period must stay far below unity. The difference is that all callbacks share one budget, as if they were a single task.
Because commands travel through the same daemon, they inherit the same head-of-line blocking. While a callback runs, xTimerStart() messages pile up in the command queue behind it. Two failure modes follow. First, with a small configTIMER_QUEUE_LENGTH the queue fills and xTimerStart() returns pdFAIL, silently abandoning a start or reset that the caller may not check. Second, you might assume that passing portMAX_DELAY to xTimerStart() inside a callback guarantees the command will succeed eventually. It does not. The FreeRTOS kernel detects when a timer API is called from the daemon task and silently overrides the block time to zero to prevent the daemon from deadlocking against its own queue. If the queue is full, the command returns pdFAIL immediately, dropping the event.
Treat the command queue as control-plane plumbing, never as timing infrastructure: the timestamp that matters is when the daemon dispatches, not when the caller posted the command.
Between expiries the daemon blocks on the command queue with a timeout equal to the nearest deadline. The tick interrupt advances time; when the earliest timer matures, the daemon wakes, collects every matured timer, and calls their callbacks in list order. Three properties of the design explain most confusing bench behavior. Dispatch granularity is one tick period. With configTICK_RATE_HZ at 1000, no callback lands with better than 1 ms alignment, so chasing microsecond jitter here is a category error.
Callbacks with equal deadlines execute in list order, which for simultaneous expiries amounts to creation order. If your fast sensor timer was created after your slow logger, the logger always goes first in a tie, and the sensor absorbs the delay.
Missed periods coalesce: an auto-reload timer that matured twice during one long callback fires once. FreeRTOS never queues expirations, which is why overloaded systems lose cycles silently instead of catching up.
The audit is mechanical: grep every callback for calls that touch buses, filesystems, locks, or logging UARTs; anything that can wait belongs elsewhere. On a 100 MHz class part a callback sharing a window with fast timers should finish in a few microseconds; if it allocates, synchronizes, or polls, it is too heavy. The anti-pattern that produced the timeline above:
/* Anti-pattern: blocking call inside a timer callback */static TimerHandle_t xLogTimer;void vLogFlushCallback(TimerHandle_t xTimer){(void)xTimer;/* Runs in the timer service task. f_sync() waits on the SD* interface, which a lower-priority task drives. The whole* timer subsystem stalls here for up to 12 ms. */f_sync(&xLogFile);}int main(void){/* ... clock and peripheral setup ... */xLogTimer = xTimerCreate("log", pdMS_TO_TICKS(500), pdTRUE,NULL, vLogFlushCallback);xTimerStart(xLogTimer, 0);vTaskStartScheduler();/* ... */}
Keep the timer as a pure trigger and hand the expensive work to a task. A direct-to-task notification is the lightest one-to-one option: no allocation, no kernel object beyond the task itself. Use a queue when the payload carries data or several consumers exist.
#define LOG_FLUSH_BIT (1UL << 0)static TimerHandle_t xLogTimer;static TaskHandle_t xLoggerTask;void vLogFlushCallback(TimerHandle_t xTimer){(void)xTimer;/* Non-blocking: request the work, do not perform it. */xTaskNotify(xLoggerTask, LOG_FLUSH_BIT, eSetBits);}void vLoggerTask(void *pvParameters){(void)pvParameters;uint32_t ulBits;for (;;){/* Wait for any bits, clear them all on exit */xTaskNotifyWait(0x00, 0xFFFFFFFFUL, &ulBits, portMAX_DELAY);if ((ulBits & LOG_FLUSH_BIT) != 0){/* Blocking calls live HERE, in worker context. */f_sync(&xLogFile);}}}
The callback now completes in well under a microsecond on a Cortex-M4. The daemon remains responsive, and the logger task can be preempted by anything more urgent without affecting any other timer.
When the periodic work itself is legitimately heavy, such as an ADC burst plus a filter chain, make coalescing explicit instead of accidental. The timer posts a token to a depth-one queue with zero block time; if the worker is busy the token drops and a counter increments, superseding the stale sample, which is exactly what most sampling loops want under overload.
typedef enum { SAMPLE_REQ = 0 } work_type_t;typedef struct { work_type_t type; int32_t seq; } work_msg_t;static QueueHandle_t xWorkQueue;static TimerHandle_t xSampleTimer;volatile uint32_t ulOverrunDrops; /* export to health telemetry */void vSampleCallback(TimerHandle_t xTimer){(void)xTimer;static int32_t seq = 0;work_msg_t msg = { .type = SAMPLE_REQ, .seq = seq++ };/* Never wait: a full queue means the worker is still busy. */if (xQueueSend(xWorkQueue, &msg, 0) != pdPASS){ulOverrunDrops++;}}void vWorkerTask(void *pvParameters){(void)pvParameters;work_msg_t msg;for (;;){xQueueReceive(xWorkQueue, &msg, portMAX_DELAY);take_filtered_sample(); /* ADC burst + DSP, up to 6 ms */push_sample_to_ringbuffer();}}
On the datalogger from the symptom section, this split moved worst-case dispatch lateness from 19 ms to under one tick at 1 kHz, and it held across a 48 hour soak with production storage traffic. The residual lateness is one context switch plus one short callback.
configTIMER_TASK_PRIORITY deserves deliberate thought. Setting the daemon above every application task hides dispatch delay but converts any accidental blocking call inside a callback into a system-wide stall, because nothing can preempt the daemon to make progress elsewhere. Setting it too low lets medium-priority tasks add scheduling noise on top of serialization. The stable configuration is: daemon above timer producers, below hard control loops, with callbacks never blocking. Once callbacks are short, the exact daemon priority stops mattering, which is the real goal.
Some work should never enter the daemon at all. The decision tree below sorts new periodic or delayed work with three questions.
NEW PERIODIC OR DELAYED WORKv+--------------------------------------------------+| Q1: JITTER BUDGET UNDER ONE TICK PERIOD? |+--------------------------------------------------+|+--> YES: hardware timer with ISR or DMA| NOv+--------------------------------------------------+| Q2: BLOCKS ON HARDWARE OR TAKES LOCKS? |+--------------------------------------------------+|+--> YES: worker task fed by a queue| NOv+--------------------------------------------------+| Q3: SHORT AND STATELESS ACTION? |+--------------------------------------------------+|+--> YES: software timer callback fits
Motor commutation, audio or DAC streaming, and anything with a jitter budget tighter than the tick period belong in a hardware timer with ISR or DMA service, where delivery is independent of any task. Work that must wait on hardware, take locks, or yield belongs in a task. Software timers earn their keep for short, stateless actions: debounced button events with a one-shot delay, heartbeat toggles, periodic statistics snapshots, and triggering heavier workers as shown above.
ISRs often discover work that must run in task context but does not justify a dedicated task. xTimerPendFunctionCallFromISR() borrows the daemon for exactly this, invoking a function with two arguments in timer service context.
void vGpioButtonIsr(void){BaseType_t xHigherPrioWoken = pdFALSE;/* Runs in the timer daemon, NOT at interrupt level. */xTimerPendFunctionCallFromISR(prvDebounceWork, NULL, 0,&xHigherPrioWoken);portYIELD_FROM_ISR(xHigherPrioWoken);}static void prvDebounceWork(void *pvParam1, uint32_t ulParam2){(void)pvParam1;(void)ulParam2;emit_button_event(); /* a few hundred microseconds at most */}
The escape hatch shares the daemon’s bandwidth, so the same runtime rules apply: pended functions must stay as short as callbacks or they reintroduce the original problem under another name. Treat it as a callback with parameters, not as a general deferred-work queue.
Measurements close this kind of defect. The probe below records worst-case dispatch lateness for one tick read per invocation.
static volatile UBaseType_t uxMaxLatenessTicks;void vProbeCallback(TimerHandle_t xTimer){/* The daemon updates the expiry time for the *next* period before* calling the callback. We subtract the period to find when this* specific callback was originally scheduled to run. */TickType_t xExpected = xTimerGetExpiryTime(xTimer) - xTimerGetPeriod(xTimer);TickType_t xNow = xTaskGetTickCount();TickType_t xLate = xNow - xExpected;/* Ignore huge values caused by tick wrap-around corner cases */if (xLate < (portMAX_DELAY / 2)){if (xLate > (TickType_t)uxMaxLatenessTicks){uxMaxLatenessTicks = (UBaseType_t)xLate;}}/* Real periodic work goes here. */}
Wire the probe into acceptance:
The numbers from the datalogger case tell the story compactly.
| Metric | Before split | After split |
|---|---|---|
| Worst dispatch lateness | 19 ms | under 1 ms |
| Sensor periods skipped per hour | 40 to 90 | 0 |
| Longest callback runtime | 12 ms | 0.4 ms |
| Daemon CPU load | 7 percent | 2 percent |
FreeRTOS software timers fail late for one structural reason: a single daemon executes every callback serially, so one long or blocking callback taxes every timer in the system and repeats the damage each period. The durable fix is architectural, not configurational. Move blocking work into tasks, keep triggers weightless, and reserve the daemon for short, stateless actions.
| Failure Mode | Symptom | Detection | Fix |
|---|---|---|---|
| Blocking callback | All timers late in bursts | Longest-callback trace vs lateness | Move work to a task, notify from callback |
| Heavy compute callback | Growing drift each period | Runtime stats on daemon | Split trigger from worker via queue |
| Command queue saturation | xTimerStart returns pdFAIL | Check return codes in tests | Raise queue length, shorten callbacks |
| Wrong mechanism | Jitter beyond one tick | Probe against tick counter | Hardware timer with ISR or DMA |
Convert the expectation into evidence with the lateness probe and the drop counter, and regressions announce themselves in the build instead of on the bench.
Quick Links
Legal Stuff





