В предыдущих примерах мы использовали delay
-задержки для торможения цикла и выполнения действий через заданное время - мигали светодиодом, выводили данные с АЦП в порт и так далее. В многозадачной программе с суперциклом задержек быть не должно или они должны быть сведены к минимуму - чтобы задача выполнялась как можно быстрее и программа переходила к следующей.
В многозадачной программе у нас может быть примерно такой набор задач в суперцикле:
void loop() {
проверить_кнопку();
опросить_датчик();
мигнуть_светодиодом();
обновить_дисплей();
}
Выполнять все эти задачи сотни тысяч раз в секунду нам не нужно: кнопку достаточно проверять например каждые 10 мс, датчик - 5 секунд, мигать светодиодом - раз в секунду, обновлять дисплей - каждые 100 мс. Если просто добавить соответствующий delay
в каждую задачу - вся программа начнёт выполняться с периодом, равным сумме задержек. И если задаче датчика с самым большим периодом это особо не помешает, то вот опрос кнопки каждые 5 секунд - это беда: отловить нажатие с таким медленным опросом будет практически невозможно. Да и вся остальная программа начнёт работать некорректно.
Любую конструкцию с delay
-задержкой можно переписать без использования задержки - она станет сложнее, но уже не будет блокировать выполнение кода.
Урок написан для объяснения принципов работы типовых конструкций с таймером. В реальных проектах пользуйтесь библиотеками, например GTimer
Таймер аптайма #
Одна из базовых вещей, которая есть почти в любом фреймворке или вручную написанной программе - возможность получить время, которое прошло с момента запуска программы, в случае с МК - с момента его включения. Такое время называется аптайм (uptime). На базе этой казалось бы примитивной возможности можно создать временную конструкцию практически любой сложности - всё что касается таймеров, тайм-аутов, измерения периодов времени и прочее прочее. В Arduino фреймворке тоже есть такая функция, даже целых две:
millis()
- время с момента запуска в миллисекундах (мс, в 1 с - 1'000 мс)micros()
- время с момента запуска в микросекундах (мкс, в 1 с - 1'000'000 мкс)
Простейший тест:
void setup() {
Serial.begin(115200);
}
void loop() {
Serial.print(millis());
Serial.print(',');
Serial.println(micros());
delay(1000);
}
millis()
иmicros()
работают "в фоне" - счёт времени идёт независимо отdelay
-задержек и сложных вычислений. На некоторых платформах счёт идёт в прерываниях таймера, поэтому отключение прерываний будет влиять на точность аптайма - он начнёт отставать- Функции возвращают данные типа
uint32_t
(максимальное значение4 294 967 295
), то есть значениеmillis()
переполнится через 49.7 суток, аmicros()
- через 1.2 часа, после переполнения счёт снова пойдёт с0
. По сути, это переполнение ни на что не влияет - нужно просто учитывать его при разработке алгоритмов
Измерение времени #
Основная идея использования аптайма - запоминание его текущего значения и дальнейшее сравнение с новым актуальным значением. Чтобы это работало корректно, нужно вычитать из аптайма прошлое значение и сравнивать результат с нужным временем - в таком случае переполнение не будет играть роли, т.к. вычисления производятся в беззнаковой арифметике. Например текущий аптайм - 1000 мс. Мы хотим отсчитать 500 мс. Запоминаем 1000 и ждём, когда разность аптайма и прошлого значения достигнет 500. Это произойдёт на значении аптайма 1500: 1500 - 1000 == 500
:
Что случится, если переполнение произойдёт как раз между началом и концом периода? Например millis()
равен 4 294 967 096
. Через 200 мс он досчитает до 4 294 967 295
(макс. значение) и начнёт счёт с нуля, досчитает до 300. В беззнаковом вычислении 300 - 4294967096 == 500
и конструкция отработает период полностью корректно, переполнение абсолютно никак на неё не повлияет.
Переполнение ограничивает максимальный измеряемый период, в случае с millis()
это ~50 суток. В реальных проектах таймеры с таким периодом уже никто не делает (используется источник времени), но всё равно это ограничение можно обойти - например сделать таймер на сутки и вести счётчик суток, хотя с типом uint32_t
хватит даже счётчика секунд - его хватит на 136 лет.
Для точного измерения периодов времени длиной в несколько миллисекунд лучше использовать микросекундный таймер, т.к. миллисекундный таймер имеет точность в 1 мс - старт таймера может быть как в начале текущей миллисекунды, так и в конце неё:
Программный таймер #
Следующая конструкция позволяет избавиться от блокирующих задержек и сделать код полностью асинхронным, на её основе можно реализовать абсолютно любую логику, связанную со временем. Суть та же, что и в реализации задержки в предыдущей главе - запоминаем стартовое значение аптайма (millis()
или micros()
в зависимости от нужной точности таймера) и сравниваем его разность с нужным периодом. Переменная, в которой хранится стартовое значение, должна иметь область существования больше, чем у функции, в которой существует таймер - быть глобальной или статической.
Дополнительную теорию и другие примеры реализации таймеров можно найти в отдельном уроке
Пример таймера отложенного запуска - код начнёт вызываться через 1000 мс после сброса таймера:
uint32_t tmr;
void setup() {
Serial.begin(115200);
Serial.println("start");
tmr = millis(); // сброс таймера
}
void loop() {
if (millis() - tmr >= 1000) {
// этот код начинает вызываться
// через 1000 мс после старта
Serial.println("hello");
}
}
Таймер можно сбрасывать и после срабатывания - получится периодический таймер. Данный код выводит hello
раз в секунду:
void setup() {
Serial.begin(115200);
}
void loop() {
static uint32_t tmr;
if (millis() - tmr >= 1000) {
tmr = millis(); // сброс таймера
Serial.println("hello");
}
}
Таким образом у нас получается типовая конструкция асинхронного "таймера на millis":
if (millis() - tmr >= prd) {
tmr = millis();
// выполнить действие
}
Программный таймер позволяет организовать задачи по времени - решить проблему, описанную в самом начале урока:
uint32_t myTimer1, myTimer2, myTimer3;
void setup() {}
void loop() {
// таймер на 500 мс (2 раза в сек)
if (millis() - myTimer1 >= 500) {
myTimer1 = millis();
// выполнить действие 1
}
// таймер на 333 мс (3 раза в сек)
if (millis() - myTimer2 >= 333) {
myTimer2 = millis();
// выполнить действие 2
}
// таймер на 100 мс (10 раз в сек)
if (millis() - myTimer3 >= 100) {
myTimer3 = millis();
// выполнить действие 3
}
}
Вот так мы можем например 10 раз в секунду опросить датчик, два раза в секунду выводить показания на дисплей и три раза в секунду мигать лампочкой. Красота! Расписывать эту конструкцию каждый раз - не очень удобно, пользуйтесь библиотеками или классом таймера из сборника по ссылке выше.
Примеры #
Отложенный запуск #
Если вместо сброса таймера остановить его, то можно получить конструкцию, которая выполнится однократно через указанное время. Например, включим светодиод и выключим через заданное время, асинхронно:
uint32_t tmr; // переменная таймера
bool state; // состояние таймера
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
// включить светодиод
digitalWrite(LED_BUILTIN, HIGH);
// запустить таймер
tmr = millis();
state = true;
}
void loop() {
// тайм-аут 3 секунды
if (state && millis() - tmr >= 3000) {
// остановить таймер
state = false;
// выключить светодиод
digitalWrite(LED_BUILTIN, LOW);
}
}
Мигаем светодиодом #
В прошлом уроке про конечный автомат мы сократили пример мигания светодиодом до одного delay
. Теперь можно и вовсе избавиться от него - заменить на программный таймер из этой главы:
void blinkLED() {
static uint32_t tmr;
if (millis() - tmr >= 500) {
tmr = millis();
static bool flag; // флаг состояния светодиода
digitalWrite(LED_BUILTIN, flag = !flag);
// инвертировать флаг и подать значение на пин
}
}
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
blinkLED();
}
Таким образом мы превратили классическую блокирующую задачу мигания светодиодом в полностью асинхронную - она больше не тормозит остальную программу.
Переключение периода #
При помощи флага можно переключать преиод таймера, например чтобы сделать программный ШИМ сигнал. В уроке про ШИМ мы делали программный ШИМ на задержках, давайте переделаем под неблокирующую конструкцию на таймере с периодами 9
и 1
мс:
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
static uint32_t tmr;
static bool flag;
if (millis() - tmr >= (flag ? 9 : 1)) {
tmr = millis();
digitalWrite(LED_BUILTIN, flag = !flag);
}
}
Светодиод горит ярко. Если поменять периоды местами - начнёт светить тускло. Для увеличения диапазона настройки можно перейти к микросекундам:
void loop() {
static uint32_t tmr;
static bool flag;
if (micros() - tmr >= (flag ? 50 : 1000)) {
tmr = micros();
digitalWrite(LED_BUILTIN, flag = !flag);
}
}
Получим уже в районе 1000 ступеней яркости.
Избавление от циклов #
Довольно часто бывает нужно сделать асинхронным цикл, особенно если он с задержками. Например следующий цикл for
просто выводит счётчик с периодом 100 мс, и так по кругу:
void loop() {
for (int i = 0; i < 10; i++) {
Serial.println(i); // выводит от 0 до 9
delay(100);
}
}
Чтобы развернуть такой цикл, достаточно вынести переменную-счётчик в область существования за суперцикл (глобально или статически), а инкремент и условие перенести в суперцикл. Получится своеобразная машина состояний на 10 состояний, при каждом вызове происходит переход в следующее состояние, а из последнего - в первое:
void loop() {
static int i = 0;
Serial.println(i);
i++;
if (i >= 10) i = 0; // сброс в начало "цикла"
delay(100);
}
От задержки здесь можно избавиться, применив периодический таймер:
void loop() {
static uint32_t tmr;
if (millis() - tmr >= 100) {
tmr = millis();
static int i = 0;
Serial.println(i);
i++;
if (i >= 10) i = 0;
}
}
Кода стало больше, появилось больше переменных в оперативной памяти - но он стал асинхронным.