Embedded HandbookПриложения
Все разделы › XVIII. ESP32: второй микроконтроллер
ESP32 · уровень средний · 13 мин чтения
Открыть в интерактивном справочнике

152. FreeRTOS на двух ядрах: закрепление задач, спин-блокировки, тик, стек, esp_timer, сторож

FreeRTOS из части IV на ESP32 не выбирают — она уже внутри ESP-IDF и запущена до app_main. Но это не та же FreeRTOS: у ESP32 два ядра, и планировщик держит на каждом свою текущую задачу. Отсюда всё новое: задачу можно привязать к ядру, taskENTER_CRITICAL больше не просто запрещает прерывания, а берёт замок: второе ядро, потянувшись к тому же замку, ждёт, стек задаётся в байтах, а не в словах, и за каждым ядром следит сторож. Раздел проходит по этим отличиям одной программой в QEMU (_verify/esp152/), а где эмулятор врёт — говорит об этом прямо.

Как это называется в документации

Оригинал Перевод
SMP (symmetric multiprocessing) два одинаковых ядра под одним планировщиком
PRO_CPU, APP_CPU ядро 0 и ядро 1
core affinity, pinned task привязка задачи к ядру
tskNO_AFFINITY задача без привязки: планировщик выбирает ядро сам
spinlock, portMUX_TYPE спин-блокировка: замок, на котором второе ядро крутится в ожидании
critical section критическая секция: прерывания выключены и замок взят
tick, tick rate тик, частота тика (CONFIG_FREERTOS_HZ)
stack high water mark минимальный остаток стека за всё время
idle task задача простоя, по одной на ядро
task watchdog (TWDT) сторож задач: ждёт отметки от задач простоя
esp_timer таймеры высокого разрешения на одном аппаратном таймере
IPC task служебная задача для вызова функции на другом ядре

152.1 Тик, задачи, ядра

Первое, что печатает программа, — параметры планировщика:

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

Тик по умолчанию — 100 Гц, то есть 10 мс: vTaskDelay(pdMS_TO_TICKS(5)) превращается в vTaskDelay(0) и не ждёт вовсе, а лишь уступает процессор. Кому нужны миллисекунды, тот ставит CONFIG_FREERTOS_HZ=1000 или берёт esp_timer. app_main — обычная задача с приоритетом 1 на ядре 0; стек её 3584 байта — на ESP32 размер стека в xTaskCreate всегда в байтах, тогда как в исходной FreeRTOS это слова.

Задачу создают с привязкой или без:

    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

Непривязанная задача может оказаться на любом ядре и даже переезжать между ними от вызова к вызову — поэтому всё, что завязано на ядро (обработчики прерываний, которые распределяются по ядру, где их установили), закрепляют явно. Wi-Fi и Bluetooth ESP-IDF держит на ядре 0, и приложениям обычно оставляют ядро 1.

Два ядра под одним планировщиком: у каждого своя текущая задача и своя задача простоя, спин-блокировка останавливает второе ядро, сторож ждёт отметок от обеих задач простоя


152.2 Гонка на двух ядрах и три способа её лечить

В разделе 139 два потока Linux теряли приращения общего счётчика; на ESP32 их теряют два ядра. Не помогают приёмы одноядерной прошивки: запрет прерываний (portDISABLE_INTERRUPTS) и остановка планировщика (vTaskSuspendAll) действуют только на своё ядро, а высокий приоритет не мешает второму ядру выполнять задачу пониже. Мьютекс FreeRTOS защищает и между ядрами (152.6), но для одного ++ он тяжёл. Для коротких мест — три способа:

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

taskENTER_CRITICAL на ESP32 обязательно принимает спин-блокировку portMUX_TYPE: ядро выключает у себя прерывания и крутится, пока замок не освободит другое ядро. Атомарное сложение компилятор делает командой Xtensa s32c1i — «сравнить и записать», та же идея, что CAS из раздела 140. В машинном коде задачи это видно:

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

plain++ — три команды l32i, addi, s32i, между которыми второе ядро может успеть сделать то же самое; атомарный вариант — wsr.scompare1 (ожидаемое значение в специальный регистр) и s32c1i в цикле до успеха. Результат в эмуляторе:

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

Здесь важнее всего то, чего нет: потерь. QEMU исполняет два ядра по очереди крупными кусками, и три команды plain++ никогда не перемежаются с чужими — гонка, которая на кристалле теряет приращения, в эмуляторе невидима. Это общее свойство эмуляторов: они верно показывают логику, но не одновременность — и не время кристалла. В эмуляторе критическая секция вышла дороже незащищённого сложения в 23 раза, атомарная команда — почти даром, но QEMU переводит команды Xtensa в команды компьютера, и цена у них своя. Отношение 23 — ориентир; настоящее число мерят на плате счётчиком тактов esp_cpu_get_cycle_count(). Правило для кода: короткие счётчики — __atomic_* или <stdatomic.h>, структуры — спин-блокировка, и никогда не держать её дольше нескольких микросекунд: на своём ядре всё это время запрещены прерывания, а второе ядро, если потянется к тому же замку, крутится вхолостую.

