If you have ever pushed an ESP32 to its limit with heavy sensor aggregation or cryptographic calculations while maintaining a Wi-Fi connection, you have likely encountered the infamous Task Watchdog Got Triggered error or unexplained network disconnects.
The ESP32 is a dual-core system, yet many firmware engineers treat it like an Arduino Uno (single-core), dumping all logic into the main loop or generic FreeRTOS tasks. This results in the "Application" code fighting for CPU cycles with the "Protocol" (Wi-Fi/Bluetooth) stack. When your blocking code wins, the Wi-Fi stack starves, the watchdog bites, and the system resets.
To build industrial-grade firmware, you must explicitly leverage the symmetric multiprocessing (SMP) capabilities of the ESP32 by pinning tasks to specific cores.
The Root Cause: PRO_CPU vs. APP_CPU
The ESP32 architecture consists of two Tensilica Xtensa LX6 microprocessors:
- Core 0 (Protocol CPU / PRO_CPU): By default, the ESP-IDF (and Arduino) handles Wi-Fi, Bluetooth, and TCP/IP stack events here.
- Core 1 (Application CPU / APP_CPU): In the Arduino context, the
setup()andloop()functions run here.
The crash occurs due to resource starvation. The Wi-Fi stack requires frequent CPU time to handle beacons and keep-alive packets. If you create a FreeRTOS task with xTaskCreate (which, depending on configuration, might default to Core 0 or tskNO_AFFINITY) and that task executes a long-running while(true) loop without yielding (vTaskDelay), Core 0 hits 100% utilization. The Wi-Fi background tasks get blocked, and the hardware watchdog timer (WDT) triggers a hard reset to save the system from an unresponsive state.
The Fix: Explicit Core Pinning
To solve this, we must offload heavy computational work to Core 1 (or Core 0, with extreme caution) using xTaskCreatePinnedToCore. This API forces the scheduler to run a specific task only on the designated CPU, ensuring true parallelism rather than just time-sliced concurrency.
Below is a modern C++ implementation demonstrating a thread-safe architecture. We will simulate a heavy blocking task (e.g., FFT analysis or image processing) running on Core 1, leaving Core 0 completely free to handle HTTP requests.
Implementation
We will use std::atomic or FreeRTOS Queues for Inter-Process Communication (IPC) to prevent race conditions, as memory access is now truly simultaneous.
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
// Configuration Constants
constexpr int BAUD_RATE = 115200;
constexpr int QUEUE_LEN = 10;
constexpr int MSG_SIZE = sizeof(int);
// Task Handles
TaskHandle_t heavyTaskHandle = nullptr;
TaskHandle_t netTaskHandle = nullptr;
// Queue for Inter-Core Communication
QueueHandle_t dataQueue;
/**
* @brief Task running on Core 1: Heavy Computation.
* This simulates a blocking sensor reading or DSP algorithm.
*/
void heavyComputationTask(void *parameter) {
int counter = 0;
// A task must contain an infinite loop
for (;;) {
// 1. Simulate heavy blocking work (e.g., 500ms processing)
// In a real scenario, this is where your FFT or Encryption happens.
unsigned long start = millis();
while (millis() - start < 500) {
// Busy wait simulation - effectively blocking this core
asm("nop");
}
counter++;
// 2. Send data to the Network Task safely
if (xQueueSend(dataQueue, &counter, portMAX_DELAY) != pdPASS) {
Serial.println("Queue Full: Could not send data.");
}
// 3. Yield to IDLE task on Core 1 to prevent WDT on this core
// Even heavy tasks need to breathe occasionally.
vTaskDelay(pdMS_TO_TICKS(10));
}
}
/**
* @brief Task running on Core 0: Network/Protocol Handling.
* Keeps the loop tight to allow RF stack high priority.
*/
void networkTask(void *parameter) {
int receivedValue = 0;
for (;;) {
// 1. Wait for data from Core 1 (Blocking wait)
if (xQueueReceive(dataQueue, &receivedValue, portMAX_DELAY) == pdPASS) {
// 2. Simulate Network Transmission
// Because this is pinned to Core 0, it shares time with Wi-Fi/BT background tasks.
// Using Serial.print here to visualize, but this represents MQTT/HTTP logic.
Serial.printf("[Core %d] Sending Data: %d\n", xPortGetCoreID(), receivedValue);
}
// 3. Crucial Yield
// On Core 0, failing to yield often is fatal for Wi-Fi.
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void setup() {
Serial.begin(BAUD_RATE);
// Initialize IPC Queue
dataQueue = xQueueCreate(QUEUE_LEN, MSG_SIZE);
if (dataQueue == NULL) {
Serial.println("Error creating the queue");
return;
}
Serial.printf("Main Loop running on Core: %d\n", xPortGetCoreID());
// ----------------------------------------------------------------
// CRITICAL SECTION: Task Creation
// ----------------------------------------------------------------
// Create Task on Core 1 (APP_CPU) for Heavy Lifting
xTaskCreatePinnedToCore(
heavyComputationTask, // Function to implement the task
"HeavyCalc", // Name of the task
10000, // Stack size in words
NULL, // Task input parameter
1, // Priority
&heavyTaskHandle, // Task handle
1 // Core ID (1 = APP_CPU)
);
// Create Task on Core 0 (PRO_CPU) for Network Logic
// Note: Wi-Fi background tasks also run on Core 0 with high priority.
// We set our priority to 1 to ensure we don't starve the actual Wi-Fi driver.
xTaskCreatePinnedToCore(
networkTask, // Function to implement the task
"NetLogic", // Name of the task
10000, // Stack size
NULL, // Parameter
1, // Priority
&netTaskHandle, // Handle
0 // Core ID (0 = PRO_CPU)
);
}
void loop() {
// The Main Loop can be deleted or used for low-priority monitoring.
// Since we assigned work explicitly, we can simply suspend this task.
vTaskDelete(NULL);
}
The Explanation
1. xTaskCreatePinnedToCore
This is the standard xTaskCreate function with one critical addition: the last parameter (xCoreID).
- 0: Pins the task to the Protocol Core. Use this for logic that interacts intimately with the network stack, but keep the logic non-blocking.
- 1: Pins the task to the Application Core. Use this for heavy math, display drivers, or sensor polling loops.
tskNO_AFFINITY: Allows the scheduler to run the task on whichever core is free. While flexible, this is dangerous for high-frequency loops as they might drift onto Core 0 and crash the Wi-Fi.
2. Thread Safety (IPC)
When code runs on two physical cores simultaneously, global variables are unsafe. In the example above, we use xQueueCreate and xQueueSend/Receive. This acts as a thread-safe buffer. Core 1 pushes calculation results into the queue, and Core 0 reads them when ready. This decouples the processing speed from the network transmission speed.
3. Priority Management
Notice we set the priority of networkTask to 1. The internal Wi-Fi tasks usually run at a much higher priority (typically 19-23). By keeping our user-land network logic at a lower priority on Core 0, we ensure that if the hardware receives a Wi-Fi beacon, the OS effectively pauses our networkTask, handles the radio event, and then resumes our task.
Conclusion
The ESP32 is not just a faster Arduino; it is a parallel processing system. Treating it as such resolves the majority of "random" disconnects and resets.
By pinning computational loads to Core 1 and leaving Core 0 primarily for the RF stack (and light network coordination), you separate the "thinking" from the "communicating." This separation of concerns is the hallmark of stable embedded firmware.