Прерывания на ESP32 и Arduino: что можно делать в обработчике
volatile, IRAM_ATTR, атомарное чтение и почему Serial.print в обработчике — плохая идея
Прерывание срабатывает посреди любой строки loop(), и от этого зависит всё, что можно и нельзя писать в обработчике. Разбираем правила на кнопке, счётчике импульсов, таймере и семафоре FreeRTOS.
Кнопка опрашивается в loop() раз в 50 мс, и этого хватает, пока loop() ничем больше не занят. Потом в цикл добавляется запрос к датчику на 200 мс, вывод на дисплей, отправка по WiFi — и короткие нажатия начинают теряться. Датчик расхода воды выдаёт сотни импульсов в секунду, и опросом их не поймать вовсе. Для таких событий у контроллера есть прерывания: аппаратура замечает фронт на выводе сама и немедленно вызывает функцию, что бы ни делала программа в этот момент.
Слово «немедленно» — ключ ко всем правилам ниже. Обработчик врывается посреди любой строки основного кода, и писать его нужно с оглядкой на это.
Как устроено прерывание по фронту
Когда на выводе происходит заданное событие, контроллер сохраняет текущее состояние, переходит по адресу обработчика, выполняет его и возвращается туда, где остановился. Основная программа этого не замечает, если обработчик не тронул её данные.
В Arduino обработчик вешается одной функцией:
attachInterrupt(digitalPinToInterrupt(pin), handler, FALLING);
Режимы: RISING (фронт, переход 0→1), FALLING (спад, 1→0), CHANGE (оба), на AVR ещё LOW (пока на входе ноль). Кнопка с подтяжкой INPUT_PULLUP замыкает вывод на землю, поэтому нажатию соответствует FALLING.
Главное различие плат — где это доступно:
| Плата | Выводы attachInterrupt | Альтернатива |
|---|---|---|
| Arduino Uno (ATmega328P) | только 2 и 3 | PCINT на любом выводе |
| Arduino Mega | 2, 3, 18, 19, 20, 21 | PCINT на части выводов |
| ESP32 | почти любой GPIO | — |
На Uno аппаратных входов INT0 и INT1 ровно два. Если перенести скетч с ESP32 и оставить кнопку на выводе 7, digitalPinToInterrupt(7) вернёт −1, и обработчик не сработает ни разу, а компилятор не скажет ни слова. Это первое, о чём предупреждает пример прерывание по фронту на Uno.
Для остальных выводов у ATmega328P есть второй механизм — PCINT, прерывание по изменению состояния. Оно доступно на каждом выводе, но обработчик один на весь порт, фронт не выбирается, и регистры настраиваются вручную. Вывод 7 — это разряд 7 порта D, PCINT23, вектор PCINT2_vect:
ISR(PCINT2_vect) {
changed = true;
}
PCICR |= (1 << PCIE2); // разрешить прерывания порта D
PCMSK2 |= (1 << PCINT23); // и только от вывода 7
Какой вывод изменился и в какую сторону, loop() выясняет сам через digitalRead.
Правило первое: volatile для общих переменных
Счётчик нажатий меняется в обработчике, а читается в loop(). Компилятор оптимизирует каждую функцию отдельно и не знает, что кто-то может изменить переменную «сбоку». Увидев цикл, в котором presses не меняется, он вправе прочитать её один раз в регистр и дальше сравнивать с копией. Программа будет работать при отладке и перестанет после безобидной правки.
volatile запрещает такие вольности: каждое обращение к переменной — настоящее чтение из памяти.
volatile unsigned long presses = 0;
void IRAM_ATTR onPress() {
presses++;
}
volatile нужен всем переменным, которые пишет обработчик и читает основной код, и наоборот. Локальным переменным самого обработчика он не нужен.
Правило второе: читать атомарно
volatile гарантирует, что чтение произойдёт, но не гарантирует, что оно произойдёт целиком. У ATmega328P восьмиразрядное ядро, и unsigned long из четырёх байт читается четырьмя командами. Если прерывание придёт между ними, в руках окажется часть старого значения и часть нового. При переходе счётчика с 255 на 256 младший байт может быть прочитан ещё старым (0xFF), а следующий — уже новым (0x01), и получится 511.
Поэтому пример для Uno снимает копию при запрещённых прерываниях:
noInterrupts();
unsigned long now = presses;
interrupts();
На ESP32 32-разрядное число читается одной командой, и само чтение безопасно. Но пара «прочитать и обнулить» — две операции, и между ними обработчик может добавить импульс, который тут же будет стёрт. В счётчике импульсов снятие окружено теми же noInterrupts() и interrupts(): раз в секунду значение забирается и обнуляется, частота печатается в порт как «N имп/с».
На двухъядерном ESP32 у этого приёма есть предел: запрет прерываний действует только на то ядро, где выполняется код. Если обработчик может работать на другом ядре, надёжнее критическая секция со спин-блокировкой:
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
portENTER_CRITICAL(&mux);
unsigned long n = pulses;
pulses = 0;
portEXIT_CRITICAL(&mux);
В обработчике та же секция пишется через portENTER_CRITICAL_ISR.
Правило третье: на ESP32 обработчик живёт в RAM
Программа ESP32 хранится во внешней флеш-памяти и исполняется через кеш. Пока идёт запись во флеш (сохранение настроек, файловая система, обновление по воздуху), кеш отключён. Если в этот момент придёт прерывание, а код обработчика лежит во флеше, процессору нечего исполнять, и плата перезагружается с ошибкой доступа к кешу.
Атрибут IRAM_ATTR кладёт функцию во внутреннюю RAM, откуда она доступна всегда. Отсюда же следствие: всё, что вызывает обработчик, тоже должно быть доступно без флеша. Инкремент переменной и micros() — можно. Длинные библиотечные функции — нельзя.
Что можно и чего нельзя в обработчике
Итог трёх правил сводится к одному: обработчик должен отметить событие и выйти. Всё остальное делает основной код.
| В обработчике можно | В обработчике нельзя |
|---|---|
| изменить volatile-переменную | delay() и ожидание в цикле |
снять время micros() / millis() | Serial.print и printf |
| прочитать или переключить вывод | обращаться к I2C, SPI, WiFi |
отдать семафор через ...FromISR | выделять память, работать со String |
Причины разные. delay() на AVR держится на прерывании таймера 0, а внутри обработчика прерывания запрещены — ожидание не закончится. Serial.print при заполненном буфере ждёт, пока буфер освободится, а на ESP32 ещё и берёт блокировку, которую из прерывания брать нельзя. Шины I2C и SPI сами работают на прерываниях и занимают миллисекунды.
Есть и оговорка про время. millis() внутри обработчика на Uno возвращает значение, которое не растёт, пока обработчик выполняется, но прочитать его для отметки времени можно. Так устроен фильтр дребезга в примере с кнопкой на ESP32: обработчик сравнивает millis() с временем прошлого срабатывания и отбрасывает всё, что пришло раньше 30 мс. В симуляторе это можно проверить: включите у кнопки свойство «Дребезг контактов» и уберите строку с фильтром — одно нажатие начнёт давать несколько срабатываний.
Точность отметки времени — главный довод в пользу прерываний даже там, где loop() успевает. В секундомере для забега старт и финиш ловятся обработчиками, которые только запоминают micros(). Цикл тем временем обновляет индикатор TM1637 и ждёт delay(20), но это не влияет на результат: сценарий нажимает кнопки с интервалом 3,25 с, и в порт уходит «финиш: 3.250 с».
Долгая работа по сигналу: семафор FreeRTOS
Бывает, что событие требует серьёзной работы: записать в файл, отправить сообщение, перерисовать экран. Флаг, который loop() проверяет раз в 50 мс, добавляет задержку. На ESP32 есть способ лучше: обработчик отдаёт семафор, а задача FreeRTOS, которая спала на этом семафоре, просыпается сразу.
void IRAM_ATTR onPress() {
BaseType_t woken = pdFALSE;
xSemaphoreGiveFromISR(buttonSemaphore, &woken);
if (woken) portYIELD_FROM_ISR();
}
Суффикс FromISR обязателен: обычные функции FreeRTOS могут заблокировать вызывающего, а обработчик блокироваться не имеет права. portYIELD_FROM_ISR() переключает на разбуженную задачу сразу при выходе из прерывания, если у неё приоритет выше текущей. В примере задача-обработчик создана с приоритетом 2, выше, чем у loop().
FreeRTOS: сигнал из прерывания
Нажмите кнопку: строка «кнопка обработана в задаче, а не в прерывании» появится в порту сразу, хотя loop() спит по секунде. Двоичный семафор не копит сигналы: замените xSemaphoreCreateBinary() на xSemaphoreCreateCounting(10, 0), и частые нажатия перестанут схлопываться в одно.
Открыть примерАппаратный таймер: прерывание по расписанию
Источником прерывания может быть не только вывод, но и таймер. Он считает такты сам и вызывает обработчик при совпадении с заданным числом, поэтому интервал не зависит от того, чем занят loop().
На ESP32 таймер тактуется от 80 МГц. Делитель 80 даёт шаг 1 мкс, и число 100 000 в timerAlarmWrite означает 0,1 с. Обработчик увеличивает счётчик, а loop() каждые десять тиков печатает «прошло N с».
На Uno то же самое делается регистрами таймера 1 в режиме CTC. Кварц 16 МГц, предделитель 1024:
16 000 000 / 1024 = 15 625 тактов в секунду
OCR1A = 15 625 − 1 = 15 624
Единица вычитается, потому что счёт идёт с нуля. Ошибка на единицу дала бы 64 мкс в секунду, около пяти секунд в сутки. Таймер 0 при этом трогать нельзя: на нём держатся millis() и delay(). Пример таймер 1 раз в секунду мигает светодиодом на выводе 8 и печатает «секунда».
Коротко
Обработчик прерывания выполняется посреди чужого кода. Поэтому общие переменные объявляют volatile, многобайтные значения читают при запрещённых прерываниях или в критической секции, на ESP32 обработчик помечают IRAM_ATTR. Внутри — только отметить событие: увеличить счётчик, запомнить время, отдать семафор. Всё, что ждёт, печатает или ходит по шинам, остаётся в loop() или в задаче. На Uno прерывания по фронту есть только на выводах 2 и 3, остальное — через PCINT. В симуляторе PinPort это проверяется на реальных скетчах: монитор порта покажет и честный счёт, и то, как он ломается без этих правил.
Попробуйте в симуляторе
Кнопка через прерывание
Замените delay(50) в loop() на delay(2000) и нажмите кнопку несколько раз подряд: строка в мониторе порта придёт с опозданием и сразу с выросшим числом, но ни одно нажатие не потеряется в счёте. Затем поменяйте FALLING на CHANGE и проверьте, что долгое нажатие считается дважды — на нажатие и на отпускание.
Кнопка на прерыванииArduino Uno: прерывание по фронту
Перенесите кнопку с вывода 2 на вывод 7 и поменяйте константу BUTTON: скетч соберётся, но нажатия перестанут считаться. Посмотрите, что вернёт digitalPinToInterrupt(7), выведя его в порт.
Прерывание на UnoArduino Uno: прерывание на любом выводе
Нажмите и отпустите кнопку: в порт уйдут две строки, «нажата» и «отпущена», потому что PCINT фронт не выбирает. Добавьте вторую кнопку на вывод 6, разрешите для неё PCINT22 в PCMSK2 и различите кнопки в loop() по digitalRead.
PCINT на любом выводеСчётчик импульсов на прерывании
Уберите noInterrupts() и interrupts() вокруг снятия счётчика и подумайте, какой импульс может потеряться между чтением и обнулением. Затем перепишите снятие через portENTER_CRITICAL.
Счётчик импульсовАппаратный таймер
Замените в loop() delay(5) на delay(700): часть строк «прошло N с» пропадёт, потому что тики успевают убежать мимо кратного десяти. Счёт в прерывании при этом верный — перепишите вывод через переменную с последней напечатанной секундой, чтобы строки снова шли каждую секунду.
Аппаратный таймер