Цена трёх способов защиты общего счётчика на двух ядрах за 4 000 000 приращений: plain++ из трёх команд — 49 мс, спин-блокировка taskENTER_CRITICAL — 1119 мс, в 23 раза дороже, атомарная команда s32c1i — 59 мс; потеряно 0 во всех трёх, потому что эмулятор не показывает одновременность


152.3 Стек и список задач

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

uxTaskGetStackHighWaterMark возвращает наименьший остаток за всё время, тоже в байтах. vTaskList печатает все задачи с ядром в последнем столбце:

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

Кроме нашей main — по задаче простоя на ядро (IDLE0, IDLE1, приоритет 0, стек 1536 байт), две служебные ipc0/ipc1 с приоритетом 24 для вызова функций на соседнем ядре и задача esp_timer с приоритетом 22, в которой выполняются все обратные вызовы программных таймеров. Состояния: X — выполняется, R — готова, B — ждёт события или срока (vTaskDelay, очередь, семафор), S — приостановлена; сюда же попадает ожидание уведомления без срока (ulTaskNotifyTake(…, portMAX_DELAY)) — так ждут ipc и esp_timer. D — удалена, но память ещё не освобождена.


152.4 esp_timer вместо программных таймеров

Программные таймеры FreeRTOS работают в тиках; esp_timer даёт микросекунды на одном аппаратном таймере для всех:

    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

Сто тиков — секунда по часам esp_timer_get_time, и за неё периодический таймер с шагом 10 мс сработал 99 или 100 раз (от прогона к прогону): первое срабатывание — через 10 мс после старта, а сотое приходится ровно на границу секунды и то успевает, то нет. Обратный вызов исполняется в задаче esp_timer, а не в прерывании, поэтому в нём можно почти всё, но задерживать его нельзя — задержится вся очередь таймеров.


152.5 Сторож задач

Задача, которая никогда не уступает процессор, на одноядерном STM32 вешает всё, что ниже приоритетом. На ESP32 второе ядро продолжит работать, но сторож задач (TWDT) это заметит:

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

По умолчанию сторож следит за задачами простоя обоих ядер (CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0/1=y) и срабатывает через 5 с (CONFIG_ESP_TASK_WDT_TIMEOUT_S=5): если задача простоя ядра не получала процессор 5 секунд — значит, кто-то на этом ядре крутится без vTaskDelay. Сторож напечатал виновника (CPU 1: spin) и трассу, но не перезагрузил: CONFIG_ESP_TASK_WDT_PANIC выключен. В боевом изделии его включают, а задачи, которым положено работать долго, подписывают на сторожа сами (esp_task_wdt_add) и отмечаются esp_task_wdt_reset. Второй сторож, прерываний, срабатывает через 300 мс, если прерывания выключены слишком долго — например, из-за затянувшейся критической секции из 152.2.


152.6 Что ещё отличается от части IV

В части IV (STM32, одно ядро) На ESP32
стек в словах стек в байтах
xTaskCreate + xTaskCreatePinnedToCore
taskENTER_CRITICAL() taskENTER_CRITICAL(&mux) со спин-блокировкой
тик 1000 Гц в примерах 100 Гц по умолчанию
vApplicationIdleHook по задаче простоя на ядро; сторож ждёт их обеих
программные таймеры в тиках esp_timer в микросекундах
одна задача самая приоритетная две: по одной на ядро одновременно
configASSERT ESP_ERROR_CHECK, abort() с трассой

Очереди, семафоры, мьютексы, группы событий, уведомления — те же, из части IV, и работают между ядрами без оговорок: они внутри уже защищены спин-блокировками.


152.7 Грабли

Симптом Причина
vTaskDelay(pdMS_TO_TICKS(5)) не ждёт тик 100 Гц: 5 мс — это 0 тиков
задача создана со стеком 256 — и падение 256 байт, не слов: минимум для печати — около 2048
счётчик из двух задач не сходится, хотя прерывания запрещены запрет действует на одно ядро: спин-блокировка, __atomic_* или мьютекс
taskENTER_CRITICAL() без аргумента не собирается на ESP32 нужен portMUX_TYPE
Task watchdog got triggered … IDLE1 задача на ядре 1 без vTaskDelay дольше 5 с
Interrupt wdt timeout on CPU1 критическая секция или обработчик дольше 300 мс
гонка в эмуляторе «не воспроизводится» QEMU исполняет ядра по очереди; проверять на плате
обратный вызов esp_timer задержал остальные все таймеры в одной задаче; длинное — в свою задачу
прерывание «переехало» на другое ядро обработчик привязан к ядру, где его установили; задачу закрепить

152.8 Итоги

Полные исходники

main.c — FreeRTOS на двух ядрах ESP32: закрепление задач, гонка, esp_timer, сторож.

/* Раздел 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");
}

Дальше: раздел 153 — GPIO и ШИМ: матрица выводов, LEDC, MCPWM.

Открыть в интерактивном справочнике