Счётчик посетителей в моей мастерской врал беззастенчиво: нажимаю кнопку один раз, а в Serial прыгает сразу пять или семь «нажатий». Я перепаял провода, сменил кнопку, проверил питание — ничего не помогало, пока не повесил на пин обычную печать состояния и не увидел своими глазами, что происходит после одного щелчка: столбик из единиц и нулей длиной в двадцать миллисекунд. Контакт внутри кнопки дребезжал, а плата честно считала каждое его касание. Это и есть дребезг — первая по-настоящему «взрослая» проблема, с которой сталкивается каждый, кто подключил кнопку к Arduino.
В этом уроке разберём, почему контакты стучат 5–20 мс и почему на старых кнопках дребезг растягивается до 100+ мс, посчитаем, во что обходится лобовое лечение через delay(50), и напишем неблокирующий антидребезг на millis() с переменными lastDebounceTime и lastButtonState — тот каркас, который потом переедет в каждый ваш проект с кнопками. Заодно научимся отличать фронт нажатия от отпускания и разберём два тупика, в которых я просидел вечера: счётчик, растущий сам по себе, и кнопка, срабатывающая через раз.
Почему контакты дребезжат: механика на 5–20 миллисекунд
Внутри тактовой кнопки — две металлические пластины, которые при нажатии прижимаются друг к другу с пружинным усилием. Пока палец давит, пластины не «соединились и всё»: они ударяются, отскакивают, ударяются снова, как молоточек по струне. Процесс занимает от 5 до 20 миллисекунд на дешёвых тактовых кнопках из стартовых наборов — для свежего контакта это норма. Слышимый щелчок — только первый удар пластины о посадочное место; вся возня продолжается ещё десятки миллисекунд, просто без звука. На старых, подпалённых или окислившихся кнопках дребезг доходит до 50 и даже 100+ мс: у меня была партия кнопок из списанного складского ЗИПа, которые «звенели» почти девяносто миллисекунд, и с ними не работал ни один учебный скетч.
Теперь взгляд Arduino. Плата работает на частоте 16 МГц — за одну миллисекунду процессор выполняет около шестнадцати тысяч тактов, и функция digitalRead() успевает отработать сотни раз, пока контакты ещё постукивают. Для человека — один щелчок, для Uno — несколько десятков смен уровня с 0 на 1 и обратно. Если печатать состояние входа в Serial каждые 5 мс, после одного клика вы увидите примерно такой столбик: 1, 0, 1, 0, 0, 1, 0, 1, 0, 0 — и только в конце устоявшийся ноль нажатой кнопки. У меня в печати с шагом 5 мс бывало и по пятнадцать смен подряд: дребезг не обязан быть симметричным, одна кнопка выдаст три звонка, другая — девять.
Дребезг не всегда заметен. Если светодиод горит «пока держишь», ложные переключения глаз не ловит: уровень меняется, но картинка не успевает. А вот счётчик, меню, выключатель света и вообще любая логика «по нажатию» ломаются на виду. По моему опыту, именно здесь новичок впервые понимает, что программирование железа — это ещё и физика: у сигнала есть время установления, и его нельзя игнорировать. Сколько продлится дребезг у конкретно вашей кнопки — не скажет никто: я всегда измеряю сам, печатью в Serial, и от этого измерения пляшу дальше. Отсюда правило, которое я усвоил на своём счётчике: пока вы не посмотрели сигнал глазами, вы не знаете свою кнопку.
Лобовое решение: delay(50) и во что оно обходится
Первое, что приходит в голову: чего тут думать — увидели нажатие, сделали дело, подождали, пока контакт затихнет, и пошли дальше. Классика жанра выглядит так: поймали LOW, увеличили счётчик, напечатали, поставили delay(300). Триста миллисекунд обычно перекрывают и дребезг, и время, пока палец уходит с кнопки. Дёшево, понятно и всегда под рукой — три причины, по которым этот путь проходят почти все. Схема даже работает — ровно до первого проекта, где кроме кнопки появляется что-то ещё.
Цена решения — мёртвое время. delay() останавливает весь скетч: во время паузы плата не читает датчики, не мигает светодиодом, не отвечает на Serial. Десять нажатий в минуту по 300 мс — это три секунды слепоты из шестидесяти; в проекте с мигалкой или датчиком это уже чувствуется. А Serial в это время молчит, и вам со стороны кажется, что плата зависла, — хотя она просто спит по расписанию. Вторая ловушка коварнее: delay(300) защищает от дребезга, но не от удержания. Держите кнопку полсекунды — и после паузы скетч насчитает ещё одно нажатие: свет мигнёт дважды, счётчик убежит вперёд. Симптом вам уже знаком по прошлому уроку: «пока держишь кнопку — она сама щёлкает».
Я не говорю, что вариант с delay() запрещён: для счётчика нажатий на стенде или кнопки «перезагрузить» он терпим. Но привычка лечить дребезг сном — привычка, от которой потом мучительно отвыкать. Правильный инструмент — проверка времени через millis(): она не останавливает программу ни на микросекунду. Мнение с оговоркой: если пишете одноразовый стенд для себя и второго процесса в нём не предвидится — берите delay и не мучайтесь. Как только в скетче появляется мигалка, датчик или дисплей с периодическим обновлением — только millis. У millis-подхода есть и отсроченный бонус: тот же каркас без переделок обслужит геркон, энкодер и счётчик импульсов — везде, где события приходят пачками.
const int BUTTON_PIN = 2;const int LED_PIN = 13; byte clicks = 0; void setup() { pinMode(BUTTON_PIN, INPUT_PULLUP); pinMode(LED_PIN, OUTPUT); Serial.begin(9600);} void loop() { if (digitalRead(BUTTON_PIN) == LOW) { // нажата: уровень, не фронт clicks = clicks + 1; digitalWrite(LED_PIN, HIGH); Serial.print("Клик "); Serial.println(clicks); delay(300); // мёртвое время: весь скетч стоит digitalWrite(LED_PIN, LOW); }}Неблокирующий антидребезг на millis(): lastDebounceTime и фронт
Каркас неблокирующего антидребезга держится на трёх переменных. lastButtonState хранит предыдущее сырое чтение пина, lastDebounceTime — время последнего изменения этого чтения, а buttonState — устоявшееся состояние, которое мы считаем настоящим. Каждый проход loop() читает пин: если чтение отличается от прошлого — контакт дрогнул, запоминаем lastDebounceTime = millis() и ждём дальше. Как только с последнего изменения прошло больше интервала (я ставлю 30 мс на свежих тактовых кнопках и 50 мс на старых — диапазон 20–50 мс покрывает большинство случаев), смотрим: если чтение за это время не менялось и отличается от устоявшегося — перед нами настоящий фронт.
Фронт — это момент смены устоявшегося состояния. Переход HIGH→LOW — нажатие, LOW→HIGH — отпускание. Реагируя только на фронты, получаем ровно одно срабатывание на один физический клик — сколько бы раз контакт ни звякнул внутри. Различать нажатие и отпускание полезно сразу: выключателю нужен только первый фронт, замеру длительности удержания — оба, а меню и энкодерам без отпускания вообще никуда. При реакции на уровень результат зависит от везения: сколько смен уровня успело произойти — столько срабатываний и вышло; фронт же фиксирует сам факт перехода, а не количество звонков вокруг него. В скетче ниже я печатаю в Serial оба события: так проще убедиться, что счётчик растёт ровно по одному на клик.
Интервал — не магическое число, а компромисс. Слишком короткий (3–5 мс) не перекроет дребезг конкретной кнопки — и счётчик снова начнёт расти сам. Слишком длинный (300 мс и больше) склеивает быстрые повторные нажатия: двойной клик срабатывает как один. Мои 30 мс — опыт, а не закон: возьмите за привычку после сборки погонять кнопку с печатью в Serial и подобрать интервал под конкретный экземпляр. Механика у всех кнопок разная — это нормально, пока вы её измерили, а не предположили. И держите интервал в const int наверху скетча: правится одним числом, логику не трогает.
- Занизьте интервал: поставьте DEBOUNCE_MS равным 3 и пощёлкайте кнопкой. Счётчик снова начнёт врать — вы своими глазами увидите дребезг, который не перекрылся.
- Теперь завысьте его до 300 мс и попробуйте двойное нажатие: два клика сработают как один. Вот цена завышенного интервала.
- Заведите второй счётчик отпусканий и увеличивайте его в ветке «отпущено»: нажатий и отпусканий должно быть ровно поровну — расхождение значит, что фильтр дырявый.
- Временно допишите в конец loop() Serial.println(reading) и delay(5), чтобы увидеть сырой столбик дребезга в Serial. После эксперимента delay обязательно удалите: он ломает антидребезг — это тот самый второй тупик из заметки выше.
- Поменяйте период мигалки с 500 мс на 100 и на 2000 и убедитесь, что счётчик нажатий работает одинаково при любом периоде: две задачи живут в одном loop() независимо.
- Дребезг — механика, а не код: контакты стучат 5–20 мс на дешёвых тактовых кнопках и до 100+ мс на старых и окислившихся.
- delay(50) после нажатия — блокирующий костыль: скетч замирает на время паузы, а удержание кнопки всё равно насчитывает лишние клики.
- Антидребезг на millis(): при каждом изменении чтения обновляется lastDebounceTime; состояние считается устоявшимся, когда прошло больше интервала.
- Интервал 20–50 мс — компромисс: короче — счётчик растёт сам, длиннее — быстрые двойные нажатия слипаются в одно.
- Фронт — смена устоявшегося состояния: HIGH→LOW это нажатие, LOW→HIGH — отпускание; реагируем на фронты, а не на уровни.
- 1. Один щелчок добавляет в счётчик сразу пять единиц. Самая вероятная причина?
- 2. Чем антидребезг на millis() принципиально лучше паузы delay(50) после нажатия?
- 3. Кнопка с дребезгом 15 мс, а интервал антидребезга стоит 3 мс. Что будет?
- 4. Что такое фронт нажатия?
Счётчик с честным антидребезгом — половина дела: мы научились получать из шумного механического контакта один чистый фронт. В следующем уроке этот фронт заработает по-настоящему: соберём выключатель — нажатие переключает свет, переменная ledState помнит состояние между кликами, а счётчик нажатий подтвердит, что фильтр больше не врёт. А пока вернитесь к симулятору и найдите границу честности виртуальной кнопки: уменьшайте интервал, пока счётчик не начнёт врать, — теперь вы точно знаете, где эта граница проходит. Ещё потренируйте глаз на Serial-логе: по числу строк «отпущено» вы сразу увидите, симметрична ли ваша кнопка и сколько звонков она успевает выдать.
Частые вопросы по теме урока
- Один щелчок добавляет в счётчик сразу пять единиц. Самая вероятная причина?
- Механический контакт при нажатии замыкается и размыкается десятки раз за 5–20 мс; без антидребезга Arduino считает каждую смену уровня отдельным нажатием. Дребезг: за 5–20 мс контакты сменили уровень много раз, и всё это посчиталось
- Чем антидребезг на millis() принципиально лучше паузы delay(50) после нажатия?
- delay() блокирует весь скетч на время паузы, а проверка millis() просто отфильтровывает дребезг, не останавливая остальной код. loop() не останавливается: мигалки, датчики и Serial продолжают жить
- Кнопка с дребезгом 15 мс, а интервал антидребезга стоит 3 мс. Что будет?
- Интервал короче реального дребезга: после трёх «тихих» миллисекунд контакт ещё дребезжит, и ложные смены уровня проходят как настоящие фронты. Часть дребезга проскочит фильтр — счётчик снова начнёт насчитывать лишнее
- Что такое фронт нажатия?
- Фронт — это переход из одного стабильного уровня в другой; реакция только на переход даёт ровно одно срабатывание на клик. Момент смены устоявшегося состояния, например переход HIGH→LOW