Як я можу впоратися з переворотом millis ()?


73

Мені потрібно читати датчик кожні п’ять хвилин, але оскільки мій ескіз також має інші завдання для виконання, я не можу просто delay()між показаннями. Існує Blink без затримки підручника пропонуючи I коду уздовж цих ліній:

void loop()
{
    unsigned long currentMillis = millis();

    // Read the sensor when needed.
    if (currentMillis - previousMillis >= interval) {
        previousMillis = currentMillis;
        readSensor();
    }

    // Do other stuff...
}

Проблема полягає в тому, що millis()через 49,7 днів повернуться до нуля приблизно через 49,7. Оскільки мій ескіз призначений для роботи довше, мені потрібно переконатися, що перекидання не призведе до того, що мій ескіз вийде з ладу. Я можу легко визначити стан перекидання ( currentMillis < previousMillis), але я не впевнений, що робити тоді.

Таким чином, моє запитання: який би був правильний / найпростіший спосіб впоратися з millis()перекиданням?


5
Примітка до редакції: Це не зовсім моє питання, а підручник у форматі запитання / відповіді. Я був свідком великої плутанини в Інтернеті (включаючи тут) з цієї теми, і цей сайт видається очевидним місцем пошуку відповіді. Ось чому я надаю цей підручник тут.
Едгар Бонет

2
Я б робив previousMillis += intervalзамість цього, previousMillis = currentMillisякби хотів певної частоти результатів.
Ясен

4
@Jasen: Правильно! previousMillis += intervalякщо ви хочете постійної частоти і впевнені, що обробка займає менше interval, але previousMillis = currentMillisдля забезпечення мінімальної затримки interval.
Едгар Бонет

Нам дійсно потрібен FAQ для таких речей.

Один з "трюків", які я використовую, - це полегшити навантаження на ардуїно, використовуючи найменший int, який містить інтервал. Наприклад, з інтервалом максимум 1 хвилину я пишуuint16_t previousMillis; const uint16_t interval = 45000; ... uint16_t currentMillis = (uint16_t) millis(); if ((currentMillis - previousMillis) >= interval) ...
frarugi87

Відповіді:


95

Коротка відповідь: не намагайтеся "впоратися" з переворотом мільйонів, замість цього запишіть безпечний код. Ваш приклад код з підручника прекрасно. Якщо ви спробуєте виявити перекидання, щоб здійснити коригувальні заходи, швидше за все, ви робите щось не так. Більшість програм Arduino мають керувати лише подіями, що тривають відносно невеликі тривалості, як-от відключення кнопки протягом 50 мс або включення нагрівача протягом 12 годин ... Потім, і навіть якщо програма призначена для роботи за один раз, перекидання міліс не повинно викликати занепокоєння.

Правильний спосіб управління (а точніше, уникнути необхідності управління) проблемою перекидання - це думка про unsigned longчисло, повернене millis()в термінах модульної арифметики . Для математично схильних деяке ознайомлення з цією концепцією дуже корисне при програмуванні. Ви можете бачити математику в дії в статті Ніка Гамона millis () overflow ... погана справа? . Для тих, хто не хоче проходити обчислювальні деталі, пропоную тут альтернативний (сподіваюсь, простіший) спосіб його роздуму. Він заснований на простому розмежуванні між моментами та тривалістю . Поки ваші тести включають лише порівняння тривалості, вам слід добре.

Примітка про мікросистему () : Все, про що йдеться тут, millis()стосується однаковою мірою micros(), за винятком того, що micros()котиться кожні 71,6 хвилини, а setMillis()функція, надана нижче, не впливає micros().

Часи, часові позначки та тривалість

Маючи справу з часом, нам доводиться розрізняти принаймні два різних поняття: моменти та тривалість . Момент - точка на осі часу. Тривалість - це тривалість часового інтервалу, тобто відстань у часі між елементами, які визначають початок і кінець інтервалу. Відмінність цих понять не завжди є дуже різкою у повсякденній мові. Наприклад, якщо я скажу « я повернусь через п’ять хвилин », то « п’ять хвилин » - це орієнтовна тривалість моєї відсутності, тоді як « через п’ять хвилин » - це момент мого передбачуваного повернення. Пам’ятайте про розрізнення важливо, оскільки це найпростіший спосіб повністю уникнути проблеми перекидання.

