Embedded HandbookApps
All sections › XVIII. ESP32: the second microcontroller
ESP32 · level intermediate · 13 min read
Open in the interactive handbook

152. FreeRTOS on two cores: task pinning, spinlocks, tick, stack, esp_timer, watchdog

On the ESP32 you do not choose FreeRTOS from part IV — it is already inside ESP-IDF and is running before app_main. But it is not the same FreeRTOS: the ESP32 has two cores, and the scheduler keeps a current task on each of them. Everything new follows from that: a task can be pinned to a core, taskENTER_CRITICAL no longer just disables interrupts but takes a lock — the other core, reaching for the same lock, waits; the stack is given in bytes, not in words; and a watchdog looks after each core. This section walks through these differences with one program in QEMU (_verify/esp152/), and where the emulator lies, it says so plainly.

Terms used in the documentation

Term Meaning
SMP (symmetric multiprocessing) two identical cores under one scheduler
PRO_CPU, APP_CPU core 0 and core 1
core affinity, pinned task binding a task to a core
tskNO_AFFINITY a task without binding: the scheduler picks the core itself
spinlock, portMUX_TYPE a lock on which the other core spins while waiting
critical section interrupts are off and the lock is taken
tick, tick rate the tick and its frequency (CONFIG_FREERTOS_HZ)
stack high water mark the minimum remaining stack over the whole run
idle task the idle task, one per core
task watchdog (TWDT) waits for check-ins from the idle tasks
esp_timer high-resolution timers on one hardware timer
IPC task a service task for calling a function on the other core

152.1 Tick, tasks, cores

The first thing the program prints is the scheduler parameters:

тик 100 Гц, pdMS_TO_TICKS(5) = 0, pdMS_TO_TICKS(10) = 1, portTICK_PERIOD_MS = 10
app_main: ядро 0, приоритет 1, стек задан 3584 байт

The default tick is 100 Hz, that is 10 ms: vTaskDelay(pdMS_TO_TICKS(5)) turns into vTaskDelay(0) and does not wait at all, it only yields the processor. Whoever needs milliseconds sets CONFIG_FREERTOS_HZ=1000 or uses esp_timer. app_main is an ordinary task with priority 1 on core 0; its stack is 3584 bytes — on the ESP32 the stack size in xTaskCreate is always in bytes, whereas in vanilla FreeRTOS it is in words.

A task is created with or without pinning:

    xTaskCreatePinnedToCore(whoami, "w0", 2048, "к ядру 0", 4, NULL, 0);
    vTaskDelay(pdMS_TO_TICKS(50));
    xTaskCreatePinnedToCore(whoami, "w1", 2048, "к ядру 1", 4, NULL, 1);
    vTaskDelay(pdMS_TO_TICKS(50));
    xTaskCreate(whoami, "wany", 2048, "любое", 4, NULL);   /* tskNO_AFFINITY */
задача к ядру 0 ядро 0 приоритет 4
задача к ядру 1 ядро 1 приоритет 4
задача любое ядро 0 приоритет 4

An unpinned task can end up on either core and even move between them from one call to the next — so anything tied to a core (interrupt handlers, which are allocated to the core they were installed on) is pinned explicitly. ESP-IDF keeps Wi-Fi and Bluetooth on core 0, and applications usually get core 1.

Two cores under one scheduler: each has its own current task and its own idle task, a spinlock stops the other core, the watchdog waits for check-ins from both idle tasks


152.2 A race on two cores and three ways to cure it

In section 139 two Linux threads lost increments of a shared counter; on the ESP32 two cores lose them. The tricks of single-core firmware do not help: disabling interrupts (portDISABLE_INTERRUPTS) and suspending the scheduler (vTaskSuspendAll) act only on their own core, and a high priority does not stop the other core from running a lower-priority task. A FreeRTOS mutex does protect across cores (152.6), but it is heavy for a single ++. For short sections there are three ways:

        if (mode == 0) {
            plain++;
        } else if (mode == 1) {
            taskENTER_CRITICAL(&lock);              /* спин-блокировка: держит оба ядра порознь */
            guarded++;
            taskEXIT_CRITICAL(&lock);
        } else {
            __atomic_fetch_add(&atomic_cnt, 1, __ATOMIC_RELAXED);
        }

