Почему ESP32 перезагружается: сторожевой таймер, стек и причины сброса

Как узнать, кто перезапустил плату, и что стоит за сторожем задач, паникой, переполнением стека и утечкой памяти

Плата работает час и начинает заново, счётчики обнулены, в мониторе порта снова приветствие из setup(). Разбираем, как узнать причину сброса и чем отличаются сторож, паника, переполнение стека и утечка памяти.

Прибор на ESP32 работает, данные идут, а через час в мониторе порта снова строка из setup(): счётчики на нуле, Wi-Fi подключается заново. Код при этом никто не менял, и ошибки компилятор не показывал. Такие перезапуски почти всегда имеют одну из пяти причин, и первая задача — не угадывать, а спросить у платы, кто её перезагрузил.

Первый вопрос: кто нажал на сброс

ESP32 помнит причину последнего сброса, и ESP-IDF отдаёт её функцией esp_reset_reason(). В примере причины перезагрузки она переводится в слова:

const char *reasonText(esp_reset_reason_t r) {
  switch (r) {
    case ESP_RST_POWERON:  return "подано питание";
    case ESP_RST_SW:       return "программная перезагрузка";
    case ESP_RST_PANIC:    return "паника или исключение";
    case ESP_RST_INT_WDT:  return "сторож прерываний";
    case ESP_RST_TASK_WDT: return "сторож задачи";
    case ESP_RST_WDT:      return "иной сторож";
    case ESP_RST_DEEPSLEEP:return "выход из глубокого сна";
    case ESP_RST_BROWNOUT: return "просадка питания";
    default:               return "неизвестно";
  }
}

Эти две строки — печать причины в начале setup() — стоит держать в любой прошивке, которая работает без присмотра. Причина сразу сужает поиск:

ПричинаЧто обычно стоит за ней
ESP_RST_POWERONпитание действительно пропадало или плату передёрнули по USB
ESP_RST_SWкто-то вызвал ESP.restart() — возможно, библиотека после ошибки
ESP_RST_PANICисключение процессора: нулевой указатель, переполнение стека, деление на ноль
ESP_RST_TASK_WDTзадача долго не отдавала процессор
ESP_RST_INT_WDTобработчик прерывания работал слишком долго или прерывания были запрещены
ESP_RST_BROWNOUTнапряжение питания просело, чаще всего при включении Wi-Fi
ESP_RST_DEEPSLEEPне авария, а выход из глубокого сна

Просадка питания — единственная строка, которую не лечат в коде. Радиомодуль при передаче берёт короткие пики тока в сотни миллиампер, и тонкий USB-кабель или слабый стабилизатор не успевают. Помогает конденсатор на 100–470 мкФ у вывода питания модуля и нормальный источник. Остальные причины — программные, и с ними работают по-разному.

Сторожевой таймер: кто его гладит и за что он кусает

Сторожевой таймер — это счётчик, который надо регулярно сбрасывать. Не сбросили за отведённое время — значит, программа зависла, и сторож перезапускает чип. В ESP-IDF есть сторож задач: каждая задача, подписанная на него, обязана вовремя вызывать esp_task_wdt_reset().

Пример сторожевого таймера настраивает его явно:

esp_task_wdt_init(TIMEOUT_S, true);   // 5 с; true — перезагружать
esp_task_wdt_add(NULL);               // следим за текущей задачей

void loop() {
  if (i < 8) esp_task_wdt_reset();    // гладим сторожа
  delay(1000);
}

Первые семь итераций сторожа гладят, с восьмой — перестают. Последний сброс приходится на седьмую итерацию, примерно на шестой секунде после старта. Добавляем пять секунд таймаута: срабатывание придётся на двенадцатую-тринадцатую итерацию. Потом всё начинается заново со строки «сторож включён».

Второй аргумент esp_task_wdt_init важен. При false сторож только напишет в порт, какая задача не отметилась, а плата продолжит работать. Так сторож задач обычно настроен в сборке Arduino по умолчанию, поэтому зависшая задача часто не перезагружает плату, а засыпает порт сообщениями task_wdt.

