Курс Arduino с нуля · Кнопки
Урок 11. Кнопка-выключатель и загадка дребезга
Нажал — горит, нажал ещё раз — погасло. По дороге разбираемся с дребезгом контактов и знакомимся с часами платы — командой millis.
Что понадобится
- Плата Uno из набора
- Кабель USB
- Макетная плата
- Тактовая кнопка
- Светодиод (любого цвета)
- Резистор 470 Ом
- 5 проводов «папа-папа»
Все детали есть в обучающем наборе, по которому идёт курс.
В прошлом уроке светодиод горел, пока вы держите палец. Но выключатель на стене работает не так: нажал — загорелось и осталось; нажал ещё раз — погасло. Кажется, что это крошечная доработка. На самом деле здесь новичка ждут две засады подряд, и вторая из них — самая знаменитая во всей микроэлектронике.
Пройдём этот путь честно: сначала напишем программу «как кажется правильным», увидим, что она сходит с ума, разберёмся почему — и починим. По дороге познакомимся с часами платы, без которых дальше в курсе не обойтись.
Сначала упростим схему
В конце прошлого урока мы обещали перейти на встроенный резистор. Пора: сегодняшняя схема станет заметно проще. Отключите USB и разберите кнопочную часть — резистор 10 кОм и все провода питания к кнопке больше не нужны.
Теперь кнопка соединяет вывод 2 не с плюсом, а с землёй. Подтягивать вывод к пяти вольтам будет резистор внутри самой платы, который включается словом INPUT_PULLUP. Светодиод остаётся на выводе 10 без изменений.
Проверьте перед включением: кнопка по-прежнему сидит верхом на канавке; жёлтый провод идёт с вывода 2 в столбик кнопки сверху от канавки; синяя перемычка идёт из того же столбика снизу от канавки в шину «−»; земля с платы пришла в ту же шину.
Не забудьте про перевёрнутую логику. С INPUT_PULLUP отпущенная кнопка даёт HIGH, а нажатая — LOW. Дальше во всех программах урока «нажата» будет проверяться как == LOW.
Шаг 1. Наивная попытка
Мысль простая: заведём переменную «горит ли светодиод» и, когда кнопка нажата, будем переключать её на противоположное значение.
const int BUTTON = 2;
const int LED = 10;
// Горит ли светодиод прямо сейчас. bool хранит только true или false.
bool ledOn = false;
void setup() {
pinMode(BUTTON, INPUT_PULLUP);
pinMode(LED, OUTPUT);
}
void loop() {
// Кнопка нажата — переключаем светодиод на противоположное состояние.
if (digitalRead(BUTTON) == LOW) {
ledOn = !ledOn;
digitalWrite(LED, ledOn);
}
}
Здесь два новых знака. bool — тип переменной, в которую помещается только да или нет (true или false). А восклицательный знак в !ledOn означает «наоборот»: было true — станет false, и обратно. Удобный способ записать «переключить».
Прошейте и нажмите кнопку. Светодиод не переключается — он начинает беспорядочно помаргивать, а после отпускания остаётся то включённым, то выключенным, как повезёт. Программа выглядит логично и при этом не работает совсем.
Почему так вышло: состояние и событие
Причина в скорости. Функция loop() выполняется не «время от времени», а сотни тысяч раз в секунду. Наша программа коротенькая, поэтому один её проход занимает считаные микросекунды. А палец, даже самый быстрый, держит кнопку хотя бы 50 миллисекунд.
Получается, за одно нажатие светодиод успевает переключиться около десяти тысяч раз. Чем он окажется в момент отпускания — чистая лотерея. Отсюда и хаос. (В следующем уроке мы измерим эту скорость точно — плата сама её посчитает.)
Здесь важно почувствовать разницу между двумя вещами:
- Состояние — «кнопка сейчас нажата». Оно длится всё время, пока палец на кнопке, и верно на множестве проходов подряд.
- Событие — «кнопку только что нажали». Это один-единственный момент, переход из отпущенной в нажатую.
В прошлом уроке нам нужно было состояние: свет горел, пока держат. Сегодня нужно событие: переключить ровно один раз на одно нажатие.
Шаг 2. Ловим момент нажатия
Плата не умеет сообщать о событиях сама — но мы легко вычислим их. Достаточно помнить, какой кнопка была на прошлом проходе, и сравнить с тем, какая она сейчас. Если была отпущена, а стала нажата — вот он, момент нажатия.
const int BUTTON = 2;
const int LED = 10;
bool ledOn = false;
// Каким кнопка была на прошлом проходе loop().
int wasState = HIGH;
void setup() {
pinMode(BUTTON, INPUT_PULLUP);
pinMode(LED, OUTPUT);
}
void loop() {
int nowState = digitalRead(BUTTON);
// Момент нажатия: только что была отпущена (HIGH), стала нажата (LOW).
if (wasState == HIGH && nowState == LOW) {
ledOn = !ledOn;
digitalWrite(LED, ledOn);
}
// Запоминаем состояние для следующего прохода.
wasState = nowState;
}
Обратите внимание на две вещи. Условие wasState == HIGH && nowState == LOW читается «была отпущена и стала нажата»: два знака && означают «и одновременно», оба условия должны выполниться. А последняя строка wasState = nowState — самая важная: без неё программа не запомнит, что кнопку уже увидели, и вернётся к прежнему хаосу.
Прошейте. Стало гораздо лучше: нажатие включает, следующее — выключает. Но поживите с этой программой минуту, понажимайте по-разному — и рано или поздно кнопка сработает дважды: нажали один раз, а светодиод мигнул и остался в прежнем состоянии.
Загадка дребезга
Это не брак кнопки и не ошибка в коде. Это дребезг контактов — настоящее физическое явление, с которым сталкивается каждый, кто работает с механическими переключателями.
Вспомните, что внутри кнопки: упругая металлическая пластинка. Когда она касается контакта, она не прилипает намертво — она отскакивает, как мячик от пола, и касается снова. И ещё раз. И ещё. Всё это занимает от одной до двадцати миллисекунд — человек не замечает ничего, а плата за это время успевает сделать сотни проходов loop().
Так что наша программа не ошибается: она действительно видит несколько нажатий. Их и правда было несколько — просто электрических, а не человеческих. Значит, задача такая: отличить настоящее нажатие от дребезга. Различить их можно по времени — человек физически не может нажать кнопку дважды за 20 миллисекунд.
Шаг 3. Лечим задержкой
Самое простое лекарство: заметили нажатие — и просто переждём дребезг, ничего не делая.
const int BUTTON = 2;
const int LED = 10;
bool ledOn = false;
int wasState = HIGH;
void setup() {
pinMode(BUTTON, INPUT_PULLUP);
pinMode(LED, OUTPUT);
}
void loop() {
int nowState = digitalRead(BUTTON);
if (wasState == HIGH && nowState == LOW) {
ledOn = !ledOn;
digitalWrite(LED, ledOn);
delay(30); // пережидаем дребезг контактов
}
wasState = nowState;
}
Одна строка delay(30) — и двойные срабатывания исчезли. Тридцати миллисекунд хватает, чтобы контакт успокоился, а человек такой паузы не замечает.
Но у этого решения есть цена. delay останавливает всю программу. Тридцать миллисекунд — мелочь, но, как только устройство начнёт делать несколько дел сразу (мигать светодиодом, следить за датчиком, играть мелодию), каждая такая пауза будет всё ломать. Пока кнопка «пережидает дребезг», плата слепа и глуха.
Часы платы: команда millis()
Настоящее решение — научиться ждать не останавливаясь. Для этого у платы есть встроенные часы. Команда millis() возвращает число миллисекунд, прошедших с момента включения платы.
Сама по себе эта цифра бесполезна. Полезной она становится, когда мы запоминаем момент в переменную, а потом сравниваем его с текущим временем. Разница и есть «сколько прошло»:
millis() - lastChange > 30 — «с того момента прошло больше 30 миллисекунд».
Почему unsigned long, а не int? Потому что миллисекунд быстро становится много. В int помещаются числа примерно до 32 тысяч — это всего 32 секунды работы. unsigned long вмещает больше четырёх миллиардов, чего хватает почти на 50 суток непрерывной работы. Для времени всегда берите именно этот тип.
Шаг 4. Антидребезг по часам
Переписываем: теперь программа не замирает, а просто не доверяет слишком быстрым изменениям.
const int BUTTON = 2;
const int LED = 10;
// Сколько миллисекунд не верим сигналу после каждого изменения.
const unsigned long BOUNCE_MS = 30;
bool ledOn = false;
int wasState = HIGH;
// Когда сигнал менялся в последний раз (по часам платы).
unsigned long lastChange = 0;
void setup() {
pinMode(BUTTON, INPUT_PULLUP);
pinMode(LED, OUTPUT);
}
void loop() {
int nowState = digitalRead(BUTTON);
if (nowState != wasState) {
// Верим изменению, только если после прошлого прошло достаточно
// времени. Всё, что случилось быстрее, — дребезг.
if (millis() - lastChange > BOUNCE_MS && nowState == LOW) {
ledOn = !ledOn;
digitalWrite(LED, ledOn);
}
lastChange = millis();
wasState = nowState;
}
}
Разберём логику. Как только сигнал изменился, программа смотрит на часы. Если с прошлого изменения прошло больше 30 миллисекунд — изменение настоящее, ему верим. Если меньше — это дребезг, пропускаем. И в любом случае запоминаем момент: lastChange = millis(). Из-за этой строки каждый новый скачок отодвигает окно, и программа спокойно дожидается, пока контакт успокоится.
Главное отличие от прошлого шага: delay в программе больше нет. Плата ни на мгновение не выключается из работы — она просто иногда поглядывает на часы.
Шаг 5. Прячем кухню в функцию
Программа работает правильно, но loop() стал заметно сложнее: в нём вперемешку и антидребезг, и полезное дело. Наведём порядок — вынесем всю возню с кнопкой в отдельную функцию.
const int BUTTON = 2;
const int LED = 10;
const unsigned long BOUNCE_MS = 30;
bool ledOn = false;
int wasState = HIGH;
unsigned long lastChange = 0;
// Возвращает true ровно один раз — в момент нажатия кнопки.
bool buttonPressed() {
int nowState = digitalRead(BUTTON);
bool pressed = false;
if (nowState != wasState) {
if (millis() - lastChange > BOUNCE_MS && nowState == LOW) {
pressed = true;
}
lastChange = millis();
wasState = nowState;
}
return pressed;
}
void setup() {
pinMode(BUTTON, INPUT_PULLUP);
pinMode(LED, OUTPUT);
}
void loop() {
// Теперь loop() читается как обычная фраза.
if (buttonPressed()) {
ledOn = !ledOn;
digitalWrite(LED, ledOn);
}
}
Функции мы писали ещё в седьмом уроке, но все они начинались со слова void — «ничего не возвращает». А здесь перед именем стоит bool: эта функция отвечает — true или false. Ответ отдаётся командой return, и его можно прямо подставить в условие:
if (buttonPressed()) — «если кнопку только что нажали».
Посмотрите, каким стал loop(): три строки, и они читаются как обычная фраза. Вся сложность никуда не делась — она просто убрана туда, где не мешает. Это тот же приём, что и setColor в девятом уроке, и им пользуются все программисты без исключения.
Шаг 6. Бонус: четыре яркости по кругу
Раз кнопка теперь работает честно, к ней можно прицепить что угодно. Соединим её с ШИМ из восьмого урока и сделаем светильник, который перебирает яркости по кольцу: выключено → треть → две трети → полная → снова выключено.
const int BUTTON = 2;
const int LED = 10;
const unsigned long BOUNCE_MS = 30;
int wasState = HIGH;
unsigned long lastChange = 0;
// Текущий уровень яркости: 0 — выключено, 3 — на полную.
int level = 0;
bool buttonPressed() {
int nowState = digitalRead(BUTTON);
bool pressed = false;
if (nowState != wasState) {
if (millis() - lastChange > BOUNCE_MS && nowState == LOW) {
pressed = true;
}
lastChange = millis();
wasState = nowState;
}
return pressed;
}
void setup() {
pinMode(BUTTON, INPUT_PULLUP);
pinMode(LED, OUTPUT);
}
void loop() {
if (buttonPressed()) {
level = level + 1;
if (level > 3) {
level = 0; // после самого яркого — снова выключено
}
analogWrite(LED, level * 85); // 0, 85, 170, 255
}
}
Весь секрет — в трёх строках: увеличиваем уровень, а если он вышел за границу, начинаем с нуля. Умножение level * 85 превращает уровни 0, 1, 2, 3 в яркости 0, 85, 170 и 255. Так устроены кнопочные настольные лампы и фонарики с несколькими режимами.
Не работает? Разбираемся
- Светодиод беспорядочно мигает при удержании. Программа реагирует на состояние, а не на событие: скорее всего, потерялась строка
wasState = nowStateили условие проверяет толькоnowState. - Одно нажатие иногда переключает дважды. Это дребезг. Проверьте, что антидребезг вообще работает: константа
BOUNCE_MSдолжна быть не меньше 20. На потрёпанной кнопке иногда нужно 50. - Кнопка срабатывает при отпускании, а не при нажатии. Перепутаны
HIGHиLOWв условии. СINPUT_PULLUPнажатие — этоLOW. - Светодиод горит всё время и не гаснет. Загляните в схему: если кнопка повёрнута на 90°, вывод 2 постоянно соединён с землёй, и плата видит бесконечное нажатие. Поверните кнопку.
- Программа перестала реагировать через полминуты работы. Почти наверняка время сохранено в
intвместоunsigned long— счётчик переполнился. - Ничего не происходит вообще. Убедитесь, что в
setup()написано именноINPUT_PULLUP, а неINPUT: без внутреннего резистора и без внешнего вывод снова «висит в воздухе».
Задания для тренировки
- РазминкаПоменяйте
BOUNCE_MSна 1000 и понажимайте кнопку. Объясните словами, что именно происходит и почему. - ПосложнееСделайте так, чтобы кнопка переключала не два, а три режима по кругу: выключено → горит ровно → мигает. Подсказка: заведите переменную с номером режима и вспомните бонусный шаг.
- Со звёздочкой ⭐Уберите из программы
delayсовсем: пусть светодиод мигает сам по себе, а кнопка при этом отзывается мгновенно, без малейшей задержки. Разбор ниже.
Показать разбор «мигание без delay»
Задача, которая на первый взгляд кажется невозможной: как мигать, если нельзя ждать? Ответ — не ждать, а сверяться с часами. Программа на каждом проходе задаёт себе два вопроса: «не пора ли переключить светодиод?» и «не нажали ли кнопку?». Оба ответа получаются мгновенно, поэтому оба дела идут будто одновременно.
const int BUTTON = 2;
const int LED = 10;
const unsigned long BOUNCE_MS = 30;
const unsigned long BLINK_MS = 300;
int wasState = HIGH;
unsigned long lastChange = 0;
bool blinking = false; // включён ли режим мигания
bool ledOn = false; // горит ли светодиод сейчас
unsigned long lastBlink = 0;
bool buttonPressed() {
int nowState = digitalRead(BUTTON);
bool pressed = false;
if (nowState != wasState) {
if (millis() - lastChange > BOUNCE_MS && nowState == LOW) {
pressed = true;
}
lastChange = millis();
wasState = nowState;
}
return pressed;
}
void setup() {
pinMode(BUTTON, INPUT_PULLUP);
pinMode(LED, OUTPUT);
}
void loop() {
// Дело первое: следим за кнопкой.
if (buttonPressed()) {
blinking = !blinking;
if (!blinking) {
ledOn = false;
digitalWrite(LED, LOW);
}
}
// Дело второе: если режим мигания включён — мигаем по часам.
if (blinking && millis() - lastBlink >= BLINK_MS) {
ledOn = !ledOn;
digitalWrite(LED, ledOn);
lastBlink = millis();
}
}
Ключевая мысль — millis() - lastBlink >= BLINK_MS. Это то же самое сравнение, что и в антидребезге, только про другое дело. Каждое «дело» в программе получает свою переменную-момент, и все они прекрасно уживаются в одном loop().
Именно так пишут все настоящие устройства: никаких delay, только проверки «а не пора ли?». Добавьте третье дело, четвёртое — программа не поперхнётся. С delay так не выйдет никогда.
Что мы узнали
INPUT_PULLUPубирает из схемы внешний резистор: кнопка соединяет вывод с землёй, нажатие даётLOW.- Состояние («кнопка нажата») и событие («кнопку только что нажали») — разные вещи. Событие вычисляют, сравнивая нынешнее состояние с прошлым.
- Дребезг контактов — не поломка, а физика: пластинка отскакивает и даёт несколько переходов за пару миллисекунд.
- Антидребезг — это «не верить изменениям, которые пришли слишком быстро». Хватает окна в 20–50 мс.
millis()— часы платы. Запомнили момент, сравнили с текущим — получили «сколько прошло». Время хранят вunsigned long.- Функция может не только выполнять действия, но и возвращать ответ:
bool buttonPressed()отдаёт результат командойreturn.
Мы всё чаще пишем программы, внутри которых что-то происходит невидимо: переменные меняются, время идёт, условия срабатывают. Пора научиться заглядывать внутрь. В следующем уроке плата начнёт разговаривать с вами текстом — через монитор порта.
Частые вопросы
Что такое дребезг контактов простыми словами?
Внутри кнопки упругая металлическая пластинка. Касаясь контакта, она отскакивает и касается снова несколько раз подряд — как мячик от пола. Это занимает от одной до двадцати миллисекунд, человек ничего не замечает, а плата успевает насчитать несколько нажатий.
Почему одно нажатие кнопки срабатывает дважды?
Из-за дребезга: программа видит не одно, а несколько переключений подряд. Лечится проверкой времени — изменениям, которые пришли быстрее чем через 20–50 мс после предыдущего, верить не нужно.
Чем millis лучше delay?
delay останавливает всю программу: пока идёт задержка, плата не видит ни кнопок, ни датчиков. millis просто сообщает, сколько миллисекунд прошло с включения. Программа сверяется с часами и продолжает работать, поэтому может делать несколько дел сразу.
Почему время нужно хранить в unsigned long?
Миллисекунд быстро становится много. В int помещаются числа примерно до 32 тысяч — это 32 секунды работы, после чего счётчик переполнится и программа начнёт вести себя странно. unsigned long вмещает больше четырёх миллиардов, этого хватает почти на 50 суток.