Повернене значення millis()може бути інтерпретоване як тривалість: час, що минув від початку програми до цього часу. Однак ця інтерпретація руйнується, як тільки мільйони переповнюються. Зазвичай набагато корисніше вважати millis()поверненням часової позначки , тобто "мітки", що ідентифікує конкретний момент. Можна стверджувати, що ця інтерпретація страждає від неоднозначності цих міток, оскільки вони повторно використовуються кожні 49,7 днів. Однак це рідко є проблемою: у більшості вбудованих додатків все, що сталося 49,7 днів тому, - це давня історія, яка нас не хвилює. Таким чином, переробка старих етикеток не повинна бути проблемою.

Не порівнюйте часові позначки

Намагатися з’ясувати, яка з двох часових позначок більша за інші, не має сенсу. Приклад:

unsigned long t1 = millis();
delay(3000);
unsigned long t2 = millis();
if (t2 > t1) { ... }

Наївно, можна було б очікувати, що умова if ()завжди буде справжньою. Але це насправді буде помилковим, якщо мільйони переповнюються протягом delay(3000). Мислення t1 і t2 як мітки, що підлягають вторинній переробці, є найпростішим способом уникнути помилки: мітка t1 чітко була присвоєна миті до t2, але через 49,7 днів вона буде перенесена на майбутній момент. Таким чином, t1 відбувається як до, так і після t2. Це повинно дати зрозуміти, що вираз t2 > t1не має сенсу.

Але, якщо це лише ярлики, очевидним є питання: як ми можемо робити з ними будь-які корисні розрахунки часу? Відповідь: обмежившись лише двома розрахунками, які мають сенс для часових позначок:

  1. later_timestamp - earlier_timestampдає тривалість, а саме кількість часу, що минув між більш раннім моментом та пізнішим моментом. Це найкорисніша арифметична операція, що включає часові позначки.
  2. timestamp ± durationдає часову позначку, яка проходить через деякий час після (якщо використовується +) або перед початковою міткою (якщо -). Не настільки корисно, як це звучить, оскільки отриману часову позначку можна використовувати лише у двох видах обчислень ...

Завдяки модульній арифметиці обидва вони гарантовано спрацьовуватимуть по всій мірі прокрутки, принаймні до тих пір, поки затримки не перевищують 49,7 днів.

Порівнювати тривалість - це добре

Тривалість - це лише кількість мілісекунд, що минула протягом певного проміжку часу. Поки нам не потрібно обробляти тривалість довше 49,7 днів, будь-яка операція, яка фізично має сенс, також повинна мати сенс обчислювальною. Ми можемо, наприклад, помножити тривалість на частоту, щоб отримати ряд періодів. Або ми можемо порівняти дві тривалості, щоб знати, яка з них довша. Наприклад, ось дві альтернативні реалізації delay(). По-перше, баггі:

void myDelay(unsigned long ms) {          // ms: duration
    unsigned long start = millis();       // start: timestamp
    unsigned long finished = start + ms;  // finished: timestamp
    for (;;) {
        unsigned long now = millis();     // now: timestamp
        if (now >= finished)              // comparing timestamps: BUG!
            return;
    }
}

І ось правильний:

void myDelay(unsigned long ms) {              // ms: duration
    unsigned long start = millis();           // start: timestamp
    for (;;) {
        unsigned long now = millis();         // now: timestamp
        unsigned long elapsed = now - start;  // elapsed: duration
        if (elapsed >= ms)                    // comparing durations: OK
            return;
    }
}

Більшість програмістів на C записували б вищезгадані цикли у більш стислій формі, як

while (millis() < start + ms) ;  // BUGGY version

і

while (millis() - start < ms) ;  // CORRECT version

Хоча вони виглядають оманливо подібними, відмінність часової позначки / тривалості має чітко пояснювати, хто з них баггі, а який - правильний.

Що робити, якщо мені дійсно потрібно порівняти часові позначки?

Краще постарайтеся уникати ситуації. Якщо це неминуче, все ще є надія, якщо відомо, що відповідні екземпляри досить близькі: ближче 24,85 дня. Так, наша максимальна затримка в 49,7 дня просто скоротилася навпіл.

Очевидним рішенням є перетворення нашої задачі порівняння часових міток у проблему порівняння тривалості. Скажімо, нам потрібно знати, чи є миттєвий t1 до або після t2. Ми вибираємо деякий опорний момент у їхньому загальному минулому і порівнюємо тривалість цього посилання до t1 і t2. Опорний момент отримується відніманням досить тривалої тривалості від t1 або t2:

unsigned long reference_instant = t2 - LONG_ENOUGH_DURATION;
unsigned long from_reference_until_t1 = t1 - reference_instant;
unsigned long from_reference_until_t2 = t2 - reference_instant;
if (from_reference_until_t1 < from_reference_until_t2)
    // t1 is before t2

Це можна спростити як:

if (t1 - t2 + LONG_ENOUGH_DURATION < LONG_ENOUGH_DURATION)
    // t1 is before t2

Далі спростити спрощення if (t1 - t2 < 0). Очевидно, що це не працює, оскільки t1 - t2, обчислюючись як безпідписане число, не може бути негативним. Це, однак, хоч і не портативно, але працює:

if ((signed long)(t1 - t2) < 0)  // works with gcc
    // t1 is before t2

signedНаведене вище ключове слово є зайвим (звичайний текст longзавжди підписується), але це допомагає зрозуміти наміри. Перетворення на підписаний довгий еквівалентно встановленню, LONG_ENOUGH_DURATIONрівному 24,85 дня. Трюк не є портативним, оскільки, згідно стандарту C, результат визначено реалізацією . Але оскільки компілятор gcc обіцяє зробити правильно , він працює надійно на Arduino. Якщо ми хочемо уникнути визначеної реалізацією поведінки, вище підписане порівняння математично еквівалентне цьому:

#include <limits.h>

if (t1 - t2 > LONG_MAX)  // too big to be believed
    // t1 is before t2

з єдиною проблемою, що порівняння дивиться назад. Це також еквівалентно, поки довгі 32-бітні тести для цього однобітного тесту:

if ((t1 - t2) & 0x80000000)  // test the "sign" bit
    // t1 is before t2

Останні три тести фактично компілюються gcc в точно той же машинний код.

Як перевірити свій ескіз проти перекидання міліс

Якщо ви будете дотримуватися вищезазначених приписів, ви повинні бути всіма добрими. Якщо ви все-таки хочете протестувати, додайте цю функцію до свого ескізу:

#include <util/atomic.h>

void setMillis(unsigned long ms)
{
    extern unsigned long timer0_millis;
    ATOMIC_BLOCK (ATOMIC_RESTORESTATE) {
        timer0_millis = ms;
    }
}

і тепер ви можете подорожувати програмою у часі, зателефонувавши setMillis(destination). Якщо ви хочете, щоб він міг переповнювати мільйони знову і знову, як Філ Коннорс, що пережив День сурка, ви можете помістити це всередину loop():

// 6-second time loop starting at rollover - 3 seconds
if (millis() - (-3000) >= 6000)
    setMillis(-3000);

Негативна часова мітка вище (-3000) компілятором неявно перетворюється на безпідписаний проміжок часу, що відповідає 3000 мілісекундам до перекидання (перетворюється на 4294964296).

Що робити, якщо мені дійсно потрібно відстежувати дуже тривалі терміни?

Якщо вам потрібно увімкнути реле і вимкнути його через три місяці, тоді вам дійсно потрібно відслідковувати переливи міліс. Існує багато способів зробити це. Найпростішим рішенням може бути просто розширення millis() до 64 біт:

uint64_t millis64() {
    static uint32_t low32, high32;
    uint32_t new_low32 = millis();
    if (new_low32 < low32) high32++;
    low32 = new_low32;
    return (uint64_t) high32 << 32 | low32;
}

Це, по суті, підраховує події перекидання, і використовуючи це підрахунок як 32 найбільш значущі біти 64-бітового мілісекундного підрахунку. Щоб цей підрахунок працював належним чином, функцію потрібно викликати не рідше одного разу на 49,7 дня. Однак якщо його викликають лише один раз на 49,7 дня, то в деяких випадках можливо перевірка (new_low32 < low32)не вдасться, а код пропускає кількість high32. Використання millis () для вирішення, коли здійснювати єдиний дзвінок до цього коду в єдиному "обгортанні" міліс (конкретне вікно 49,7 дня), може бути дуже небезпечним, залежно від того, як вирівнюються часові рамки. З метою безпеки, якщо використовувати millis (), щоб визначити, коли робити єдині дзвінки на millis64 (), повинно бути щонайменше два виклики кожні 49,7-денне вікно.

Майте на увазі, що 64-бітна арифметика дорога на Arduino. Можливо, варто зменшити роздільну здатність часу, щоб залишитися на 32 біті.


2
Отже, ви говорите, що код, записаний у запитанні, насправді буде працювати правильно?
Ясен