On the ESP32 taskENTER_CRITICAL always takes a portMUX_TYPE spinlock: the core disables its own interrupts and spins until the other core releases the lock. The compiler implements an atomic addition with the Xtensa instruction s32c1i — "compare and store", the same idea as the CAS from section 140. It is visible in the machine code of the task:

      2 s32i.n a8,
      2 memw 
      2 addi.n a8,
      1 wsr.scompare1 a8
      1 s32c1i a11,

plain++ is three instructions, l32i, addi, s32i, between which the other core can manage to do the same; the atomic variant is wsr.scompare1 (the expected value into a special register) and s32c1i in a loop until it succeeds. The result in the emulator:

без защиты 4000000 из 4000000, потеряно 0, 49 мс
спин-блокировка 4000000 из 4000000, потеряно 0, 1119 мс
atomic           4000000 из 4000000, потеряно 0, 59 мс

The most important thing here is what is not there: losses. QEMU executes the two cores in turn in large chunks, and the three instructions of plain++ are never interleaved with the other core's — a race that loses increments on silicon is invisible in the emulator. This is a general property of emulators: they show the logic correctly, but not simultaneity — and not the timing of the silicon. In the emulator the critical section came out 23 times more expensive than the unprotected addition, and the atomic instruction almost free, but QEMU translates Xtensa instructions into host instructions, and those have their own cost. The ratio of 23 is a rough guide; the real figure is measured on the board with the cycle counter esp_cpu_get_cycle_count(). The rule for code: short counters — __atomic_* or <stdatomic.h>, structures — a spinlock, and never hold it longer than a few microseconds: on its own core interrupts are disabled all that time, and the other core, if it reaches for the same lock, spins idle.

The cost of three ways to protect a shared counter on two cores over 4000000 increments: plain++ of three instructions — 49 ms, the spinlock taskENTER_CRITICAL — 1119 ms, 23 times more expensive, the atomic instruction s32c1i — 59 ms; 0 lost in all three, because the emulator does not show simultaneity


152.3 The stack and the task list

стек app_main: свободно 1188 байт из 3584

uxTaskGetStackHighWaterMark returns the smallest remainder over the whole run, also in bytes. vTaskList prints all tasks with the core in the last column:

main            X   1   1188    4   0
IDLE1           R   0   1036    6   1
IDLE0           R   0   1024    5   0
ipc1            S   24  496 2   1
esp_timer       S   22  3576    3   0
ipc0            S   24  504 1   0

Besides our main there is one idle task per core (IDLE0, IDLE1, priority 0, stack 1536 bytes), two service tasks ipc0/ipc1 with priority 24 for calling functions on the neighbouring core, and the esp_timer task with priority 22, in which all callbacks of the software timers run. States: X — running, R — ready, B — waiting for an event or a timeout (vTaskDelay, queue, semaphore), S — suspended; a wait for a notification without a timeout (ulTaskNotifyTake(…, portMAX_DELAY)) also lands here — this is how ipc and esp_timer wait. D — deleted, but the memory is not freed yet.


152.4 esp_timer instead of software timers

FreeRTOS software timers work in ticks; esp_timer gives microseconds on one hardware timer shared by all:

    esp_timer_create(&ta, &th);
    esp_timer_start_periodic(th, 10000);
    int64_t t0 = esp_timer_get_time();
    vTaskDelay(pdMS_TO_TICKS(1000));
    int64_t dt = esp_timer_get_time() - t0;
    esp_timer_stop(th);
esp_timer: за vTaskDelay(100 тиков) прошло 999592 мкс, срабатываний по 10 мс: 99

A hundred ticks is one second by the esp_timer_get_time clock, and in that second the periodic timer with a 10 ms step fired 99 or 100 times (from run to run): the first firing comes 10 ms after the start, and the hundredth falls exactly on the one-second boundary and sometimes makes it, sometimes not. The callback runs in the esp_timer task, not in an interrupt, so almost anything is allowed in it, but you must not delay it — the whole timer queue will be delayed.


152.5 The task watchdog

A task that never yields the processor hangs everything of lower priority on a single-core STM32. On the ESP32 the other core keeps running, but the task watchdog (TWDT) will notice:

