Откуда берётся время?


Начнём с того, откуда вообще микроконтроллер знает, сколько проходит времени. Ведь у него нет часов! Для работы микроконтроллера жизненно важен так называемый тактовый генератор, или кварцевый генератор, или он же кварц. Он же oscillator, он же clock. Clock по-английски это часы. Да, но не всё так просто =) Кварц расположен рядом с МК на плате (также во многих МК есть встроенный тактовый генератор), на Ардуинах обычно стоит генератор на 16 МГц, также встречаются модели на 8 МГц. Тактовый генератор выполняет очень простую вещь: он пинает микроконтроллер со своей тактовой частотой, то есть 16 МГц кварц пинает МК 16 миллионов раз в секунду. Микроконтроллер, в свою очередь зная частоту кварца, может прикинуть время между пинками (16 МГц = 0.0625 микросекунды), и таким образом ориентироваться во времени. Но на деле не всё так просто, потому что принимают пинки таймера так называемые таймеры-счётчики (Timer-counter). Это физически расположенные внутри МК устройства, которые занимаются подсчётом пинков тактового генератора. И вот микроконтроллер уже может обратиться к счётчику и спросить, а сколько там натикало? И счётчик ему расскажет.

И вот этим мы уже можем пользоваться, для этого у Ардуино есть готовые функции времени. В Ардуино на ATmega328 имеются три счётчика, и подсчётом времени занимается таймер под номером 0. Этим может заниматься любой другой счётчик, но работая в Arduino IDE вы сразу получаете такую настройку, т.к. создавая скетч в Arduino IDE вы автоматически работаете с библиотекой Arduino.h, где и реализованы все удобные функции.

Задержки


Простейшей с точки зрения использования функцией времени является задержка, их у нас две:

  • delay(time) – “приостанавливает” выполнение кода на time миллисекунд. Дальше функции delay() выполнение кода не идёт, за исключением прерываний. Использовать рекомендуется только в самых крайних или тех случаях, когда delay не влияет на скорость работы устройства. time принимает тип данных unsigned long и может приостановить выполнение на срок от 1 мс до ~50 суток (4 294 967 295 миллисекунд) с разрешением в 1 миллисекунду. Работает на системном таймере Timer 0, поэтому не работает внутри прерывания и при отключенных прерываниях.
  • delayMicroseconds(time) – Аналог delay(), приостанавливает выполнение кода на time микросекунд. time принимает тип данных unsigned int и может приостановить выполнение на срок от 4 до 16383 мкс с разрешением 4 мкс. Важно: delayMicroseconds работает не на таймере, как остальные функции времени в Arduino, а на счёте тактов процессора. Из этого следует, что delayMicroseconds может работать в прерывании и при отключенных прерываниях.

Задержки использовать очень просто:

void setup() {}

void loop() {
  // что-то выполнить
  delay(500); // подождать полсекунды
}

И вот мы можем делать какое-то действие два раза в секунду.

delayMicroseconds() иногда не совсем корректно работает с переменными, нужно стараться использовать константы (const или просто число). Для создания микросекундных задержек с переменным периодом и корректной работы в циклах лучше использовать следующую конструкцию:

// самодельная функция мкс задержки
void myDelayMicroseconds(uint32_t us) {
  uint32_t tmr = micros();
  while (micros() - tmr < us);
}

А что делать, если нам нужно выполнять одно действие два раза в секунду, а другое – три? А третье – 10 раз в секунду например. Сразу привыкаем к той мысли, что задержки лучше вообще не использовать в реальном коде. Разве что delayMicroseconds(), он бывает нужен для генерации каких-то протоколов связи. Нормальным инструментом для тайм-менеджмента своего кода являются функции, которые считают время со старта МК.

Функции счёта времени


Данные функции возвращают время, прошедшее с момента запуска микроконтроллера, так называемый аптайм (англ. uptime). Таких функций у нас две:

  • millis() – Возвращает количество миллисекунд, прошедших с запуска. Возвращает unsigned long, от 1 до 4 294 967 295 миллисекунд (~50 суток), имеет разрешение 1 миллисекунда, после переполнения сбрасывается в 0. Работает на системном таймере Timer 0
  • micros() – Возвращает количество микросекунд, прошедших с запуска. Возвращает unsigned long, от 4 до 4 294 967 295 микросекунд (~70 минут), имеет разрешение в 4 микросекунды, после переполнения сбрасывается в 0. Работает на системном таймере Timer 0

