ESP32 Dual-Core Task Pinning with FreeRTOS: A Production Guide
Most ESP32 tutorials ignore dual-core pinning entirely. Here's how production firmware uses both cores — with real task allocation patterns from the field.
Most ESP32 tutorials show you how to create a FreeRTOS task. Almost none of them tell you which core to run it on — and that decision alone can make the difference between reliable firmware and random watchdog resets at 3am.
This is the guide I wish existed when I was debugging a production IoT gateway that kept crashing under load.
What You'll Learn
- How ESP32's two cores are actually used by ESP-IDF internals
- When to pin tasks vs. let the scheduler decide
- Real task allocation patterns from production firmware
- Common mistakes that cause core starvation and WDT resets
The Two Cores: What ESP-IDF Actually Does With Them
ESP32 has two Xtensa LX6 cores:
| Core | Name | Default Role in ESP-IDF |
|---|---|---|
| Core 0 | PRO_CPU | WiFi, BT stack, system tasks |
| Core 1 | APP_CPU | Your application code |
Common mistake: Most developers create all their tasks without pinning them. The FreeRTOS scheduler then distributes them across both cores — which sounds fine until your WiFi stack and a sensor polling task start competing for Core 0 at the same time.
xTaskCreate vs xTaskCreatePinnedToCore
This is the core API decision:
1// ❌ Unpinned — scheduler picks the core
2xTaskCreate(
3 sensor_task, // Task function
4 "sensor_task", // Task name
5 4096, // Stack size in bytes
6 NULL, // Parameters
7 5, // Priority
8 &sensor_task_handle // Task handle
9);
10
11// ✅ Pinned to APP_CPU — you control the core
12xTaskCreatePinnedToCore(
13 sensor_task, // Task function
14 "sensor_task", // Task name
15 4096, // Stack size in bytes
16 NULL, // Parameters
17 5, // Priority
18 &sensor_task_handle,// Task handle
19 1 // Core ID: 0 = PRO_CPU, 1 = APP_CPU
20);Rule of thumb: In production firmware, always use
xTaskCreatePinnedToCore. Never leave core assignment to chance.
Production Task Allocation Pattern
Here's the actual pattern used across IoT gateway firmware:
1// main.c — Production task allocation
2#include "freertos/FreeRTOS.h"
3#include "freertos/task.h"
4#include "esp_log.h"
5
6static const char *TAG = "MAIN";
7
8TaskHandle_t sensor_task_handle = NULL;
9TaskHandle_t mqtt_task_handle = NULL;
10TaskHandle_t inference_task_handle = NULL;
11TaskHandle_t display_task_handle = NULL;
12
13#define STACK_SENSOR 4096
14#define STACK_MQTT 8192
15#define STACK_INFERENCE 16384
16#define STACK_DISPLAY 4096
17
18#define PRIO_SENSOR 6
19#define PRIO_MQTT 5
20#define PRIO_INFERENCE 4
21#define PRIO_DISPLAY 3
22
23void app_main(void) {
24 ESP_LOGI(TAG, "Starting task allocation...");
25
26 // Core 0 (PRO_CPU) — only tasks that NEED WiFi proximity
27 xTaskCreatePinnedToCore(mqtt_publish_task, "mqtt_task",
28 STACK_MQTT, NULL, PRIO_MQTT, &mqtt_task_handle, 0);
29
30 // Core 1 (APP_CPU) — all application workload
31 xTaskCreatePinnedToCore(sensor_polling_task, "sensor_task",
32 STACK_SENSOR, NULL, PRIO_SENSOR, &sensor_task_handle, 1);
33
34 xTaskCreatePinnedToCore(edge_inference_task, "inference_task",
35 STACK_INFERENCE, NULL, PRIO_INFERENCE, &inference_task_handle, 1);
36
37 xTaskCreatePinnedToCore(display_update_task, "display_task",
38 STACK_DISPLAY, NULL, PRIO_DISPLAY, &display_task_handle, 1);
39
40 ESP_LOGI(TAG, "All tasks pinned and running.");
41}The Stack Size Problem Nobody Talks About
This is where most production crashes come from — not task pinning itself, but undersized stacks.
1// Monitor actual stack usage at runtime
2void monitor_task(void *pvParameters) {
3 while (1) {
4 ESP_LOGI("STACK", "sensor_task watermark: %d bytes free",
5 uxTaskGetStackHighWaterMark(sensor_task_handle));
6 ESP_LOGI("STACK", "mqtt_task watermark: %d bytes free",
7 uxTaskGetStackHighWaterMark(mqtt_task_handle));
8 ESP_LOGI("STACK", "inference watermark: %d bytes free",
9 uxTaskGetStackHighWaterMark(inference_task_handle));
10
11 vTaskDelay(pdMS_TO_TICKS(5000));
12 }
13}Production rule: Run your firmware under full load and log stack watermarks for 24 hours. If headroom drops below 512 bytes — double your stack allocation immediately.
Real-World Task Map: Industrial IoT Gateway
| Task | Core | Priority | Stack | Notes |
|---|---|---|---|---|
modbus_poll | 1 | 8 | 4096 | RS485 sensor polling |
edge_inference | 1 | 6 | 16384 | Anomaly detection model |
data_buffer | 1 | 7 | 4096 | Ring buffer management |
mqtt_publish | 0 | 5 | 8192 | TLS MQTT to AWS IoT |
ota_monitor | 0 | 3 | 4096 | OTA update checks |
display_ui | 1 | 2 | 4096 | OLED status display |
watchdog_feed | 1 | 9 | 2048 | System health monitor |
This setup ran for 6 months on 40 deployed units without a single unexpected reboot.
Key Takeaways
- Always pin tasks using
xTaskCreatePinnedToCore— never leave it to the scheduler in production - Keep Core 0 light — WiFi and BT stacks live there
- Run heavy compute on Core 1 — ML inference, sensor processing, display updates
- Monitor stack watermarks during development — crashes in production are almost always stack overflows
- Sensor data collection gets highest priority — you can retry MQTT, you can't recover lost sensor readings
Go from Arduino to Production Firmware
The ESP32-IDF Workshop covers ESP-IDF from scratch — tasks, queues, OTA, Wifi management, and deploying firmware that doesn't break at 3am.
Frequently Asked Questions
Quick answers to common questions

I build things that run on chips and the software that talks to them. ESP32, STM32, FreeRTOS, FastAPI, TinyML — from bare-metal firmware to cloud backends to on-device inference. Based in Bengaluru. Founder of Analog Data.