Настольный перекрёсток для кружка: три светодиода — красный на пине 12, жёлтый на 11, зелёный на 10 — и кнопка пешехода на пине 2 с INPUT_PULLUP, схема та же, что в уроке про кнопку. Скетч я написал за вечер: digitalWrite, delay, снова digitalWrite. Всё выглядело правильно ровно до первого испытателя. Семилетний Сеня нажал кнопку пешехода — и ничего. Нажал ещё, придержал подольше — и вдруг светофор включил красный прямо над игрушечными машинками, без единой секунды жёлтого.
Разбирались вместе. Цикл длился двенадцать секунд, и одиннадцать с половиной из них плата провела внутри delay(): пять на зелёном, два на жёлтом, пять на красном. Внутри delay() не выполняется ничего: ни digitalRead, ни digitalWrite, ни Serial — кнопка опрашивается один раз за цикл. Сеня держал кнопку около полусекунды — шанс попасть в это мгновение примерно один к двадцати четырём, около четырёх процентов. Остальные нажатия он не видел, а когда видел — выполнял «приказ» немедленно: красный, пять секунд, без жёлтого.
Виновата не кнопка и не Сеня — структура: устройство с режимами, которое большую часть жизни спит. Светофор — хрестоматийный конечный автомат: всегда ровно в одном состоянии, переходы по правилам — и всё без единой паузы в loop. Половина инструментария у нас есть: неблокирующий таймер на millis() разобран в отдельном уроке, коммутатор — в уроке про switch/case. Осталось соединить: переменная состояния, таймер переходов, кнопка, которая просит, а не приказывает, — и светофор будет видеть каждое нажатие, не нарушая фаз.
Почему светофор на delay() глух
Что именно ломает delay()? Симптом обманчив: светофор ведь работает. delay(5000) — пять тысяч миллисекунд, в течение которых loop заморожен, пины стоят и кнопка молчит, даже жми её двумя пальцами. Полный оборот скетча — двенадцать секунд, и digitalRead выполняется ровно один раз за оборот. Моментарное нажатие между опросами для платы не существует.
Вторая беда хуже первой — она про отсутствие правил. Когда кнопка «пробивалась», скетч делал написанное: гасил зелёный и зажигал красный. Без жёлтого, без «сейчас будет красный» — для игрушечных машинок это гололёд. Правил переходов в скетче на delay() нет: они размазаны по паузам, и вплести новую логику — а кнопка и есть новая логика — некуда. Мой вывод: delay() приемлем, пока устройство ни на что не реагирует — мигалка, гирлянда, тест светодиодов; с появлением кнопки он превращается в приговор.
const int PIN_KRAS = 12;const int PIN_ZHOLT = 11;const int PIN_ZELEN = 10;const int KNOPKA = 2; void setup() { pinMode(PIN_KRAS, OUTPUT); pinMode(PIN_ZHOLT, OUTPUT); pinMode(PIN_ZELEN, OUTPUT); pinMode(KNOPKA, INPUT_PULLUP);} void loop() { // опрос кнопки — один раз за 12-секундный цикл if (digitalRead(KNOPKA) == LOW) { digitalWrite(PIN_ZELEN, LOW); digitalWrite(PIN_ZHOLT, LOW); digitalWrite(PIN_KRAS, HIGH); // красный без жёлтого: «приказ» delay(5000); return; } digitalWrite(PIN_KRAS, LOW); digitalWrite(PIN_ZELEN, HIGH); delay(5000); digitalWrite(PIN_ZELEN, LOW); digitalWrite(PIN_ZHOLT, HIGH); delay(2000); digitalWrite(PIN_ZHOLT, LOW); digitalWrite(PIN_KRAS, HIGH); delay(5000); digitalWrite(PIN_KRAS, LOW);}Автомат: состояния, переходы и таймер
Конечный автомат звучит как страница из учебника, а по сути это честный ответ: чем устройство занято сейчас. У светофора три состояния: зелёный, жёлтый, красный. В каждый момент включено ровно одно, и скетч знает, какое, — переменная svet. Ничего не происходит «параллельно»: есть состояние, его возраст в миллисекундах и правила смены. loop крутится свободно: каждый оборот опрашивает кнопку, считает таймер и при необходимости делает один переход. Слепых окон, когда loop спит, больше нет.
Состояниям — имена. В C++ для этого есть enum: enum Svet { ZELEN, ZHOLT, KRAS } объявляет три константы со значениями 0, 1 и 2, и дальше по скетчу пишется svet = ZHOLT вместо svet = 1. Числа никуда не делись, но case ZHOLT читается глазами, а не как магическая единица. А раз состояния — целые константы, их принимает switch из урока про switch/case: одна ветвь — одно состояние, вся таблица переходов на виду.
Таймер переходов — формула из урока про millis(): if (millis() - lastSwitch >= 5000), где lastSwitch — момент начала текущего состояния; разность растёт с каждым оборотом loop, дотянула до длительности фазы — пора переходить. Порядок вычитания важен: unsigned long переполняется примерно на 49,7 сутках, и millis() - lastSwitch остаётся корректным даже через переполнение, а millis() >= lastSwitch + 5000 в тот день сломается. Длительности фаз держу именованными константами наверху: методист попросила зелёный подлиннее — правка заняла одну строку.
enum Svet { ZELEN, ZHOLT, KRAS }; // 0, 1, 2const int KNOPKA = 2; Svet svet = ZELEN; // единственная переменная состоянияunsigned long lastSwitch = 0; // когда началось текущее состояниеbool zapros = false; // пешеход попросил void stavit(Svet novy) { svet = novy; lastSwitch = millis(); // новое состояние — новый отсчёт Serial.print("t="); Serial.print(millis()); Serial.print(" svet="); Serial.println(novy);} void setup() { pinMode(KNOPKA, INPUT_PULLUP); Serial.begin(9600);} void loop() { // 1. опрос мира: кнопка только ставит запрос if (digitalRead(KNOPKA) == LOW) { zapros = true; } // 2. сколько уже длится текущее состояние unsigned long vozrast = millis() - lastSwitch; // 3. автомат: переходы решает switch switch (svet) { case ZELEN: if (zapros) { if (vozrast >= 2000) { stavit(ZHOLT); } // укороченный зелёный } else { if (vozrast >= 5000) { stavit(ZHOLT); } } break; case ZHOLT: if (vozrast >= 2000) { zapros = false; // просьба обслужена: начинается красный stavit(KRAS); } break; case KRAS: if (vozrast >= 5000) { stavit(ZELEN); } break; }}Самая недооценённая строка каркаса — lastSwitch = millis() внутри stavit(). Каждое состояние начинает отсчёт заново — в одном-единственном месте, рядом со сменой. Это не украшение: однажды я разбил stavit() на «понятные» строки и потерял перезапуск таймера — фазы забегали; разбор в блоке про грабли. Правило из урока про свои функции переносится напрямую: две правки, обязанные происходить вместе, должны жить в одной функции.
Теперь пешеход. Кнопка не переключает светофор — она просит: нажатие ставит флаг zapros. Что с ним делать, решает автомат по правилам фаз. Зелёный старше двух секунд заканчивается сразу: укороченный, но честный. Моложе — дорабатывает минимум: машины успевают покинуть перекрёсток. Дальше жёлтый, дальше красный на пять секунд — на нём пешеход переходит; при включении красного флаг гаснет, просьба обслужена. Запрос, нажатый во время красного, доживёт до зелёного и сделает его укороченным. Нетерпеливое тыканье ничего не ускоряет: укороченный зелёный — единственная уступка; нажатие на жёлтом гаснет вместе с жёлтым — впереди полный красный.
Опрос. Кнопка проверяется в начале loop, до switch, в каждом обороте: на Uno это десятки тысяч чтений в секунду, почти бесплатно. Прерывания здесь не нужны: они нужны, когда импульсы быстрее, чем loop, или когда loop подолгу спит. Ошибка, которую я вижу чаще прочих, — обработка кнопки внутри ветви зелёного: нажатие «работает», только пока горит зелёный. Уши автомата — опрос мира — обязаны торчать снаружи, до switch.
| Состояние | Горит | Длительность | Куда и когда переходит |
|---|---|---|---|
| ZELEN (0) | зелёный, пин 10 | 5000 мс; при zapros=1 — до 2000 | по таймеру — в ZHOLT, всегда через жёлтый |
| ZHOLT (1) | жёлтый, пин 11 | 2000 мс | по таймеру — в KRAS; обязательна для машин |
| KRAS (2) | красный, пин 12 | 5000 мс | по таймеру — в ZELEN; здесь пешеход переходит |
| zapros (флаг) | ламп не трогает | живёт до включения красного | не состояние, а просьба пешехода |
Тупик: вечный жёлтый
Первая рабочая версия была «умнее»: я вписал кнопку прямо в ветвь зелёного — if (digitalRead(KNOPKA) == LOW) { svet = ZHOLT; lastSwitch = millis(); }. Работало: нажатие — зелёный гас. Пока Сеня не догадался кнопку держать. Зажатая кнопка на каждом обороте loop выполняла те же строки заново — svet = ZHOLT и снова lastSwitch = millis(); таймер обнулялся, и красный не наступал никогда. Сеня смотрел на вечный жёлтый как на фокус, а я десять минут смотрел на скетч без неверных символов. Неправильной была структура: кнопка командовала каждый оборот, а не просила один раз.
Лечение — разделить права. Кнопка делает ровно одно: замечена — флаг zapros стал единицей. Повторное нажатие флагу безвредно: единица остаётся единицей. Печать запроса я повесил на фронт, переход с HIGH на LOW, как в уроке про кнопку и дребезг, — иначе зажатая кнопка засыпала бы порт сотнями строк в секунду. Правило, которое я теперь рассказываю раньше схемы: у кнопки — право просить, у автомата — право переключать. Дальше по плану раздела — кнопочное меню: флаг-запрос вырастет в выбранный пункт, дисциплина останется той же.
Скетч в симуляторе — тот же каркас с одной подменой: enum заменён на константы const int, потому что движок слово enum не разбирает; на плате верните enum. Откройте симулятор на сайте и жмите кнопку в разных фазах. В мониторе пойдут пары имя=значение: t=… svet=… — миллисекунды от старта и номер фазы, 0 зелёный, 1 жёлтый, 2 красный, — а по нажатию zapros=1. На красном понажимайте — следующий зелёный будет укороченным; в первые две секунды зелёного автомат досидит минимум и лишь потом уйдёт на жёлтый.
- Добавьте четвёртое состояние ZHOLT_KRAS — красный с жёлтым перед зелёным, как на московских светофорах: константа, ветвь case с двумя digitalWrite и таймером на 1500 мс, правка перехода из KRAS. Смена состояния должна остаться только внутри stavit(), иначе поймаете строб из блока про грабли.
- Сломайте автомат нарочно: удалите lastSwitch = millis() из stavit() и запустите. В мониторе фазы мелькнут за миллисекунды, столбиком t=… svet=… — это и есть строб. Верните строку и запомните симптом: состояния печатаются быстрее, чем вы успеваете их читать.
- Проверьте дисциплину запроса: нажмите кнопку на красном и досмотрите до зелёного — он должен быть укороченным, до 2000 мс, а порядок фаз прежним. Затем нажмите кнопку в первые две секунды зелёного: автомат обязан досидеть минимум и только потом уйти на жёлтый.
- Верните enum на место: замените блок const int на enum Svet и соберите скетч в настоящей Arduino IDE. В движке симулятора он не запустится — это честное ограничение подмножества, о котором урок предупреждал. Заодно попробуйте bool вместо int у zapros.
- Конечный автомат — переменная состояния плюс правила переходов: устройство всегда ровно в одном состоянии, loop не засыпает ни на миллисекунду, и кнопка замечена в каждом обороте.
- Переход по таймеру: millis() - lastSwitch >= длительность; lastSwitch обновляется в единственной функции смены состояния, иначе автомат строчит фазы за доли миллисекунды.
- Кнопка ставит флаг-запрос и не переключает ничего; автомат исполняет запрос в безопасной точке: минимальный зелёный, обязательный жёлтый, полный красный.
- Опрос кнопки — в начале loop, до switch, всегда; мгновенная реакция «прямо в ветви» при зажатой кнопке перезапускает состояние каждый оборот и даёт вечный жёлтый.
- enum даёт состояниям имена вместо магических чисел и дружит со switch; движок симулятора enum не парсит — там константы с теми же именами, на плате верните enum.
- 1. Светофор на delay() с фазами 5 + 2 + 5 секунд: почему нажатие кнопки чаще всего пропадает?
- 2. Что в автомате урока делает переменная zapros?
- 3. Светофор строчит фазы за миллисекунды, в мониторе столбик t=… svet=…. Что искать в скетче?
- 4. Чем плоха «мгновенная» реакция на кнопку, вписанная прямо в ветвь зелёного?
Частые вопросы по теме урока
- Светофор на delay() с фазами 5 + 2 + 5 секунд: почему нажатие кнопки чаще всего пропадает?
- delay() не исполняет ничего, включая digitalRead: опрос один раз за 12-секундный оборот, полсекундное нажатие попадает в него с шансом около четырёх процентов. loop заморожен внутри delay() 11 секунд из 12: опрос раз за цикл, нажатие между опросами для платы не существует
- Что в автомате урока делает переменная zapros?
- Кнопка не переключает фазы — она просит: у кнопки право просить, у автомата — право переключать. Это флаг запроса: кнопка его ставит, а автомат исполняет в безопасной точке смены фаз
- Светофор строчит фазы за миллисекунды, в мониторе столбик t=… svet=…. Что искать в скетче?
- Возраст состояния не становится меньше порога — переход повторяется на каждом обороте. Лечение: смена состояния только через stavit(). Смену состояния без lastSwitch = millis(): условие перехода остаётся истинным на каждом обороте loop
- Чем плоха «мгновенная» реакция на кнопку, вписанная прямо в ветвь зелёного?
- Это смешивание запроса и состояния: зажатая кнопка командует на каждом обороте. Флаг и правила автомата дают и отзывчивость, и корректные фазы. Пока кнопку держат, каждый оборот loop заново ставит жёлтый и обнуляет его таймер — красного не будет никогда