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
| Method | Execution Context | Blocking? | Typical Jitter | Max Concurrent Instances |
|---|---|---|---|---|
| FreeRTOS Software Timer | Timer Service Task (Tmr Svc) | No | 1 - 5 ms | Limited by heap RAM |
| Hardware Timer (GPTimer) | Interrupt Service Routine (ISR) | No | < 1 µs | 4 per SoC |
| delay() | Current Calling Task | Yes | N/A | 1 (blocks thread) |
| millis() Polling | Current Calling Task | No | Loop dependent | Unlimited (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.
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
| Component | ESP32 GPIO | Direction | Notes |
|---|---|---|---|
| Onboard Status LED | GPIO 2 | Output | Active HIGH on most DevKit V1 boards |
| External Action LED | GPIO 15 | Output | Driven via 220Ω resistor |
| Tactile Button | GPIO 0 | Input | Active 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: - 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:
- Blocking calls inside the callback: Inspect your
TimerCallbackFunction_t. Did you accidentally leave adelay(),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 usexQueueSendinside the callback, as shown in the code above. - 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 Svctask will never yield. Fix: Increase the timer period or optimize the callback logic. - Timer command queue starvation: FreeRTOS timers are controlled via a command queue (configured by
configTIMER_QUEUE_LENGTHin 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 ofxTimerStart()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.