3
@Jasen: Саме так! Мені здається, не раз люди намагаються «виправити» проблему, яка не існувала в першу чергу.
Едгар Боне

2
Я радий, що знайшов це. У мене було це питання раніше.
Себастьян Фріман

1
Один з найкращих і найкорисніших відповідей на StackExchange! Дуже дякую! :)
Фалько

Це така дивовижна відповідь на питання. До цієї відповіді я повертаюсь в основному раз на рік, тому що я параноїдальний, що псує перекидання.
Джефрі Кеш

17

TL; DR Коротка версія:

An unsigned longстановить від 0 до 2994 967 295 (2 ^ 32 - 1).

Отже, скажімо previousMillis, 4,294,967,290 (5 мс перед перекиданням) і currentMillis10 (10 мс після перекидання). Тоді currentMillis - previousMillisактуальна 16 (НЕ -4294967280) , так як результат буде розраховуватися як беззнаковое довге (який не може бути негативним, тому сама по собі буде обертатися). Ви можете перевірити це просто:

Serial.println( ( unsigned long ) ( 10 - 4294967290 ) ); // 16

Тож наведений вище код буде працювати чудово. Хитрість полягає в тому, щоб завжди обчислювати різницю в часі, а не порівнювати два значення часу.


Як щодо 15мс перед перекиданням та 10мс після перекидання (тобто через 49,7 днів після ). 15> 10 , але штамп розміром 15 мс майже півтора місяця. 15-10> 0 і 10-15> 0 unsigned логіка, так що тут немає ніякої користі!
ps95

@ prakharsingh95 10ms-15ms стане ~ 49,7 дня - 5ms, що є правильною різницею. Математика працює, поки не millis()перевернеться двічі, але це малоймовірно, що трапляється з відповідним кодом.
BrettAM

Дозвольте перефразувати. Припустимо, у вас є дві часові позначки 200 мс і 10 мс. Як ви можете сказати, що перекинуто?
ps95

@ prakharsingh95 Той, що зберігається в previousMillis, повинен бути виміряний раніше currentMillis, тому якщо currentMillisвін менший, ніж previousMillisвідбувся перекидання. Математика виявляється, що якщо два перекидання не відбулися, вам навіть не потрібно думати про це.
БреттАМ

1
Ну, тоді добре. якщо ви це зробите t2-t1, і якщо ви можете гарантувати, що t1це виміряно раніше, t2це еквівалент підписаному (t2-t1)% 4,294,967,295 , отже, автоматично обгортання. Приємно !. Але що робити, якщо є два перекидання, або intervalце 4,294,967,295?
ps95

1

Загорніть millis()у клас!

Логіка:

  1. Використовуйте ідентифікатори замість millis()безпосередньо.
  2. Порівняйте зворотні зміни за допомогою ідентифікаторів. Це чисто і незалежно від перекидання.
  3. Для конкретних застосувань, щоб обчислити точну різницю між двома ідентифікаторами, слідкуйте за зворотами та марками. Обчисліть різницю.

Ведення обернень:

  1. Оновити локальну марку періодично швидше, ніж millis(). Це допоможе вам з’ясувати, чи millis()перелетіла.
  2. Період таймера визначає точність
class Timer {

public:
    static long last_stamp;
    static long *stamps;
    static int *reversals;
    static int count;
    static int reversal_count;

    static void setup_timer() {
        // Setup Timer2 overflow to fire every 8ms (125Hz)
        //   period [sec] = (1 / f_clock [sec]) * prescale * (255-count)
        //                  (1/16000000)  * 1024 * (255-130) = .008 sec


        TCCR2B = 0x00;        // Disable Timer2 while we set it up

        TCNT2  = 130;         // Reset Timer Count  (255-130) = execute ev 125-th T/C clock
        TIFR2  = 0x00;        // Timer2 INT Flag Reg: Clear Timer Overflow Flag
        TIMSK2 = 0x01;        // Timer2 INT Reg: Timer2 Overflow Interrupt Enable
        TCCR2A = 0x00;        // Timer2 Control Reg A: Wave Gen Mode normal
        TCCR2B = 0x07;        // Timer2 Control Reg B: Timer Prescaler set to 1024

        count = 0;
        stamps = new long[50];
        reversals = new int [10];
        reversal_count =0;
    }

    static long get_stamp () {
        stamps[count++] = millis();
        return count-1;
    }

    static bool compare_stamps_by_id(int s1, int s2) {
        return s1 > s2;
    }

