Многозадачность в Arduino
Вот и закончился базовый курс уроков программирования Arduino. Мы с вами изучили самые базовые понятия, вспомнили (или изучили) часть школьной программы по информатике, изучили большую часть синтаксиса и инструментов языка C++ и вроде бы весь набор Ардуино-функций, который предлагает нам платформа. Теперь перед нами чистый лист блокнота Arduino IDE и желание творить и программировать. Давайте попробуем!
Многозадачность
Микроконтроллер может выполнять только одну задачу в один момент времени, так как у него одно вычислительное ядро (у некоторых больше, например ESP32), поэтому реальной "многозадачности" нет и быть не может. Но за счёт большой скорости выполнения ядро может выполнять задачи по очереди, и для человека это будет казаться многозадачностью: ведь что для нас "раз Миссисипи" (одна секунда), для микроконтроллера - десятки миллионов операций!
В целом существует два подхода к организации сложной программы: суперцикл с прерываниями и костылями и использование операционной системы RTOS. Операционные системы мы разбирать не будем, остановимся на первом подходе.
Суперцикл с костылями
Суперцикл - главный цикл программы, который выполняется сверху вниз и начинает с самого начала, когда доходит до конца. В Arduino IDE нашим суперциклом является loop()
. В главном цикле мы можем опрашивать датчики, управлять внешними устройствами, выводить данные на дисплеи, производить вычисления и всё такое, но в любом случае эти действия будут происходить друг за другом, последовательно. Большинство действий не требуют постоянного выполнения: например нам не нужно миллион раз в секунду обновлять дисплей, опрашивать кнопки или датчики - достаточно делать это несколько раз в секунду при помощи программного таймера (об этом ниже). Некоторые действия требуют относительно большого времени работы процессора, например сложные вычисления или отправка изображения на дисплей, и за это время мы можем пропустить какое-то важное событие (импульс с датчика оборотов, поворот энкодера, входящие данные по интерфейсу связи). Как успеть их обработать?
Помимо основного цикла у нас есть прерывания, которые при наступлении определённого события позволяют остановить выполнение кода в основном цикле и перейти к выполнению кода в обработчике прерывания, а после его завершения вернуться к основному циклу и продолжить работу. Некоторые задачи можно решить на одних лишь прерываниях, не написав ни одной строчки в цикл loop()
!
- Мы с вами изучали внешние аппаратные прерывания, позволяющие прерваться по внешнему сигналу (нажатие кнопки, поворот энкодера, импульс с тахометра).
- Также у микроконтроллера есть внутренние прерывания, которые вызываются его периферией. Таких прерываний может быть несколько десятков! Одним из таких прерываний является прерывание таймера: по настроенному периоду программа будет прерываться и выполнять указанный код.
- В реализации Arduino один из таймеров настраивается на счёт реального времени, благодаря чему у нас работают функции
millis()
иmicros()
(читай урок про функции времени). Именно эти функции являются готовым инструментом для тайм-менеджмента нашего кода и позволяют создавать работу "по расписанию" (об этом ниже). - Ещё один пример - прерывания UART. Вы не задумывались, где и как микроконтроллер принимает данные из монитора порта? Мы ведь не опрашиваем его вручную. В реализации Arduino входящие по UART данные вызывают прерывание, в котором складываются в буфер, и уже из этого буфера мы читаем их в программе в любой удобный нам момент.
Рассмотрим некоторые варианты реализации многозадачности в Arduino.
Многозадачность с yield()
В уроке про функции времени мы коснулись функции yield()
, которая позволяет выполнять свой код внутри задержек delay()
. Данный костыль позволяет очень быстро реализовать "параллельное" выполнение двух задач: одной по задержке, а второй - постоянно. В том уроке мы рассмотрели пример, в котором мигает светодиод и опрашивается кнопка:
void setup() { pinMode(13, OUTPUT); } void loop() { digitalWrite(13, 1); delay(1000); digitalWrite(13, 0); delay(1000); } void yield() { // а тут можно опрашивать кнопку // и не пропустить нажатия из за delay! }
Таким же образом можно опрашивать энкодер или другие железки, которые требуют максимально частого опроса. Не менее жизненным будет пример со сценарием движения шагового мотора или плавного движения сервопривода, которые требуют частого вызова "функций движения".
Рассмотрим абстрактный пример движения мотора по нескольким заданным точкам, функция вращения мотора должна вызываться как можно чаще (так сделано почти во всех библиотеках для шаговых моторов):
void setup() {} void loop() { // задать целевой угол №1 delay(1000); // задать целевой угол №2 delay(120); // задать целевой угол №3 delay(2000); // задать целевой угол №4 delay(250); // задать целевой угол №5 delay(600); } void yield() { // вращать мотор }
Таким образом мы быстро и просто расписали "траекторию" движения для шагового мотора по времени, не используя какие-то таймеры и библиотеки. Для более сложных программ, например с движением двух моторов, такой фокус уже может не пройти и проще работать с таймером.
Многозадачность с millis()
Большинство примеров к различным модулям/датчикам используют задержку delay()
в качестве "торможения" программы, например для вывода данных с датчика в последовательный порт. Именно такие примеры портят восприятие новичка и он тоже начинает использовать задержки. А на задержках далеко не уедешь!
При помощи функций времени millis()
или micros()
можно организовать программный таймер, по которому и выполнять нужные действия. Схема такая:
- Заводим переменную для таймера типа
unsigned long
(uint32_t
) - именно этот тип возвращаетmillis()
. - Ищем разницу между текущим временем работы программы и переменной таймера.
- Если разница больше необходимого периода - выполняем нужный код и сбрасываем таймер.
Реализация классического "таймера на millis()" выглядит так:
#define MY_PERIOD 500 // период в мс uint32_t tmr1; // переменная таймера void setup() {} void loop() { if (millis() - tmr1 >= MY_PERIOD) { // ищем разницу tmr1 = millis(); // сброс таймера // выполнить действие } }
- Данная конструкция "уходит" с периода, если в коде есть задержки и прочие блокирующие участки, во время выполнения которых
millis()
успевает увеличиться на время, большее чем период таймера. Это может быть критично например для счёта времени и других похожих ситуаций, когда период срабатывания таймера не должен смещаться. - В то же время, если заблокировать выполнение кода на время, большее чем один период - алгоритм просто скорректирует эту разницу, так как мы сбрасываем его актуальным значением
millis()
.
Несколько таймеров
Вернёмся к вопросу многозадачности: хотим выполнять одно действие два раза в секунду, второе - три, и третье - 10. Нам понадобится 3 переменные таймера и 3 конструкции с условием:
// переменные таймеров uint32_t myTimer1, myTimer2, myTimer3; void setup() {} void loop() { if (millis() - myTimer1 >= 500) { // таймер на 500 мс (2 раза в сек) myTimer1 = millis(); // сброс таймера // выполнить действие 1 } if (millis() - myTimer2 >= 333) { // таймер на 333 мс (3 раза в сек) myTimer2 = millis(); // сброс таймера // выполнить действие 2 } if (millis() - myTimer3 >= 100) { // таймер на 100 мс (10 раз в сек) myTimer3 = millis(); // сброс таймера // выполнить действие 3 } }
И вот так мы можем например 10 раз в секунду опросить датчик, фильтровать значения, и два раза в секунду выводить показания на дисплей. И три раза в секунду мигать лампочкой. Красота!
Другие варианты реализации
Рассмотрим ещё несколько вариантов реализации и сброса таймера.
Переполнение
Все рассмотренные выше конструкции таймеров спокойно переживают переход через 0 и работают дальше без изменения и смещения периода, потому что мы используем беззнаковый тип данных.
Примеры
Начнём с простого: классический blink:
void setup() { pinMode(13, OUTPUT); // пин 13 как выход } void loop() { digitalWrite(13, HIGH); // включить delay(1000); // ждать digitalWrite(13, LOW); // выключить delay(1000); // ждать }
Программа полностью останавливается на команде delay()
, ждёт указанное время, а затем продолжает выполнение. Чем это плохо? (А вы ещё спрашиваете?) Первым делом внесём такую оптимизацию: сократим код вдвое и избавимся от одной задержки, используя флаг:
boolean LEDflag = false; void setup() { pinMode(13, OUTPUT); } void loop() { digitalWrite(13, LEDflag); // вкл/выкл LEDflag = !LEDflag; // инвертировать флаг delay(1000); // ждать }
Хитрый ход, запомните его! Такой алгоритм позволяет переключать состояние при каждом вызове. Сейчас наш код всё ещё заторможен задержкой в 1 секунду, давайте от неё избавимся при помощи таймера на millis()
:
boolean LEDflag = false; uint32_t myTimer; // переменная времени void setup() { pinMode(13, OUTPUT); } void loop() { if (millis() - myTimer >= 1000) { myTimer = millis(); // сбросить таймер digitalWrite(13, LEDflag); // вкл/выкл LEDflag = !LEDflag; // инвертировать флаг } }
Многозадачность с прерываниями таймера (для AVR)
Для особо критичных ко времени задач можно использовать выполнение по прерыванию таймера. Какие это могут быть задачи:
- Динамическая индикация
- Генерация определённого сигнала/протокола связи
- Программный ШИМ
- "Тактирование" шаговых моторов
- Любой другой пример выполнения через указанное время или просто периодическое выполнение по строгому периоду
Настройка таймера на нужную частоту и режим работы - непосильная для новичка задача, хоть и решается в 2-3 строчки кода, поэтому предлагаю использовать библиотеки. Для настройки прерываний по таймерам 1 и 2 есть библиотеки TimerOne и TimerTwo. Мы сделали свою библиотеку, GyverTimers, в которой есть таймер 0 (для программирования без использования Arduino.h), а также все таймеры на Arduino MEGA, а их там целых 6 штук. Ознакомиться с документацией и примерами можно на странице библиотеки.
Сейчас рассмотрим простой пример, в котором "параллельно" выполняющемуся Blink будут отправляться данные в порт. Пример оторван от реальности, так делать нельзя, но он важен для понимания самой сути: код в прерывании выполнится в любом случае, ему безразличны задержки и бесконечные циклы в основном коде.
#include "GyverTimers.h" void setup() { Serial.begin(9600); // Устанавливаем период таймера 333000 мкс -> 0.333 c (3 раза в секунду) Timer2.setPeriod(300000); Timer2.enableISR(); // запускаем прерывание на канале А таймера 2 pinMode(13, OUTPUT); // будем мигать } void loop() { // "блинк" digitalWrite(13, 1); delay(1000); digitalWrite(13, 0); delay(1000); } // Прерывание А таймера 2 ISR(TIMER2_A) { Serial.println("isr!"); }
Прерывания по таймеру являются очень мощным инструментом, но таймеров у нас не так уж много и нужно использовать их только в случае реальной необходимости. 99% задач можно решить без прерываний таймера, написав оптимальный основной цикл и грамотно применяя millis()
.
Посмотрите мой проект контроллера теплицы: там не используются прерывания таймера, но система постоянно выполняет большое количество опросов датчиков, вычислений, управляет железками, выводит на дисплей и управляется при помощи энкодера.
Библиотеки
В крупном проекте с кучей задач будет довольно неприятно описывать одну и ту же конструкцию таймера, в этом случае гораздо выгоднее использовать библиотеки (либо обернуть её в класс, как в примерах выше).
TimerMs
Это "Таймер на миллис" с дополнительными фишками и настройками:
- Режим таймера и периодичного выполнения
- Подключение функции-обработчика
- Сброс/запуск/перезапуск/остановка/пауза/продолжение отсчёта
- Возможность форсировать переполнение таймера
- Возврат оставшегося времени в мс, а также условных единицах 8 и 16 бит
- Несколько функций получения текущего статуса таймера
- Алгоритм держит стабильный период и не боится переполнения millis()
Скачать и изучить документацию - ссылка на GitHub.
GyverOS
Очень лёгкий и простой в использовании диспетчер задач: просто указываем функции и период их выполнения, остальное библиотека сделает сама. Возможности:
- Лёгкий вес
- Статическое выбираемое количество задач
- Возможность остановки, отключения и прямого вызова задач
- Вычисление времени до ближайшей задачи (для сна на этот период)
- Встроенный бенчмарк: время выполнения задачи и загруженность процессора
- Алгоритм работает на системном таймере millis()
Скачать и изучить документацию - ссылка на GitHub.
Минутка юмора
Шуточный видео урок, из которого можно узнать несколько необычных решений:
Как-то раз я запостил в нашей группе картинку и очень многие её не поняли. Надеюсь после изучения данного урока всё станет понятно =)
Видео
Полезные страницы
- Набор GyverKIT – большой стартовый набор Arduino моей разработки, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
- Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
- Полная документация по языку Ардуино, все встроенные функции и макросы, все доступные типы данных
- Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
- Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
- Поддержать автора за работу над уроками
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])