static void spinner(void *arg)                      /* никогда не уступает — морит задачу простоя ядра 1 */
{
    (void)arg;
    for (;;) { }
}
E (8882) task_wdt: Task watchdog got triggered. The following tasks/users did not reset the watchdog in time:
E (8882) task_wdt:  - IDLE1 (CPU 1)
E (8882) task_wdt: Tasks currently running:
E (8882) task_wdt: CPU 0: IDLE0
E (8882) task_wdt: CPU 1: spin

By default the watchdog watches the idle tasks of both cores (CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0/1=y) and fires after 5 s (CONFIG_ESP_TASK_WDT_TIMEOUT_S=5): if a core's idle task has not had the processor for 5 seconds, someone on that core is spinning without vTaskDelay. The watchdog printed the culprit (CPU 1: spin) and a backtrace, but did not reboot: CONFIG_ESP_TASK_WDT_PANIC is off. In a production device it is turned on, and tasks that are supposed to run for a long time subscribe to the watchdog themselves (esp_task_wdt_add) and check in with esp_task_wdt_reset. The second watchdog, the interrupt watchdog, fires after 300 ms if interrupts are disabled for too long — for example, because of a drawn-out critical section from 152.2.


152.6 What else differs from part IV

In part IV (STM32, one core) On the ESP32
stack in words stack in bytes
xTaskCreate + xTaskCreatePinnedToCore
taskENTER_CRITICAL() taskENTER_CRITICAL(&mux) with a spinlock
1000 Hz tick in the examples 100 Hz by default
vApplicationIdleHook one idle task per core; the watchdog waits for both
software timers in ticks esp_timer in microseconds
one highest-priority task two: one per core at the same time
configASSERT ESP_ERROR_CHECK, abort() with a backtrace

Queues, semaphores, mutexes, event groups, notifications are the same as in part IV, and they work across cores without caveats: inside they are already protected by spinlocks.


152.7 Pitfalls

Symptom Cause
vTaskDelay(pdMS_TO_TICKS(5)) does not wait 100 Hz tick: 5 ms is 0 ticks
a task created with a stack of 256 crashes 256 bytes, not words: the minimum for printing is about 2048
a counter updated from two tasks does not add up, although interrupts are disabled disabling works on one core: a spinlock, __atomic_* or a mutex
taskENTER_CRITICAL() without an argument does not build the ESP32 needs a portMUX_TYPE
Task watchdog got triggered … IDLE1 a task on core 1 without vTaskDelay for longer than 5 s
Interrupt wdt timeout on CPU1 a critical section or handler longer than 300 ms
a race "does not reproduce" in the emulator QEMU runs the cores in turn; test on the board
an esp_timer callback delayed the others all timers run in one task; move long work into its own task
an interrupt "moved" to the other core the handler is bound to the core it was installed on; pin the task

152.8 Summary

Full sources

main.c — FreeRTOS on the two ESP32 cores: task pinning, a race, esp_timer, the watchdog.

/* Раздел 152: FreeRTOS на двух ядрах ESP32 — закрепление задач, гонка и её лечение,
 * тик и pdMS_TO_TICKS, стек в байтах, esp_timer, сторож задач. */
#include <stdio.h>
#include <string.h>
#include "esp_log.h"
#include "esp_task_wdt.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"

#define N 2000000

static volatile int plain;                          /* без защиты */
static int guarded;                                 /* под спин-блокировкой */
static int atomic_cnt;                              /* атомарная команда */
static portMUX_TYPE lock = portMUX_INITIALIZER_UNLOCKED;
static SemaphoreHandle_t done_sem;
static volatile int timer_hits;

static void whoami(void *arg)                       /* печатает, на каком ядре оказалась */
{
    printf("задача %-8s ядро %d приоритет %u\n", (const char *)arg, xPortGetCoreID(), (unsigned)uxTaskPriorityGet(NULL));
    vTaskDelete(NULL);
}

static void racer(void *arg)
{
    int mode = (int)arg;
    for (int i = 0; i < N; i++) {
        if (mode == 0) {
            plain++;
        } else if (mode == 1) {
            taskENTER_CRITICAL(&lock);              /* спин-блокировка: держит оба ядра порознь */
            guarded++;
            taskEXIT_CRITICAL(&lock);
        } else {
            __atomic_fetch_add(&atomic_cnt, 1, __ATOMIC_RELAXED);
        }
    }
    xSemaphoreGive(done_sem);
    vTaskDelete(NULL);
}

