delay() против millis(): как научить контроллер делать несколько дел сразу
Почему второй светодиод не мигает в своём ритме и как переписать loop() без ожиданий
Первый скетч с delay() работает, второй — уже нет: кнопка не реагирует, светодиоды мешают друг другу. Разбираем, что делает delay(), как устроено сравнение с millis() и почему оно переживает переполнение счётчика.
Мигать светодиодом умеют все: digitalWrite, delay(500), digitalWrite, delay(500). Трудности начинаются со второй задачи. Нужно, чтобы второй светодиод мигал в другом ритме, или чтобы кнопка переключала режим, или чтобы раз в пять секунд в монитор порта уходило сообщение. Скетч разрастается вложенными задержками, кнопка срабатывает через раз, а ритм «плывёт».
Причина одна: delay() не «ждёт в фоне», он останавливает выполнение программы. Пока идёт задержка, ваш код не работает вообще.
Что на самом деле делает delay()
Возьмём скетч Мигание светодиодом для ESP32:
void loop() {
digitalWrite(LED, HIGH);
delay(500);
digitalWrite(LED, LOW);
delay(500);
}
Один проход loop() длится секунду, и 99,99 % этого времени процессор крутится внутри delay(). Если добавить сюда чтение кнопки, оно выполнится один раз в секунду. Короткое нажатие длиной 150 мс скорее всего целиком поместится внутрь задержки и не будет замечено — вывод прочитан до нажатия и после отпускания.
Второй светодиод в другом ритме тоже не получится. Допустим, первый должен мигать с периодом 1 с, а второй — 300 мс. Периоды не кратны, общий цикл составит 3 с, и придётся вручную расписать последовательность из десятка задержек разной длины. Для трёх светодиодов это уже нечитаемо, а для кнопки — невозможно.
Стоит оговориться: у ESP32 во время delay() может работать другое — прерывания, фоновые задачи FreeRTOS, Wi-Fi. Но ваш loop() стоит, и всё, что написано в нём, стоит вместе с ним.
Идея: не ждать, а спрашивать, пора ли
Функция millis() возвращает число миллисекунд с момента включения платы. Это счётчик, который тикает сам, независимо от кода. Вместо «подожди 500 мс» программа на каждом проходе loop() задаёт вопрос: «с прошлого переключения прошло 500 мс или ещё нет». Если нет — сразу идёт дальше.
Так устроен пример Мигание без delay():
const unsigned long PERIOD = 500;
unsigned long last = 0;
bool state = false;
void loop() {
if (millis() - last >= PERIOD) {
last = millis();
state = !state;
digitalWrite(LED, state);
}
// Здесь можно делать что угодно ещё — мигание не мешает.
}
Внешне результат тот же: светодиод на D2 мигает раз в секунду. Разница внутри: loop() теперь проходит за микросекунды и выполняется сотни тысяч раз в секунду. Каждое «дело» получает свой шанс на каждом проходе.
Мигание без delay()
Добавьте в loop() после блока if чтение кнопки или печать в монитор порта раз в секунду по той же схеме — мигание не собьётся.
Открыть примерИз этого шаблона следуют три правила:
- У каждой задачи своя переменная времени (
last) и свой период. - Переменные времени —
unsigned long, неint. - Сравнение записывается как
now - last >= period, и никак иначе.
Третье правило требует объяснения.
Переполнение: почему вычитание, а не сложение
millis() возвращает 32-битное беззнаковое число. Максимум — 4 294 967 295 мс, это примерно 49,7 суток. Дальше счётчик переходит через ноль и начинает заново. Для устройства, которое работает неделями, это не теория.
Посмотрим, что происходит с двумя формами сравнения у границы. Пусть last = 4 294 967 000, период 500 мс, а счётчик уже перешёл через ноль и now = 200.
Вычитание беззнаковых чисел выполняется по модулю 2³²:
now - last = 200 − 4 294 967 000 (mod 2³²)
= 296 + 200 = 496
496 < 500 — ещё не пора. Через 4 мс разность станет 500, и задача сработает вовремя. Переход через ноль не заметен.
Теперь форма now >= last + period. Сумма last + 500 тоже переполняется и даёт 204. Пока счётчик не перешёл через ноль, now огромное, и условие now >= 204 истинно на каждом проходе — задача срабатывает сотни тысяч раз подряд. Если же вы храните заранее вычисленный момент target у самой границы, а now уже перешёл через ноль, условие станет ложным почти на 49 суток. В комментарии к примеру Два ритма без delay это сформулировано так: одна форма переживает переполнение, другая зависает, и проявляется это раз в семь недель — когда найти причину уже невозможно.
Отсюда же требование к типу. На ESP32 int 32-битный, и скетч с int last случайно работает: при вычитании значение приводится к беззнаковому и ничего не теряет. На Arduino Uno int 16-битный: как только millis() превысит 32 767 мс, в last запишутся только младшие 16 бит, разность станет огромной, и задача начнёт срабатывать на каждом проходе. Это произойдёт через 32,8 с после включения.
Несколько задач и планировщик
С одной задачей шаблон выглядит избыточным. Смысл появляется, когда задач две и больше. В примере для Uno два светодиода на выводах 8 и 9 мигают с периодами 150 и 1000 мс — ровно то, что на delay() не пишется:
void loop() {
unsigned long now = millis();
if (now - fastAt >= 150) {
fastAt = now;
fastOn = !fastOn;
digitalWrite(LED_FAST, fastOn);
}
if (now - slowAt >= 1000) {
slowAt = now;
slowOn = !slowOn;
digitalWrite(LED_SLOW, slowOn);
}
}
Обратите внимание: millis() вызывается один раз в начале прохода и запоминается в now. Все задачи сравниваются с одним и тем же моментом, а не с немного разными.
Когда задач становится пять, повторяющиеся блоки if собирают в таблицу. Так сделано в примере Несколько задач по времени: структура Task хранит период, время последнего запуска и указатель на функцию.
Task tasks[] = {
{150, 0, fastBlink},
{900, 0, slowBlink},
{5000, 0, report},
};
void loop() {
unsigned long now = millis();
for (int i = 0; i < COUNT; i++) {
if (now - tasks[i].last >= tasks[i].period) {
tasks[i].last = now;
tasks[i].run();
}
}
}
В симуляторе PinPort видно два светодиода на D2 и D4 с разными ритмами, а в мониторе порта раз в пять секунд появляется строка работаю 5 с, работаю 10 с и так далее. Добавить задачу — значит дописать одну строку в массив.
Такой планировщик называют кооперативным: каждая задача обязана быстро вернуть управление. Если одна из функций сама вызовет delay(1000), встанут все остальные.
Последовательности: светофор на состояниях
Мигание — периодическое действие. Светофор — последовательность шагов разной длины. На delay() он пишется в четыре строки, и в этом ловушка: как только понадобится кнопка пешехода, код придётся переписать целиком.
Вместо цепочки задержек программа хранит две вещи: номер текущей фазы и момент, когда она началась. В светофоре на Uno длительности фаз лежат в массиве PHASE_MS = {4000, 1000, 4000, 1000}, а переход выглядит так:
if (millis() - phaseAt >= PHASE_MS[phase]) {
phaseAt = millis();
phase = (phase + 1) % 4;
show();
}
Выводы переключаются только при смене фазы. Проверка кнопки встаёт рядом отдельным if и может, например, сократить текущую фазу — ничего остального менять не нужно.
Типичные ошибки
| Ошибка | Что происходит | Как правильно |
|---|---|---|
int last вместо unsigned long | На Uno сбой через 32,8 с; на ESP32 работает до первого переноса кода | unsigned long для всех моментов времени |
now >= last + period | Срабатывает на каждом проходе или замирает на 49 суток | now - last >= period |
delay() внутри одной из задач | Все остальные задачи ждут | Разбить задачу на шаги с состоянием |
millis() вызывается в каждом if заново | Задачи сравниваются с разными моментами | Один now на проход |
| Действие на каждом проходе вместо смены состояния | Выход дёргается тысячи раз в секунду | Писать в вывод только при переходе |
Отдельный вопрос — дрейф. Строка last = now отсчитывает следующий период от момента, когда задачу заметили, а не от момента, когда она должна была сработать. Если loop() задержался на 3 мс, эти 3 мс добавятся к периоду навсегда. Для мигания это неважно. Для часов, которые должны идти сутками, пишут last += period — тогда средний период точный, хотя отдельное срабатывание может опоздать. Если нужна точность лучше миллисекунды и независимость от того, чем занят loop(), используют аппаратный таймер с прерыванием — как в примере Аппаратный таймер, где ESP32 отсчитывает ровно 100 000 мкс между срабатываниями.
Что попробовать в симуляторе
Лучше всего разница видна, если сломать работающий неблокирующий скетч. Откройте два ритма на Uno и добавьте в конец loop() строку delay(300). Быстрый светодиод перестанет мигать с периодом 150 мс: теперь он переключается не чаще, чем раз в 300 мс, потому что проверка выполняется только между задержками. Уберите задержку — ритм вернётся.
Затем в планировщике поменяйте период задачи report с 5000 на 1000 и посмотрите, как чаще пойдут строки в мониторе порта, а светодиоды при этом не собьются.
Коротко
delay() останавливает весь loop(), поэтому годится только для скетча из одного дела. Для нескольких задач каждая хранит свой момент последнего срабатывания, а loop() на каждом проходе проверяет now - last >= period и сразу идёт дальше. Время хранят в unsigned long, сравнивают вычитанием — это переживает переполнение через 49,7 суток. Последовательности оформляют как состояние плюс время начала, а когда нужна точность — переходят на аппаратный таймер.
Попробуйте в симуляторе
Мигание светодиодом
Добавьте второй светодиод на D4 и попробуйте заставить его мигать втрое быстрее первого, не убирая delay(500) — станет видно, почему это не выходит
Мигание на delay()Arduino Uno: два ритма без delay
Вставьте в конец loop() строку delay(300) и посмотрите, как быстрый светодиод на выводе 8 потеряет свой период 150 мс
Два ритма на UnoНесколько задач по времени
Добавьте в массив tasks четвёртую задачу с периодом 2000 мс, которая печатает в монитор порта состояние вывода 2
Планировщик задачArduino Uno: светофор на состояниях
Сократите в PHASE_MS зелёную фазу (третий элемент) с 4000 до 2000 мс, допишите в печать millis() и проверьте по монитору порта, что короче стал только промежуток от «фаза 2» до «фаза 3»
Светофор на состояниях