The Short Answer: ESP32 Arduino Max Task Priority Limits

The maximum task priority in the ESP32 Arduino core (which wraps ESP-IDF and FreeRTOS) is 24. FreeRTOS defines configMAX_PRIORITIES as 25, meaning valid priority levels range from 0 (lowest) to 24 (highest).

If you assign a user task to priority 24, it will preempt almost every other process on the chip, including the Arduino loop() (which runs at priority 1) and the Wi-Fi/Bluetooth stacks (which typically run at priority 23). However, setting a task to max priority introduces a critical hazard: Task Watchdog Timer (TWDT) starvation.

Bench Warning: The FreeRTOS Idle Task runs at priority 0. Its primary job on the ESP32 is to reset the hardware Task Watchdog Timer. If your priority 24 task enters a tight loop without explicitly yielding control (via vTaskDelay() or taskYIELD()), the Idle Task never gets CPU time. The watchdog times out, assumes the system is locked up, and hard-resets the ESP32.

Always build high-priority tasks with explicit yield points. For hard real-time requirements where you cannot afford the jitter of vTaskDelay(), use direct-to-task notifications triggered by hardware interrupts rather than polling loops.

Hardware Spec Sheet & Pin Mapping

The code and debugging steps below target the ESP32-WROOM-32 DevKit V1 (30-pin or 38-pin variants). This dual-core board runs at 240 MHz and is the baseline for most Arduino-ESP32 development. We are pairing it with a BME280 I2C environmental sensor to demonstrate a high-priority data acquisition task that must not be delayed by background Wi-Fi telemetry.

Bill of Materials

  • MCU: ESP32-WROOM-32 DevKit V1 (Espressif or reputable clone like NodeMCU-32S)
  • Sensor: BME280 Breakout Board (3.3V logic, I2C interface)
  • Pull-ups: 2x 4.7kΩ resistors (if breakout lacks onboard I2C pull-ups)
  • Debugging: USB Logic Analyzer (e.g., Saleae Logic 8 or DSLogic) to verify I2C bus hold times

Pin Mapping Table

Component ESP32 GPIO Function / Notes
BME280 VCC 3V3 Do not use 5V; BME280 is strictly 3.3V
BME280 GND GND Common ground with ESP32
BME280 SDA GPIO 21 Default Hardware I2C Data (Add 4.7k pull-up to 3V3)
BME280 SCL GPIO 22 Default Hardware I2C Clock (Add 4.7k pull-up to 3V3)
Status LED GPIO 2 Onboard LED on most DevKit V1 boards

Complete Code: High-Priority Sensor Polling on Core 1

This sketch creates a dedicated FreeRTOS task pinned to Core 1. We assign it a priority of configMAX_PRIORITIES - 1 (which evaluates to 24). Notice the strict error handling on task creation and the mandatory vTaskDelay() inside the loop to prevent watchdog starvation.

#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

// Pin Definitions
#define I2C_SDA 21
#define I2C_SCL 22
#define STATUS_LED 2

// Task Parameters
#define SENSOR_TASK_STACK_SIZE 4096
#define SENSOR_TASK_PRIORITY (configMAX_PRIORITIES - 1) // Evaluates to 24
#define SENSOR_TASK_CORE 1

Adafruit_BME280 bme;
TaskHandle_t SensorTaskHandle = NULL;

void sensorAcquisitionTask(void *pvParameters) {
    // Initialize I2C and sensor inside the task context
    Wire.begin(I2C_SDA, I2C_SCL);
    if (!bme.begin(0x76, &Wire)) {
        Serial.println("[ERROR] BME280 initialization failed. Check wiring.");
        // Blink LED rapidly to indicate hardware fault, then delete task
        while (1) {
            digitalWrite(STATUS_LED, !digitalRead(STATUS_LED));
            vTaskDelay(pdMS_TO_TICKS(100));
        }
    }

    TickType_t xLastWakeTime = xTaskGetTickCount();
    const TickType_t xFrequency = pdMS_TO_TICKS(50); // 50ms polling rate

    for (;;) {
        float temp = bme.readTemperature();
        float pressure = bme.readPressure() / 100.0F;
        
        // Output data (Note: Serial.print is relatively slow, 
        // but acceptable here because we yield immediately after)
        Serial.printf("[Core %d] Temp: %.2f C | Press: %.2f hPa\n", 
                      xPortGetCoreID(), temp, pressure);
        
        digitalWrite(STATUS_LED, !digitalRead(STATUS_LED));

        // CRITICAL: Yield to lower priority tasks (including Idle/Watchdog)
        vTaskDelayUntil(&xLastWakeTime, xFrequency);
    }
}

void setup() {
    Serial.begin(115200);
    pinMode(STATUS_LED, OUTPUT);
    delay(1000); // Allow serial monitor to connect

    Serial.println("Starting ESP32 High-Priority Sensor Task...");

    BaseType_t taskCreated = xTaskCreatePinnedToCore(
        sensorAcquisitionTask,
        "SensorTask",
        SENSOR_TASK_STACK_SIZE,
        NULL,
        SENSOR_TASK_PRIORITY,
        &SensorTaskHandle,
        SENSOR_TASK_CORE
    );

    if (taskCreated != pdPASS) {
        Serial.println("[FATAL] Failed to create Sensor Task. Insufficient heap RAM.");
        while (1) { delay(1000); } // Halt
    }
}

