База знаний · Язык скетчей

millis() и delay(): паузы, которые не останавливают программу

Почему delay() замораживает плату, как заменить его на millis() и правда ли, что через 50 дней всё сломается.

delay() — первая функция, которой учат новичка, и первая, от которой потом приходится избавляться. Причина в том, что она делает буквально: останавливает всю программу. Пока идёт пауза, плата не читает кнопки, не отвечает на команды и не следит за датчиками. millis() решает это иначе — она просто сообщает, сколько миллисекунд прошло с момента включения.

Что на самом деле делает delay()

Вопреки названию, никакого «сна» там нет. В исходнике ядра delay() — это цикл, который крутится и сравнивает показания micros(), пока не набежит нужное число миллисекунд. Процессор всё это время крутит цикл на полной тактовой частоте: в спящий режим он не уходит и энергию не экономит. Более того, функция yield(), которую delay() вызывает внутри, на AVR-платах пустая — то есть время уходит полностью впустую.

Важная оговорка: на ESP8266 и ESP32 всё наоборот. Там delay() отдаёт время фоновым задачам Wi-Fi и сторожевому таймеру, и убирать его из длинных циклов опасно — плата перезагрузится. Совет «выкинь все delay» верен для Uno и Nano, но не для ESP8266.

Откуда Arduino знает время

РЕЗОНАТОР 16 МГц16 млн тактов в секундуДЕЛИТЕЛЬ ÷ 64один шаг = 4 мксТАЙМЕР 0: 0…255256 шагов = 1024 мкспереполнениеПРЕРЫВАНИЕ TIMER0_OVFсрабатывает около 977 раз в секундуmillis() + 1плюс дробный остаток 3 из 125Лишние 24 мкс за каждое переполнение копятся отдельно: когда дробь дорастает до 125,счётчик прибавляет сразу 2 — поэтому часы не убегают, но отдельные значения millis() пропускаются.
Счётчик миллисекунд работает всегда — с первой миллисекунды после включения, даже если вы ни разу не написали millis(). Обработчик прерывания таймера уже лежит в любом, даже пустом скетче.

Отсюда неочевидное следствие: millis() ничего не «включает» и не добавляет накладных расходов — вы за неё уже заплатили. В пустом скетче на 444 байта обработчик этого прерывания занимает 148 байт, а все девять байт ОЗУ — три переменные того же счётчика.

Вторая неочевидная деталь: таймер переполняется не ровно раз в миллисекунду, а каждые 1024 микросекунды. Лишние 24 микросекунды ядро копит в дробном счётчике, поэтому долговременного ухода часов нет, но отдельные значения millis() иногда пропускаются — счётчик перескакивает сразу на два. Практический вывод: никогда не пишите if (millis() == 5000), только сравнение «больше или равно».

И ещё одна честная оговорка: арифметика счётчика точна, а вот сам тактовый генератор — нет. На Uno частоту задаёт керамический резонатор, его точность заметно ниже кварцевой, поэтому за сутки часы на millis() убегают на минуты. Для настоящих часов нужен отдельный модуль времени.

Мигание через delay() — как все начинают

void setup() {
  // Настраиваем вывод со встроенным светодиодом на выход.
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);  // подали напряжение — светодиод горит
  delay(500);                       // ждём полсекунды
  digitalWrite(LED_BUILTIN, LOW);   // сняли напряжение — светодиод погас
  delay(500);                       // и снова ждём полсекунды
}

Собрано: 924 байта флеша, 9 байт ОЗУ. Пока в программе одно дело, всё отлично. Проблемы начинаются, когда появляется второе.

То же самое на millis()

unsigned long previous = 0;   // когда переключали в прошлый раз
const unsigned long PERIOD = 500;  // период мигания, миллисекунды
bool ledOn = false;           // текущее состояние светодиода

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  // millis() — сколько миллисекунд плата работает с момента включения.
  unsigned long now = millis();

  // Пора переключать? Тогда переключаем и запоминаем момент.
  if (now - previous >= PERIOD) {
    previous = now;
    ledOn = !ledOn;
    digitalWrite(LED_BUILTIN, ledOn ? HIGH : LOW);
  }

  // Здесь плата свободна: можно читать кнопку, порт, датчики —
  // в отличие от delay(), который останавливает всю программу.
}

