Контроллер полива на балконе дожил до пятидесятого дня и тихо сошёл с ума: реле щёлкает, а насос не включается. Перезагрузка лечила ровно на очередные 49 дней. Я неделю искал виноватого в проводке, пока не перечитал описание millis() и не наткнулся на фразу, которая тогда показалась мне буквоедством: «значение переполняется примерно через 49,7 дня». Оказалось, это не мелкий шрифт, а весь смысл: число, которое возвращает millis(), живёт в 4 байтах беззнакового типа, и на потолке 4 294 967 295 оно не останавливается — сворачивается в ноль, как одометр старой машины.
С тех пор любой тип в моих скетчах проходит три вопроса: сколько байтов занимает, где у него потолок и что происходит за потолком. int, long и unsigned — не три синонима для «целое число», а хранилища разного объёма с разными потолками, и цена просчёта — мёртвый проект в самый неподходящий день. Ниже разберём каждый, переведём байты в секунды и дни и найдём места, где и byte — уже роскошь.
Объявлять переменные вы уже умеете: базовый урок о переменных и типах закрыл вопрос «где объявить и как присвоить». Здесь мы спускаемся этажом ниже — в байты, разряды и переходы через край.
Сколько вмещает каждый тип и на сколько хватает
На Uno и Nano стоит ATmega328 — 8-битный процессор с 2 КБ ОЗУ. Для него int занимает 2 байта и вмещает от −32768 до 32767, и это первая ловушка при переносе: на ESP32 тот же int — уже 4 байта, код компилируется, а потолки молча меняются. long — 4 байта со знаком, примерно плюс-минус 2,1 миллиарда. unsigned long — те же 4 байта без минуса: от 0 до 4 294 967 295. millis() возвращает именно unsigned long, и до первого перехода через край это число протянет 49,71 дня — 49 суток 17 часов. Родственный micros() живёт в тех же 4 байтах, но тикает в тысячу раз быстрее: его хватает на 71,6 минуты.
Арифметика вокруг millis() нагляднее любого учебника. Хранить его в int — значит обрезать 4 байта до двух: через 32,8 секунды значение перевалит за 32767 и уйдёт в минус. В unsigned int влезает 65,5 секунды. Ни один таймер дольше минуты на таких хранилищах не выживет, и компилятор не скажет ни слова — присваивание long в int он считает вашим осознанным решением.
unsigned long встречается чаще, чем кажется: pulseIn в дальномере возвращает длительность эха тоже в нём, и таймауты там ползут к миллионам микросекунд. Правило, к которому я пришёл на своих скетчах: всё, что связано со временем от millis() и micros(), живёт только в unsigned long. Отступать от этого — торговаться с физикой.
Размеры проверяются не по памяти, а оператором sizeof: sizeof(int) на Uno печатает 2, sizeof(long) — 4, sizeof(byte) — 1. Две такие печати в setup() на новой плате экономят вечер тестов на границах — особенно перед переносом таймерного кода с ESP32. Заодно держите в голове бюджет: 32 КБ флеша — это для программы, а 2 КБ ОЗУ — для переменных, стека и строк; каждый сэкономленный глобальный байт уходит туда, где его действительно ждут.
Проверка на живом скетче занимает минуту: заведите глобальную переменную того типа, что выбрали для таймера, печатайте её в мониторе порта раз в секунду и дождитесь потолка. Для byte это 255 шагов по секунде — четыре с половиной минуты и вы своими глазами видите, как число прыгает в ноль. Экономия получаса на эксперимент оборачивается пониманием, которое не даст вам наступить на те же грабли с long через пару лет.
| Тип | Байт | Диапазон | В millis() это |
|---|---|---|---|
| byte | 1 | 0–255 | 0,26 с |
| int | 2 | −32768…32767 | 32,8 с |
| unsigned int | 2 | 0–65535 | 65,5 с |
| unsigned long | 4 | 0–4 294 967 295 | 49,7 суток |
void setup() { Serial.begin(9600); int t = 32767; // потолок int на Uno t = t + 1; // за потолком Serial.println(t); // -32768 byte b = 255; // потолок byte b = b + 1; Serial.println(b); // 0: беззнаковый одометр unsigned long m = millis(); // только так хранят millis() Serial.println(m);} void loop() {}На плате вы увидите −32768 после сложения 32767 и единицы, и ноль после байта 255. Для int выход за потолок — территория неопределённого поведения: формально компилятор вправе сделать что угодно, на практике avr-gcc просто теряет старший бит, и число прыгает в минус. Беззнаковые типы ведут себя предсказуемо: арифметика по модулю 256, 65536 или 4 294 967 296 — тот самый одометр, который честно прокручивается. Поэтому переполнение unsigned — не авария, а часть замысла, если вы его учли заранее.
Я обычно вешаю в таких проектах маленький индикатор возраста: каждые 60 секунд печатаю millis() / 1000 и счётчик дней millis() / 86400000UL — и не гадаю по Serial, день 47-й или 50-й. Суффикс UL в делителе не украшение: 86 400 000 в int не влезает, и без UL компилятор попробует посчитать в 16 битах. Одна строка в setup и одна в loop — и переполнение ловится в момент, когда счётчик дней рискует свернуться, а не по факту сухого насоса.
В симуляторе ниже байт переполняется честно: движок заворачивает его по модулю 256, как настоящий ATmega328. А вот int в симуляторе шире двух байт Uno, так что его катастрофу на отметке 32767 смотрите в код-блоке на реальной плате — там она воспроизводится за один запуск.
Где хватает byte, и грабли 49-го дня
byte хватает чаще, чем кажется. Номер пина (на Uno максимум 19), счётчик нажатий до 255, яркость ШИМ, минуты и секунды, индексы журнала на сотню записей — всё это живёт в одном байте. Экономия ощутима в массивах: журнал показаний на 100 значений займёт 100 байтов, если показания byte, и 200, если int. На плате с 2 КБ ОЗУ это разница между «работает» и «внезапно упёрся в потолок» на пятом датчике. Целые двоичные часы на восьми светодиодах целиком построены на байтах — побитовый разбор показывает, как один байт разворачивается в восемь ламп.
Есть места, где byte задаёт сам интерфейс: analogWrite принимает яркость строго 0–255, счётчик витков цикла на сотню повторений и позиция в меню из пяти пунктов тоже не перерастут байт. Выбирая его, вы одной буквой сообщаете читателю: это число не вырастет. Для восьми флагов сразу есть приём тоньше — побитовые операции упаковывают их в один байт через bitWrite и маску, а bool в Arduino C++, кстати, занимает тот же 1 байт и тратит все восемь бит на ноль и единицу. Паковать флаги битами стоит, когда их десятки; в остальных случаях читаемость дороже сэкономленных битов.
Мой расклад по умолчанию: локальный счётчик и флаги — byte или bool, накопители времени — unsigned long, всё остальное — int. Экономией байта в каждой переменной я не страдаю, но по типу в объявлении сразу виден замысел, и через полгода чужой код читается без археологии. Перенос на ESP32 этот расклад выдерживает: unsigned long там тоже 4 байта, а вот int становится 4-байтовым, и таймер в int случайно даже работает — до первого возврата на Uno.
Переход через ноль пугает сильнее, чем заслуживает. Если таймер написан вычитанием — if (millis() - start >= interval) — переход проходит незаметно: беззнаковое вычитание даёт верную разницу даже когда millis() уже свернулся, а start ещё у прежнего края. Вся классическая схема планировщика держится именно на этом вычитании.
Ломается другой вариант — сравнение вперёд: if (millis() > start + interval). Когда start большой, сумма start + interval сама уходит за потолок unsigned long, замыкается по модулю, и порог однажды оказывается позади — ветка перестаёт срабатывать, хотя код не менялся ни на символ. Балконный насос молчал на 50-й день именно поэтому. Мораль проста: вычитайте, не сравнивайте вперёд.
Модуль видно карандашом. Пусть start = 4 294 967 000, а после перехода now = 300. Вычитание now - start даёт −4 294 966 700, что по модулю 4 294 967 296 превращается в 396 — ровно сколько прошло. Сравнение вперёд складывает start + interval в 4 294 968 000, по модулю это 704 — и порог, который давно пора пройти, впереди now на 404 единицы. Один такой расчёт на бумаге — и рука сама пишет вычитание.
Обратная ловушка живёт в знаке: ноль минус один в беззнаковом типе — не −1, а максимум для размера, у unsigned int на Uno это 65535. Отсюда классика вечного обратного цикла for (unsigned int i = n - 1; i >= 0; i--): условие i >= 0 для беззнакового всегда истинно, и индекс шагает по чужой памяти. Я развожу типы жёстко: индексы — int, размеры и время — unsigned long, и никогда не смешиваю их в одном сравнении.
- 1. Какой тип возвращает millis()?
- 2. Сколько байт занимает int на Arduino Uno?
- 3. byte b = 254; b = b + 3; что теперь в b?
- 4. Какая запись таймера переживёт переход millis() через ноль?
- 5. Проект отсчитывает сутки от millis(). Что хранит «прошло мс»?
- millis() возвращает unsigned long: 4 294 967 295 мс — 49 суток 17 часов, потом одометр сворачивается в ноль.
- На Uno int — 2 байта (−32768…32767), byte — 1 байт, long и unsigned long — 4 байта; на ESP32 int уже 4-байтовый.
- Хранить millis() в int — 32,8 секунды жизни таймера, в unsigned int — 65,5; компилятор молчит.
- Таймер переживает переполнение, если считать вычитанием: millis() - start >= interval.
- byte хватает для пинов, счётчиков и яркости: журнал на 100 показаний — 100 байтов вместо 200.
Частые вопросы по теме урока
- Какой тип возвращает millis()?
- millis() объявлен как unsigned long: 4 байта без знака, потолок 4 294 967 295 мс — 49 суток 17 часов, потом значение сворачивается в ноль. unsigned long
- Сколько байт занимает int на Arduino Uno?
- На 8-битном ATmega328 int — 2 байта (−32768…32767). На ESP32 — уже 4: код переносится, потолки меняются, проверяйте при портировании. 2 байта
- byte b = 254; b = b + 3; что теперь в b?
- Беззнаковый байт живёт по модулю 256: 254 + 3 = 257, минус виток одометра — 1. Компилятор об этом не предупредит. 1
- Какая запись таймера переживёт переход millis() через ноль?
- Беззнаковое вычитание даёт правильную разницу на любом витке одометра. Сравнение вперёд ломается, когда сумма уходит за потолок. millis() - start >= interval
- Проект отсчитывает сутки от millis(). Что хранит «прошло мс»?
- Сутки — 86 400 000 мс: ни int, ни unsigned int не вмещают. Только 4-байтовый unsigned long с потолком 4 294 967 295. unsigned long