Светофор для школьного макета я собрал за вечер, а кнопку «вызов пешехода» к нему прикрутил три дня. Не потому, что кнопка была плохая — потому что в коде стоял delay(30000): полный цикл светофора длился тридцать секунд, и все эти тридцать секунд плата спала. Нажимал на кнопку — ничего. Отпускал — тоже ничего. Кнопка срабатывала только в те редкие мгновения, когда loop() успевал добежать до digitalRead между задержками. Тогда я не знал главного: delay() — это не пауза в программе. Это остановка всей программы целиком.
В этом уроке мы перепишем мигалку так, чтобы плата продолжала жить во время «пауз»: проверяла кнопки, читала датчики, слушала Serial. Инструмент — функция millis(), и с ней вы напишете первый по-настоящему взрослый паттерн в Arduino: неблокирующий таймер в одну строку — if (millis() - prev >= N).
Нового синтаксиса здесь почти нет: одна функция, одна переменная, одно условие. Но именно этот приём отличает скетч из туториала от прошивки, которая живёт месяцами без перезагрузки. В симуляторе собраны обе мигалки — сломанная и починенная, — переключайтесь между ними и сравнивайте поведение на одной и той же кнопке.
Что именно ломает delay()
Посмотрим на честную хронологию скетча с задержками. loop() делает один круг: включает светодиод, замирает на 1000 мс, выключает, снова замирает на 1000 мс. Между проверками кнопки проходит ровно 2000 мс. Теперь арифметика: если нажатие длится 150 мс, а loop() заглядывает на пин раз в две секунды, то примерно 92% нажатий начинаются и заканчиваются между проверками — плата их вообще не видит. Человеку кажется, что он нажал, а железо не в курсе.
Моё первое решение было классическим тупиком: я решил «прятать» проверку кнопки внутрь задержек. Разбил delay(30000) на тридцать delay(1000), между ними воткнул if (digitalRead(...)). Кнопка ожила, но реагировала с задержкой до секунды. Раздробил дальше, до delay(100), — и получил код, в котором каждая правка периода светофора требовала переписать полсотни строк, а вложенные флаги перестали помещаться в голове. Если вы ловите себя на мысли «разобью большую задержку на много маленьких» — остановитесь: это симптом того, что выбран неверный паттерн, а не неверные цифры.
Физика проблемы простая: delay() съедает не время, а реакцию. Пока он крутится, не выполняется ничего — ни чтение датчиков, ни Serial, ни кнопка. Если в loop() суммарная задержка 2000 мс, а сам полезный код занимает 100 микросекунд, то 99,995% жизни платы проходит в спячке. Флаги, вложенные проверки и дробление задержек это не лечат — лечит только отказ от блокирующих ожиданий.
millis(): счётчик миллисекунд, который никогда не спит
millis() возвращает количество миллисекунд с момента включения платы. Считает его не ваш код, а аппаратный таймер внутри ATmega328P, поэтому millis() тикает всегда, чем бы ни был занят скетч. Сам вызов занимает микросекунды и ничего не блокирует: спросить «который час?» можно хоть тысячу раз за один проход loop().
Тип результата — unsigned long: 32-битное число без знака, максимум 4 294 967 295. В миллисекундах это 49,7 суток непрерывной работы, после чего счётчик переполнится и пойдёт с нуля. Звучит пугающе, но шаблон ниже устроен так, что переполнение его не ломает — почему, показано во врезке. Родственнику millis() — функции micros() — запаса хватает только на 71,6 минуты, это стоит помнить, когда меряете длительности в микросекундах.
Идея неблокирующей мигалки меняет порядок слов: не «подожди и моргни», а «проверь, не прошло ли N миллисекунд с прошлого переключения». Прошло — переключаем и запоминаем новое время. Не прошло — мгновенно идём дальше и занимаемся остальными делами. loop() в такой программе крутится тысячи раз в секунду, и кнопка физически не может пролететь между проверками.
// Эталонный паттерн: мигаем без единой задержкиconst int led = 13;unsigned long prev = 0; // когда переключались в прошлый разconst unsigned long interval = 1000; // период, мсint ledState = LOW; void setup() { pinMode(led, OUTPUT); Serial.begin(9600);} void loop() { unsigned long now = millis(); if (now - prev >= interval) { prev = now; // запоминаем момент переключения if (ledState == HIGH) { ledState = LOW; } else { ledState = HIGH; } digitalWrite(led, ledState); } // Сюда попадаем тысячи раз в секунду: // можно читать кнопки, датчики и Serial без потерь}Два светодиода с разными периодами
Сила паттерна раскрывается, когда таймеров становится несколько. Каждому независимому процессу — своя пара «метка времени плюс период»: prevFast и fastPeriod для быстрой вспышки, prevSlow и slowPeriod для медленной. Таймеры не мешают друг другу, потому что каждое условие проверяется независимо на каждом проходе loop(), а проход длится микросекунды.
Записей две, и разница между ними тонкая. Вариант prev = now держит период «от момента срабатывания»: если loop() на миг занялся чем-то долгим, следующее переключение чуть сдвинется. Вариант prev = prev + interval фиксирует точную сетку времени: метки идут ровно через 1000, 2000, 3000 мс, а запаздывание обработки просто отыгрывается на следующем шаге. Для мигалок разницы не видно, для часов реального времени или генерации меандра — существенна.
Моё рабочее правило: как только в loop() появляется кнопка, датчик или чтение Serial — delay() вычёркивается. Если же это одноразовая гирлянда на новогоднюю ёлку, которой не нужно ни на что реагировать, — честный delay() отработает и код будет читаться проще. Инструмент не бывает плохим, бывает неподходящий контекст.
Блокирующая пауза против неблокирующего таймера: нажмите кнопку и сравните.
Подгружаем интерактив…
Эксперименты для закрепления
- Переведите медленный таймер на вариант prevSlow = prevSlow + slowPeriod вместо prevSlow = now. На экране разницы не увидите, но оба способа должны быть в пальцах — выбирать будете по задаче.
- Добавьте третий таймер: светодиод на пине 8, который вспыхивает дважды по 100 мс раз в 5 секунд. Подсказка: одного условия мало, заведите переменную-фазу и переключайте её по millis().
- Сломайте паттерн намеренно: замените unsigned long на int у prev и now. Через 32,8 секунды int переполнится на 32 767 — и мигалка сойдёт с ума. Лучшая ошибка та, которую вы поймали в симуляторе.
- Сделайте саморазгон: каждые 10 секунд уменьшайте fastPeriod на 50, пока не упрётся в 50. Заодно проверьте, почему вычитание из unsigned long ниже нуля — плохая идея.
- delay(N) останавливает весь скетч: между проверками кнопки — целые секунды простоя
- millis() возвращает unsigned long: миллисекунды с включения, переполнение через 49,7 дней
- Неблокирующий таймер: if (millis() - prev >= N) { prev = millis(); ... }
- Разность now - prev корректна даже при переполнении, если оба значения unsigned long
- Каждому независимому процессу — своя пара prev и interval
- prev = now — период от переключения, prev = prev + interval — точная сетка без дрейфа
- 1. Почему скетч с двумя delay(1000) почти не замечает нажатий кнопки?
- 2. Что произойдёт с millis() через 49,7 суток непрерывной работы?
- 3. Какой тип обязателен для переменных, в которых хранится millis()?
- 4. Чем отличается prev = now от prev = prev + interval?
- 5. Сколько независимых таймеров можно построить на одном millis()?
Теперь у вас есть базовый строительный элемент любой многозадачной прошивки. В следующем уроке спустимся на уровень ниже: аппаратные прерывания заставят сам чип следить за кнопкой, пока loop() занят чем-то другим, — и посчитаем импульсы, которые пролетают за микросекунды и для millis()-опроса слишком быстры.
А пока посидите в симуляторе. Переведите любой свой старый скетч с delay() на millis() — обычно это тридцать минут работы. И обязательно сломайте шаблон через int: подсознание запоминает падение лучше, чем любое правило из текста.
Частые вопросы по теме урока
- Почему скетч с двумя delay(1000) почти не замечает нажатий кнопки?
- Задержки занимают 2000 мс из каждого прохода loop(), поэтому нажатие длительностью 150 мс с вероятностью около 92% проходит между проверками. loop() проверяет кнопку лишь раз в 2 секунды, всё остальное время стоит внутри delay
- Что произойдёт с millis() через 49,7 суток непрерывной работы?
- unsigned long переполняется на 4 294 967 295 мс — это 49,7 суток — и продолжает счёт с нуля. Паттерн с разностью now - prev переживает переполнение без потерь. Счётчик переполнится и продолжит с нуля
- Какой тип обязателен для переменных, в которых хранится millis()?
- millis() возвращает unsigned long. Хранение в int теряет старшие биты, и уже через 32,8 секунды сравнения начинают врать. unsigned long
- Чем отличается prev = now от prev = prev + interval?
- Прибавление к прошлой метке фиксирует интервалы ровно на 1000, 2000, 3000 мс и отыгрывает запаздывания; prev = now просто откладывает следующий тик на interval от текущего момента. prev = now держит период от момента срабатывания, prev = prev + interval даёт точную сетку времени без дрейфа
- Сколько независимых таймеров можно построить на одном millis()?
- Аппаратный таймер один, но софтверных таймеров на его основе можно завести сколько угодно — у каждого своя метка времени и свой период. Сколько угодно: каждому процессу — своя пара prev и interval