Собрано: 832 байта флеша, 14 байт ОЗУ. Обратите внимание — версия на millis() оказалась на 92 байта легче, чем версия с delay(). Расхожее «millis тяжелее и сложнее» неверно как минимум по размеру: delay() тянет за собой micros() и собственный цикл ожидания, а скетчу на millis() они не нужны. Плата за таймер — 5 байт ОЗУ на каждый: 4 байта на unsigned long и байт на флаг состояния.

То, чего delay() не умеет вообще

Два светодиода с периодами 300 и 1100 миллисекунд через delay() напрямую не мигают. Свести их можно только вручную: взять общий шаг 100 мс, вести два счётчика проходов — и получить тот же конечный автомат, только с платой, глухой на каждой паузе. А при периодах вроде 300 и 1105 общего шага уже не будет вовсе. На millis() это просто два независимых блока, и подбирать шаг не нужно:

const byte LED_A = 12;                  // первый светодиод
const byte LED_B = 13;                  // второй светодиод

const unsigned long PERIOD_A = 300;     // полупериод первого, мс
const unsigned long PERIOD_B = 1100;    // полупериод второго, мс

unsigned long prevA = 0;                // у каждого светодиода свои «часы»
unsigned long prevB = 0;
bool stateA = false;
bool stateB = false;

void setup() {
  pinMode(LED_A, OUTPUT);
  pinMode(LED_B, OUTPUT);
}

void loop() {
  unsigned long now = millis();         // время читаем один раз за проход

  if (now - prevA >= PERIOD_A) {        // пора переключать первый?
    prevA = now;
    stateA = !stateA;
    digitalWrite(LED_A, stateA ? HIGH : LOW);
  }

  if (now - prevB >= PERIOD_B) {        // пора переключать второй?
    prevB = now;
    stateB = !stateB;
    digitalWrite(LED_B, stateB ? HIGH : LOW);
  }
}

Собрано: 924 байта флеша и 19 байт ОЗУ — ровно столько же флеша, сколько у обычной мигалки на delay(), но дел вдвое больше.

Кнопка, которая не нажимается

Вот код, который приносят в поддержку чаще всего: светодиод мигает, кнопка «не работает».

const byte LED = 13;
const byte BUTTON = 2;

void setup() {
  pinMode(LED, OUTPUT);
  pinMode(BUTTON, INPUT_PULLUP);
  Serial.begin(9600);
}

void loop() {
  digitalWrite(LED, HIGH);
  delay(1000);                 // целую секунду плата глухая
  digitalWrite(LED, LOW);
  delay(1000);                 // и ещё секунду

  // Сюда программа доходит раз в 2000 мс — почти все нажатия пропущены.
  if (digitalRead(BUTTON) == LOW) {
    Serial.println("Кнопка нажата");
  }
}

Кнопка здесь опрашивается ровно один раз за 2000 миллисекунд. Обычное нажатие длится около 200 мс, то есть будет замечено примерно в одном случае из десяти. Никакая «дребезгозащита» не поможет — плата просто спит в этот момент. Лечение: убрать оба delay(), мигать по схеме из предыдущего раздела, а digitalRead() оставить в loop() без пауз. Готовый пример с обработкой дребезга — в уроке про кнопку-выключатель.

Сколько это стоит: измеренные размеры

СкетчФлеш, байтОЗУ, байт
Пустой скетч (только setup и loop)4449
Мигание светодиодом через delay()9249
То же мигание на millis()83214
Два светодиода с разной частотой на millis()92419
Кнопка и мигание через delay() (пропускает нажатия)2144214

Переполнение через 49,7 суток — и почему это не страшно

millis() возвращает unsigned long: 4 байта, максимум 4 294 967 295. Дальше счётчик обнуляется. Точное время — 49 суток 17 часов 2 минуты 47 секунд, а не «ровно 50 дней», как обычно пишут.

