Переповнення millis() в Arduino і 16-бітний int на AVR: як уникнути збою через 49 днів

Переповнення millis() в Arduino і 16-бітний int на AVR

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

Переповнення millis() в Arduino і 16-бітний int на AVR

Що таке 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
char8 біт8 біт
int16 біт32 біти
long32 біти32 біти
long long64 біти64 біти
float32 біти32 біти
double32 біти (як 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).

Читайте також

Related Posts

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься.