Часы я собирал из чистого упрямства: для теплицы нужен был экран со временем последнего полива, а модуль часов ещё ехал с маркетплейса. millis() к тому моменту был освоен, деление на тысячу — дело техники. Первые сутки часы радовали: секунды тикали, формат был как на вокзале. На третьи сутки я сверил их с телефоном — отстали на четыре минуты. Ещё через сутки — на девять. В коде ошибок не было ни одной: часы честно считали секунды из millis(), просто резонатор на плате тикал чуть медленнее настоящего времени.
Этот урок про две вещи сразу. Первая — механика: как из счётчика миллисекунд собрать красивое время с ведущими нулями, чтобы экран показывал 09:07:05, а не 9:7:5, и почему перерисовывать его нужно раз в секунду, а не в каждом обороте loop. Вторая — честные пределы: куда уходит точность, зачем нужен тип unsigned long и что случится на сорок девятые сутки. Модуль реального времени у нас будет в своих уроках, но собирать время из millis() нужно уметь в любом случае — это и метка аптайма на экране, и таймеры, и логи событий. Если вы только открыли дисплеи, сначала гляньте урок про первый текст по I2C — там схема из четырёх проводов.
Секунды из millis(): арифметика и пределы
millis() возвращает unsigned long — беззнаковое 32-битное число миллисекунд с момента включения. Максимум 4 294 967 295, то есть 49 суток и 17 часов, после чего счётчик обнуляется и часы с ним. Отсюда первое правило: любые переменные, в которых вы храните millis() или его производные, должны быть unsigned long. Ещё жёстче с int: он держит максимум 32 767, и если написать int sec = millis() / 1000, переменная переполнится через 32 секунды — часы начнут показывать и отрицательное время тоже, и ловить это будете вы, а не компилятор.
Сама арифметика умещается в четыре строки. Всё измеряем в секундах: total = millis() / 1000. Дальше чистые остатки: секунда — total % 60, минута — (total / 60) % 60, час — (total / 3600) % 24. Деление нацело и остаток раскладывают большое число по разрядам, как школьное деление с уголком, и каждый разряд живёт в своём понятном диапазоне. Формулы не боятся ни минут, ни суток — единственный честный скачок случится на 49-е сутки, когда millis() обнулится и время на экране перескочит в ноль. Для теплицы это несущественно, для сервера аптайма — уже повод поставить RTC-модуль, о чём у нас есть отдельный разговор про millis и настоящие часы. Проверить арифметику можно без железа: подставьте в формулы любое число секунд и убедитесь, что разряды сходятся — я всегда гоняю такие проверки на бумажке, прежде чем писать код.
| Величина | Формула от millis() | Диапазон |
|---|---|---|
| Всего секунд | total = millis() / 1000 | 0 … 4 294 967 (49,7 суток) |
| Секунда | total % 60 | 0–59 |
| Минута | (total / 60) % 60 | 0–59 |
| Час | (total / 3600) % 24 | 0–23 |
Теперь вёрстка. Число 7 и число 07 занимают разную ширину, и формат времени на этом ломается: колонки двоеточий ползут по экрану. Лечится функцией на три строки: перед печатью числа меньше десяти печатаем ведущий нуль. Это родня затирания пробелами из урока про печать чисел и нулей — обе техники держат ширину ячейки постоянной, просто работают на разные случаи. Собирать строку целиком через String я бы не стал — на Uno всего два килобайта ОЗУ, а каждая конкатенация плодит новый буфер в куче; за сутки перерисовок фрагментация накапливается. Печать по частям из print() и двоеточий стоит ноль лишней памяти и выглядит на экране один в один.
Второй пункт дисциплины — частота перерисовки. Экран обновляем только когда сменилась отображаемая секунда: сравнили total с показанным, совпало — молчим. Печать строки по I2C небыстрая, а в loop без задержек она случилась бы тысячи раз в секунду — трата тактов и плавающий курсор без всякой пользы. Заодно повесьте на экран что-то живое: в моих часах светодиод мигает на чётной секунде — видно, что скетч жив, даже если смотреть из другого конца комнаты.
#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); int shownSec = -1; // что сейчас на экране void printTwo(int v) { if (v < 10) lcd.print("0"); // ведущий нуль: 07 вместо 7 lcd.print(v);} void drawTime(int total) { int s = total % 60; int m = (total / 60) % 60; int h = (total / 3600) % 24; lcd.setCursor(0, 0); printTwo(h); lcd.print(":"); printTwo(m); lcd.print(":"); printTwo(s); lcd.print(" "); // запас на случай широких цифр} void setup() { lcd.init(); lcd.backlight();} void loop() { int total = millis() / 1000; if (total != shownSec) { // перерисовка только при смене секунды shownSec = total; drawTime(total); }}А как же выставить текущее время? Часами без RTC управляют кнопками: одна прибавляет часы, другая минуты — и здесь всплывает переменная сдвига. Храните не «текущее время», а поправку в секундах: total = millis() / 1000 + offset. Кнопка «плюс минута» добавляет к offset шестьдесят — и формат сам переносит минуты в часы через свои остатки, трогать его не нужно. Скажу честно: после подключения кнопок настройки такие часы становятся почти настоящими, и именно в этот момент появляется соблазн повесить на них будильник. Не спешите: один таймер loop ещё держится, а два-три независимых расписания на millis() в одном скетче превращают код в кашу — для этого лучше подошёл бы планировщик задач на millis, где каждое событие живёт своей строкой и своим интервалом. А сброс поправки в ноль — это просто offset = 0 по длинному нажатию: три строки кода, которые сэкономят вам перекопку всего скетча.
Тренажёр: часы с кнопкой паузы
В тренажёре к каркасу добавлена кнопка на D2 — пауза и пуск — с окном тишины из урока про счётчик: один щелчок меняет режим ровно один раз, сколько бы дребезг ни производил фронтов. Светодиод на D12 тикает вместе с секундами: горит на чётной, гаснет на нечётной. При паузе нижняя строка показывает PAUZA, секунды замирают, светодиод перестаёт мигать — время замерло вместе с экраном. Отпустите кнопку — счёт продолжится ровно с той же секунды, без перескоков: пауза хранит замороженное значение, а не стирает его. Попробуйте поймать момент смены минуты: формат должен перейти 07:59:59 в 08:00:00 без сдвига колонок — это и есть работа ведущих нулей.
- 1. Какого типа значение возвращает millis() и когда оно переполняется?
- 2. Почему нельзя хранить millis() / 1000 в переменной int?
- 3. Что делает printTwo с числом меньше десяти?
- 4. Как часто стоит перерисовывать экран часов?
- 5. Часы за сутки ушли на четыре минуты. Сколько это примерно в процентах?
- Время из millis(): total = millis() / 1000, дальше % 60 и деления — разряды сами раскладываются по остаткам
- millis() живёт 49 суток 17 часов; int для секунд мал уже на 32-й секунде — только unsigned long
- Ведущие нули — if (v < 10) lcd.print(0) перед числом: экран ждёт 09:07:05, а не 9:7:5
- Перерисовка — один раз на смену секунды; печать в каждом loop жжёт такты и шину I2C зря
- Резонатор Uno уходит на 0,2–0,5 % — до семи минут в сутки; настенным часам нужен DS3231
Частые вопросы по теме урока
- Какого типа значение возвращает millis() и когда оно переполняется?
- 32 бита без знака — 4 294 967 295 мс, это 49 суток 17 часов. После обнуления часы с ним перескочат в ноль. unsigned long — через 49 суток и 17 часов
- Почему нельзя хранить millis() / 1000 в переменной int?
- Максимум int — 32 767, а секунд через полминуты уже больше. Часы начнут показывать мусор, и компилятор молча это пропустит. Int переполнится после 32 767 — через полминуты работы
- Что делает printTwo с числом меньше десяти?
- Ведущий нуль держит ширину ячейки постоянной, поэтому двоеточия и колонки времени стоят как приклеенные. Печатает ведущий нуль: 07 вместо 7
- Как часто стоит перерисовывать экран часов?
- Сравнение с показанной секундой экономит такты и шину I2C: печать происходит раз в секунду, а не тысячи раз. Только когда сменилась отображаемая секунда
- Часы за сутки ушли на четыре минуты. Сколько это примерно в процентах?
- Четыре минуты из 1440 — примерно 0,28 %. Это типичный уход керамического резонатора Uno; DS3231 держит на порядок лучше. Около 0,3 %