If you need to execute a function after a specific delay or at a recurring interval on the ESP32, the direct answer is to use a FreeRTOS Software Timer via xTimerCreate() for application-level tasks, or a Hardware Timer via the ESP-IDF GPTimer API for microsecond-precision interrupts. For 95% of IoT and hobbyist projects—like debouncing buttons, blinking status LEDs, or scheduling sensor reads—the FreeRTOS software timer is the correct, safest choice because it avoids interrupt context nightmares and stack overflows.

This guide cuts through the abstraction. We will wire a physical circuit, write robust C++ code targeting the ESP32 Arduino Core v3.x (which uses ESP-IDF v5.x under the hood), and debug the exact kernel panics that occur when timer callbacks block the system.

The Decision Path: Software vs. Hardware Timers

Before writing code, you must choose the right timer primitive. The ESP32 has four 64-bit hardware timers, but using them requires writing Interrupt Service Routines (ISRs). FreeRTOS software timers run in a dedicated daemon task context, meaning you can safely call most FreeRTOS APIs from within their callbacks.

Criteria FreeRTOS Software Timer (xTimerCreate) Hardware Timer (GPTimer / Legacy timerWrite)
Execution Context Timer Daemon Task (Thread context) Interrupt Service Routine (ISR context)
Resolution ~1ms (depends on configTICK_RATE_HZ) Microseconds (APB clock derived)
Allowed Operations Most FreeRTOS APIs, I2C/SPI, Serial.print Strictly ISR-safe macros (FromISR), volatile flags only
Jitter Higher (subject to RTOS scheduler load) Near-zero (hardware triggered)
If you need... ...to debounce a switch or poll a BME280 every 5 seconds. ...to bit-bang a WS2812B LED strip or decode a 38kHz IR remote.
Final Verdict Default Pick: FreeRTOS Software Timer. Use hardware timers only when sub-millisecond jitter is a hard failure condition.

Parts List and Pin Mapping

This build assumes you are using the most common development board on the bench. The code and pin mappings below are strictly validated for this variant.

Target Board Variant: ESP32-WROOM-32 DevKit V1 (commonly sold as NodeMCU-32S or HiLetgo ESP32). This board features the dual-core Tensilica LX6, 4MB flash, and maps the built-in blue LED to GPIO 2.

Bill of Materials

  • MCU: ESP32-WROOM-32 DevKit V1 (30-pin or 38-pin variant)
  • Switch: 6x6mm Tactile pushbutton (SPST-NO)
  • Resistors: 1x 330Ω (LED current limiting), 1x 10kΩ (Pull-up for button)
  • LED: 5mm Standard diffused LED (or use the onboard GPIO 2 LED)
  • Wiring: 22 AWG solid core jumper wires, half-size breadboard

Pin Mapping Table

Component ESP32 GPIO Notes & Constraints
External LED Anode GPIO 2 Shares pin with onboard blue LED. Safe for output.
LED Cathode GND Via 330Ω resistor.
Tactile Button (Leg 1) GPIO 15 Configured with internal pull-up in code; external 10kΩ optional.
Tactile Button (Leg 2) GND Drives pin LOW when pressed.

How to Use FreeRTOS Timers with ESP32 Arduino: The Build

Follow these steps to wire and flash the board. This example creates an auto-reload timer that blinks an LED, and a one-shot timer that triggers a "timeout" warning if the button isn't pressed within 5 seconds. Pressing the button resets the one-shot timer.

Step 1: Wire the Hardware

  1. Connect the LED anode to GPIO 2 and cathode to GND via the 330Ω resistor.
  2. Connect one leg of the tactile button to GPIO 15 and the other to GND.
  3. Verify all ground connections share a common bus on the breadboard.

Step 2: Flash the Compilable Code

The following code includes full error handling for timer creation and execution. It explicitly checks FreeRTOS return codes rather than assuming success.

#include <Arduino.h>

