ESP32 FreeRTOS Dual-Core Task Pinning: The Complete IoT Engineer's Guide (2026)
Every ESP32 tutorial starts with a single loop(). But the moment your project needs to read a sensor, maintain a WiFi connection, publish MQTT data, and respond to button presses simultaneously — a single loop collapses. Tasks starve each other. WiFi disconnects mid-read. Sensors miss samples. You've hit the wall that FreeRTOS was built to knock down.
What most tutorials miss is this: the ESP32 has two independent CPU cores, and FreeRTOS lets you decide which core each task runs on. Used correctly, this is one of the most powerful performance tools available on any $5 microcontroller. Used wrong — or ignored completely — you get the same jitter and crashes as a bad single-threaded loop, just with more complex code.
I've been building production IoT systems with ESP32 for over five years. In this guide I'm giving you the architecture I actually use — not the "blink two LEDs in two tasks" example you've already seen everywhere, but a real IoT pipeline: sensors on Core 1, WiFi and MQTT on Core 0, connected by a safe queue, with real CPU usage benchmarks from my bench.
📋 Table of Contents
- Why This Topic Is Underserved (and Why That Matters for You)
- ESP32 Dual-Core Architecture: What the Datasheet Actually Says
- What Runs on Which Core by Default
- xTaskCreatePinnedToCore() — Full API Breakdown
- Core Affinity Strategy for Real IoT Projects
- FreeRTOS Queues: Safe Inter-Core Data Transfer
- Mutexes: Protecting Shared Resources Across Cores
- Full Project: Dual-Core Sensor + MQTT Pipeline (Complete Code)
- Stack Sizing Without Guessing
- Real CPU Usage Benchmarks
- 5 Mistakes That Crash Dual-Core ESP32 Projects
- FAQ
- Conclusion
1. Why This Topic Is Underserved — and Why That Matters for You
I analyzed the existing ESP32 FreeRTOS content landscape before writing this guide. Here's what I found: the top-ranking tutorials all cover single-core FreeRTOS — two LEDs blinking at different rates, one task printing to serial. That is the "Hello World" of FreeRTOS, and it is useful for understanding the API. But it maps nothing like a real IoT application onto the ESP32's actual dual-core advantage.
The specific question — "which tasks should I pin to Core 0 vs Core 1, and why?" — is asked constantly on the ESP32 forums, Reddit's r/esp32, and Stack Overflow. The answers there are fragmented and often wrong. No single guide walks through the full picture: architecture → strategy → code → measurement.
That gap is what this article fills. It is also why this keyword combination has real search traffic but almost no authoritative content competing for it — which is exactly the condition you want for a page targeting position 1.
2. ESP32 Dual-Core Architecture: What the Datasheet Actually Says
The ESP32 (WROOM-32 and most common variants) contains two Xtensa LX6 32-bit processors running at up to 240 MHz each. Espressif labels them CPU0 (PRO_CPU) and CPU1 (APP_CPU). Both cores share:
- 520 KB internal SRAM (split into multiple banks)
- 4 MB flash (on most dev boards)
- All peripherals: GPIO, I²C, SPI, UART, ADC, DAC
- The WiFi/BT radio coprocessor (connected via a separate interface)
What they do not share is execution. Both cores can be running different machine code simultaneously — true SMP (Symmetric Multiprocessing), not fake multitasking. This is a fundamentally different capability from a single-core microcontroller doing rapid context switching.
The ESP32-S3 (used in the TinyML guide on xloge.site) upgrades to dual-core Xtensa LX7 at 240 MHz with a Neural Network Accelerator. The FreeRTOS dual-core behavior is identical — same API, same core affinity rules.
3. What Runs on Which Core by Default
Before you write a single line of FreeRTOS code, you need to understand the default layout. Most engineers don't, and this is the root cause of the majority of ESP32 WiFi instability bugs.
⚙️ Core 0 (PRO_CPU) — Default Occupants
- WiFi radio stack tasks (lwIP, wpa_supplicant)
- Bluetooth controller tasks
- System event loop (esp_event)
- MQTT client internal tasks (if using esp-mqtt)
- Your app's WiFi callbacks run here
⚙️ Core 1 (APP_CPU) — Default Occupants
- Arduino
setup()andloop() - Any task created with
xTaskCreate()(floats freely) - Most user application code
- FreeRTOS idle task for Core 1
The critical implication: if you create a WiFi-heavy task with plain xTaskCreate(), the scheduler might place it on Core 1, forcing the WiFi stack to constantly hand off data across cores via shared memory — introducing context switch overhead, cache misses, and timing jitter that manifests as random disconnects.
| Task Type | Recommended Core | Reason |
|---|---|---|
| WiFi connect / reconnect | Core 0 | Co-located with radio stack — no cross-core handoff |
| MQTT publish / subscribe | Core 0 | Uses network stack on Core 0 |
| HTTP GET / POST | Core 0 | Same as MQTT — lwIP lives on Core 0 |
| I²C sensor reads (BMP280, SSD1306) | Core 1 | Isolated from WiFi jitter — deterministic timing |
| ADC sampling | Core 1 | ADC accuracy degrades during WiFi transmission; Core 1 isolates it |
| PWM / motor control | Core 1 | Real-time precision — unaffected by WiFi bursts on Core 0 |
| Display updates (SPI TFT) | Core 1 | SPI transactions need consistent timing |
| TinyML inference | Core 1 | CPU-intensive; isolate from network interruptions |
| Logging / Serial print | Either (with mutex) | Low priority — protect with mutex if used on both cores |
4. xTaskCreatePinnedToCore() — Full API Breakdown
The standard FreeRTOS API xTaskCreate() works on ESP32 but gives up the dual-core advantage. The ESP32-specific function is:
BaseType_t xTaskCreatePinnedToCore(
TaskFunction_t pvTaskCode, // Task function pointer
const char* pcName, // Debug name (max 16 chars)
uint32_t usStackDepth, // Stack size in WORDS (not bytes!)
void* pvParameters, // Argument passed to task function
UBaseType_t uxPriority, // 0 (lowest) to configMAX_PRIORITIES-1 (highest)
TaskHandle_t* pvCreatedTask, // Handle for task control (NULL if not needed)
const BaseType_t xCoreID // 0 = Core 0, 1 = Core 1, tskNO_AFFINITY = either
);4.1 Parameter Deep-Dive
Stack Depth (usStackDepth)
This is measured in words (4 bytes each on ESP32), not bytes. A value of 4096 allocates 16,384 bytes (16 KB) of stack. This is the most common source of silent crashes — too little stack causes stack overflow, which on ESP32 manifests as random reboots or corrupted output. Start larger and measure with uxTaskGetStackHighWaterMark() (covered in Section 9).
Priority (uxPriority)
Higher number = higher priority. The WiFi stack internally uses priorities in the range 20–23. Your application tasks should stay in the range 1–10 to avoid interfering with the radio stack. Recommended ranges:
| Task Role | Suggested Priority |
|---|---|
| Critical real-time (motor control, ISR-adjacent) | 8–10 |
| Sensor reading, time-sensitive | 5–7 |
| MQTT publish, data processing | 4–6 |
| Display update, logging | 1–3 |
| WiFi management / reconnect loop | 3–5 |
Core ID (xCoreID)
xTaskCreatePinnedToCore(myTask, "MyTask", 4096, NULL, 5, NULL, 1);
// ^ Core 1
xTaskCreatePinnedToCore(mqttTask, "MQTT", 4096, NULL, 4, NULL, 0);
// ^ Core 0
xTaskCreatePinnedToCore(logTask, "Log", 2048, NULL, 1, NULL, tskNO_AFFINITY);
// ^ scheduler decidestskNO_AFFINITY (value: 2) does not pin the task. The scheduler can move it between cores at any time. For any task with real-time requirements or WiFi dependency, always pin explicitly to 0 or 1.5. Core Affinity Strategy for Real IoT Projects
Here's the mental model I use for every ESP32 project. Think of the two cores as two engineers in two separate rooms:
🌐 Core 0 — The Network Engineer
- Speaks to the outside world (WiFi, MQTT, HTTP)
- Handles connection management
- Receives data from Core 1 via queue
- Sends data to the cloud / broker
- Priority: medium (4–6)
🔬 Core 1 — The Lab Technician
- Reads all physical sensors
- Runs any signal processing or ML inference
- Controls any physical outputs (PWM, relays)
- Puts processed data into queue for Core 0
- Priority: medium-high (5–8)
The queue is the communication channel between rooms. Neither engineer barges into the other's room — they pass notes through a slot in the wall (the queue). This is exactly what xQueueSend and xQueueReceive do.
6. FreeRTOS Queues: Safe Inter-Core Data Transfer
A FreeRTOS queue is a thread-safe FIFO buffer. Tasks on different cores can send and receive from it without race conditions — the queue internally handles all the synchronization with critical sections. This is the correct way to move data between a Core 1 sensor task and a Core 0 network task.
6.1 Creating a Queue
// Define a struct for your sensor data
typedef struct {
float temperature;
float humidity;
uint32_t timestamp_ms;
} SensorReading_t;
// Create a queue that holds up to 10 SensorReading_t items
// Do this in setup() or globally before tasks are created
QueueHandle_t sensorQueue = xQueueCreate(10, sizeof(SensorReading_t));6.2 Sending to the Queue (Core 1 — Sensor Task)
void sensorTask(void *pvParameters) {
SensorReading_t reading;
for (;;) {
// Read sensor (DHT22 example)
reading.temperature = dht.readTemperature();
reading.humidity = dht.readHumidity();
reading.timestamp_ms = millis();
if (!isnan(reading.temperature) && !isnan(reading.humidity)) {
// xQueueSend: blocks for up to 100ms if queue is full
if (xQueueSend(sensorQueue, &reading, pdMS_TO_TICKS(100)) != pdTRUE) {
Serial.println("[Sensor] Queue full — reading dropped");
}
}
vTaskDelay(pdMS_TO_TICKS(5000)); // Read every 5 seconds
}
}6.3 Receiving from the Queue (Core 0 — MQTT Task)
void mqttTask(void *pvParameters) {
SensorReading_t reading;
for (;;) {
// xQueueReceive: blocks indefinitely until data arrives
if (xQueueReceive(sensorQueue, &reading, portMAX_DELAY) == pdTRUE) {
// Build JSON payload
char payload[128];
snprintf(payload, sizeof(payload),
"{\"temp\":%.1f,\"humidity\":%.1f,\"ts\":%lu}",
reading.temperature, reading.humidity, reading.timestamp_ms);
// Publish via MQTT (WiFi stack on same core — zero cross-core overhead)
if (mqttClient.connected()) {
mqttClient.publish("home/climate", payload);
}
}
}
}portMAX_DELAY in xQueueSend from a high-frequency sensor task — you risk blocking and starving the sensor read cycle.7. Mutexes: Protecting Shared Resources Across Cores
Some resources can't go through a queue — they must be accessed directly from multiple tasks. The most common example on ESP32 projects is Serial: both your sensor task (Core 1) and your MQTT task (Core 0) want to print debug output. Without protection, their output interleaves randomly and corrupts both messages.
7.1 Creating a Mutex
SemaphoreHandle_t serialMutex = xSemaphoreCreateMutex();7.2 Using the Mutex
// Anywhere in any task on any core:
void safePrint(const char* msg) {
if (xSemaphoreTake(serialMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
Serial.println(msg);
xSemaphoreGive(serialMutex);
}
// If take fails (timeout), the print is skipped rather than blocking
}xSemaphoreGiveFromISR() and xQueueSendFromISR(). Calling the standard versions from an ISR will crash your ESP32 with a "Guru Meditation Error: Core X panic'ed."8. Full Project: Dual-Core Sensor + MQTT Pipeline
Here is the complete, production-ready sketch combining everything from Sections 3–7. This is the exact architecture I use in sensor nodes for remote environmental monitoring:
/*
* ESP32 FreeRTOS Dual-Core IoT Pipeline
* ─────────────────────────────────────
* Core 0: WiFi management + MQTT publish task
* Core 1: DHT22 sensor reading task
* Bridge: FreeRTOS queue (thread-safe, cross-core)
* Shared resource: Serial (protected by mutex)
*
* Author : Malik Hassan | xloge.site
* Date : June 2026
* Tested : ESP32 WROOM-32, Arduino IDE 2.3.x, ESP32 core 3.x
* Libraries: PubSubClient, DHTesp, ArduinoJson
*/
#include
#include
#include
#include
// ── WiFi / MQTT credentials ──────────────────────────────────
const char* WIFI_SSID = "YourNetworkName";
const char* WIFI_PASS = "YourPassword";
const char* MQTT_BROKER = "192.168.1.100"; // Your local broker IP
const int MQTT_PORT = 1883;
const char* MQTT_USER = "esp32user";
const char* MQTT_PASS = "yourpassword";
const char* MQTT_TOPIC = "home/sensor/climate";
const char* CLIENT_ID = "esp32-dualcore-node-01";
// ── Hardware ─────────────────────────────────────────────────
#define DHT_PIN 4
// ── Data structure shared via queue ──────────────────────────
typedef struct {
float temperature;
float humidity;
uint32_t ts_ms;
uint16_t boot_count;
} SensorReading_t;
// ── FreeRTOS primitives ───────────────────────────────────────
QueueHandle_t sensorQueue;
SemaphoreHandle_t serialMutex;
// ── Objects ──────────────────────────────────────────────────
DHTesp dht;
WiFiClient espClient;
PubSubClient mqtt(espClient);
uint16_t bootCount = 0;
// ─────────────────────────────────────────────────────────────
// HELPER: thread-safe Serial print
// ─────────────────────────────────────────────────────────────
void safePrint(const char* msg) {
if (xSemaphoreTake(serialMutex, pdMS_TO_TICKS(30)) == pdTRUE) {
Serial.println(msg);
xSemaphoreGive(serialMutex);
}
}
void safePrintf(const char* fmt, ...) {
char buf[160];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
safePrint(buf);
}
// ─────────────────────────────────────────────────────────────
// CORE 1 TASK: Sensor Reading
// Pinned to Core 1 — completely isolated from WiFi radio
// ─────────────────────────────────────────────────────────────
void sensorTask(void *pvParameters) {
dht.setup(DHT_PIN, DHTesp::DHT22);
// Verify we are on the right core
safePrintf("[Sensor] Running on Core %d", xPortGetCoreID());
TickType_t xLastWakeTime = xTaskGetTickCount();
const TickType_t xPeriod = pdMS_TO_TICKS(10000); // 10-second interval
for (;;) {
vTaskDelayUntil(&xLastWakeTime, xPeriod); // Accurate periodic timing
TempAndHumidity data = dht.getTempAndHumidity();
if (dht.getStatus() != 0) {
safePrintf("[Sensor] DHT22 error: %s", dht.getStatusString());
continue;
}
SensorReading_t reading = {
.temperature = data.temperature,
.humidity = data.humidity,
.ts_ms = millis(),
.boot_count = ++bootCount
};
safePrintf("[Sensor] T=%.1f°C H=%.1f%% (reading #%d)",
reading.temperature, reading.humidity, reading.boot_count);
// Send to queue — non-blocking with 200ms timeout
if (xQueueSend(sensorQueue, &reading, pdMS_TO_TICKS(200)) != pdTRUE) {
safePrint("[Sensor] ⚠ Queue full — reading dropped");
}
// Stack health check (remove in production)
safePrintf("[Sensor] Stack HWM: %u words",
uxTaskGetStackHighWaterMark(NULL));
}
}
// ─────────────────────────────────────────────────────────────
// CORE 0 TASK: WiFi + MQTT
// Pinned to Core 0 — co-located with WiFi radio stack
// ─────────────────────────────────────────────────────────────
void wifiMqttTask(void *pvParameters) {
safePrintf("[Network] Running on Core %d", xPortGetCoreID());
// ── Connect to WiFi ──────────────────────────────────────
WiFi.begin(WIFI_SSID, WIFI_PASS);
safePrint("[Network] Connecting to WiFi...");
while (WiFi.status() != WL_CONNECTED) {
vTaskDelay(pdMS_TO_TICKS(500));
}
safePrintf("[Network] WiFi connected — IP: %s", WiFi.localIP().toString().c_str());
// ── Configure MQTT ───────────────────────────────────────
mqtt.setServer(MQTT_BROKER, MQTT_PORT);
mqtt.setKeepAlive(60);
mqtt.setSocketTimeout(10);
SensorReading_t reading;
for (;;) {
// ── Keep MQTT connected ──────────────────────────────
if (!mqtt.connected()) {
safePrint("[Network] MQTT reconnecting...");
if (mqtt.connect(CLIENT_ID, MQTT_USER, MQTT_PASS)) {
safePrint("[Network] MQTT connected");
// Publish online status
mqtt.publish("home/nodes/status",
"{\"node\":\"esp32-dualcore\",\"status\":\"online\"}", true);
} else {
safePrintf("[Network] MQTT failed rc=%d — retry in 5s", mqtt.state());
vTaskDelay(pdMS_TO_TICKS(5000));
continue;
}
}
mqtt.loop(); // Process incoming messages
// ── Wait for sensor data from Core 1 ─────────────────
// Block here until data arrives (or 12s timeout — slightly > sensor interval)
if (xQueueReceive(sensorQueue, &reading, pdMS_TO_TICKS(12000)) == pdTRUE) {
// ── Build JSON payload ────────────────────────────
StaticJsonDocument<192> doc;
doc["temperature"] = reading.temperature;
doc["humidity"] = reading.humidity;
doc["timestamp"] = reading.ts_ms;
doc["reading_num"] = reading.boot_count;
doc["core"] = xPortGetCoreID();
doc["rssi"] = WiFi.RSSI();
char payload[192];
serializeJson(doc, payload, sizeof(payload));
// ── Publish ───────────────────────────────────────
if (mqtt.publish(MQTT_TOPIC, payload)) {
safePrintf("[Network] Published: %s", payload);
} else {
safePrint("[Network] Publish failed — will retry next reading");
}
}
// WiFi watchdog: reconnect if dropped
if (WiFi.status() != WL_CONNECTED) {
safePrint("[Network] WiFi lost — reconnecting...");
WiFi.reconnect();
vTaskDelay(pdMS_TO_TICKS(3000));
}
}
}
// ─────────────────────────────────────────────────────────────
// setup() — Creates queue, mutex, and pins tasks to cores
// ─────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(300);
Serial.println("\n=== ESP32 Dual-Core FreeRTOS IoT Node ===");
Serial.printf("Arduino loop() is running on Core %d\n", xPortGetCoreID());
// ── Create FreeRTOS primitives ───────────────────────────
sensorQueue = xQueueCreate(10, sizeof(SensorReading_t));
serialMutex = xSemaphoreCreateMutex();
if (sensorQueue == NULL || serialMutex == NULL) {
Serial.println("FATAL: Failed to create FreeRTOS objects. Halting.");
while (true) vTaskDelay(pdMS_TO_TICKS(1000));
}
// ── Pin sensor task to Core 1 ────────────────────────────
xTaskCreatePinnedToCore(
sensorTask, // Task function
"SensorTask", // Name
4096, // Stack depth in words (16 KB)
NULL, // Parameters
6, // Priority (higher — real-time sensor)
NULL, // Task handle
1 // ← CORE 1
);
// ── Pin WiFi+MQTT task to Core 0 ─────────────────────────
xTaskCreatePinnedToCore(
wifiMqttTask, // Task function
"WiFiMQTT", // Name
8192, // Stack depth in words (32 KB — WiFi needs more stack)
NULL, // Parameters
5, // Priority
NULL, // Task handle
0 // ← CORE 0
);
// loop() itself is a task on Core 1 at priority 1
// We don't use it — the real work is in the pinned tasks above
}
// loop() is intentionally empty — all work is in FreeRTOS tasks
void loop() {
vTaskDelay(pdMS_TO_TICKS(10000));
} 9. Stack Sizing Without Guessing
The most dangerous silent bug in FreeRTOS is a stack overflow. It corrupts adjacent heap memory and manifests as random crashes, watchdog resets, and Guru Meditation Errors that look unrelated to the actual problem. Here's how to size stacks correctly.
9.1 Measure High-Water Mark After Running
Add this line inside any task to see the minimum free stack space ever observed (in words):
// Inside your task, during normal operation:
UBaseType_t hwm = uxTaskGetStackHighWaterMark(NULL);
Serial.printf("[TaskName] Stack HWM: %u words (%u bytes)\n", hwm, hwm * 4);If this number is below 200 words (800 bytes), increase your stack. If it's above 1000 words, you can reduce it to save RAM. The target is at least 300–500 words of headroom.
9.2 Enable Stack Overflow Detection
Add this to your FreeRTOSConfig.h (or ensure it's enabled via idf.py menuconfig):
#define configCHECK_FOR_STACK_OVERFLOW 2 // Mode 2 = most thorough
// Define this hook function in your application code:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
Serial.printf("STACK OVERFLOW in task: %s\n", pcTaskName);
// Force restart after printing
ESP.restart();
}9.3 Recommended Starting Stack Sizes for Common Tasks
| Task Type | Starting Stack (words) | Notes |
|---|---|---|
| Simple sensor read (DHT22, BMP280) | 2048–3072 | I²C + minimal local vars |
| MQTT + WiFi + JSON | 6144–8192 | Network stack + ArduinoJson needs headroom |
| HTTP GET/POST | 8192–12288 | HTTP parsing uses significant stack |
| TFLite inference (TinyML) | 4096–8192 | Depends on model size and ops |
| TFT display (SPI) | 3072–4096 | Larger if rendering text/images |
10. Real CPU Usage Benchmarks
I ran two configurations on the same hardware (ESP32 WROOM-32, 240 MHz, DHT22 on GPIO4, MQTT publish every 10 seconds to a Mosquitto broker on LAN) and measured with FreeRTOS's built-in vTaskGetRunTimeStats().
Configuration A: No Pinning (xTaskCreate, floating)
Configuration B: Correct Core Pinning (xTaskCreatePinnedToCore)
The differences are dramatic. The WiFi disconnect issue disappears entirely because the network tasks no longer compete for CPU time with the radio stack. MQTT latency drops by 74%. DHT22 NaN errors — caused by the WiFi radio bursting during sensor reads — fall to near-zero.
This is not a theoretical improvement. These numbers came from 24-hour continuous runs on my test bench in Lahore before deploying nodes to a remote temperature monitoring application.
11. Five Mistakes That Crash Dual-Core ESP32 Projects
Mistake 1: Sharing global variables between cores without protection
On a single-core system, this often "works" because context switches only happen at specific points. On dual-core, both CPUs genuinely execute simultaneously. A global float sensorTemp written on Core 1 while Core 0 reads it can produce a torn read — half the bytes are the old value, half are the new one. Always use queues or mutexes.
Mistake 2: Calling Serial.print() directly from multiple tasks
Serial on ESP32/Arduino is not thread-safe. Two tasks printing simultaneously corrupts both outputs and can crash the UART peripheral. Use the safePrint() / safePrintf() mutex-wrapper pattern shown in Section 7.
Mistake 3: Calling delay() instead of vTaskDelay()
delay() blocks the entire core — it is a busy-wait loop. vTaskDelay() suspends only the current task, allowing the scheduler to run other tasks on the same core during the wait. In a FreeRTOS system, never use Arduino's delay() inside a task. Use vTaskDelay(pdMS_TO_TICKS(ms)).
Mistake 4: Creating tasks before the queue and mutex are initialized
If a task starts executing before xQueueCreate() completes, the queue handle is NULL and the first xQueueSend() call crashes immediately. Always create all FreeRTOS primitives first in setup(), then create tasks. Check return values — both return NULL on allocation failure.
Mistake 5: Assigning the wrong core to WiFi reconnection logic
WiFi reconnection requires interacting with the lwIP stack and the supplicant — both live on Core 0. If you put your reconnection logic in a task pinned to Core 1, it works but introduces unnecessary inter-core messaging. Pin anything that calls WiFi.begin(), WiFi.reconnect(), or checks WiFi.status() to Core 0.
12. Frequently Asked Questions
What core does ESP32 Arduino loop() run on?
setup() and loop() run on Core 1 by default in the Arduino framework. Core 0 is occupied by the WiFi radio stack. You can verify this anytime with Serial.println(xPortGetCoreID()) called from inside setup().
What is the difference between xTaskCreate and xTaskCreatePinnedToCore?
xTaskCreate() creates a floating task that the scheduler assigns to either core dynamically. xTaskCreatePinnedToCore() locks the task to a specific core permanently. For any task with WiFi dependency or real-time timing requirements, always use the pinned variant.
Can FreeRTOS tasks on different cores share variables safely on ESP32?
No — not without synchronization. On dual-core hardware, two cores genuinely execute simultaneously. A raw global variable is not atomic. Use FreeRTOS queues for passing data, mutexes for protecting shared resources, and event groups for flag-based synchronization between tasks on different cores.
How do I check which core a FreeRTOS task is running on?
Call xPortGetCoreID() from inside any task. It returns 0 or 1. Print this during startup to verify your pinning assignments are working as intended. I always add a startup log line in every task: Serial.printf("Task %s running on Core %d\n", pcTaskGetName(NULL), xPortGetCoreID());
Does task pinning improve ESP32 WiFi stability?
Yes, measurably. In my 24-hour benchmark (Section 10), pinning WiFi and MQTT tasks to Core 0 eliminated all random disconnects and reduced peak MQTT latency by 74%. The root cause of WiFi instability on ESP32 is often sensor tasks competing for Core 0 CPU time with the radio stack — pinning eliminates that competition.
13. Conclusion
The ESP32's dual-core architecture is one of the most underutilized features in the maker and IoT community. Most projects use it as a fast single-core processor with WiFi — which is fine — but knowing how to correctly distribute work across both cores changes the reliability profile of your projects entirely.
The core rules are simple: WiFi and networking on Core 0, sensors and real-time tasks on Core 1, queues for communication between them, mutexes for shared resources. Get those four things right and the random disconnects, NaN sensor errors, and Guru Meditation crashes that plague ESP32 IoT projects largely disappear.
If this guide helped you, try applying the same architecture to one of the projects already on xloge.site — the Smart Home MQTT guide or the TinyML on ESP32-S3 project both become significantly more reliable when the dual-core pattern is applied. Questions, build photos, or benchmark results? Drop them in the comments below.
