Why Arduino FreeRTOS Timers Beat delay() and Hardware Interrupts

When you move beyond basic Arduino sketches into concurrent embedded systems, the standard delay() function becomes a liability. It blocks the executing thread, starving other tasks of CPU time. While millis() polling solves the blocking issue, it clutters your main loop with state-machine logic. FreeRTOS software timers offer a cleaner architectural pattern: they allow you to schedule callback functions to execute after a specific delay or at periodic intervals, entirely managed by the RTOS daemon.

Crucially, FreeRTOS software timers do not execute in a hardware interrupt context. They execute in the context of a dedicated background task (usually named Tmr Svc). This means your callback can safely call most FreeRTOS API functions (like sending to a queue), but it also means the callback is subject to RTOS scheduling jitter. If you need sub-microsecond precision, you need a hardware timer. If you need clean, non-blocking application logic (like debounce delays, periodic sensor polling, or watchdog feeding), software timers are the correct tool.

Timing Method Comparison on ESP32-WROOM-32

MethodExecution ContextBlocking?Typical JitterMax Concurrent Instances
FreeRTOS Software TimerTimer Service Task (Tmr Svc)No1 - 5 msLimited by heap RAM
Hardware Timer (GPTimer)Interrupt Service Routine (ISR)No< 1 µs4 per SoC
delay()Current Calling TaskYesN/A1 (blocks thread)
millis() PollingCurrent Calling TaskNoLoop dependentUnlimited (CPU bound)

Parts List, Pin Mapping, and Build Specs

This build targets the ESP32 DevKit V1 (specifically the ESP32-WROOM-32 module). The ESP32 Arduino core natively includes FreeRTOS, meaning no external library installation is required. We will build a circuit that uses a one-shot timer for switch debouncing and an auto-reload timer for a periodic heartbeat LED.

Difficulty Rating: Intermediate (3/5) | Estimated Time: 45 Minutes
Prerequisites: Familiarity with basic C++ pointers and Arduino IDE configured for ESP32 board manager (v2.0.x or v3.0.x).

Bill of Materials

  • 1x ESP32 DevKit V1 (ESP32-WROOM-32, 30-pin or 38-pin variant)
  • 1x 5mm LED (any color) + 1x 220Ω current-limiting resistor
  • 1x Tactile pushbutton switch
  • 1x 10kΩ pull-up resistor (if not using internal pull-ups)
  • Breadboard and jumper wires

Pin Mapping Table

ComponentESP32 GPIODirectionNotes
Onboard Status LEDGPIO 2OutputActive HIGH on most DevKit V1 boards
External Action LEDGPIO 15OutputDriven via 220Ω resistor
Tactile ButtonGPIO 0InputActive LOW (pulled to GND when pressed)

Complete ESP32 FreeRTOS Timer Implementation

The golden rule of FreeRTOS timer callbacks is: never block inside the callback. The Tmr Svc task processes a queue of timer commands. If your callback executes a delay(), a blocking Serial.print(), or a long Wire.requestFrom(), you stall the entire timer daemon, preventing all other software timers from expiring on time.

The code below demonstrates the correct pattern: the timer callback quickly sends a message to a FreeRTOS Queue, and a separate worker task reads from that queue to perform the heavy lifting (like serial logging or I2C transactions).

#include <Arduino.h>

// --- Pin Definitions ---
#define PIN_STATUS_LED 2
#define PIN_ACTION_LED 15
#define PIN_BUTTON     0

// --- FreeRTOS Handles ---
TimerHandle_t xDebounceTimer = NULL;
TimerHandle_t xHeartbeatTimer = NULL;
QueueHandle_t xActionQueue = NULL;

// --- Callback Prototypes ---
void vDebounceTimerCallback(TimerHandle_t xTimer);
void vHeartbeatTimerCallback(TimerHandle_t xTimer);
void vWorkerTask(void *pvParameters);

// ISR for button press
void IRAM_ATTR isrButtonPress() {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // Restart the one-shot debounce timer from the ISR
    xTimerStartFromISR(xDebounceTimer, &xHigherPriorityTaskWoken);
    if (xHigherPriorityTaskWoken == pdTRUE) {
        portYIELD_FROM_ISR();
    }
}

void setup() {
    Serial.begin(115200);
    pinMode(PIN_STATUS_LED, OUTPUT);
    pinMode(PIN_ACTION_LED, OUTPUT);
    pinMode(PIN_BUTTON, INPUT_PULLUP);

    // Create a queue to pass events from timers to the worker task
    xActionQueue = xQueueCreate(10, sizeof(char));
    if (xActionQueue == NULL) {
        Serial.println("FATAL: Failed to create action queue.");
        while(1) { delay(1000); }
    }

    // Create a one-shot timer for debouncing (50ms period)
    xDebounceTimer = xTimerCreate(
        "Debounce", pdMS_TO_TICKS(50), pdFALSE, (void *)0, vDebounceTimerCallback);
    
    // Create an auto-reload timer for heartbeat (1000ms period)
    xHeartbeatTimer = xTimerCreate(
        "Heartbeat", pdMS_TO_TICKS(1000), pdTRUE, (void *)0, vHeartbeatTimerCallback);

    if (xDebounceTimer == NULL || xHeartbeatTimer == NULL) {
        Serial.println("FATAL: Failed to create timers. Check heap.");
        while(1) { delay(1000); }
    }

    // Start the heartbeat timer
    xTimerStart(xHeartbeatTimer, 0);

    // Attach hardware interrupt to button (Falling edge)
    attachInterrupt(digitalPinToInterrupt(PIN_BUTTON), isrButtonPress, FALLING);

    // Spawn the worker task on Core 1
    xTaskCreatePinnedToCore(vWorkerTask, "WorkerTask", 4096, NULL, 1, NULL, 1);
}

