Инструкции

Прерывания и таймеры ESP32: attachInterrupt, timerBegin и дребезг

13 мин чтения·Август 2026·RoboArm R1

Опрашивать кнопку в loop() можно, пока цикл быстрый. Как только в программе появляется что-то долгое, нажатия начинают теряться, а импульсы энкодера — и подавно. Тогда нужны прерывания: железо само зовёт вашу функцию в момент события. Разбираем прерывания по выводу, аппаратные таймеры и главную ловушку — API таймеров полностью изменился в arduino-esp32 3.x, и почти все примеры в интернете устарели.

Прерывание по выводу

#define BUTTON_PIN 15

volatile bool isButtonPressed = false;

void ARDUINO_ISR_ATTR handleButtonPress() {
  isButtonPressed = true;
}

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), handleButtonPress, FALLING);
}

void loop() {
  if (isButtonPressed) {
    isButtonPressed = false;
    Serial.println("Кнопка нажата");
  }
}

Обработчик делает единственное — выставляет флаг. Вся полезная работа происходит в loop(). Это и есть основной приём: прерывание должно быть максимально коротким.

⚠️

У схемы с флагом есть цена, о которой стоит знать заранее: события теряются. Если прерывание сработает дважды до того, как loop() дойдёт до проверки, флаг всё равно останется одним, и второе событие исчезнет. Хуже того, возможна гонка: loop() прочитал true, в этот момент сработало новое прерывание, а loop() следом записал false — и затёр свежее событие.

Для кнопки это обычно приемлемо: человек не нажимает быстрее, чем крутится цикл. А вот для подсчёта импульсов энкодера или расходомера флаг не годится — там нужен счётчик под критической секцией или очередь FreeRTOS, как показано ниже.

Режимы срабатывания:

РежимКогда срабатывает
RISINGпереход с низкого уровня на высокий
FALLINGс высокого на низкий
CHANGEлюбое изменение
ONLOW, ONHIGHпока держится уровень, а не по фронту
ℹ️

В отличие от AVR, где прерывания доступны на двух-трёх выводах, на ESP32 их поддерживает любой валидный GPIOdigitalPinToInterrupt() там просто возвращает номер пина. Оговорки те же, что обычно: GPIO 34–39 работают только на вход и не имеют внутренних подтяжек, так что кнопке на них нужен внешний резистор. Подробности — в статье Пины ESP32.

Атрибут ARDUINO_ISR_ATTR

В примерах встречаются два варианта пометки обработчика — IRAM_ATTR и ARDUINO_ISR_ATTR. Для 3.x правильный ответ — ARDUINO_ISR_ATTR: именно его использует Espressif в собственных примерах.

Разница в том, что ARDUINO_ISR_ATTR подстраивается под конфигурацию сборки. В стандартной поставке Arduino он раскрывается в пустоту, и обработчик живёт во flash — это значит, что во время записи в NVS или файловую систему прерывание временно отключается и срабатывает с задержкой. Для кнопки это незаметно.

IRAM_ATTR кладёт функцию в оперативную память, где она доступна всегда. Но поставить его недостаточно: чтобы это дало эффект, нужно, чтобы весь вызываемый из обработчика код тоже лежал в IRAM, иначе плата упадёт с ошибкой InstrFetchProhibited ровно в момент работы с flash. Поэтому по умолчанию берите ARDUINO_ISR_ATTR и не усложняйте.

Что нельзя делать в обработчике

  • delay(), vTaskDelay() и любые блокирующие вызовы. Из прерывания доступны только варианты с суффиксом FromISR.
  • Serial.print(). Захватывает мьютекс и может заблокироваться.
  • malloc(), new, String. Выделение памяти тоже берёт мьютекс.
  • Долгие вычисления. Обработчик должен укладываться в единицы микросекунд.

Переменные, которые меняются в прерывании и читаются в loop(), обязаны быть volatile — иначе компилятор решит, что их никто не меняет, и закеширует значение в регистре.

Одного volatile хватает для простого флага. Для составных данных на двухъядерном ESP32 нужна защита от гонки между ядрами:

portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
volatile uint32_t pulseCount = 0;

void ARDUINO_ISR_ATTR handlePulse() {
  portENTER_CRITICAL_ISR(&mux);
  pulseCount++;
  portEXIT_CRITICAL_ISR(&mux);
}

Когда данных больше, чем счётчик, надёжнее очередь FreeRTOS: обработчик кладёт событие и сразу выходит, а разбирает его отдельная задача.

