Как я могу справиться с опрокидыванием millis ()?

73

Мне нужно читать датчик каждые пять минут, но, поскольку у моего эскиза есть и другие задачи, я не могу просто delay()между показаниями. Существует обучающее руководство по Blink без промедления, предлагающее кодировать по следующим направлениям:

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

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

    // Do other stuff...
}

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

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

Эдгар Бонет
источник
5
От редакции. Это не совсем мой вопрос, а учебник в формате вопрос / ответ. Я был свидетелем большого замешательства в Интернете (в том числе здесь) по этой теме, и этот сайт кажется очевидным местом для поиска ответа. Вот почему я предоставляю этот урок здесь.
Эдгар Бонет
2
Я бы сделал previousMillis += intervalвместо того, previousMillis = currentMillisчтобы хотеть определенную частоту результатов.
Jasen
4
@Jasen: Это верно! previousMillis += intervalесли вы хотите постоянную частоту и уверены, что ваша обработка занимает меньше interval, но previousMillis = currentMillisдля обеспечения минимальной задержки interval.
Эдгар Бонет
Нам действительно нужен FAQ для таких вещей.
Один из «трюков», которые я использую, состоит в том, чтобы облегчить нагрузку на Arduino, используя наименьшее значение 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()в терминах модульной арифметики . Для математически склонных некоторое знакомство с этой концепцией очень полезно при программировании. Вы можете увидеть математику в действии в статье переполнения миллиметра () Nick Gammon ... плохо? , Для тех, кто не хочет разбираться в вычислительных деталях, я предлагаю здесь альтернативный (надеюсь, более простой) способ мышления об этом. Он основан на простом различии между моментами и продолжительностью . Пока ваши тесты включают только сравнение длительностей, у вас все будет хорошо.

Обратите внимание на тысячные () : здесь сказано все о 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;
    }
}

Большинство программистов на С написали вышеуказанные циклы в более краткой форме, например:

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

с единственной проблемой, что сравнение выглядит в обратном направлении. Это также эквивалентно, пока longs 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 () для определения того, когда совершать единственный вызов этого кода в одной «оболочке» из millis (конкретное 49,7-дневное окно), может быть очень опасным, в зависимости от того, как выстроены временные рамки. В целях безопасности, если с помощью функции millis () определить, когда совершать единственные вызовы функции millis64 (), должно быть не менее двух вызовов в каждом 49,7-дневном окне.

Имейте в виду, однако, что 64-битная арифметика стоит дорого на Arduino. Возможно, стоит уменьшить временное разрешение, чтобы остаться на 32 битах.

Эдгар Бонет
источник
2
Итак, вы говорите, что код, написанный в вопросе, на самом деле будет работать правильно?
Jasen
3
@Jasen: Точно! Кажется, я уже не раз пытался «исправить» проблему, которой изначально не было.
Эдгар Бонет
2
Я рад, что нашел это. У меня был этот вопрос раньше.
Себастьян Фриман
1
Один из лучших и самых полезных ответов на StackExchange! Большое спасибо! :)
Фалько
Это такой удивительный ответ на вопрос. Я возвращаюсь к этому ответу в основном раз в год, потому что у меня параноидальный переворот.
Джеффри Кэш
17

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

An unsigned longсоставляет от 0 до 4 294 967 295 (2 ^ 32 - 1).

Допустим, что previousMillisэто 4 294 967 290 (5 мс до ролловера) и currentMillis10 (10 мс после ролловера). Тогда currentMillis - previousMillisфактическое значение 16 (не -4,294,967,280), так как результат будет рассчитан как длинная без знака (которая не может быть отрицательной, поэтому сама будет катиться). Вы можете проверить это просто:

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

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

Gerben
источник
Примерно за 15 мс до ролловера и 10 мс после ролловера (то есть через 49,7 дней после ). 15> 10 , но марка 15 мс почти полтора месяца назад. 15-10> 0 и 10-15> 0 unsigned логика, так что здесь нет смысла!
PS95
@ prakharsingh95 10 мс-15 мс станет ~ 49,7 дней - 5 мс, что является правильной разницей. Математика работает до тех пор, пока не millis()перевернется дважды, но это вряд ли произойдет с рассматриваемым кодом.
BrettAM
Позвольте мне перефразировать. Предположим, у вас есть две метки времени 200 мс и 10 мс. Как вы говорите, что (переворачивается)?
PS95
@ prakharsingh95 Тот, который хранится в previousMillis, должен быть измерен ранее currentMillis, поэтому, если currentMillisон меньше, чем previousMillisпроизошло опрокидывание. Математика работает так: если не произошло двух опрокидываний, вам даже не нужно об этом думать.
BrettAM
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
}

Таймер кредитов .

PS95
источник
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;
}
brainbarker
источник
1
Вы правы, мой millis64()работает только в том случае, если он вызывается чаще, чем период пролонгации. Я отредактировал свой ответ, чтобы указать на это ограничение. Ваша версия не имеет этой проблемы, но имеет другой недостаток: она выполняет 64-битную арифметику в контексте прерываний , что иногда увеличивает задержку при ответе на другие прерывания.
Эдгар Бонет