Главная ошибка со сторожем — лечить симптом. Если цикл ожидания вида while (!ready) {} роняет плату, соблазнительно вставить в него esp_task_wdt_reset(). Сторож замолчит, но зависание останется: вы отключили единственный механизм, который из него выводил. Правильно — не держать процессор в пустом цикле: delay(1) или vTaskDelay() внутри ожидания отдают управление планировщику, и холостая задача успевает отметиться.

Сторожевой таймер

Поменяйте условие i < 8 на i < 3 и таймаут TIMEOUT_S на 2: перезагрузка наступит примерно на четвёртой-пятой итерации вместо двенадцатой-тринадцатой

Открыть сторожа

Паника: ядро не может продолжать

Паника — это исключение процессора, после которого выполнять программу дальше нельзя. В мониторе порта она выглядит как Guru Meditation Error: Core 1 panic'ed (LoadProhibited) с адресами и трассировкой вызовов. Название в скобках — первая подсказка:

  • LoadProhibited / StoreProhibited — чтение или запись по недопустимому адресу. Если в строке EXCVADDR стоит ноль или число вроде 0x00000010, это обращение по нулевому указателю или к полю структуры по нулевому указателю.
  • IllegalInstruction — процессор прыгнул туда, где нет кода. Обычно следствие испорченного стека: адрес возврата затёрт.
  • IntegerDivideByZero — целочисленное деление на ноль.

Самый частый источник нулевого указателя — результат, который не проверили: malloc, вернувший NULL, объект, который не создался, строка, которую не нашли. Панику обычно ловят не в той строке, где ошибка возникла, а там, где указатель впервые разыменовали. Поэтому смотрят на трассировку вызовов, а не только на верхнюю строку.

В PinPort исключение попадает в журнал симуляции с расшифровкой: «исключение StoreProhibited при обращении к 0x0» и адрес команды. После паники прошивка перезапускается, и в журнале появляется запись о том, что модель запущена заново.

Стек задачи: 2048 байт — это сколько

Каждая задача FreeRTOS получает свой стек фиксированного размера. Локальные переменные, аргументы, адреса возврата — всё это живёт там. Если задача вылезла за границу, она портит соседнюю память, и авария случается позже и в другом месте. ESP-IDF ставит на границу стека контрольное слово и при его порче выдаёт панику с сообщением о переполнении стека конкретной задачи — это лучший случай.

В примере запаса стека задача создаётся со стеком 2048:

void watched(void *) {
  for (;;) {
    char buffer[256];   // локальный массив живёт на стеке задачи
    snprintf(buffer, sizeof(buffer), "время %lu", millis());
    UBaseType_t left = uxTaskGetStackHighWaterMark(NULL);
    Serial.printf("%s | запас стека %u байт\n", buffer, left);
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}
xTaskCreate(watched, "watched", 2048, NULL, 1, NULL);

Тонкость единиц: в классическом FreeRTOS размер стека задаётся в словах, а в ESP-IDF — в байтах, и uxTaskGetStackHighWaterMark там тоже возвращает байты. Документация и старые примеры в сети часто говорят «в словах» по привычке классического FreeRTOS; на ESP32 2048 в xTaskCreate — это 2048 байт стека, и остаток пример печатает в байтах.

Из 2048 байт четверть сразу уходит на buffer. Ещё заметную долю съедают snprintf и Serial.printf: форматированный вывод — одна из самых прожорливых по стеку вещей. Функция возвращает наименьший остаток за всю жизнь задачи. Если запас меньше 200–300 байт, стек стоит увеличить: любое редкое ветвление, которое вы не прогнали при проверке, может его добрать.

Для сравнения, у основной задачи Arduino, в которой крутится loop(), стек 8 КБ. Поэтому массив на пару килобайт, объявленный внутри loop(), ещё помещается, а тот же массив в самодельной задаче на 2048 — уже нет. Большие буферы лучше делать static или глобальными: тогда они лежат не на стеке.

Куча: медленная смерть

Утечка памяти не роняет плату сразу. Каждый malloc без free, каждый new без delete откусывает от кучи, и через час или сутки очередной запрос возвращает NULL. Если результат не проверяют, дальше — паника по нулевому указателю, перезапуск, куча снова полная, и цикл повторяется. Снаружи это выглядит как перезагрузка раз в N часов без видимой причины.

Пример слежения за утечкой создаёт утечку нарочно: каждые 500 мс malloc(1024) и потерянный указатель. В мониторе порта свободная память убывает на килобайт с небольшим за шаг — 1024 байта плюс служебный заголовок блока. Разделите стартовое число из первой строки на 1024, и получите, сколько шагов плата продержится.

Там же видна вторая величина — ESP.getMaxAllocHeap(), наибольший непрерывный кусок. Если свободно 60 КБ, а наибольший кусок 4 КБ, память изрезана, и запрос на 8 КБ не пройдёт. Это фрагментация, и её дают частые выделения и освобождения блоков разного размера — например, склейка String в цикле.

На Arduino Uno ситуация жёстче. У ATmega328P 2048 байт ОЗУ на всё сразу, и никакой защиты нет: стек, наехавший на кучу, не вызывает ни паники, ни перезагрузки. Плата начинает печатать мусор или зависает в случайных местах. Пример свободной памяти на Uno меряет расстояние от вершины кучи до вершины стека и показывает главный способ сэкономить: строка в F() остаётся во флеше, а без F() каждая строка в кавычках копируется в ОЗУ при старте.

Порядок разбора

  1. Печатать причину сброса в начале setup(). Без неё дальнейшее — гадание.
  2. Сторож задач — искать цикл, который не отдаёт процессор: пустое ожидание, долгий расчёт, блокирующий сетевой вызов.
  3. Паника — смотреть на тип исключения и адрес. Ноль — нулевой указатель; сообщение о стеке — увеличить стек задачи или вынести буфер из него.
  4. Периодическая перезагрузка раз в несколько часов — выводить ESP.getFreeHeap() раз в минуту. Число, которое только убывает, — утечка.
  5. Просадка питания — проверять источник и кабель, а не код.

В PinPort это удобно проверять тем, что перезапуск виден сразу: монитор порта снова показывает первые строки setup(), а журнал отмечает, что модель запущена заново. esp_reset_reason() в модели отвечает так же, как на плате: «подано питание» при запуске, «программная перезагрузка» после ESP.restart(), «сторож задачи» после срабатывания сторожа, «паника или исключение» после исключения, «выход из глубокого сна» после сна. Сторожевые таймеры обеих групп смоделированы, поэтому сторож, настроенный на перезагрузку, перезапускает модель так же, как плату, а не молчит.

Итог

Внезапная перезагрузка ESP32 — не мистика, а сработавшая защита. Сторож говорит, что задача не отдаёт процессор, паника — что программа обратилась туда, куда нельзя, переполнение стека — что задаче мало места, а медленно убывающая куча — что где-то нет free. Начинать всегда с esp_reset_reason(): одна строка в порт экономит часы поиска не там.

Попробуйте в симуляторе

Причина перезагрузки

Замените ESP.restart() на запись по нулевому указателю *(volatile int *)0 = 1; — в журнале появится исключение StoreProhibited при обращении к 0x0, а после перезапуска первая строка скажет «причина перезагрузки: паника или исключение»

Запустить в симуляторе

Слежение за утечкой памяти

Добавьте free(leaked) перед malloc: свободная куча перестанет убывать и будет держаться на одном числе от шага к шагу

Утечка памяти

Arduino Uno: сколько осталось оперативной памяти

Уберите F() у строки «свободно при старте: » и сравните первое число: свободной памяти станет меньше примерно на 39 байт — 17 русских букв по два байта в UTF-8, четыре знака ASCII и завершающий ноль

ОЗУ Arduino Uno

← Все статьи