Прерывания и таймеры ESP32: attachInterrupt, timerBegin и дребезг
Опрашивать кнопку в 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 их
поддерживает любой валидный GPIO — digitalPinToInterrupt() там просто
возвращает номер пина. Оговорки те же, что обычно: 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, ×tamp, &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++ — неопределённое поведение.
Что дальше
- Задачи, очереди и второе ядро — Два ядра ESP32 и FreeRTOS.
- Какие выводы годятся под кнопку — Пины ESP32.
- Неблокирующий код без прерываний — Язык Arduino.