Таймер на millis()


Вы спросите, а как время со старта МК поможет нам организовать действия по времени? Очень просто, схема вот такая:

  • Выполнили действие
  • Запомнили текущее время со старта МК (в отдельную переменную)
  • Ищем разницу между текущим временем и запомненным
  • Как только разница больше нужного нам времени “Таймера” – выполняем действие
  • “Сбрасываем” таймер
    • Тут есть два варианта, приравнивать переменную таймера к актуальному millis(), или увеличивать на размер периода

Реализация такого “таймера на millis()” выглядит вот так:

// переменная хранения времени (unsigned long)
uint32_t myTimer1;

void setup() {}

void loop() {
  if (millis() - myTimer1 >= 500) {   // ищем разницу (500 мс)
    myTimer1 = millis();              // сброс таймера
    // выполнить действие
  }
}

Второй вариант сброса таймера будет записан вот так:

// переменная хранения времени (unsigned long)
uint32_t myTimer1;
int period = 500;

void setup() {}

void loop() {
  if (millis() - myTimer1 >= period) {   // ищем разницу (500 мс)
    myTimer1 += period;              // сброс таймера
    // выполнить действие
  }
}

В чём преимущества и недостатки? Первый способ ( timer = millis(); ) “уходит”, если в коде появляются задержки и прочие блокирующие участки, во время выполнения которых millis() успевает увеличиться на большее чем период время, и в перспективе период будет “уходить”! Но в то же время если заблокировать выполнение кода на время, большее чем один период – таймер скорректирует эту разницу, так как мы его сбрасываем актуальным миллисом.

Второй способ ( timer += period; ) жёстко отрабатывает период, то есть не “уходит” со временем, если в коде присутствует малая задержка. Минусом здесь является то, что если таймер пропустит период – он “сработает” несколько раз при следующей проверке.

Напомню, что uint32_t это второе название типа данных unsigned long, просто оно короче в записи. Почему переменная должна быть именно такого типа? Потому что функция millis() возвращает именно этот тип данных, т.е. если мы сделаем нашу переменную например типа int, то она переполнится через 32.7 секунды. Но миллис тоже ограничен числом 4 294 967 295, и при переполнении тоже сбросится в 0. Сделает он это через 4 294 967 295 / 1000 / 60 / 60 / 24 = 49.7 суток. Значит ли это, что наш таймер “сломается” через 50 суток? Нет, данная конструкция спокойно переживает переход через 0 и работает дальше, не верьте диванным экспертам, проверьте =)

// миллис хранится в переменной timer0_millis в файлах ядра
// делаем её extern для возможности прямого изменения
extern volatile unsigned long timer0_millis;

void setup() {
  Serial.begin(9600);

  // за 5 секунд до переполнения миллис
  timer0_millis = UINT32_MAX - 5000;
}

uint32_t timer;
void loop() {
  // наш стандартный таймер с секундным периодом
  if (millis() - timer >= 1000) {
    timer = millis();

    // выводим миллис в секундах
    Serial.println(millis() / 1000L);
    // наблюдаем как перешёл через переполнение
    // и работает себе спокойно дальше
  }
}

Почему эта конструкция работает и не ломается? Потому что мы используем беззнаковый тип данных, который при переполнении начинает считать с нуля. Подробнее об этом читайте в уроке про вычисления. Таким образом когда миллис становится равен нулю и растёт, а мы вычитаем из него огромное число – получаем не отрицательное, а вполне корректное значение, которое является временем с предыдущего сброса таймера. Поэтому конструкция не то что продолжает работать через ~50 суток, но и проходит момент “переполнения” без потери периода!

Вернёмся к вопросу многозадачности: хотим выполнять одно действие два раза в секунду, второе – три, и третье – 10. Нам понадобится 3 переменные таймера и 3 конструкции с условием:

// переменная хранения времени (unsigned long)
uint32_t myTimer1, myTimer2, myTimer3;

void setup() {}