void loop() {
    // The Arduino loopTask runs on Core 1 at Priority 1.
    // Keep it empty or use it for low-priority Wi-Fi telemetry.
    vTaskDelay(pdMS_TO_TICKS(1000));
}

Debugging: Task Watchdog Errors and Ranked Causes

When a high-priority task misbehaves, the ESP32 will panic and reboot. If you are monitoring the serial output at 115200 baud, you will see this exact error string right before the reset:

E (6015) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (6015) task_wdt: - IDLE (CPU 1)
E (6015) task_wdt: Tasks currently running: CPU 0: IDLE CPU 1: SensorTask
E (6015) task_wdt: Aborting.
abort() was called at PC 0x400d5b3b on core 0

If you encounter this crash, here are the first three things to check:

  1. Verify Yield Points: Search your high-priority task for while() or for() loops. Ensure every loop contains vTaskDelay(), vTaskDelayUntil(), or taskYIELD().
  2. Check I2C/SPI Timeouts: If your sensor hangs and the Wire library blocks indefinitely, your task will never reach the yield statement. Use libraries that support I2C timeouts, or implement a hardware watchdog on the sensor bus.
  3. Inspect Interrupt Disables: Ensure you are not using portDISABLE_INTERRUPTS() or noInterrupts() for more than a few microseconds inside the task.

Ranked Causes of Priority Starvation

Rank Cause Fix
1 (Most Likely) Tight polling loop without vTaskDelay() in a task with priority > 0. Add vTaskDelay(pdMS_TO_TICKS(1)) or use ulTaskNotifyTake() to wait for ISR triggers.
2 Blocking I2C read (e.g., Wire.requestFrom()) hanging due to missing pull-up resistors. Verify 4.7kΩ pull-ups on SDA/SCL. Measure bus idle state with a multimeter (should read ~3.3V).
3 Stack overflow corrupting the FreeRTOS kernel control block. Increase SENSOR_TASK_STACK_SIZE from 2048 to 4096. Use uxTaskGetStackHighWaterMark() to monitor usage.
4 Calling delay() instead of vTaskDelay() in an ESP-IDF native task context. Replace Arduino delay() with FreeRTOS vTaskDelay(). Arduino delay yields, but behaves unpredictably in pinned core tasks.

For deeper architectural guidance on how the ESP32 handles kernel panics, refer to the Espressif ESP-IDF Watchdog Timer Documentation and the official FreeRTOS Kernel Features guide.

Extending or Simplifying the Build

Not every project requires max-priority RTOS tasks. Misusing priorities adds complexity and debugging overhead. Use this framework to decide how to scale your architecture:

How to Simplify (Drop to Priority 1)

If your sensor only needs to be read every 500ms, and you are uploading data to MQTT over Wi-Fi, delete the custom task entirely. Move the sensor read logic directly into the standard Arduino loop(). The loopTask runs at priority 1, which cooperates perfectly with the Wi-Fi stack (priority 23) and prevents TWDT issues natively. Use delay() or non-blocking millis() timers.

How to Extend (True Hard Real-Time)

If you are building a motor controller or reading high-speed encoders where a 50ms yield is unacceptable, do not use polling. Extend the build by attaching a hardware interrupt to the sensor's data-ready pin. Inside the ISR, use vTaskNotifyGiveFromISR() to wake your priority 24 task. The task sleeps at priority 24 (consuming zero CPU and allowing the Idle task to run) and wakes instantly when hardware triggers it.

Frequently Asked Questions

What happens if I set my ESP32 task priority to 25?

The compiler will accept it, but FreeRTOS will trigger a configASSERT failure at runtime because 25 is outside the valid array bounds of the ready lists. The ESP32 will immediately panic and reboot with a assert failed: prvAddNewTaskToReadyList error in the serial monitor. Always cap your manual assignments at configMAX_PRIORITIES - 1 (24).

How do I change the default Arduino loop task priority?

You cannot change it dynamically at runtime via standard Arduino functions, as the loopTask is created by the ESP32 core initialization code before setup() runs. However, you can create your own high-priority task and simply leave the Arduino loop() empty (using a long vTaskDelay inside it to sleep the thread). This effectively shifts your application logic to your custom high-priority thread.

Can two FreeRTOS tasks on the ESP32 have the same priority?

Yes. If two tasks share the same priority (e.g., both at priority 5), the FreeRTOS scheduler will time-slice them, alternating execution based on the tick rate (default 1ms on ESP32). However, if one task enters a blocking state (waiting on a queue or delay), the other will consume the remaining time slice. For dual-core ESP32s, pinning same-priority tasks to different cores (Core 0 and Core 1) allows them to run truly in parallel without time-slicing overhead.

Why does my Wi-Fi drop when I run a high-priority task?

The ESP32 Wi-Fi and Bluetooth stacks run as background RTOS tasks, typically at priority 23. If you create a user task at priority 24 and it consumes too much CPU time without yielding, it will preempt the Wi-Fi task. The Wi-Fi stack will miss critical beacon intervals and ACK timeouts, causing the access point to disassociate the ESP32. Keep user tasks at priority 20 or lower unless you have strict microsecond-level timing requirements and know exactly how to yield.