Поливалку на балконе я собирал в выходной, а RTC-модуль ехал с маркетплейса две недели. Расписание нужно было уже завтра: в семь утра — десять секунд полива, к полудню — притеняющая шторка, и рассада на подоконнике жила ровно до первой жары. Первая попытка обойтись без модуля была наивной до слёз: я решил «ловить» минуту условием if (millis() % 60000 == 0). Поливалка молчала сутки, потому что это условие истинно один-единственный цикл — ровно на той миллисекунде, когда счётчик кратен минуте, — и если в этот миг loop был занят чем-то другим, момент проскакивал безвозвратно.
Тупик научил главному: millis() нельзя «ловить» на совпадения — от него нужно считать. Часы без модуля строятся в три хода: один раз завести часы (запомнить, который час при старте), посчитать, сколько секунд прошло с завода, и сложить. Всё. Ни одного ожидания совпадения, ни одной потери минуты — время выводится арифметикой в любой момент.
Этот урок — про механику таких часов и про их честную цену. Завод возьмём из Serial-монитора, как у микроволновки; посчитаем часы, минуты и секунды делением с остатком; поймаем переход через полночь; а в конце поговорим о том, почему такие часы годятся для «полить утром» и не годятся для «включить ровно в 07:00:00».
Завод и счёт
Механика помещается в две переменные. seedSec — завод: сколько секунд от полуночи показывал момент старта, например 86 385 для 23:59:45. seedMs — показание millis() в тот же миг. Дальше каждый цикл: прошло = (millis() - seedMs) / 1000, и total = seedSec + прошло — текущее время в секундах от полуночи. Вычитание двух беззнаковых не боится переполнения millis() через 49,7 суток — этот приём с разностями уже был разобран в уроке про millis против часов, здесь он работает так же.
Из total часы, минуты и секунды достаются делением и остатком: h = (total / 3600) % 24, m = (total / 60) % 60, s = total % 60. Номер суток — days = total / 86400: когда счёт переваливает за полночь, часы обнуляются, а days становится единицей. Ничего не «срабатывает» и не «пропускается»: время — производная от счётчика, её можно вычислить хоть каждый цикл.
Откуда берётся завод — три честных варианта. Первый — человек в Serial-мониторе, как в симуляторе ниже. Второй — макрос __TIME__ из времени компиляции, о нём отдельная заметка под скетчем. Третий — разовый запрос точного времени из сети на платах с Wi-Fi, как в уроке про первый Wi-Fi на ESP32. После завода часы живут на millis() автономно до ближайшей перезагрузки.
long seedSec = 0; // завод: секунды от полуночиunsigned long seedMs = 0; // millis() в момент заводаunsigned long lastTick = 0; void setup() { Serial.begin(9600); Serial.println("Chasy bez RTC. Zavod: ch m s"); Serial.println("Naprimer: 23 59 50");} void loop() { if (Serial.available() > 0) { int h = Serial.parseInt(); int m = Serial.parseInt(); int s = Serial.parseInt(); if (h >= 0 && h < 24 && m >= 0 && m < 60 && s >= 0 && s < 60) { seedSec = h * 3600L + m * 60L + s; seedMs = millis(); Serial.println("Ustanovleno."); } else { Serial.println("Diapazon: 0-23 0-59 0-59"); } } if (millis() - lastTick < 1000) return; lastTick += 1000; long total = seedSec + (millis() - seedMs) / 1000; int h = (total / 3600) % 24; int m = (total / 60) % 60; int s = total % 60; int days = total / 86400; Serial.print("h="); Serial.print(h); Serial.print(" m="); Serial.print(m); Serial.print(" s="); Serial.print(s); Serial.print(" days="); Serial.print(days); Serial.print(" uptime="); Serial.println((millis() - seedMs) / 1000);}Пара мест, на которых спотыкаются. Чтение команды — три Serial.parseInt() подряд за одним available(): так цифры из монитора разбирались в уроке про числа из монитора порта. Валидация диапазонов не косметика, а защита содержимого: без неё случайно введённые 25 часов или 90 минут молча записываются в завод, и часы начинают показывать арифметически честную, но бессмысленную чепуху. И заметьте: при каждой установке обновляется не только seedSec, но и seedMs — иначе новые часы «начнут» со старым счётчиком и прыгнут на время работы платы.
Печать раз в секунду построена на проверке lastTick, а не на delay(): мигалка на том же принципе разбиралась в уроке про millis вместо delay. Сравните скетч с будильником из урока про часы с LCD и RTC: там время приходило из чипа и сравнение шло по часам и минутам, здесь та же логика питается от арифметики. Расписание на таких часах пишется одинаково: если сейчас h == 7 и m == 0 — пора.
Цена решения
Теперь минус, о котором нельзя молчать: тактовый генератор Arduino — керамический резонатор с допуском до ±0,5 %. На худшей плате это до семи минут ухода в сутки; мои три Уно за неделю показали 3,5–5 минут в день. За месяц миллис-часы разъезжаются с реальностью на часы, и никакая прошивка этого не лечит — источник опорной частоты просто хуже. Единственная подстройка — периодический завод заново.
Отсюда граница применимости, которую я для себя сформулировал так: millis-часам доверяю «полить утром» и «включить подсветку на пару часов» — там сдвиг на пять минут никто не заметит. А «ровно в 07:00:00», журналы с метками и всё, что сверяется с внешним миром, — только на RTC-чип или на сетевое время. Для ESP32-проектов разумная связка — NTP раз в сутки и millis() между синхронизациями: при живой сети уход доли секунды, а после выдёргивания роутера часы честно превращаются в тыкву до reconnect.
В таблице ниже — все три источника времени рядом. Выбирайте не «самый точный», а соразмерный задаче: модуль за 150 рублей против полчаса возни с заводом — спорить надо не о точности, а о том, что случится с проектом, когда свет мигнёт.
| Источник | Переживает отключение | Точность за месяц | Когда беру |
|---|---|---|---|
| RTC-чип с батарейкой | да — годы на CR2032 | секунды у DS3231, минуты у DS1307 | расписания, журналы, будильники |
| Часы на millis() | нет — после сброса завод заново | минуты (±0,5% резонатора) | пока модуль едет, простые поливы |
| NTP по сети | нет — без сети слеп | доли секунды при живой сети | ESP32 с роутером под рукой |
В симуляторе — те же часы вживую. Движок заводит их из своего системного времени в момент запуска (это снимок, дальше он не тикает — потому эталонной сверкой в демо служит сам ход секунд), а вы можете перевести стрелки командой из монитора. На плате всё то же самое, только завод вы делаете один раз и вручную.
- Часы без модуля = завод один раз (seedSec + seedMs) плюс арифметика: (millis() - seedMs) / 1000
- Поля времени — деление и остаток: (total / 3600) % 24, (total / 60) % 60, total % 60; сутки считаются как total / 86400
- Завод: команда в Serial с валидацией диапазонов, строка __TIME__ или разовый NTP-запрос; ловить минуты через millis() % 60000 == 0 нельзя — момент проскакивает
- Цена: резонатор Уно уходит до ±0,5%, это до 7 минут в сутки — millis-часам верят «полить утром», но не «ровно в 07:00:00»
- Любая перезагрузка возвращает часы к заводскому значению: надолго одни millis-часы не оставляют
- 1. Что означает выражение (millis() - seedMs) / 1000 при заводе seedMs?
- 2. Как из total (секунды от полуночи) достать часы?
- 3. Почему условие if (millis() % 60000 == 0) пропускает минуты?
- 4. Сколько уходят керамические часы Uno в худшем случае?
- 5. Часы завели из макроса __TIME__. Что с ними станет после перепрошивки?
Такие часы — не replacement для RTC, а достойный костыль на время доставки модуля или для задач, где пять минут сдвига не значат ничего. В финальном уроке раздела всё встанет на места: настоящий DS3231 поведёт расписание теплицы, реле включат свет и насос, а датчики почвы и воздуха не дадут утопить рассаду. Соберём весь раздел в одном скетче.
Частые вопросы по теме урока
- Что означает выражение (millis() - seedMs) / 1000 при заводе seedMs?
- Это «прошло с завода». Складывая его с seedSec, получаем текущее время в секундах от полуночи — основа всех программных часов. Секунды, прошедшие с момента завода часов
- Как из total (секунды от полуночи) достать часы?
- Деление на 3600 даёт полные часы, остаток от деления на 24 срезает пройденные сутки. Минуты — (total / 60) % 60, секунды — total % 60. (total / 3600) % 24
- Почему условие if (millis() % 60000 == 0) пропускает минуты?
- Совпадение живёт ровно одну миллисекунду. Если в этот миг loop занят чем-то ещё, минута потеряна. Поэтому время считают арифметикой, а не ловят совпадения. Условие истинно один цикл, и loop может проскочить его мгновение
- Сколько уходят керамические часы Uno в худшем случае?
- Допуск керамического резонатора — до ±0,5%. Мои платы за неделю дали 3,5–5 минут ухода в день. Термокомпенсирован кварц DS3231, а не генератор Уно. До ±0,5% — до семи минут в сутки
- Часы завели из макроса __TIME__. Что с ними станет после перепрошивки?
- __TIME__ подставляется компилятором на компьютере. Перезаливка прошивки вечером выставит часы на утреннее время сборки — завод из макроса только стартовая точка. Часы сбросятся на момент компиляции, а не включения