Контроллер полива в теплице я собрал за выходные, а коллега по мастерской поспорил на бутылку кваса: через сорок девять суток millis() кончится, и всё повиснет. Согласиться было сложно, но и доказать на пальцах тоже, поэтому плату я оставил жить в углу с журналом на Serial, а сам пошёл считать. millis() хранится в unsigned long — 32 бита без знака, потолок 4294967295 миллисекунд. Делим на 86400000 миллисекунд в сутках, выходит 49,71 суток — 49 суток и 17 часов. После этого счётчик не кончается, а начинается заново: на 4294967295-м миллисекундном тике он склеивается в ноль и идёт дальше, как одометр после 999999. Спор я выиграл: журнал шёл больше семидесяти суток, и ни одной паузы в мигалке за это время не случилось.
Здесь разберём, почему переход через максимум — не авария, а штатная арифметика: как беззнаковое вычитание даёт верный интервал сквозь момент перехода, почему строка if (millis() - start >= 1000) безопасна всегда, а её соседка if (millis() > start + 1000) стреляет холостым раз в 49 суток. Базу мы закладывали в уроке про millis() вместо delay — там шаблон шёл без пояснений; теперь спускаемся до битов.
Отдельный план — границы. Разность двух меток живёт, пока реальный интервал короче 49,7 суток; что делать, когда интервал длиннее, и почему micros() живёт по тем же правилам, но в масштабе 71,6 минуты. И честная оговорка заранее: в симуляторе этого урока прыжок счётчика не изображается — до 49-х суток ему не добежать, поэтому живой счётчик покажет только сам паттерн, а не момент склейки.
32 бита и момент перехода
Считать байты здесь полезно, потому что весь механизм лежит в типе. millis() возвращает unsigned long: 32 бита, все отведены под число, знакового бита нет. Максимум — 4294967295, это 49,71 суток. Никакого конца света при переходе не запланировано: на 4294967296 счётчик склеивается в ноль и продолжает считать. Плата не перезагружается, скетч не останавливается, таймерное прерывание не спотыкается — меняется только показание счётчика. Это самая скучная катастрофа в мире: цифра обнулилась, и всё.
Проверить на бумаге можно в одну строку. Пусть таймер заведён меткой start = 4294960000 и ждём интервал 8000 мс. Истинный момент отбоя — 4294968000, но такой номер в 32 бита не влезает: после потолка счётчик продолжает с 704. Теперь разность: 704 минус 4294960000. В школьной арифметике выходит минус 4294959296, в беззнаковой 32-битной — ровно 8000: разность снимается по модулю 2 в 32-й степени, и украденные у потолка миллисекунды возвращаются. Пока реальный интервал короче полного круга, разность всегда верна — не по везению, а по стандарту языка, который гарантирует для беззнаковых типов арифметику по модулю.
Тот же приём мелькал в планировщике задач на millis одной строкой и точно так же работает в кооперативном цикле. Там это была ремарка, здесь — фундамент: без понимания модульной арифметики следующий раздел выглядит чёрной магией, а с ней — как таблица умножения.
Ловушка: millis() > start + 1000
Паттерн-ловушка отличается от правильного одной строкой, и именно поэтому живуч: он выглядит понятнее. Возьмите start = 4294966800 — метку, заведённую за 495 мс до перехода, — и условие if (millis() > start + 1000). Компилятор сначала сложит: 4294966800 + 1000 = 4294967800, что склеивается в 504. Дальше сравнение: текущие millis() = 4294966800 больше 504? Разумеется, — условие срабатывает в то же мгновение, хотя интервал только завёлся. Сумма переполнилась раньше срока, таймер выстрелил на тысячу миллисекунд раньше, чем следовало, и на 49-е сутки проекта такие выстрелы посыпятся из каждого сравнения, записанного в этой форме.
Правильная форма вычитает, а не прибавляет: if (millis() - start >= 1000). Слева — беззнаковая разность, корректная и через переход; справа — константа, которая вообще не участвует ни в каком переполнении. Мнемоника на каждый день: millis() не ставят справа от знака сравнения в компании арифметики — только разность слева и число справа. Ровно так написан маяк ниже, так же устроены все таймеры в уроке про точные замеры micros — там круг короче, и цена промаха была бы видна через час, а не через 49 дней.
Подозреваю, у вас уже зреет вопрос: если правильная форма одна, почему в чужих скетчах так много сломанных? Потому что промах невидим. Сломанный таймер отрабатывает 49 дней подряд идеально — каждое сравнение сходится, каждое нажатие обрабатывается, и только один раз за весь срок службы, в узкое окно у границы круга, условие стреляет рано. Отладить такое на столе нельзя: пока не прогоните плату через момент перехода, баг не существует. Поэтому форму сравнения я проверяю глазами при code review — не тестами, а одной привычкой: вижу millis() правее знака сравнения — стоп.
const int LED = 12;const int BTN = 2; unsigned long lastBlink = 0;unsigned long lastReport = 0;int period = 500;int btnPrev = LOW;int ledState = 0; void setup() { pinMode(LED, OUTPUT); pinMode(BTN, INPUT); // кнопка с подтяжкой 10 кОм на GND Serial.begin(9600); Serial.println("Mayak: raznost millis() - last rabotaet vsegda");} void loop() { int btn = digitalRead(BTN); if (btn == HIGH && btnPrev == LOW) { // фронт нажатия if (period == 500) { period = 150; } else { period = 500; } Serial.print("period="); Serial.println(period); } btnPrev = btn; if (millis() - lastBlink >= period) { // разность слева, число справа lastBlink = millis(); ledState = 1 - ledState; digitalWrite(LED, ledState); } if (millis() - lastReport >= 5000) { lastReport = millis(); Serial.print("now="); Serial.print(millis()); Serial.print(" elapsed="); Serial.println(millis() - lastBlink); }}По маяку удобно сверить и саму печать журнала. now= — это сырые показания счётчика, elapsed= — разность с последним переключением светодиода: она всегда меньше периода и никогда не уходит в минус. Если в вашем журнале разность однажды напечатается отрицательной — вы где-то привели millis() к знаковому типу: int или long без unsigned. Это вторая по частоте ловушка после суммы: int на AVR — 16 бит, круг не 49 суток, а 65 секунд, и таймеры у такого скетча сходят с ума ежеминутно. Тип меток — всегда unsigned long, и в этом стоит перечитать объявление после любой чужой правки.
| Запись | Что делает | Переход через максимум | Можно ли так |
|---|---|---|---|
| if (millis() - start >= N) | разность слева, константа справа | работает всегда, пока N меньше 49,7 суток | да: мигалки, планировщик, кнопки |
| if (millis() > start + N) | сумма справа переполняется | ложное срабатывание у границы круга | нет: портит ровно 49-е сутки |
| start = millis() + N | метка в будущем | ломается при переходе | не советую: сдвигайте метку в loop() |
| разность меток с интервалом дольше круга | полный круг не помещается | разность врёт | только со счётчиком переходов или RTC |
Теперь граница, о которой честно говорят реже, чем о самом переполнении: разность millis() - start измеряет интервалы до 49,7 суток. Полив раз в два месяца из одной разности не соберёшь — за два месяца счётчик успеет сделать полный круг, и разность перепутает «было» и «сейчас». Лечение двоякое: завести свой счётчик переходов, который тикает при склейке, или поставить часы реального времени с календарём — у DS3231 сутки, дни недели и месяцы уже встроены, и батарейка держит их дешевле, чем мои нервы при отладке.
Честное мнение в придачу: подавляющее большинство таймеров в хобби-проектах живут в пределах суток — мигалки, опросы датчиков, выдержки, антидребезг. Для них переполнение — мифическое событие, о котором нужно знать ровно для того, чтобы не портить код страхом: не добавлять принудительную перезагрузку раз в сутки, не обвешивать каждое сравнение проверками на переход. Правильный паттерн один, он короткий, и он не требует ни одной лишней строки. А цифры в журнале при отладке снимаются через первый разговор с монитором порта — там же привычка печатать счётчик и наблюдать его живьём.
Если хочется увидеть склейку своими глазами, не ждите 49 суток — ускорьте круг. Заведите на столе счётчик поменьше: unsigned long, который каждый миллисекундный тик умножается на десять, — и его 32 бита обернутся за пять суток; для стенда хватит и восьмибитной метки, обёртывающейся каждые 255 миллисекунд. Механика та же до бита: разность двух меток остаётся верной и через склейку, сумма — ломается. Час такой проверки стоит дешевле, чем один ночной звонок от проекта, который я когда-то жалел переписать.
- millis() — unsigned long на 32 бита: потолок 4294967295 мс = 49,71 суток, после счётчик склеивается в ноль, не перезагружая плату
- Разность millis() - start корректна сквозь переход по беззнаковой модульной арифметике — пока интервал короче 49,7 суток
- Числовой пример: start = 4294960000, после перехода millis() = 704, беззнаковая разность даёт ровно 8000
- Ловушка: if (millis() > start + N) — сумма переполняется и стреляет ложным срабатыванием у границы; пиши разность слева
- Интервалы длиннее 49,7 суток одной разностью не измерить: счётчик переходов или RTC с календарём
- 1. Через сколько времени millis() на Uno проходит через максимум?
- 2. Почему разность millis() - start >= 1000 работает и после перехода счётчика через максимум?
- 3. Тайник заведён меткой start = 4294960000 с интервалом 8000 мс. Счётчик уже перешёл через максимум и показывает 704. Что даст беззнаковая разность millis() - start?
- 4. Почему условие if (millis() > start + 1000) ломается у границы круга?
- 5. Проект должен напоминать о поливе раз в два месяца. Что с таймером на одной разности millis()?
Частые вопросы по теме урока
- Через сколько времени millis() на Uno проходит через максимум?
- millis() возвращает unsigned long: 32 бита миллисекунд — 4294967295, то есть 49,71 суток или 49 суток 17 часов. У micros() те же 32 бита, но микросекунд — всего 71,6 минуты. 49,7 суток — 2 в 32-й степени миллисекунд
- Почему разность millis() - start >= 1000 работает и после перехода счётчика через максимум?
- Стандарт языка гарантирует для беззнаковых типов арифметику по модулю: украденные у потолка миллисекунды возвращаются в разность. Работает, пока реальный интервал меньше 49,7 суток. беззнаковая арифметика идёт по модулю 2 в 32-й степени: разность верна, пока интервал короче полного круга
- Тайник заведён меткой start = 4294960000 с интервалом 8000 мс. Счётчик уже перешёл через максимум и показывает 704. Что даст беззнаковая разность millis() - start?
- 704 минус 4294960000 в беззнаковой 32-битной арифметике — ровно 8000: разность снимается по модулю 2 в 32-й степени. Школьное вычитание дало бы минус, но unsigned знака не имеет. 8000
- Почему условие if (millis() > start + 1000) ломается у границы круга?
- При start = 4294966800 сумма даёт 4294967800, что склеивается в 504, — и условие срабатывает мгновенно. Правильная форма вычитает: if (millis() - start >= 1000). сумма start + 1000 переполняется, и millis() сравнивается с маленьким числом
- Проект должен напоминать о поливе раз в два месяца. Что с таймером на одной разности millis()?
- Два месяца длиннее круга millis(): одна разность перепутает «было» и «сейчас». Либо счётчик переходов, либо часы реального времени — у DS3231 календарь уже есть. считать переходы счётчика отдельно или взять RTC с календарём