Dual core tasks
Two cores do not make a slow loop fast. What they do is stop one job blocking another - which matters because Wi-Fi lives on core 0 and is bursty, and anything with timing in it wants to be somewhere else.
Which core, and what happens if you pick wrong
loop() needs a queue or a mutex, not just volatile.The rules, short
- Wi-Fi and Bluetooth live on core 0.
loop()runs on core 1. - Pin timing-sensitive work to core 1. Sampling, stepping, LEDs, audio.
- Every task must yield.
vTaskDelay(1)at minimum, once per pass. - Share through a queue. Not through a global, however
volatileit is. - Size the stack. 4096 bytes is a start; overflowing it crashes somewhere else entirely.
When not to bother
If your loop() is a state machine that never blocks, you do not need tasks.
Most "I need the second core" problems are really a delay() that should have
been a millis() comparison, and a second core will not fix a design that
blocks — it will just move the blockage.
The code
A sampling task on core 1 and a queue back to loop. The queue is the part that matters - two cores sharing a variable without one is a race, and races on this chip look like corrupt readings rather than crashes.
QueueHandle_t q;
void sampler(void *arg) {
for (;;) {
int v = analogRead(34);
xQueueSend(q, &v, 0); // never blocks, drops if full
vTaskDelay(pdMS_TO_TICKS(10)); // yields - this is not delay()
}
}
void setup() {
Serial.begin(115200);
q = xQueueCreate(64, sizeof(int));
xTaskCreatePinnedToCore(
sampler, "sampler",
4096, // stack in bytes - too small crashes elsewhere
nullptr,
2, // priority. loop() runs at 1
nullptr,
1); // core 1, away from Wi-Fi
}
void loop() {
int v;
while (xQueueReceive(q, &v, 0) == pdTRUE) Serial.println(v);
delay(100);
}4096 bytes of stack is a reasonable start. Too small and it crashes somewhere unrelated, which is the hardest bug on this page to find.
MicroPython on the ESP32 runs on one core, so the answer here is asyncio rather than threads. Different mechanism, same goal - nothing blocks anything else.
import asyncio
from machine import ADC, Pin
adc = ADC(Pin(34))
queue = []
async def sampler():
while True:
queue.append(adc.read_u16())
if len(queue) > 64:
queue.pop(0)
await asyncio.sleep_ms(10) # yields to everything else
async def reporter():
while True:
if queue:
print(sum(queue) // len(queue))
await asyncio.sleep(1)
asyncio.run(asyncio.gather(sampler(), reporter()))_thread exists and gives you a second thread, not a second core, with no locking primitives worth the name. asyncio is the supported route.
When it does not work
The task never yields, so the idle task never runs, and the idle task is what feeds the watchdog. One vTaskDelay(1) per pass fixes it.
Stack overflow in a task. Large locals, deep calls or a printf with a big buffer. Raise the stack size and move big buffers off it.
Two cores wrote it at once. volatile is not a lock. Use a queue, a mutex, or a FreeRTOS notification.
The C3 and C6 are single core. Tasks and queues still work - FreeRTOS just interleaves them - so the code above runs unchanged with the core argument ignored.
Two cores means twice as many ways to hang. When one does, the board reboots and leaves an address — here is how to read it.
Crash dumps and the watchdog →Edit this page — content/esp32/dual-core-tasks.mdx
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.