Дребезг контактов: почему одно нажатие считается пятью
Что происходит в кнопке за первые миллисекунды, как это видит loop() и как отфильтровать программно и схемой
Счётчик нажатий прибавляет по три за раз, а кнопка-триггер то включает, то нет. Причина не в коде, а в механике контакта. Разбираем, как устроен дребезг и три способа от него защититься.
Типичная жалоба новичка звучит так: «Написал счётчик нажатий, жму кнопку один раз, а в мониторе порта сразу три, иногда пять». Код при этом правильный: вывод прочитан, переход от отпущенной кнопки к нажатой замечен, счётчик увеличен. Ошибка в предположении, что механический контакт замыкается один раз.
Что происходит внутри кнопки
Тактовая кнопка — это металлическая пластина, которая при нажатии прижимается к неподвижному контакту. В момент удара пластина упруго отскакивает, снова касается, снова отскакивает. Пока колебания не затухнут, цепь несколько раз замыкается и размыкается. Длится это обычно от долей миллисекунды до 5–10 мс, у изношенных или дешёвых кнопок — до 20 мс. То же самое, хотя обычно короче, происходит и при отпускании.
Для человека нажатие одно. Для контроллера, который опрашивает вывод, картина другая. Пустой loop() на ESP32 проходит за единицы микросекунд, то есть за 5 мс дребезга вывод будет прочитан тысячи раз. Каждое кратковременное размыкание выглядит как отпускание, каждое повторное касание — как новое нажатие.
Схема подключения во всех примерах галереи одна: кнопка между выводом и землёй, на выводе включена внутренняя подтяжка INPUT_PULLUP. Отпущенная кнопка даёт HIGH, нажатая — LOW. При дребезге вывод несколько раз прыгает между этими уровнями.
В PinPort это можно увидеть, а не только прочитать. У кнопки на схеме есть свойство «Дребезг контактов». Выключенное — контакт замыкается идеально, так нагляднее первые примеры. Включённое — как у настоящей тактовой кнопки: около полутора миллисекунд отскоков при нажатии и около половины миллисекунды при отпускании. Отскоки видны в анализаторе, их считает прерывание по фронту, и код без фильтра ловит лишние нажатия ровно так же, как на столе. Дребезг при этом одинаковый от нажатия к нажатию, поэтому опыт повторяется.
Уровень, фронт и почему дребезг бьёт именно по фронту
Задачи с кнопкой бывают двух видов.
Реакция на уровень. «Пока кнопка нажата, светодиод горит.» Дребезг здесь почти безвреден: светодиод мигнёт несколько раз за 5 мс, глаз этого не заметит.
Реакция на событие. «Нажал — включил, нажал ещё раз — выключил», «посчитать нажатия», «перейти к следующему пункту меню». Здесь нужно поймать момент перехода — фронт сигнала. Ловят его, сравнивая текущее чтение с предыдущим, как в примере Кнопка-триггер:
int now = digitalRead(BUTTON);
if (now != previous) {
// Переход HIGH -> LOW и есть момент нажатия.
if (now == LOW) {
on = !on;
digitalWrite(LED, on);
}
// Пауза после любого фронта — и нажатия, и отпускания.
delay(30);
}
previous = now;
Без сравнения с previous светодиод переключался бы на каждом проходе loop(), пока кнопка удерживается, — сотни тысяч раз в секунду, и итоговое состояние зависело бы от того, в какую микросекунду палец отпустил кнопку. А каждый отскок контакта — это новый фронт HIGH -> LOW. Поэтому именно событийная логика страдает от дребезга.
Обратите внимание, где стоит delay(30): после любой смены уровня, а не только после нажатия. Отпускание тоже дребезжит, и если пауза есть только после нажатия, при отпускании вывод прыгает HIGH -> LOW -> HIGH, а код видит ещё один фронт нажатия. Светодиод переключается лишний раз — та самая «кнопка срабатывает через раз». Фильтр должен работать в обе стороны. Раньше в этом примере пауза стояла только после нажатия, и с включённым дребезгом кнопки это было видно сразу.
Программный фильтр на millis()
Надёжный способ — принимать новое состояние кнопки, только если оно продержалось без изменений какое-то время. Так устроен пример Антидребезг кнопки:
const unsigned long DEBOUNCE = 30;
void loop() {
int now = digitalRead(BUTTON);
if (now != lastRead) {
if (now == LOW) rawEdges++; // все спады, с отскоками
lastRead = now;
changedAt = millis();
}
// Значение принимается, только если продержалось достаточно долго.
if (millis() - changedAt > DEBOUNCE && now != stable) {
stable = now;
if (stable == LOW) {
presses++;
Serial.printf("нажатие %lu (сырых фронтов всего: %lu)\n", presses, rawEdges);
}
}
}
У кнопки в этом примере дребезг включён, и рядом с каждым принятым нажатием пример печатает, сколько сырых спадов пришло на вывод с запуска: счётчик растёт на семь за нажатие — пять при нажатии и два при отпускании. Здесь три переменные состояния. lastRead — последнее сырое чтение, changedAt — когда оно в последний раз менялось, stable — принятое состояние кнопки. Каждый отскок сбрасывает changedAt, поэтому таймер 30 мс начинает отсчёт заново. Пока контакт дребезжит, 30 мс без изменений не набирается, и stable не меняется. Как только контакт успокоился, через 30 мс состояние принимается один раз. Работает одинаково для нажатия и отпускания.
Антидребезг кнопки
Нажмите кнопку и сравните числа: нажатие одно, сырых фронтов пять. Поставьте DEBOUNCE = 0 — часть нажатий засчитается дважды: millis() идёт целыми миллисекундами, и лишнее нажатие появляется, только если смена миллисекунды пришлась на отскок.
Открыть примерКак выбирать окно:
| Окно | Что получится |
|---|---|
| 5 мс | Хватает для хороших кнопок, пропускает дребезг изношенных |
| 20–50 мс | Обычный выбор: дольше любого дребезга, короче любого человеческого нажатия |
| 100 мс и больше | Задержка реакции становится заметна, быстрые двойные нажатия теряются |
Человек нажимает и отпускает кнопку примерно за 80–150 мс, а два нажатия подряд делает не чаще 5–7 раз в секунду. Окно в 30 мс лежит между дребезгом и пальцем с запасом в обе стороны.
Вариант для Uno в примере Нажал — включил, нажал — выключил устроен немного иначе: после любого принятого изменения следующие 30 мс изменения игнорируются. Реакция мгновенная (первый же фронт принимается сразу), а отскоки после него отбрасываются. Такой подход хорош, когда важна скорость отклика, но он чувствителен к помехам: одиночная короткая наводка на длинном проводе будет принята за нажатие. Фильтр «подтверди стабильность» из ESP32-примера к помехам устойчивее ценой задержки 30 мс.
Прерывания не спасают от дребезга
Кажется, что прерывание по фронту решает проблему опроса. На деле оно делает дребезг заметнее: обработчик вызывается на каждом отскоке, причём с точностью до микросекунд. В примере Кнопка через прерывание фильтр стоит прямо в обработчике:
void IRAM_ATTR onPress() {
unsigned long now = millis();
if (now - lastISR < 30) return; // антидребезг прямо в обработчике
lastISR = now;
presses++;
}
Логика та же, что у варианта для Uno: первый фронт засчитан, всё, что пришло в течение 30 мс после него, отброшено. Переменные объявлены volatile, потому что меняются вне обычного хода программы, а обработчик помечен IRAM_ATTR, чтобы ESP32 мог исполнять его из оперативной памяти.
Аппаратный фильтр: RC-цепь и триггер Шмитта
Иногда дребезг удобнее убрать до контроллера: вход читает чужая прошивка, кнопок много, или это счётчик импульсов, который работает без участия программы. Классическое решение — RC-цепочка: резистор последовательно, конденсатор с вывода на землю. Конденсатор не может зарядиться или разрядиться мгновенно, и короткие отскоки не успевают сдвинуть напряжение на нём.
Постоянная времени τ = R × C. Для 10 кОм и 1 мкФ получается 10 мс, то есть дольше типичного дребезга. Как заряжается конденсатор по экспоненте, видно в примере Заряд конденсатора: там τ = 10 кОм × 10 мкФ = 0,1 с, и за это время вольтметр доходит до 6,3 В из 10.
У RC-фильтра есть обратная сторона: он превращает резкий фронт в пологий. Когда медленно растущее напряжение проходит через порог входа, шум заставляет вход переключаться несколько раз — дребезг возвращается уже электрический. Поэтому после RC-цепи ставят вход с гистерезисом, триггер Шмитта, например микросхему 74HC14. У него два порога: вверх он переключается на одном напряжении, вниз — на другом, заметно ниже. Как это выглядит на осциллографе, показывает пример Триггер Шмитта: синусоида на входе, прямоугольник с отвесными фронтами на выходе, и точки переключения вверх и вниз не совпадают.
Типичные ошибки
- Фильтр только на нажатие.
delay(30)после фронта нажатия не защищает от отскоков при отпускании. Фильтр должен подтверждать любое изменение. - Длинный
delay()вместо фильтра.delay(200)вloop()действительно убирает дребезг, но заодно замораживает всё остальное и теряет быстрые нажатия. Фильтр наmillis()не блокирует программу. - Слишком короткое окно. 2–3 мс работают на новой кнопке и перестают через полгода, когда контакт окислится.
- Проверка на идеальной кнопке. Без дребезга код без фильтра работает — и в симуляторе с выключенным свойством, и с новой кнопкой на столе. Проверяйте с включённым «Дребезгом контактов»: так ведёт себя настоящая кнопка через полгода.
- Счёт по уровню вместо фронта. Пока кнопка нажата, условие
digitalRead(BUTTON) == LOWистинно на каждом проходе. Счётчик или переключатель должен реагировать на переход.
Что попробовать в симуляторе
- В антидребезге дребезг уже включён: сравните число нажатий с числом сырых фронтов, затем поставьте
DEBOUNCE = 0и посмотрите, как часть нажатий засчитывается дважды. Анализатор на выводе D15 покажет сами отскоки — пачку узких импульсов в начале нажатия. - В кнопке-триггере включите кнопке «Дребезг контактов» и уберите
delay(30): в анализаторе на D2 видно, что светодиод переключается на каждом отскоке, пять раз при нажатии и два при отпускании. Итог сходится лишь потому, что отскоков нечётное число; у кнопки с другим числом отскоков он был бы случайным. Затем уберите сравнение сpreviousи подержите кнопку: так выглядит реакция на уровень там, где нужна реакция на фронт. - В примере короткого и длинного нажатия граница задана константой
LONG_MS = 800. Опрос там идёт раз в 10 мс (delay(10)в концеloop()), и это само по себе грубый фильтр: отскоки короче 10 мс чаще всего проходят между двумя чтениями. На плате с изношенной кнопкой этого может не хватить — добавьте фильтр из первого примера.
Коротко
Механический контакт при замыкании и размыкании отскакивает несколько миллисекунд, а loop() успевает прочитать каждый отскок. Логика, которая реагирует на фронт, видит несколько нажатий вместо одного. Лечится это окном: новое состояние принимается, только если оно продержалось 20–50 мс, либо изменения игнорируются 30 мс после принятого фронта. Фильтр работает в обе стороны — на нажатие и отпускание. Если фильтровать нужно схемой, ставят RC-цепочку с τ около 10 мс и вход с триггером Шмитта.
Попробуйте в симуляторе
Кнопка-триггер
Включите у кнопки в свойствах «Дребезг контактов», уберите delay(30) и откройте анализатор на D2: одно нажатие даёт пять переключений светодиода, отпускание — ещё два. Итог верен только потому, что отскоков нечётное число
Кнопка-триггерArduino Uno: нажал — включил, нажал — выключил
Включите кнопке «Дребезг контактов», затем поменяйте 30 на 0 в условии millis() - changedAt > 30: одно нажатие даст в мониторе порта несколько строк. Верните 30 — строка снова одна
Триггер на UnoКороткое и длинное нажатие
Уменьшите LONG_MS с 800 до 400 и проверьте по монитору порта, где теперь граница между «коротким» и «длинным» нажатием
Длинное и короткоеКнопка через прерывание
Замените FALLING на CHANGE в attachInterrupt: счёт в мониторе порта станет расти на два за нажатие, потому что обработчик сработает и на отпускании
Кнопка через прерывание