Счётчик воды YF-S201 я подключил к Uno на второй день знакомства с платой: один импульс — примерно 2,2 миллилитра, считать можно прямо в loop() через digitalRead. На тонкой струйке всё сходилось до миллилитра. Стоило открыть кран сильнее — и литры начали уходить куда-то мимо: при расходе 30 литров в минуту датчик выдаёт 225 импульсов в секунду, а мой loop() с Serial.println успевал делать круг за 6-7 мс. Импульсы падали ровно между проверками — я честно их не видел и в счёт не записывал.
Первый порыв был типичным тупиком: убрать все Serial.print, вычистить код, ускорить loop(). Стало лучше на треть. Добавил дисплей — и снова недосчёт. Вечер ушёл на оптимизацию симптома, пока не дошло: опрос в принципе не умеет ловить события, которые короче прохода цикла. Это не вопрос скорости — это вопрос архитектуры.
Архитектурное решение называется прерывание: аппаратный механизм, при котором сам микроконтроллер следит за пином и мгновенно вызывает вашу функцию, что бы в этот момент ни делал основной код. В этом уроке — attachInterrupt(), три режима срабатывания, слово volatile, дребезг кнопки и счётчик, который не теряет ни одного тика.
Опрос против прерывания: в чём разница
При опросе вы сами, каждый круг loop(), спрашиваете пин: нажата? События между вопросами теряются. Пустой loop() на Uno с тактовой частотой 16 МГц крутится за единицы микросекунд, но стоит добавить Serial.print, дисплей или пару вычислений — и круг растёт до миллисекунд. Импульс длительностью 2 мс — типичный полупериод того же датчика потока — может целиком поместиться «между кадрами».
Прерывание переворачивает схему: не вы спрашиваете пин, а пин зовёт вас. ATmega328P следит за выбранным пином на аппаратном уровне, и как только сигнал изменился так, как вы задали, процессор доигрывает текущую инструкцию, бросает основной код и выполняет вашу функцию — обработчик прерывания, ISR. Потом возвращается ровно туда, где остановился. Вся реакция занимает единицы микросекунд, и основному коду даже не нужно знать, что что-то произошло.
Важная оговорка про железо: на Uno аппаратные внешние прерывания есть только на пинах 2 и 3 (их внутренние имена INT0 и INT1). Это не случайные номера — запомните их и не занимайте чем-то второстепенным при планировании макетки. В коде вызов выглядит как attachInterrupt(2, имяФункции, режим); на настоящих платах вместо голого номера принято писать digitalPinToInterrupt(2) — макрос, который превращает номер пина в номер прерывания и спасает при переносе скетча на другие платы.
volatile int pulses = 0; // меняется из ISR — обязательно volatile void onPulse() { pulses = pulses + 1; // обработчик короткий: только счёт} void setup() { pinMode(2, INPUT); attachInterrupt(2, onPulse, RISING); Serial.begin(9600);} void loop() { int snapshot = pulses; // копия, чтобы число не «прыгнуло» при печати Serial.print("Импульсов: "); Serial.println(snapshot); delay(1000); // delay здесь допустим: счёт идёт на аппаратном уровне}Три режима: RISING, FALLING, CHANGE
Третий аргумент attachInterrupt задаёт, какое именно изменение сигнала считать событием. RISING — переход с 0 В на 5 В, передний фронт: кнопка нажалась. FALLING — переход с 5 В на 0 В, задний фронт: кнопка отпустилась. CHANGE — любое изменение уровня, оба фронта сразу. На настоящем AVR есть ещё режим LOW — срабатывание при удержании низкого уровня, но для счётчиков он не нужен, а в симуляторе поддержаны три первых.
Выбор режима зависит от физики события. Для счёта импульсов датчика потока берут FALLING или RISING — важно считать один импульс за период, а не два. Для энкодера часто нужен CHANGE на обоих пинах: только так получается отслеживать и вращение, и направление. Для кнопки от человека RISING и FALLING дадут одинаковый счёт — разница лишь в том, считать нажатие или отпускание.
Мнение с оговоркой: я считаю, что прерывания нужны там, где событие короче вашего loop(). Кнопку человека можно спокойно опрашивать по millis() — палец держит контакт сотни миллисекунд. А вот импульсы датчика потока, счётчика электроэнергии с импульсным выходом или колёсного одометра без прерывания теряются гарантированно. Но если обработчик начинает разрастаться — значит, вы делаете в нём чужую работу: собирать данные должен loop(), прерывание только сообщает «было событие».
Дребезг: одно нажатие — несколько событий
Кнопка — механика, а механика дребезжит. Контакты соприкасаются не один раз, а серией: типичная длительность отскоков — от 1 до 20 миллисекунд. Логика видит не нажатие, а лесенку фронтов, и на RISING счётчик спокойно насчитает пять нажатий там, где было одно. Отсюда и название — bounce, дребезг.
Лечится временем: событие считается настоящим, только если оно отстоит от предыдущего принятого больше чем на 20-50 мс. Можно глушить дребезг железом — конденсатор 100 нФ параллельно кнопке плюс подтяжка, — но я для кнопок на прерывании делаю программное окно: ISR мгновенно взводит флаг и запоминает millis(), а решение о «настоящем» нажатии принимает loop(), сверяясь с таймером. Так обработчик остаётся коротким, а вся логика — читаемой.
Насколько широким делать окно — зависит от источника. Для человеческой кнопки хватает 50 мс: палец физически не может нажать дважды за полсотни миллисекунд. Для реле и старых пыльных кнопок отскоки тянутся до 20 мс и больше — окно придётся расширять. У механических энкодеров своя беда: там дребезг фильтруют не одним окном, а таблицей состояний, и это тема отдельного урока.
Эксперименты для закрепления
- Смените режим на FALLING и предположите до запуска: счёт вырастет или останется тем же? Запустите и проверьте себя — событие теперь ловится на отпускание кнопки.
- Поставьте CHANGE: каждое нажатие стало считаться дважды — фронт и спад. Уберите окно дребезга (порог 50 мс заменить на 1 мс) и посмотрите, как отскоки ломают счёт.
- Уменьшите окно до 5 мс и пощёлкайте быстро: часть нажатий начнёт склеиваться или, наоборот, отскоки просочатся. Подберите минимальное окно, при котором счёт остаётся честным.
- Добавьте вторую кнопку на пин 3 со своим обработчиком и своим счётчиком. Два независимых прерывания — уже готовый каркас кнопочного регулятора «больше/меньше».
- Внешние прерывания на Uno — только пины 2 (INT0) и 3 (INT1)
- attachInterrupt(пин, функция, режим) — режимы RISING, FALLING, CHANGE
- Обработчик короткий: инкремент или взвод флага, без delay и Serial
- Переменные из ISR объявляйте volatile — иначе компилятор закэширует значение
- Дребезг кнопки лечится окном 20-50 мс по millis(), решает loop(), а не ISR
- Пока ISR работает, основной код стоит, а millis() на реальной плате не продвигается
- 1. Какие пины Arduino Uno поддерживают внешние прерывания?
- 2. Что означает режим RISING в attachInterrupt()?
- 3. Зачем переменной из обработчика прерывания слово volatile?
- 4. Чего точно не должно быть внутри обработчика прерывания?
- 5. Кнопка дребезжит 8 мс. Какое программное окно фильтра выберете?
Прерывание — это нижний этаж архитектуры: чип сам следит за внешним миром и дёргает вас только по делу. В следующем уроке таймеры покажут противоположную работу: не ловить события, а генерировать сигнал — широтно-импульсную модуляцию, которая делает светодиод тусклым на треть и мотор медленным наполовину, не имея на выходе ни одного промежуточного вольта.
Перед переходом проведите один честный эксперимент: вернитесь к счётчику воды из начала урока и попробуйте пропустить через старый опросный код 200 быстрых нажатий, а через прерывание — те же 200. Разница в цифрах монитора убедит вас надёжнее любого текста.
Частые вопросы по теме урока
- Какие пины Arduino Uno поддерживают внешние прерывания?
- На ATmega328P внешние прерывания INT0 и INT1 выведены на пины 2 и 3. На других платах список шире, поэтому в коде используют digitalPinToInterrupt(). Только 2 и 3
- Что означает режим RISING в attachInterrupt()?
- RISING — передний фронт, рост уровня. FALLING — спад, CHANGE — оба фронта. Режим LOW с уровнем есть только на реальном AVR. Срабатывание при переходе с 0 В на 5 В
- Зачем переменной из обработчика прерывания слово volatile?
- volatile запрещает компилятору кэшировать чтения: каждая проверка идёт в память. Классический симптом без volatile — вечный while, хотя переменная давно изменилась. Без неё компилятор может закэшировать значение, и loop() увидит устаревшее число
- Чего точно не должно быть внутри обработчика прерывания?
- ISR обязана быть короткой: пока она работает, основной код стоит, а таймеры не обслуживаются. delay и Serial внутри ISR — гарантированное зависание и мусор в мониторе. delay(500) и Serial.println
- Кнопка дребезжит 8 мс. Какое программное окно фильтра выберете?
- Окно должно перекрывать самый длинный отскок: 50 мс надёжно глушит 8-миллисекундный дребезг и при этом не путает два честных нажатия человека. 50 мс