void loop() {
  if (millis() - myTimer1 >= 500) {   // таймер на 500 мс (2 раза в сек)
    myTimer1 = millis();              // сброс таймера
    // выполнить действие 1
    // 2 раза в секунду
  }
  if (millis() - myTimer2 >= 333) {   // таймер на 333 мс (3 раза в сек)
    myTimer2 = millis();              // сброс таймера
    // выполнить действие 2
    // 3 раза в секунду
  }
  if (millis() - myTimer3 >= 100) {   // таймер на 100 мс (10 раз в сек)
    myTimer3 = millis();              // сброс таймера
    // выполнить действие 3
    // 10 раз в секунду
  }
}

И вот так мы можем например 10 раз в секунду опросить датчик, фильтровать значения, и два раза в секунду выводить показания на дисплей. И три раза в секунду мигать лампочкой. Почему нет?

Рассмотрим ещё несколько алгоритмов.

Ещё варианты


В классическом варианте таймера нам приходится создавать отдельную 32-х битную переменную под каждый таймер. Весьма расточительно! Давайте рассмотрим другие варианты организации периодических действий на базе счётчика аптайма millis().

if (millis() % period == 0) {
  // ваше действие
}

Часто можно встретить вот такую конструкцию: условие выполняется, когда остаток от деления миллис на период равен нулю. Казалось бы, очень крутой и простой алгоритм! Но у него есть один серьёзный недостаток: если условие проверяется чаще одного раза в миллисекунду – оно успеет выполниться несколько раз! То есть для корректной работы такого таймера должна быть задержка, либо естественная (какие-то блокирующие функции), либо самостоятельно созданная. Например так:

void loop() {
  if (millis() % 1000 == 0) {
    // таймер 1
  }
  if (millis() % 500 == 0) {
    // таймер 2
  }
  delay(1);
}

Иначе это всё будет работать некорректно, вот наглядный пример:

void setup() {
  Serial.begin(9600);
  int counter = 0;
  while (millis() < 50) {
    if (millis() % 5 == 0) counter++;
  }
  Serial.println(counter);  // выведет 265
  // хотя казалось бы
}

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

Можно сделать более хитро: один “правильный” таймер на миллис, который инкрементирует счётчик, и по этому счётчику работают остальные таймеры:

void loop() {
  static uint32_t counter = 0;
  static uint32_t tmr;
  if (millis() - tmr >= 1) {
    tmr = millis();
    if (counter % 500 == 0) {
      // таймер 1
    }
    if (counter % 9000 == 0) {
      // таймер 2
    }
    counter++;
  }
}

Такой подход хорош тем, что у нас всего одна тяжёлая переменная для таймера, а также мы выполняем “остаток от деления” не постоянно, а по своему таймеру. Период своего таймера можно поставить отличным от 1 мс, чтобы снизить нагрузку на процессор. Но не забывать, что счётчик будет отсчитывать уже новый период!

//==== MILLISTIMER MACRO ====
#define EVERY_MS(x) \
  static uint32_t tmr;\
  bool flag = millis() - tmr >= (x);\
  if (flag) tmr = millis();\
  if (flag)
//===========================

Либо вот такой вариант, он не уходит в отличие от предыдущего (разбирали разницу в главе “Таймер на millis()”). Разница напомню в способе сброса таймера.

//=== MILLISTIMER MACRO v2 ===
#define EVERY_MS(x) \
  static uint32_t tmr;\
  bool flag = millis() - tmr >= (x);\
  if (flag) tmr += (x);\
  if (flag)
//===========================

Данный макрос заменяет “таймер на миллис” одной строчкой, без использования библиотек и создания классов! Пользоваться очень просто: добавьте указанный выше макрос в самое начало кода и вызывайте его как функцию

EVERY_MS(100) {
  // ...
  // данный код будет выполняться каждые 100 мс
}

Единственное ограничение: нельзя вызывать макрос больше одного раза в одном и том же блоке кода, это приведёт к ошибке =) То есть вот так нельзя:

void loop() {
  EVERY_MS(100) {
    // ваш код
  }
  EVERY_MS(500) {
    // ваш код
  }
}

Если очень нужна такая конструкция – помещаем каждый вызов в свой блок кода:

void loop() {
  {
    EVERY_MS(100) {
      // ваш код
    }
  }
  
  {
    EVERY_MS(500) {
      // ваш код
    }
  }
  
}

