
Содержание
Функции времени в Ардуино
Откуда берётся время?
Начнём с того, откуда вообще микроконтроллер знает, сколько проходит времени. Ведь у него нет часов! Для работы микроконтроллера жизненно важен так называемый тактовый генератор, или кварцевый генератор, или он же кварц. Он же 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 0micros()
– Возвращает количество микросекунд, прошедших с запуска. Возвращает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) { // ваш код } }
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;
#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 были обновлены до этого алгоритма, можете работать с ними.
Я думаю вы уже осознали удобство и гибкость таймера аптайма. Но что делать, если нужен “глобальный” и привязанный к реальной дате “миллис”? Например для выполнения каких-то задач в определённый период времени в сутках. Ниже делюсь примером функций для перевода даты в номер дня с 1 января 2000 года, и номера дня – в дату. Я думаю вы уже понимаете, как этим пользоваться:
// Полезные функции для получения номера дня по дате (день по счёту с 01.01.2000) // Можно использовать в качестве "миллиса" при подключенном RTC // Вторая функция получает дату из дня (день по счёту с 01.01.2000) void setup() { Serial.begin(9600); // смотрим каким днём (по счёту с 01.01.2000) будет 20 июня 2066 года Serial.println(daySince2000(20, 6, 2066)); // временные переменные для работы dayToDate() byte day; byte month; int year; // смотрим, в какую дату попадает день 24278 (по счёту с 01.01.2000) // данная функция запишет результат в указанные переменные! dayToDate(24278, day, month, year); Serial.print(day); Serial.print('.'); Serial.print(month); Serial.print('.'); Serial.println(year); } void loop() { } // ============== САМИ ФУНКЦИИ ============ const int daysMonth[] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365}; // возвращает количество дней с 01.01.2000 (день 1-30/31, месяц 1-12, год 2000-...) int daySince2000(byte day, byte month, int year) { int days = day; // + день текущего месяца days += daysMonth[month - 1]; // + дней за прошедшие месяцы if (month > 2 && year % 4 == 0) days++; // + високосный days += (year - 2000) * 365; // + предыдущие года days += (year - 2000 + 3) / 4; // + предыдущие високосные года return days; } // записывает дату дня с номером day2000 в переменные по ссылкам void dayToDate(int day2000, byte &day, byte &month, int &year) { int countDays = day2000; year = 0; while (countDays > 0) { year++; countDays -= 365; if (year % 4 == 0) countDays--; } year--; day2000 -= year * 365; day2000 -= (year + 3) / 4; month = 0; while (day2000 > daysMonth[month]) month++; day = day2000 - daysMonth[month - 1]; year += 2000; }
Библиотека 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 в библиотеке, полную информацию смотрите в документации.
Видео
Важные страницы
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
- Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
- Полная документация по языку Ардуино, все встроенные функции и макро, все доступные типы данных
- Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
- Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
- Поддержать автора за работу над уроками
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту (alex@alexgyver.ru)
- Articles coming soon