    static long long get_stamp_difference(int s1, int s2) {
        int no_of_reversals = 0;
        for(int j=0; j < reversal_count; j++)
        if(reversals[j] < s2 && reversals[j] > s1)
            no_of_reversals++;
        return stamps[s2]-stamps[s1] + 49.7 * 86400 * 1000;       
    }

};

long Timer::last_stamp;
long *Timer::stamps;
int *Timer::reversals;
int Timer::count;
int Timer::reversal_count;

ISR(TIMER2_OVF_vect) {

    long stamp = millis();
    if(stamp < Timer::last_stamp) // reversal
        Timer::reversals[Timer::reversal_count++] = Timer::count;
    else 
        ; // no reversal
    Timer::last_stamp = stamp;    
    TCNT2 = 130;     // reset timer ct to 130 out of 255
    TIFR2 = 0x00;    // timer2 int flag reg: clear timer overflow flag
};

// Usage

void setup () {
    Timer::setup_timer();

    long s1 = Timer::get_stamp();
    delay(3000);
    long s2 = Timer::get_stamp();

    Timer::compare_stamps_by_id(s1, s2); // true

    Timer::get_stamp_difference(s1, s2); // return true difference, taking into account reversals
}

Кредити таймера .


9
Я відредагував код, щоб видалити maaaaany помилки, які заважали йому збиратись. Цей матеріал обійдеться вам приблизно в 232 байти оперативної пам’яті та два канали ШІМ. Він також почне псувати пам’ять після вас get_stamp()51 раз. Порівняння затримок замість часових позначок, безумовно, буде більш ефективним.
Едгар Бонет

1

Мені сподобалося це питання, і великі відповіді, які воно породило. Спочатку швидкий коментар до попередньої відповіді (я знаю, я знаю, але у мене поки немає відповіді для коментарів. :-)

Відповідь Едгара Бонета була дивовижною. Я кодую 35 років, і сьогодні я дізнався щось нове. Дякую. Однак, я вважаю, що код "Що робити, якщо мені дійсно потрібно відстежувати дуже тривалі терміни?" перерви, якщо ви не викликаєте millis64 () принаймні один раз за період перекидання. Дійсно, невдало, і навряд чи це буде проблемою в реальному впровадженні, але там ви йдете.

Тепер, якщо ви дійсно хотіли, щоб часові позначки охоплювали будь-який розумний часовий діапазон (64-біт мілісекунд - це приблизно півмільярда років за моїм рахунком), виглядає просто розширити існуючу реалізацію millis () до 64 біт.

Ці зміни на attinycore / wiring.c (я працюю з ATTiny85), здається, працюють (я припускаю, що код для інших AVR дуже схожий). Дивіться рядки з // коментарями BFB та новою функцією millis64 (). Зрозуміло, що це буде як більший (98 байт коду, 4 байти даних), так і повільніше, і, як зазначав Едгар, ви майже напевно можете досягти своїх цілей, просто краще розуміючи непідписану цілу математику, але це було цікавою вправою .

volatile unsigned long long timer0_millis = 0;      // BFB: need 64-bit resolution

#if defined(__AVR_ATtiny24__) || defined(__AVR_ATtiny44__) || defined(__AVR_ATtiny84__)
ISR(TIM0_OVF_vect)
#else
ISR(TIMER0_OVF_vect)
#endif
{
    // copy these to local variables so they can be stored in registers
    // (volatile variables must be read from memory on every access)
    unsigned long long m = timer0_millis;       // BFB: need 64-bit resolution
    unsigned char f = timer0_fract;

    m += MILLIS_INC;
    f += FRACT_INC;
    if (f >= FRACT_MAX) {
        f -= FRACT_MAX;
        m += 1;
    }

    timer0_fract = f;
    timer0_millis = m;
    timer0_overflow_count++;
}

// BFB: 64-bit version
unsigned long long millis64()
{
    unsigned long long m;
    uint8_t oldSREG = SREG;

    // disable interrupts while we read timer0_millis or we might get an
    // inconsistent value (e.g. in the middle of a write to timer0_millis)
    cli();
    m = timer0_millis;
    SREG = oldSREG;

    return m;
}

1
Ви маєте рацію, я працюю millis64()лише тоді, коли її називають частіше, ніж період перекидання. Я відредагував свою відповідь, щоб вказати на це обмеження. Ваша версія не має цього питання, але вона має ще один недолік: вона робить 64-бітну арифметику в контексті переривання , що періодично збільшує затримку у відповіді на інші переривання.
Едгар Бонет
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.