static void run_race(int mode, const char *name)
{
    int64_t t0 = esp_timer_get_time();
    xTaskCreatePinnedToCore(racer, "r0", 2048, (void *)mode, 5, NULL, 0);
    xTaskCreatePinnedToCore(racer, "r1", 2048, (void *)mode, 5, NULL, 1);
    xSemaphoreTake(done_sem, portMAX_DELAY);
    xSemaphoreTake(done_sem, portMAX_DELAY);
    int got = mode == 0 ? plain : mode == 1 ? guarded : atomic_cnt;
    printf("%-16s %d из %d, потеряно %d, %lld мс\n", name, got, 2 * N, 2 * N - got, (long long)((esp_timer_get_time() - t0) / 1000));
}

static void on_timer(void *arg) { (void)arg; timer_hits++; }

static void spinner(void *arg)                      /* никогда не уступает — морит задачу простоя ядра 1 */
{
    (void)arg;
    for (;;) { }
}

void app_main(void)
{
    done_sem = xSemaphoreCreateCounting(2, 0);
    printf("тик %d Гц, pdMS_TO_TICKS(5) = %u, pdMS_TO_TICKS(10) = %u, portTICK_PERIOD_MS = %u\n",
           CONFIG_FREERTOS_HZ, (unsigned)pdMS_TO_TICKS(5), (unsigned)pdMS_TO_TICKS(10), (unsigned)portTICK_PERIOD_MS);
    printf("app_main: ядро %d, приоритет %u, стек задан %d байт\n", xPortGetCoreID(), (unsigned)uxTaskPriorityGet(NULL), CONFIG_ESP_MAIN_TASK_STACK_SIZE);

    xTaskCreatePinnedToCore(whoami, "w0", 2048, "к ядру 0", 4, NULL, 0);
    vTaskDelay(pdMS_TO_TICKS(50));
    xTaskCreatePinnedToCore(whoami, "w1", 2048, "к ядру 1", 4, NULL, 1);
    vTaskDelay(pdMS_TO_TICKS(50));
    xTaskCreate(whoami, "wany", 2048, "любое", 4, NULL);   /* tskNO_AFFINITY */
    vTaskDelay(pdMS_TO_TICKS(50));

    run_race(0, "без защиты");
    run_race(1, "спин-блокировка");
    run_race(2, "atomic");

    /* стек задачи считается в байтах, не в словах */
    printf("стек app_main: свободно %u байт из %d\n", (unsigned)uxTaskGetStackHighWaterMark(NULL), CONFIG_ESP_MAIN_TASK_STACK_SIZE);

    /* esp_timer: периодический вызов раз в 10 мс, считаем за секунду по тикам FreeRTOS */
    esp_timer_handle_t th;
    const esp_timer_create_args_t ta = { .callback = on_timer, .name = "t10ms" };
    esp_timer_create(&ta, &th);
    esp_timer_start_periodic(th, 10000);
    int64_t t0 = esp_timer_get_time();
    vTaskDelay(pdMS_TO_TICKS(1000));
    int64_t dt = esp_timer_get_time() - t0;
    esp_timer_stop(th);
    printf("esp_timer: за vTaskDelay(100 тиков) прошло %lld мкс, срабатываний по 10 мс: %d\n", (long long)dt, timer_hits);

    char buf[1024];
    vTaskList(buf);
    printf("-- задачи (имя, состояние, приоритет, остаток стека, номер, ядро)\n%s", buf);

    /* сторож задач: задача, которая не уступает процессор, лишает ядро 1 задачи простоя */
    printf("запускаю бесконечный цикл на ядре 1, сторож — %d с\n", CONFIG_ESP_TASK_WDT_TIMEOUT_S);
    xTaskCreatePinnedToCore(spinner, "spin", 2048, NULL, 1, NULL, 1);
    vTaskDelay(pdMS_TO_TICKS(7000));
    printf("done\n");
}

Next: section 153 — GPIO and PWM: the pin matrix, LEDC, MCPWM.

Open in the interactive handbook