Счётчик я собирал для знакомого столяра: у него в мастерскую завезли двести досок-заготовок, и он хотел знать остаток, не отрываясь от станка. Схема простая до смешного — LCD 16x2 по I2C, чья схема из четырёх проводов осталась в первом уроке раздела, и тактовая кнопка: нажал, счётчик прибавил. Загрузил скетч, нажал один раз — на экране плюс четыре. Ещё раз — плюс шесть. Кнопка была живая, провода сидели намертво, а счётчик жил своей жизнью: за десять нажатий он насчитал тридцать восемь заготовок, из которых я трогал только десять. Так я впервые познакомился с дребезгом контактов — и в тот же вечер первым делом залепил его delay(200), после чего получил вторую проблему: счётчик прибавлял честно, но интерфейс стал ощущаться как пьяный.
В этом уроке соберём честный счётчик: две кнопки — плюс и минус, значение на верхней строке дисплея, светодиод как индикатор незакрытой смены. Разберём, почему механический контакт врёт первые двадцать миллисекунд, чем плох лобовой фикс с delay(), как устроено окно тишины на millis() и почему счёт ловится на фронте нажатия, а не на уровне. В конце — тренажёр в симуляторе с двумя кнопками. Саму кнопку к плате мы подключали в уроке про digitalRead и первую кнопку, а первая встреча с дребезгом — это целый урок про антидребезг в основах — теперь прикрутим это знание к дисплею.
Почему кнопка врёт: физика первых двадцати миллисекунд
Внутри тактовой кнопки две металлические пластины, которые при нажатии ударяются друг о друга и пару раз отпружинивают. Контакт замыкается не один раз, а серией: раз, раз, раз — всё в пределах 5–20 миллисекунд. Для пальца это один щелчок, для процессора на 16 МГц — до трёхсот тысяч тактов, за которые loop успевает опросить пин десятки раз и честно посчитать каждое подрагивание как отдельное нажатие. Отсюда и плюс четыре за один щелчок — плата не врёт, она слишком хорошо слышит.
Второй обязательный ингредиент — подтяжка. Ножку кнопки настраивают входом с внутренним подтягивающим резистором: pinMode(pin, INPUT_PULLUP). Пока кнопка не нажата, вход через подтяжку читает HIGH; при нажатии контакт прижимает пин к земле — LOW. Без подтяжки вход повиснет в воздухе и начнёт ловить наводки от рук, розетки и соседних проводов — счётчик станет прибавлять сам, без всякого нажатия. Это классическая болезнь первой недели, и она не про дребезг: просто обе дают один симптом, «прибавляет непонятно когда», и лечатся разными строками кода.
| Способ антидребезга | Как работает | Когда я его беру |
|---|---|---|
| delay(200) после срабатывания | Счёт и пауза: loop стоит, всё остальное ждёт | Нигде: на паузе глухнут вторая кнопка и перерисовка дисплея |
| RC-цепочка из резистора и конденсатора | Сглаживает фронт: 10 кОм плюс 100 нФ дают окно около миллисекунды | На готовых платах, где код трогать нельзя |
| Окно тишины на millis() | Сигнал верен, если держится без фронтов заданное время | Почти всегда: без деталей, loop не замирает |
| Библиотека Bounce2 | То же окно, но в готовой обёртке | Когда кнопок больше трёх и лень плодить переменные |
Окно тишины работает так. Сырой сигнал с пина читаем в каждом обороте loop, но верим ему только когда он держится без изменений дольше порога. Как только сигнал дрогнул — запоминаем millis() момента фронта. Прошло 50 миллисекунд, а сигнал на месте — значит, дребезг кончился, контакт устаканился, и только теперь фиксируем новое состояние: был HIGH, стал LOW, это нажатие, прибавляем. Ещё через 50 миллисекунд при отпускании фиксируем HIGH и ничего не считаем — счёт у нас сидит на фронте нажатия, отпускание для него пустой звук. Если вы ещё не гоняли millis() против delay(), откройте сначала разбор millis против delay: там фундамент — почему разница millis() - t безопасна и почему пауза и таймер — разные вещи.
Порог 50 миллисекунд — не магия. У моих тактовых кнопок дребезг заканчивается за десять миллисекунд, порог с запасом в пять раз; на старых кнопках из донорских пультов бывает до 20–30. Больше 100–150 я бы не ставил: два быстрых нажатия подряд сливаются в одно, и счётчик начнёт врать уже в другую сторону — недосчитывать. Мой рабочий диапазон — 40–60 миллисекунд, и за десяток сборок этот выбор ни разу не подвёл. Дешевле порога ничего нет: ни конденсатора, ни библиотеки — только millis() и по три переменных на кнопку.
#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); const int plusPin = 2; // кнопка «плюс»const int minusPin = 3; // кнопка «минус»const int ledPin = 12; // светится, пока счёт больше нуля int count = 0;int lastRawP = HIGH, stableP = HIGH; unsigned long edgeP = 0;int lastRawM = HIGH, stableM = HIGH; unsigned long edgeM = 0; void redraw() { lcd.setCursor(0, 0); lcd.print("Schet: "); lcd.print(count); lcd.print(" "); // пробелы затирают цифры старых разрядов lcd.setCursor(0, 1); if (count > 0) { lcd.print("Smena otkryta "); digitalWrite(ledPin, HIGH); } else { lcd.print("Smena zakryta "); digitalWrite(ledPin, LOW); }} void setup() { pinMode(plusPin, INPUT_PULLUP); pinMode(minusPin, INPUT_PULLUP); pinMode(ledPin, OUTPUT); lcd.init(); lcd.backlight(); redraw();} void loop() { // «плюс»: окно тишины 50 мс, счёт по фронту нажатия int raw = digitalRead(plusPin); if (raw != lastRawP) { lastRawP = raw; edgeP = millis(); } if (millis() - edgeP > 50 && raw != stableP) { stableP = raw; if (stableP == LOW) { count++; redraw(); } } // «минус»: тот же приём, но в минус уводить нельзя raw = digitalRead(minusPin); if (raw != lastRawM) { lastRawM = raw; edgeM = millis(); } if (millis() - edgeM > 50 && raw != stableM) { stableM = raw; if (stableM == LOW) { if (count > 0) count--; redraw(); } }}Механизм — это три переменных на кнопку. lastRaw — что было на пине в прошлый оборот loop, stable — что мы признали истиной, edgeP — время последнего фронта. Счёт происходит в единственном месте: stable сменился на LOW, то есть фронт нажатия прошёл окно тишины и признан настоящим. Удержание кнопки ничего не даёт — прибавится ровно один раз. Это же поведение удобно проверить пальцем: нажмите и не отпускайте, экран должен отреагировать один раз, а не строчить.
В redraw() посмотрите на пробелы после числа. «Schet: 8» занимает семь знакомест, «Schet: 100» — девять; если сначала было 100, а потом 8, без хвоста из пробелов на экране останется «800» — призрак старого разряда. Полный разбор этой болезни с дробями и ведущими нулями был в уроке про печать чисел. И заметьте: redraw() вызывается только при изменении счёта. Перерисовывать дисплей в каждом обороте loop без нужды — плохая привычка, и ближе к концу тройки мы увидим, как она мерцает на термометре.
Тренажёр: две кнопки и индикатор смены
В тренажёре собрана та же связка, что в скетче: плюс на D2, минус на D3, светодиод на D12 горит, пока счёт больше нуля, и гаснет на нуле — как лампа над дверью цеха «смена не закрыта». Нижняя строка дублирует состояние словами. Прогоните сценарий целиком: прибавьте до десяти, отнимите обратно до нуля, попробуйте уйти в минус — скетч не пустит, остаток досок не бывает отрицательным. Потом зажмите кнопку и держите: прибавится ровно один шаг, потому что счёт по фронту. Дальше из этого счётчика естественно вырастает меню: отнимать и прибавлять пункты списка на второй строке мы уже делали в уроке про меню и курсор.
- 1. Сколько обычно длится дребезг у тактовой кнопки?
- 2. Чем плох антидребезг через delay(200) сразу после срабатывания?
- 3. Что фиксирует условие if (stable == LOW) сразу после окна тишины?
- 4. Зачем после lcd.print(count) печатают хвост из пробелов?
- 5. Какой pinMode нужен кнопке в этом счётчике и что читается без нажатия?
- Дребезг контакта длится 5–20 мс — на 16 МГц этого хватает на десятки ложных чтений
- Окно тишины: сигнал верен, только если держится 40–60 мс без фронтов; loop при этом не замирает
- Счёт — по фронту нажатия, не по уровню: удержание кнопки даёт ровно один шаг
- delay(200) глушит весь loop: вторая кнопка, дисплей и Serial молчат на каждой паузе
- Число на LCD печатай с хвостом из пробелов — иначе старые цифры не сотрутся
Частые вопросы по теме урока
- Сколько обычно длится дребезг у тактовой кнопки?
- Контакт отпружинивает 5–20 мс, на старых кнопках бывает до 20–30 мс. Секунды — это уже не дребезг, а что-то механически сломанное. 5–20 миллисекунд
- Чем плох антидребезг через delay(200) сразу после срабатывания?
- delay останавливает весь loop: ни опроса кнопок, ни перерисовки экрана. Дребезг он прячет, но интерфейс заодно гасит. Он съедает память контроллера
- Что фиксирует условие if (stable == LOW) сразу после окна тишины?
- stable — стабилизированное состояние после окна тишины. Его переход в LOW и есть признанное нажатие; отпускание счёт не трогает. Уровень сигнала каждую миллисекунду
- Зачем после lcd.print(count) печатают хвост из пробелов?
- Новая запись короче старой — хвост остаётся. Пробелы переписывают знакоместа поверх призраков старых разрядов. Чтобы затереть цифры, оставшиеся от большего числа
- Какой pinMode нужен кнопке в этом счётчике и что читается без нажатия?
- Внутренняя подтяжка держит вход на HIGH в покое; нажатие прижимает пин к земле. Без подтяжки вход ловит наводки и прибавляет сам. INPUT_PULLUP, не нажата — HIGH