Переповнення millis() — класичний баг Arduino-проєктів: пристрій бездоганно працює тижнями, а приблизно через 49,7 доби реле раптом клацає без причини, таймер «зависає» або дисплей показує нісенітницю. Поруч живе ще одна пастка — 16-бітний int на AVR, через який вираз 60 * 1000 перетворюється на від’ємне число. Розбираємо, чому так стається, як писати код, якому переповнення не страшне, і як перевірити це без очікування півтора місяця.

Що таке millis() і коли він переповнюється
Функція millis() повертає кількість мілісекунд від старту мікроконтролера як unsigned long — на AVR це 32-бітне беззнакове число. Максимум, який воно вміщує, — 4 294 967 295. Наступна мілісекунда повертає лічильник у нуль:
| Функція | Тип | Переповнення через |
|---|---|---|
| millis() | uint32_t, мілісекунди | 2³² мс ≈ 49 діб 17 год 2 хв 47 с |
| micros() | uint32_t, мікросекунди | 2³² мкс ≈ 71 хв 35 с |
Саме переповнення — не помилка: це нормальна поведінка беззнакової арифметики. Проблему створює код, який його не враховує. Для micros() вона проявляється вже за годину роботи, тож помилки з ним помітити легше.
Ще одна деталь AVR: millis() рахується в перериванні переповнення Timer0, яке на 16 МГц спрацьовує кожні 1,024 мс. Тому лічильник зрідка перестрибує одне значення. Перевірка рівності millis() == 5000 може просто ніколи не спрацювати — порівнюйте лише через «більше або дорівнює».
Як ламається неправильна перевірка
Найпоширеніший варіант — додати інтервал до збереженого часу:
unsigned long start = millis();
// ...
if (millis() >= start + 1000) { // ПОМИЛКА біля переповнення
doSomething();
}
Нехай start = 4 294 967 000, тобто до переповнення лишилося 296 мс. Тоді start + 1000 переповнюється і дорівнює 704. Будь-яке поточне значення 4 294 967 001…4 294 967 295 більше за 704, і умова спрацює одразу, а не через секунду. Інший варіант помилки — while (millis() < start + 1000) {}: цикл завершиться передчасно або, навпаки, зависне до наступного переповнення.
Правильно: порівнювати різницю
Беззнакове віднімання в C виконується за модулем 2³², тому різниця now – start завжди дорівнює реальному часу, що минув — навіть якщо між ними стався перехід через нуль:
unsigned long last = 0;
const unsigned long PERIOD = 1000;
void loop() {
unsigned long now = millis();
if (now - last >= PERIOD) { // ПРАВИЛЬНО: працює завжди
last = now;
doSomething();
}
}
Перевіримо на числах: last = 4 294 967 000, після переповнення now = 200. Різниця now – last = 200 + (2³² − 4 294 967 000) = 200 + 296 = 496 мс. Саме стільки й минуло.
Правила, які роблять код стійким до переповнення:
- Час зберігайте лише в unsigned long або uint32_t. Не в int, не в long зі знаком і не у float.
- Порівнюйте різницю з інтервалом, а не абсолютні значення часу.
- Інтервал має бути меншим за 49,7 доби. Для довших періодів рахуйте цілі доби окремим лічильником.
- Не перевіряйте точну рівність на кшталт millis() == ….
last = now чи last += PERIOD
Обидва варіанти стійкі до переповнення, але поводяться по-різному:
- last = now — наступний період починається з моменту фактичного виконання. Якщо loop() затримався на 30 мс, усі наступні події зсунуться на 30 мс. Для опитування кнопок і датчиків це нормально.
- last += PERIOD — події йдуть рівно кожні 1000 мс без накопичення похибки, як у годиннику. Але якщо програма зависла на кілька періодів, функція виконається кілька разів поспіль, «наздоганяючи» пропущене.
// рівномірний крок без дрейфу, із захистом від серії «наздоганяння»
if (now - last >= PERIOD) {
last += PERIOD;
if (now - last >= PERIOD) last = now; // відстали більше ніж на період
tick();
}
Як перевірити код без очікування 49 днів
У ядрі Arduino для AVR лічильник зберігається в змінній timer0_millis. Її можна виставити близько до переповнення і подивитися, як поводиться програма:
extern volatile unsigned long timer0_millis;
void setup() {
Serial.begin(115200);
noInterrupts();
timer0_millis = 4294967295UL - 5000; // до переповнення 5 секунд
interrupts();
}
Через п’ять секунд після старту відбудеться переповнення. Виводьте значення в Serial і стежте в онлайн COM-порт терміналі, що таймери спрацьовують з правильним інтервалом. Цей прийом працює лише для AVR-плат (Uno, Nano, Mega); на ESP32 і STM32 лічильник реалізований інакше, там логіку зручніше перевіряти юніт-тестом на ПК з uint32_t.
Пастка 16-бітного int на AVR
На 8-бітних AVR тип int займає 16 біт і вміщує лише від −32 768 до 32 767. На ESP32, STM32 і Arduino Due int 32-бітний. Через це код, що працював на ESP32, на Arduino Nano може зламатися:
| Тип | AVR (Uno, Nano, Mega) | ESP32, STM32, ARM |
|---|---|---|
| char | 8 біт | 8 біт |
| int | 16 біт | 32 біти |
| long | 32 біти | 32 біти |
| long long | 64 біти | 64 біти |
| float | 32 біти | 32 біти |
| double | 32 біти (як float) | 64 біти |
Типові помилки:
1. Збереження millis() в int. Значення обрізається до 16 біт і вже через 32,8 с стає від’ємним:
int t = millis(); // ПОМИЛКА: через 32 767 мс t < 0
unsigned long t = millis(); // правильно
2. Обчислення констант у int. Компілятор рахує вираз у типі int, якщо обидва множники вміщуються в int, і результат переповнюється ще до присвоєння:
unsigned long minute = 60 * 1000; // ПОМИЛКА на AVR: у int виходить -5536, а в змінній — 4294961760
unsigned long minute = 60UL * 1000; // правильно: обчислення в unsigned long
#define HOUR (60UL * 60 * 1000) // суфікс UL у першому множнику
Компілятор зазвичай попереджає «integer overflow in expression», але в Arduino IDE попередження за замовчуванням приховані. Увімкніть їх у налаштуваннях: «Попередження компілятора: Усі».
3. Множення сирого значення АЦП. raw * 5000 при raw = 1023 дає 5 115 000 — далеко за межами int:
int mv = analogRead(A0) * 5000 / 1023; // ПОМИЛКА на AVR
long mv = (long)analogRead(A0) * 5000 / 1023; // правильно
4. Нескінченний цикл з uint8_t. Змінна 8-бітного типу ніколи не стане 256:
for (uint8_t i = 0; i < 256; i++) { } // ПОМИЛКА: i переповнюється в 0, цикл нескінченний
for (uint16_t i = 0; i < 256; i++) { } // правильно
Атомарність: 32-бітні змінні з переривань
AVR читає пам’ять по одному байту, тож 32-бітне число читається за чотири операції. Якщо переривання змінить змінну посередині, ви отримаєте «склеєне» значення зі старих і нових байтів. Сам millis() цю проблему вирішує: усередині він на мить вимикає переривання. А от для власних лічильників, які змінюються в обробнику переривання, потрібен захист:
#include <util/atomic.h>
volatile uint32_t pulses; // змінюється в ISR
ISR(INT0_vect) { pulses++; }
uint32_t readPulses() {
uint32_t copy;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { copy = pulses; }
return copy;
}
Те саме стосується 16-бітних змінних на AVR. На 32-бітних ESP32 і STM32 читання uint32_t атомарне, але для 64-бітних значень захист теж потрібен.
Пристрій збоїть після тривалої роботи?
Перевіримо прошивку на переповнення, типи й атомарність або розробимо надійну прошивку для STM32, ESP32 чи AVR.
Звернутися до інженераЧек-лист
- Час зберігається в unsigned long або uint32_t.
- Інтервали перевіряються як now – last >= PERIOD.
- Немає перевірок рівності з millis().
- Константи з множенням мають суфікс UL: 60UL * 1000.
- Проміжні результати з АЦП приводяться до long до множення.
- Багатобайтові змінні з переривань читаються в ATOMIC_BLOCK.
- Код перевірено з timer0_millis, виставленим перед переповненням.
- Увімкнено всі попередження компілятора.
Висновок
Переповнення millis() саме по собі нешкідливе: проблеми створює код, який додає інтервал до часу замість того, щоб порівнювати різницю. Пишіть now – last >= PERIOD, зберігайте час у беззнакових 32-бітних змінних і перевірте прошивку, виставивши timer0_millis перед переповненням. А на AVR пам’ятайте, що int має лише 16 біт: суфікс UL у константах і явне приведення типів позбавлять від помилок, які не видно на ESP32.
Детальніше: millis() — Arduino Reference, Integer overflow (Wikipedia).