// --- Pin Definitions ---
#define LED_PIN       2
#define BUTTON_PIN    15

// --- Timer Handles ---
TimerHandle_t blinkTimerHandle = NULL;
TimerHandle_t timeoutTimerHandle = NULL;

// --- Callback Prototypes ---
void blinkTimerCallback(TimerHandle_t xTimer);
void timeoutTimerCallback(TimerHandle_t xTimer);

void setup() {
  Serial.begin(115200);
  delay(500); // Allow serial monitor to connect
  Serial.println("\n[BOOT] ESP32 FreeRTOS Timer Demo Starting...");

  // Configure Pins
  pinMode(LED_PIN, OUTPUT);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  digitalWrite(LED_PIN, LOW);

  // 1. Create Auto-Reload Blink Timer (500ms period)
  blinkTimerHandle = xTimerCreate(
    "BlinkTimer",           // Text name
    pdMS_TO_TICKS(500),     // Period
    pdTRUE,                 // Auto-reload (pdTRUE = yes)
    (void *)0,              // Timer ID
    blinkTimerCallback      // Callback function
  );

  // 2. Create One-Shot Timeout Timer (5000ms period)
  timeoutTimerHandle = xTimerCreate(
    "TimeoutTimer",
    pdMS_TO_TICKS(5000),
    pdFALSE,                // One-shot (pdFALSE = no auto-reload)
    (void *)1,
    timeoutTimerCallback
  );

  // --- Error Handling: Verify Creation ---
  if (blinkTimerHandle == NULL || timeoutTimerHandle == NULL) {
    Serial.println("[FATAL] Failed to create timers. Insufficient heap?");
    while(1) { delay(1000); } // Halt execution
  }

  // --- Start Timers ---
  if (xTimerStart(blinkTimerHandle, 0) != pdPASS) {
    Serial.println("[ERROR] Blink timer command queue full. Failed to start.");
  }
  
  if (xTimerStart(timeoutTimerHandle, 0) != pdPASS) {
    Serial.println("[ERROR] Timeout timer command queue full. Failed to start.");
  }

  Serial.println("[BOOT] Timers active. Press button to reset timeout.");
}

void loop() {
  // Poll button to reset the one-shot timer
  if (digitalRead(BUTTON_PIN) == LOW) {
    // Debounce delay
    delay(50); 
    if (digitalRead(BUTTON_PIN) == LOW) {
      Serial.println("[BTN] Pressed. Resetting timeout timer.");
      
      // Resetting a one-shot timer restarts its countdown
      if (xTimerReset(timeoutTimerHandle, 0) != pdPASS) {
        Serial.println("[WARN] Timer reset command failed (Queue full).");
      }
      
      // Wait for button release
      while(digitalRead(BUTTON_PIN) == LOW) { delay(10); }
    }
  }
  
  // Yield to lower priority tasks
  vTaskDelay(pdMS_TO_TICKS(10));
}

// --- Timer Callbacks ---

void blinkTimerCallback(TimerHandle_t xTimer) {
  // Safe to toggle GPIO directly in timer daemon task context
  digitalWrite(LED_PIN, !digitalRead(LED_PIN));
}

void timeoutTimerCallback(TimerHandle_t xTimer) {
  // WARNING: Do NOT use delay() or blocking I2C/SPI here!
  Serial.println("[ALERT] Timeout! Button was not pressed within 5 seconds.");
  // Fast blink indication
  for(int i=0; i<3; i++) {
    digitalWrite(LED_PIN, HIGH);
    vTaskDelay(pdMS_TO_TICKS(100)); // Safe in daemon task, NOT safe in ISR
    digitalWrite(LED_PIN, LOW);
    vTaskDelay(pdMS_TO_TICKS(100));
  }
}

Debugging: First Three Things to Check When Timers Fail

When working with RTOS primitives, failures rarely manifest as simple logic errors. They usually crash the kernel. If your ESP32 reboots or your timers silently fail, check these three things in order.

