Дребезг контактов: почему одно нажатие считается пятью

Что происходит в кнопке за первые миллисекунды, как это видит 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 истинно на каждом проходе. Счётчик или переключатель должен реагировать на переход.

Что попробовать в симуляторе

  1. В антидребезге дребезг уже включён: сравните число нажатий с числом сырых фронтов, затем поставьте DEBOUNCE = 0 и посмотрите, как часть нажатий засчитывается дважды. Анализатор на выводе D15 покажет сами отскоки — пачку узких импульсов в начале нажатия.
  2. В кнопке-триггере включите кнопке «Дребезг контактов» и уберите delay(30): в анализаторе на D2 видно, что светодиод переключается на каждом отскоке, пять раз при нажатии и два при отпускании. Итог сходится лишь потому, что отскоков нечётное число; у кнопки с другим числом отскоков он был бы случайным. Затем уберите сравнение с previous и подержите кнопку: так выглядит реакция на уровень там, где нужна реакция на фронт.
  3. В примере короткого и длинного нажатия граница задана константой 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: счёт в мониторе порта станет расти на два за нажатие, потому что обработчик сработает и на отпускании

Кнопка через прерывание

← Все статьи