HomeAbout UsContact Us

Fixing FreeRTOS Software Timer Callback Overruns

By Jithin Tom
Published in Embedded OS
August 26, 2026
7 min read
Fixing FreeRTOS Software Timer Callback Overruns

Table Of Contents

01
Symptom: Timers Fire Late and the Drift Repeats Every Period
02
Root Cause: One Task Runs Every Callback You Ever Created
03
Fix 1: Move Blocking Work Out of the Callback
04
Fix 2: Split Long Periodic Work Across Timer and Worker
05
Fix 3: Recognize When Software Timers Are the Wrong Tool
06
Verification: Proving the Fix With Numbers
07
Prevention Checklist
08
Summary
09
Related Reading
10
References
11
Frequently Asked Questions

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.

Symptom: Timers Fire Late and the Drift Repeats Every Period

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:

  • Raising the daemon priority changes nothing, because the delay comes from serialized execution, not preemption.
  • Adding more timers makes it worse even when the new timers sit idle, because every expiry joins the same wakeup window.
  • The lateness tracks the runtime of the longest callback almost linearly.

Any one of these points away from scheduling arithmetic and toward the daemon architecture itself.

Root Cause: One Task Runs Every Callback You Ever Created

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.

The Command Queue Is Not a Timing Mechanism

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.

What the Daemon Does Between Callbacks

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.

Fix 1: Move Blocking Work Out of the Callback

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();
/* ... */
}

Route the Result Through a Notification or a Queue

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.

Fix 2: Split Long Periodic Work Across Timer and Worker

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.

Choosing Priorities That Do Not Hide the Problem

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.

Fix 3: Recognize When Software Timers Are the Wrong Tool

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 WORK
v
+--------------------------------------------------+
| Q1: JITTER BUDGET UNDER ONE TICK PERIOD? |
+--------------------------------------------------+
|
+--> YES: hardware timer with ISR or DMA
| NO
v
+--------------------------------------------------+
| Q2: BLOCKS ON HARDWARE OR TAKES LOCKS? |
+--------------------------------------------------+
|
+--> YES: worker task fed by a queue
| NO
v
+--------------------------------------------------+
| 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.

The xTimerPendFunctionCall Escape Hatch

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.

Verification: Proving the Fix With Numbers

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:

  1. Register the probe timer alongside the real workload, including storage activity on real media.
  2. Soak for at least 24 hours and read uxMaxLatenessTicks from a console or diagnostics command.
  3. Fail the release if lateness exceeds one tick or if ulOverrunDrops grows during steady state.
  4. Cross-check daemon load with configGENERATE_RUN_TIME_STATS enabled; prvTimerTask should sit in low single-digit percentages.

The numbers from the datalogger case tell the story compactly.

MetricBefore splitAfter split
Worst dispatch lateness19 msunder 1 ms
Sensor periods skipped per hour40 to 900
Longest callback runtime12 ms0.4 ms
Daemon CPU load7 percent2 percent

Prevention Checklist

  • Audit every timer callback for blocking calls: filesystems, mutexes, bus drivers, and logging UARTs are the usual offenders.
  • Budget callback runtime as a fraction of the fastest period sharing the daemon, and keep it in the low microseconds on typical parts.
  • Hand off heavy work through task notifications or depth-limited queues with zero block time, and count the drops as telemetry.
  • Give configTIMER_QUEUE_LENGTH headroom and check xTimerStart() return codes; a failed reset on a watchdog feeder is a latent reset loop.
  • Never call timer APIs with infinite block times from inside a callback; the kernel will force the block time to zero to avoid deadlock, potentially dropping the command silently.
  • Order timer creation deliberately when deadlines can coincide, remembering that simultaneous expiries run in list order.
  • Keep the daemon priority above timer producers and below hard control loops, and make the choice irrelevant by keeping callbacks short.
  • Escalate to a hardware timer plus ISR or DMA whenever the jitter requirement dips below one tick period.

Summary

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 ModeSymptomDetectionFix
Blocking callbackAll timers late in burstsLongest-callback trace vs latenessMove work to a task, notify from callback
Heavy compute callbackGrowing drift each periodRuntime stats on daemonSplit trigger from worker via queue
Command queue saturationxTimerStart returns pdFAILCheck return codes in testsRaise queue length, shorten callbacks
Wrong mechanismJitter beyond one tickProbe against tick counterHardware 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.

References

  1. FreeRTOS, “Software Timers,” Kernel Features Documentation. https://www.freertos.org/Documentation/02-Kernel/02-Kernel-features/05-Software-timers/01-Software-timers
  2. FreeRTOS, “Software Timer API Functions,” API Reference. https://freertos.org/Documentation/02-Kernel/04-API-references/11-Software-timers/00-FreeRTOS-Software-Timer-API-Functions
  3. FreeRTOS, “xTimerStart(),” API Reference. https://freertos.org/Documentation/02-Kernel/04-API-references/11-Software-timers/04-xTimerStart
  4. R. Barry et al., “Mastering the FreeRTOS Real Time Kernel: A Hands-On Tutorial Guide,” book, FreeRTOS Team. https://freertos.gitbook.io/mastering-the-freertos-tm-real-time-kernel
  5. FreeRTOS, “The FreeRTOS Reference Manual,” PDF edition. https://www.freertos.org/media/2025/FreeRTOS_Reference_Manual_V8.2.1.pdf

Frequently Asked Questions

Why are my FreeRTOS software timer callbacks executing late?

All callbacks run serially inside one timer service task. A single long callback delays every other expiry processed in the same wakeup, and the lateness repeats each period for as long as the overload lasts.

Can a FreeRTOS timer callback block or wait on a mutex?

No. The callback runs in the timer service task context, so blocking there stalls the timer command queue and every remaining callback until the blocking call returns.

What does configTIMER_TASK_PRIORITY control?

It sets the timer service task priority. Raising it reduces preemption-induced dispatch delay but cannot fix serialization, and a blocking callback at the highest priority stalls the entire system.

How do I start a software timer from an interrupt?

Use xTimerStartFromISR or xTimerResetFromISR with a pxHigherPriorityTaskWoken pointer, or defer arbitrary work with xTimerPendFunctionCallFromISR. Never call the blocking variants from an ISR.

When should I replace a software timer with a dedicated task?

When the work blocks on hardware or locks, needs more than a few hundred microseconds of compute, or must share data with other tasks. Software timers fit short, stateless, fire-and-forget actions.

Tags

freertossoftware-timersrtosdebugging

Share


Previous Article
Fixing Zephyr Devicetree Overlays That Silently Fail
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

Fixing FreeRTOS Task Starvation: Priority Boosting & Aging
Fixing FreeRTOS Task Starvation: Priority Boosting & Aging
August 22, 2026
3 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media