Либо используем блоки кода по условиям или как отдельную функцию, которая “оборачивает” макрос:

//===========================
#define EVERY_MS(x) \
  static uint32_t tmr;\
  bool flag = millis() - tmr >= (x);\
  if (flag) tmr = millis();\
  if (flag)
//===========================

void setup() {}

void loop() {
  myAction1();
  myAction2();
}
void myAction1() {
  EVERY_MS(1000) {
    // ваш код
  }
}
void myAction2() {
  EVERY_MS(500) {
    // ваш код
  }
}

Таймер, который после срабатывания переключает период на другой, например включаем реле на 10 секунд каждые 60 минут

uint32_t tmr;
bool flag;
#define period1 10*1000L
#define period2 60*60*1000L

void setup() {

}

void loop() {
  if (millis() - tmr >= (flag ? period1 : period2)) {
    tmr = millis();
    flag = !flag;
    // тут можно сделать digitalWrite(pin, flag);
    // для переключения реле
  }
}

uint32_t now = millis();
while (millis () - now < 5000) {
// тут в течение 5000 миллисекунд вертится код
// удобно использовать для всяких калибровок
}

  // получаем из миллиса часы, минуты и секунды работы программы 
  // часы не ограничены, т.е. аптайм
  uint32_t sec = millis() / 1000ul;
  int timeHours = (sec / 3600ul);
  int timeMins = (sec % 3600ul) / 60ul;
  int timeSecs = (sec % 3600ul) % 60ul;
  // получаем из миллиса часы, минуты и секунды работы программы. 
  // Часы ограничиваем до 23, т.е. режим часов
  uint32_t sec = millis() / 1000ul;
  int timeHours = (sec / 3600ul) % 24;
  int timeMins = (sec % 3600ul) / 60ul;
  int timeSecs = (sec % 3600ul) % 60ul;

Недавно я задался вопросом: а можно ли сделать таймер на миллис, который будет корректно обходить переполнение millis() и не сбивать период? Можно, сделал:

#define PERIOD 500
uint32_t timer = 0;
void loop() {
  if (millis() - timer >= PERIOD) {
    // ваше действие
    do {
      timer += PERIOD;
      if (timer < PERIOD) break;  // переполнение uint32_t
    } while (timer < millis() - PERIOD); // защита от пропуска шага
  }
}

Данный таймер имеет механику классического таймера с хранением переменной таймера, а его период всегда кратен PERIOD и не сбивается. Эту конструкцию можно упростить до

#define PERIOD 500
uint32_t timer = 0;
void loop() {
  if (millis() - timer >= PERIOD) {
    // ваше действие
    timer += PERIOD;
  }
}

В этом случае алгоритм получается короче, кратность периодов сохраняется (период не уходит при наличии задержек в коде), но теряется защита от пропуска вызова. Мои библиотеки GyverTimer и timerMinim были обновлены до этого алгоритма, можете работать с ними.

Библиотека GyverTimer


Конструкция таймера на миллис используется очень часто, у меня в крупном проекте может быть десяток таких таймеров. Поэтому для ускорения написания кода я сделал библиотечку GyverTimer.

Полную документацию и ссылку на загрузку ищите на странице о библиотеке. Смотрим простой  пример – сравнение с предыдущим кодом.

Помимо метода isReady(), который сигнализирует о срабатывании таймера, в библиотеке есть куча других:

GTimer(timerType type, uint32_t interval);  // объявление таймера с указанием типа (MS, US) и интервала (таймер не запущен, если не указывать)
void setInterval(uint32_t interval);        // установка интервала работы таймера (также запустит и сбросит таймер) - режим интервала
void setTimeout(uint32_t timeout);          // установка таймаута работы таймера (также запустит и сбросит таймер) - режим таймаута
boolean isReady();                          // возвращает true, когда пришло время
boolean isEnabled();                        // вернуть состояние таймера (остановлен/запущен)
void reset();                               // сброс таймера на установленный период работы
void start();                               // запустить/перезапустить (со сбросом счёта)
void stop();                                // остановить таймер (без сброса счёта)	
void resume();                              // продолжить (без сброса счёта)	

Пользуйтесь на здоровье! Несколько примеров также есть в папке examples в библиотеке, полную информацию смотрите в документации.

Видео


Важные страницы