Метеопанель с экраном 320 на 240 пикселей: раз в секунду она перерисовывала график — и опрос кнопок заметно подвисал, а бегущая строка дёргалась. Виноват не экран, а соседство: перерисовка и логика жили в одном loopTask на ядре 1, и SPI-заливка кадра на сорок-восемьдесят миллисекунд тормозила всё подряд. Диагноз подтвердился за вечер: я вынес перерисовку в отдельную задачу на ядре 0, и обе беды исчезли разом — кнопки опрашивались вовремя, кадр перестал дёргаться. Выход подсказала сама плата: ядро 0 рядом, и WiFi-стек занимает в нём не всё — место для одной фоновой задачи там есть, если её правильно попросить.
Механику xTaskCreatePinnedToCore — семь аргументов, стек в байтах, watchdog — я уже разбирал в уроке про dual core, а очереди и мьютексы для связи задач — в уроке про FreeRTOS. Здесь — стартовая площадка для новичка: что безопасно повесить на ядро 0, почему приоритетов 1-3 достаточно и главная ловушка из разряда «работало же»: общая переменная, к которой ходят две задачи. Из этих трёх вопросов первые два решаются за вечер, а третий — тот, на котором строятся недельно отлаживаемые случаи, поэтому на него придётся большая часть урока.
Честное мнение напоследок вводной части: второе ядро — не витрина, а инструмент от конкретной боли. Пока loop() успевает всё — датчики, экран, кнопки — задач не заводите вовсе: лишняя синхронизация стоит вечеров. Мой триггер простой: в loop() появился блок, который держит процессор дольше тридцати миллисекунд за раз — SPI-кадр, HTTPS-запрос, бегущая строка, — и от этого страдает ритм остального кода. Вот этот блок и есть кандидат на выезд, а дальше речь о том, куда его селить и как не подорваться на общих переменных.
Своя задача на ядре 0: тяжёлый экран и WiFi в одной комнате
Первый страх новичка — «не сломаю ли я WiFi своей задачей» — снимается приоритетами. WiFi-стек на ядре 0 живёт с приоритетами в районе двадцати трёх; ваша задача с приоритетом 1-2 в этом доме — сосед второго сорта: придёт пакет — радио вытеснит её на миллисекунды и вернёт управление. Экрану это только на руку: он рисует кадр, когда сеть молчит, и честно ждёт, когда она заговорит — мигания и разрывов кадров при коротких передачах я не замечал. А loop() на ядре 1 перестаёт захлёбываться: кнопки опрашиваются вовремя, сервы не спотыкаются.
Второе железное правило: в теле задачи обязан быть vTaskDelay или хотя бы delay. Без паузы задача занимает ядро бесконечно, голодает системный idle — и watchdog перезагружает плату через несколько секунд после старта. Мой рецепт приоритетов — три ступени и не больше: 1 — фоновая перерисовка и медленные журналы, 2 — то, что обязано попадать в ритм (тоны, мигалки), 3 — редкое, но срочное; loopTask живёт на приоритете 1, а выше третьей на старте я ничего не даю. Если же нужен простой ритм «раз в N миллисекунд и всё», иногда честнее обойтись Ticker из урока про периодические задачи или millis-планировщиком.
Стек задаче ставлю 4096 байт на простые вещи и 8192, если внутри Serial, String или HTTP — про размеры стека и stack canary в логах подробнее рассказывал урок про dual core. Приоритет 1 у фонового экрана вовсе не признак бедности: чем ниже приоритет, тем мягче ваша задача для радио — и тем меньше шансов, что кто-то обвинит её в пропавших пакетах. Имя задаче давайте говорящее: ekran в логах паники читабельнее task2, а искать потом приходится не по номеру ядра, а по имени.
| Задача | Ядро и приоритет | Почему |
|---|---|---|
| тяжёлая перерисовка экрана | ядро 0, приоритет 1 | WiFi перебьёт, когда придёт пакет; loop() на ядре 1 остаётся отзывчивым, стек 4096-8192 байт |
| датчики и логика | ядро 1, loopTask, приоритет 1 | дом скетча: не трогаем без нужды, тяжёлые кадры сюда не возвращаем — иначе всё вернётся на круги своя |
| тоны, мигалки, ШИМ-ритмы | ядро 1, приоритет 2 | чуть выше loopTask — ритм не ломается от долгих вычислений соседа по ядру |
| всё, что жило на Uno | ядро 1, loop | стартовая точка: пока задача одна, второе ядро вам не нужно вовсе |
TaskHandle_t hEkran;portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED; volatile int temperatura = 215; // десятые градуса: сюда ходят обе задачи void zadachaEkran(void *p) { // живёт на ядре 0 for (;;) { // «тяжёлая» перерисовка: на плате — SPI-заливка кадра, 40-80 мс portENTER_CRITICAL(&mux); int t = temperatura; // честное чтение общего числа portEXIT_CRITICAL(&mux); Serial.print("[yadro 0] kadr s t="); Serial.println(t); vTaskDelay(pdMS_TO_TICKS(300)); // без паузы здесь голодает IDLE0 }} void setup() { Serial.begin(115200); // имя, стек 4096 байт, аргумент NULL, приоритет 1, хэндл, ядро 0 xTaskCreatePinnedToCore(zadachaEkran, "ekran", 4096, NULL, 1, &hEkran, 0);} void loop() { // ядро 1: датчики и логика static unsigned long t = 0; if (millis() - t >= 1000) { t = millis(); portENTER_CRITICAL(&mux); temperatura += random(-5, 6); // моделируем свежее измерение portEXIT_CRITICAL(&mux); Serial.print("[yadro 1] izmerenie "); Serial.println(temperatura); }}Теперь та самая гонка. temperatura += 1 из loop() и чтение температуры из задачи экрана выглядят безобидно, но инкремент — три операции: прочитать, прибавить, записать. Между ними ядро 0 успевает вклиниться со своим чтением, и часть обновлений теряется: счётчик за сутки уползал на пару единиц от правды, и я неделю подозревал датчик. volatile решает половину беды: запрещает компилятору держать значение в регистре между итерациями — но не делает инкремент атомарным; подробности в уроке про volatile и прерывания.
Разберу гонку на числах, чтобы она перестала быть мистикой. Писатель читает 100, прибавляет единицу; в этот момент читатель успевает скопировать себе те же 100; писатель записывает 101, читатель рисует 100 — и одно обновление исчезло. При десяти обновлениях в секунду потеря редка, при тысячах счёт уже идёт на проценты, потому «работает же» и «врёт иногда» описывают один и тот же код. Если у переменной один писатель, а остальным достаточно видеть последнее значение, volatile с чтением под портом — достаточный набор; очередь выручает там, где терять события нельзя. Счётчики для статистики, выбранный режим, последний замер — всё это жить может на volatile; журнал событий — уже нет.
Проверить гонку на своей плате можно без осциллографа: заведите счётчик, инкрементируйте его из двух задач без секции и печатайте раз в секунду, сколько прибавлений должно было быть и сколько счётчик насчитал. Уже на сотнях обновлений в секунду расхождение станет заметным. Потом оберните инкремент в portENTER_CRITICAL/portEXIT_CRITICAL — расхождение исчезнет, и это самый наглядный аргумент в пользу критических секций, который я знаю для новичка.
Вторая половина закрывается критической секцией: portENTER_CRITICAL(&mux) перед работой с общей переменной и portEXIT_CRITICAL(&mux) после. Внутри этой пары скобок задачу на своём ядре никто не перебьёт — спинлок блокирует даже прерывания, поэтому секция обязана быть короткой: пара строк, никаких Serial, delay и тем более vTaskDelay. Для больших обменов правильнее очередь из урока про FreeRTOS: она копирует значение в ящик и снимает сам вопрос синхронизации. Мой потолок для portMUX — обмен одним числом или флажком.
FreeRTOS-механику движок симулятора не эмулирует — потому здесь честная модель: тот же millis-планировщик из урока про миллис вместо delay ведёт две независимые задачи с собственными таймерами. «Экран» обновляется каждые 300 мс, «датчик» раз в две секунды печатает свежее показание — и ритм LED не сбивается, пока Serial занят. На реальной плате эти две функции уедут в xTaskCreatePinnedToCore на разные ядра, и поведение останется тем же.
Проверка руками простая: покрутите потенциометр — показания датчика поедут, а мигание LED не собьётся с ритма трёхсот миллисекунд. Именно так выглядит независимость задач: печать в Serial из одной ветки не трогает тайминги другой. На настоящей плате добавится и честная одновременность: пока ядро 0 рисует кадр, ядро 1 в то же самое время опрашивает кнопки — millis-модель показывает очередность, RTOS даёт параллельность. А счётчик тактов экрана в печати — та же проверка живости из tip-заметки выше: цифры ползут — обе задачи дышат.
Разница между моделью и RTOS, о которой честно стоит сказать: millis-ветка не спит, а проверяет условие на каждом проходе loop(), и процессор тратится даже на ожидание. Задача с vTaskDelay отдаёт ядро планировщику и не ест ничего — на батарейке и при десятках задач разница становится заметной. Плюс приоритеты: планировщик сам поднимет срочную задачу над фоновой, а в millis-планировщике порядок задаёте вы сами. Для двух-трёх веток на плате с розеткой модель честна, дальше — RTOS.
- 1. На каком ядре выполняется loop() обычного скетча?
- 2. Задача на ядре 0 без vTaskDelay в теле — что случится?
- 3. Спасает ли volatile от потерянного инкремента из двух задач?
- 4. Какой приоритет дать фоновой перерисовке экрана на ядре 0?
- 5. Чего нельзя делать внутри portENTER_CRITICAL/portEXIT_CRITICAL?
- Ядро 0 — дом WiFi-стека с приоритетами около 23: фоновая задача с 1-2 рисует экран, когда радио молчит, и честно уступает пакетам.
- loop() — задача loopTask на ядре 1: датчики и логика остаются отзывчивыми, пока экран живёт на соседнем ядре.
- В теле задачи — всегда vTaskDelay или delay: без паузы голодает IDLE0, и watchdog перезагружает плату.
- volatile запрещает кэш в регистрах, но не делает инкремент атомарным; portENTER_CRITICAL(&mux)/portEXIT_CRITICAL(&mux) вокруг пары строк закрывают гонку.
- Приоритетов 1-3 хватает: 1 — фон, 2 — ритм, 3 — срочное; выше — только после измерения, что задача не успевает.
Частые вопросы по теме урока
- На каком ядре выполняется loop() обычного скетча?
- loopTask прибит к ядрю 1: WiFi-стек живёт на ядре 0, ваш скетч — на ядре 1. Собственные задачи можно селить на любое ядро через xTaskCreatePinnedToCore. На ядре 1 (APP_CPU)
- Задача на ядре 0 без vTaskDelay в теле — что случится?
- Бесконечный цикл без пауз не даёт системному idle выполняться: через несколько секунд срабатывает watchdog. vTaskDelay или delay в теле задачи обязательны. Голодает IDLE0, watchdog перезагружает плату
- Спасает ли volatile от потерянного инкремента из двух задач?
- Инкремент — три операции, и между ними вклинивается вторая задача. volatile лишь честно перечитывает значение; атомарность даёт portENTER_CRITICAL или очередь. Нет: он запрещает кэш в регистрах, но инкремент рвётся — нужна критическая секция
- Какой приоритет дать фоновой перерисовке экрана на ядре 0?
- WiFi-стек живёт на приоритетах в районе двадцати трёх, так что фоновая задача с 1-2 рисует, когда сеть молчит, и уступает пакетам. Выше третьей на старте я не даю. 1-2 — фон, WiFi всё равно главнее
- Чего нельзя делать внутри portENTER_CRITICAL/portEXIT_CRITICAL?
- Спинлок блокирует прерывания и планировщик на ядре: длинная секция замораживает всё вокруг. Внутри — пара строк обмена, всё остальное за скобками. delay, Serial, vTaskDelay — секция должна быть короче микросекунд