void ARDUINO_ISR_ATTR handlePulseQueued() {
  uint32_t timestamp = micros();
  BaseType_t higherPriorityTaskWoken = pdFALSE;
  xQueueSendFromISR(pulseQueue, &timestamp, &higherPriorityTaskWoken);
  if (higherPriorityTaskWoken) {
    portYIELD_FROM_ISR();
  }
}

Про очереди и задачи — в статье Два ядра ESP32 и FreeRTOS.

Дребезг контактов

Механическая кнопка при нажатии не переключается один раз: контакты пружинят, и за несколько миллисекунд уровень скачет туда-обратно. Прерывание честно отрабатывает каждый скачок, и одно нажатие превращается в десяток.

Самый простой способ — игнорировать повторные срабатывания в течение окна времени:

constexpr uint32_t debounceIntervalUs = 50000;   // 50 мс

volatile uint32_t lastInterruptTimeUs = 0;
volatile bool isButtonPressed = false;

void ARDUINO_ISR_ATTR handleButtonPress() {
  uint32_t now = micros();
  if (now - lastInterruptTimeUs >= debounceIntervalUs) {
    lastInterruptTimeUs = now;
    isButtonPressed = true;
  }
}

Вычитание, а не сравнение с суммой, — важная деталь: так код корректно переживает переполнение micros().

Программный способ хорош для кнопок. Для механических энкодеров, где импульсы идут часто, он теряет полезные события — там ставят RC-фильтр на входе.

Аппаратные таймеры: новый API

Таймер нужен, когда действие должно происходить строго по расписанию, независимо от того, чем занят loop().

Внимание: в arduino-esp32 3.x API таймеров переписан. Старые примеры не компилируются. Вот как выглядит рабочий код сейчас:

hw_timer_t* timer = nullptr;
volatile uint32_t tickCount = 0;
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;

void ARDUINO_ISR_ATTR onTimer() {
  portENTER_CRITICAL_ISR(&timerMux);
  tickCount++;
  portEXIT_CRITICAL_ISR(&timerMux);
}

void setup() {
  Serial.begin(115200);

  timer = timerBegin(1000000);              // частота счёта: 1 МГц, то есть тик = 1 мкс
  if (timer == nullptr) {
    Serial.println("Не удалось настроить таймер");
    return;
  }

  timerAttachInterrupt(timer, &onTimer);
  timerAlarm(timer, 1000000, true, 0);      // сработать через 1 000 000 тиков = 1 с,
                                            // true — повторять, 0 — без ограничения
}

void loop() {
  uint32_t value;
  portENTER_CRITICAL(&timerMux);
  value = tickCount;
  portEXIT_CRITICAL(&timerMux);

  Serial.printf("Сработало раз: %u\n", value);
  delay(2000);
}

Что изменилось по сравнению с версией 2.x:

Было в 2.xСтало в 3.x
timerBegin(0, 80, true) — номер, делитель, направлениеtimerBegin(1000000)только частота в герцах
timerAttachInterrupt(timer, &fn, true)timerAttachInterrupt(timer, &fn) — параметр убран
timerAlarmWrite(...) + timerAlarmEnable(...)timerAlarm(timer, value, autoreload, count) — слиты в одну
timerAlarmDisable, timerSetDivider, timerStartedудалены

Логика стала проще: вы задаёте не делитель частоты шины, а прямо желаемую частоту счёта, а ядро само подбирает источник тактирования и делитель. Если нужная частота недостижима, timerBegin() вернёт nullptr — результат стоит проверять.

⚠️

Если пример из интернета не компилируется с ошибкой вроде too many arguments to function 'timerBegin' — вы нашли код под версию 2.x. Переписывайте по таблице выше, а не ищите «правильную» библиотеку.

millis и переполнение

millis() и micros() на ESP32 внутри читают 64-битный счётчик, но возвращают 32-битное значение. Отсюда переполнение: millis() обнуляется примерно через 49 суток непрерывной работы, micros() — через 71 минуту.

Правильная запись переживает это без единого сбоя:

if (millis() - previousMillis >= interval) {
  previousMillis = millis();
}

А эти две — сломаются ровно один раз за период:

if (millis() >= previousMillis + interval) { }   // переполняется сумма
if (millis() > targetTime) { }                   // сравнение абсолютных значений

Разность беззнаковых чисел даёт правильный результат даже когда счётчик перешёл через ноль, — поэтому всегда сравнивайте именно разность. И не кладите результат millis() в int или long: знаковое переполнение в C++ — неопределённое поведение.

Что дальше

Рекомендуемые продукты

Связанные статьи