Половина статей пугает этим и советует «перезагружать плату раз в месяц». Это не нужно, если сравнение написано через разность. Беззнаковая арифметика считает по кругу, поэтому millis() - previous даёт верное «сколько прошло» даже когда счётчик перескочил через ноль. А вот привычная на вид запись millis() >= previous + interval ломается: сумма обрезается, и условие начинает срабатывать на каждом проходе. Вот трассировка у самой границы (previous = 4 294 967 196, интервал 250 мс):

millis()millis() − previousРазность ≥ 250millis() ≥ previous + 250
4 294 967 1960нетДА — ложное срабатывание
4 294 967 27175нетДА — ложное срабатывание
0100нетнет
150250ДА — верноДА

Вторая колонка справа — правильная запись, она сработала ровно на 250-й миллисекунде. Крайняя правая 100 миллисекунд подряд срабатывала на каждом проходе loop(), потому что previous + 250 обрезалось до 150.

Три ловушки, о которых редко пишут

  • int previous = millis(); — ломает всё через 32,7 секунды работы. В int на Uno помещается только 32 767, дальше число становится отрицательным, и разность оказывается больше настоящей на 65 536 — а после каждого следующего переполнения ещё на столько же. Момент времени хранят только в unsigned long.
  • delayMicroseconds() больше 16 383 — врёт. Аргумент 16-битный, и внутри он умножается на четыре, поэтому delayMicroseconds(20000) даст вовсе не 20 миллисекунд. Больше 16 мс — только delay(). Заодно: вызовы с 0 и 1 не ждут вообще.
  • Пока прерывания запрещены, millis() стоит на месте. Долгий обработчик прерывания, noInterrupts() или блокирующая библиотека крадут время безвозвратно: плата помнит только одно отложенное переполнение таймера. Внутри обработчика прерывания delay() тоже не работает — он завершается почти мгновенно вместо запрошенной паузы.

Когда delay() всё-таки уместен

Когда программа делает одно дело и ждать ей не жалко: пауза между замерами датчика, задержка на запуск модуля, простое мигание в первом уроке. Плохим delay() становится там, где плата должна одновременно следить за чем-то ещё. Хорошее правило: как только в программе появилось второе дело — переходите на millis().

Проверено на нашей сборке

Все размеры получены 21 августа 2026 года: arduino-cli 1.5.2-rc.1, ядро arduino:avr 1.8.8, плата Arduino Uno. Устройство счётчика приведено по исходникам ядра cores/arduino/wiring.c и cores/arduino/hooks.c. Поведение во время работы мы не замеряли на живой плате — оно доказано кодом ядра и арифметикой.

Частые вопросы

Как заменить delay() на millis()?

Завести переменную unsigned long с моментом последнего действия и в каждом проходе loop() сравнивать разность: если millis() минус этот момент больше интервала — пора действовать и запомнить новое время. Готовый скетч мигания есть в статье; на Uno он занял 832 байта против 924 у версии с delay().

Через сколько переполняется millis()?

Через 4 294 967 296 миллисекунд — это 49 суток 17 часов 2 минуты 47 секунд, а не «ровно 50 дней». Если сравнение написано через разность (millis() − previous >= интервал), переполнение проходит незаметно. Ломается только запись вида millis() >= previous + интервал.

Как мигать светодиодом и читать кнопку одновременно?

Убрать все delay() и держать в loop() два независимых блока: один сравнивает millis() с моментом последнего переключения светодиода, второй каждый проход читает кнопку. Так плата опрашивает кнопку тысячи раз в секунду, а не раз в две секунды, как в версии с двумя delay(1000).

Можно ли вообще использовать delay()?

Да, если в программе одно дело: мигнуть, выдержать паузу между замерами датчика, дать модулю время на запуск. Более того, на ESP8266 и ESP32 delay() полезен — он отдаёт время фоновым задачам Wi-Fi и сторожевому таймеру. Вредным он становится там, где плата должна одновременно следить за чем-то ещё.

Обновлено: 21 августа 2026