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 чтение кнопки или печать в монитор порта раз в секунду по той же схеме — мигание не собьётся.

Открыть пример

Из этого шаблона следуют три правила:

  1. У каждой задачи своя переменная времени (last) и свой период.
  2. Переменные времени — unsigned long, не int.
  3. Сравнение записывается как 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»

Светофор на состояниях

← Все статьи