void loop() {
    // Main loop is intentionally left empty or used for low-priority background tasks.
    vTaskDelay(pdMS_TO_TICKS(10000));
}

// --- Timer Callbacks (Execute in Tmr Svc context - MUST NOT BLOCK) ---

void vDebounceTimerCallback(TimerHandle_t xTimer) {
    char event = 'B'; // Button event
    // Non-blocking send to queue. Do NOT use Serial.println here!
    xQueueSendToBackFromISR(xActionQueue, &event, NULL);
}

void vHeartbeatTimerCallback(TimerHandle_t xTimer) {
    // Fast GPIO toggle is safe in timer context
    digitalWrite(PIN_STATUS_LED, !digitalRead(PIN_STATUS_LED));
}

// --- Worker Task (Executes in its own context - CAN BLOCK) ---

void vWorkerTask(void *pvParameters) {
    char receivedEvent;
    while (1) {
        // Block until a message arrives in the queue
        if (xQueueReceive(xActionQueue, &receivedEvent, portMAX_DELAY) == pdPASS) {
            if (receivedEvent == 'B') {
                digitalWrite(PIN_ACTION_LED, HIGH);
                Serial.println("[Worker] Button press registered and debounced.");
                vTaskDelay(pdMS_TO_TICKS(200)); // Safe to block here
                digitalWrite(PIN_ACTION_LED, LOW);
            }
        }
    }
}

Debugging: "Task Watchdog Got Triggered" on Tmr Svc

The most common failure mode when implementing Arduino FreeRTOS timers on the ESP32 is stalling the timer daemon task. When the Tmr Svc task fails to yield to the Idle task, the ESP32's hardware Task Watchdog Timer (TWDT) resets the chip. You will see this exact error string in your serial monitor:

E (4582) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (4582) task_wdt: - Tmr Svc (CPU 0)
E (4582) task_wdt: Aborting.

If your ESP32 is rebooting with this panic, here are the first three things to check, ranked by probability:

  1. Blocking calls inside the callback: Inspect your TimerCallbackFunction_t. Did you accidentally leave a delay(), vTaskDelay(), Serial.print() (which blocks if the TX buffer is full), or a slow I2C/SPI transaction inside it? Fix: Move the heavy logic to a worker task and use xQueueSend inside the callback, as shown in the code above.
  2. Timer period is shorter than callback execution time: If you have an auto-reload timer set to 10ms, but the callback takes 15ms to execute (even if it's non-blocking, like iterating through a massive array), the Tmr Svc task will never yield. Fix: Increase the timer period or optimize the callback logic.
  3. Timer command queue starvation: FreeRTOS timers are controlled via a command queue (configured by configTIMER_QUEUE_LENGTH in FreeRTOSConfig.h). If you are rapidly starting/stopping timers from a high-priority ISR and the queue fills up, the daemon can lock up trying to process a backlog. Fix: Ensure you are checking the return values of xTimerStart() and not flooding the daemon from ISRs.

For deeper architectural limits, consult the Espressif Watchdog Timer documentation, which details how to adjust the TWDT timeout period if your legitimate timer operations simply require more than the default 5-second window.

Extending and Simplifying Your Timer Build

Once you have the baseline architecture running, you will inevitably need to adapt it to production constraints. Here is how to scale the build up or down.

How to Extend: Dynamic Periods and Exponential Backoff

Software timers are not locked to their initial period. If you are building a battery-powered sensor node that publishes via MQTT, you can use xTimerChangePeriod() to implement exponential backoff on connection failures.

Inside your worker task, if the WiFi connection drops, call xTimerChangePeriod(xSensorTimer, pdMS_TO_TICKS(new_period), 0). This allows you to poll a sensor every 1 second when connected, but gracefully back off to 60 seconds when the network is down, all without destroying and recreating the timer object (which causes heap fragmentation).

How to Simplify: Dropping FreeRTOS for Hardware Timers

FreeRTOS software timers carry overhead. Each timer consumes a small amount of heap RAM for its control block, and the Tmr Svc task consumes a dedicated stack (typically 2048 to 4096 bytes on ESP32). If your project only requires a single 1Hz heartbeat blink and you do not need the RTOS queue architecture, simplify the build.

Use the ESP32's native LEDC (LED Control) peripheral or the hardware GPTimer API. Hardware timers execute in an ISR context, bypassing the RTOS scheduler entirely. They use zero heap RAM and offer sub-microsecond jitter. Only pay the "FreeRTOS tax" when you actually need the concurrent task management and queue routing that software timers provide.