1. The Watchdog Panic (Blocking in Callback)

Exact Error String: Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1) or Task watchdog got triggered. The following tasks did not reset the watchdog in time: Tmr Svc.

The Cause: You put blocking code inside a software timer callback. While the timer daemon task can use vTaskDelay() (as shown in our timeout callback), doing so blocks the daemon task from processing any other software timers in your system. If you use standard Arduino delay() or a blocking Wire.requestFrom() that hangs, the RTOS Task Watchdog Timer (TWDT) detects the daemon task is starved and reboots the chip.

The Fix: Keep callbacks short. If you must do heavy lifting, have the timer callback send a notification to a dedicated worker task using xTaskNotifyGive() or push an event to a FreeRTOS Queue.

2. The Command Queue Overflow

Exact Error String: xTimerStart or xTimerReset returns pdFAIL (often accompanied by assert failed: prvProcessReceivedCommands timers.c in raw ESP-IDF builds).

The Cause: FreeRTOS software timers are controlled via a command queue. If your loop() or an ISR spams xTimerReset() hundreds of times a second, the queue fills up, and subsequent commands are dropped.

The Fix: Increase the queue length. In the Arduino IDE, you can sometimes adjust this via the "FreeRTOS" menu in the Tools dropdown, or by editing sdkconfig directly in PlatformIO/ESP-IDF to increase CONFIG_FREERTOS_TIMER_TASK_QUEUE_LENGTH from the default 10 to 20 or 30.

3. The Silent Failure (Timer Never Fires)

Symptom: Code compiles and uploads, serial monitor shows boot messages, but the LED never blinks and the timeout never triggers.

The Cause: You forgot to call xTimerStart() after xTimerCreate(), or you are using a pin tied to the SPI flash (GPIO 6-11) which prevents the GPIO matrix from routing the signal.

The Fix: Verify your pin mapping against the Espressif FreeRTOS documentation and ESP32 pinout strapping pin warnings. Ensure xTimerStart(handle, 0) is explicitly called and its return value is checked.

Extending and Simplifying Your Timer Architecture

Once you have basic timers running, you will inevitably need to scale. Here is how to extend this architecture for production-grade IoT firmware, and how to simplify it if you are over-engineering a simple prototype.

How to Extend (Production IoT)

  • Use Timer IDs for Routing: Notice the (void *)0 and (void *)1 parameters in xTimerCreate. You can pass a pointer to a custom struct or use pvTimerGetTimerID(xTimer) inside the callback. This allows you to use a single callback function for 10 different timers, using a switch statement on the Timer ID to route the logic. This saves RAM by reducing code duplication.
  • Combine with Event Groups: Instead of toggling GPIOs directly in the timer callback, use xEventGroupSetBits() to signal an Event Group. Your main application task can then block on xEventGroupWaitBits(), waking up only when the timer fires. This centralizes state management.

How to Simplify (Quick Prototyping)

If you are just building a quick proof-of-concept and the RTOS overhead feels heavy, you don't need FreeRTOS timers for simple delays. Use the ESP32's hardware ESP Timer API (esp_timer_create), which is lighter weight than a full FreeRTOS daemon task but still runs in a thread context (unlike raw hardware interrupts). You can read more about the underlying FreeRTOS Software Timer architecture to understand the exact memory trade-offs.

Safety & Hardware Warning: Never use FreeRTOS software timers to trigger high-voltage relays or solid-state contactors directly without hardware zero-crossing detection or watchdog interlocks. The RTOS scheduler can experience jitter during heavy WiFi/Bluetooth stack operations, resulting in unpredictable relay switching times that can weld contacts or damage inductive loads.

By defaulting to xTimerCreate for your application logic and reserving hardware interrupts strictly for signal decoding, you will eliminate the vast majority of watchdog panics and timing bugs in your ESP32 projects. Wire the circuit, flash the code, and press the button to watch the daemon task handle the heavy lifting.