Немного о программах #
Что есть программа на компьютере и программа на микроконтроллере? На компьютере у нас может быть много разных программ, их можно запускать и завершать в любой момент, а также удалять или устанавливать новые. Это возможно благодаря операционной системе (Windows, Linux, Mac) - главной программе для всех программ, "внутри" которой они и запускаются. Когда мы включаем компьютер, то сначала загружается операционная система, а затем мы запускаем условную программу и попадаем в её int main() (если она написана на Си). На МК всё гораздо проще - мы пишем программу не под операционную систему, а напрямую под МК, она одна будет находиться у него в памяти и запустится сразу же после подачи питания. Да, на МК тоже можно "поднять" операционную систему и запускать подпрограммы, в том числе с внешних носителей. Но микроконтроллер на то и "микро", что обычно решает более простые задачи, для которых не нужна громоздкая система - всё работает в рамках одной программы.
"Одна программа" не означает, что МК сможет делать только что-то одно - запрограммировать можно всё что угодно, программа - это просто код, он может выполнять разные задачи. Под задачей понимается некоторая самостоятельная часть программы, которая выполняет конкретную работу, например опрос кнопки, мигание светодиодом, обновление дисплея и т.д. Соответственно на макро уровне программа может быть однозадачной и многозадачной - рассмотрим, как это можно реализовать.
Суперцикл #
Самым простым способом организации программы является суперцикл (superloop) - вся программа помещается в бесконечный цикл, который выполняется раз за разом. В этом цикле могут опрашиваться кнопки, читаться датчики, отправляться данные по условию и так далее, всё это происходит друг за другом. Для наглядности я разделил программу на самостоятельные функции, которые делают что-то своё:
while (true) {
if (клик_по_кнопке()) отправить_данные();
мигать_светодиодом();
читать_датчик();
}
Этот подход "родной" для чистого C/C++ и широко используется во встраиваемых системах и на слабых МК - это буквально самый легковесный способ, не требующий ни байта лишнего кода, а большинство остальных подходов работают на его основе.
Arduino loop #
Как обсуждалось ранее, Ардуиновская функция loop вызывается на всём протяжении работы программы, т.е. является суперциклом. Как часто она это делает? По сути - с максимальной скоростью, на которую способен процессор. "Вне" этой функции он выполняет некоторые системные задачи, поэтому loop вызывается с чуть меньшей скоростью - порядка десятков и сотен тысяч раз в секунду.
"Под капотом" Arduino фреймворка основная программа выглядит примерно так - фреймворк делает какие-то свои дела и вызывает наш loop, а так как мы работаем на C++ - всё это лежит в файле main.cpp и помещено в функцию int main, т.е. в стандартную точку входа в программу на этом языке:
// main.cpp
int main() {
// системный код
setup();
for (;;) {
// системный код
loop();
}
}
Если писать без Arduino-фреймворка, то ваша программа скорее всего будет выглядеть похожим образом - сначала код, выполянемый при запуске, затем суперцикл.
Arduino loop() - не цикл! Это функция, которая вызывается в цикле внутри файлов фреймворка
Superloop polling #
Когда мы пишем многозадачную программу в суперцикле, то будет логично представить задачи в виде отдельных функций, которые вызываются из цикла, как в примере выше. При написании самих функций нужно понимать, что они будут вызываться по очереди, тысячи и десятки тысяч раз в секунду. Задачи выполняются не мгновенно, поэтому чем больше в цикле задач - тем реже они будут вызываться (речь о нано- и микросекундах, но сути это не меняет):
void loop() {
if (клик_по_кнопке()) отправить_данные();
мигать_светодиодом();
читать_датчик();
}
Функция для вызова в суперцикле должна быть "прозрачной" и не содержать конструкций, блокирующих выполнение программы. В идеале она должна быть ещё и оптимизирована для скорости, т.е. банально не выполнять сложные вычисления при каждом вызове, не выделять-заполнять большие объёмы памяти и прочие очевидные долгие операции. Такие функции называют тикерами, они предназначены для вызова в суперцикле, а сам подход называется superloop polling - опрос в суперцикле. В библиотеках такие функции и методы часто содержат в названии слова tick, update, loop или poll.
Блокирующие конструкции #
Если по какой-то причине одна задача "зависнет" или будет выполняться долго, т.е. блокировать выполнение программы - остальные задачи будут ждать своего вызова и рискуют пропустить событие (например нажатие кнопки) или просто "тормозить", например обновление дисплея. В то же время, блокирующие конструкции позволяют писать очень простой и понятный код, который выполняется линейно - как написан. Такой код также называется синхронным, т.е. выполняется по порядку, блокируя программу. Рассмотрим основные блокирующие конструкции.
Задержка #
Классическое мигание светодиодом через delay() - задержка тормозит программу, чтобы задать нужную скорость переключения:
// мигаем встроенным светодиодом
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}
void blinkLED() {
digitalWrite(LED_BUILTIN, HIGH); // включить
delay(500); // ждать 500 мс
digitalWrite(LED_BUILTIN, LOW); // выключить
delay(500); // ждать 500 мс
}
void loop() {
blinkLED();
}
Цикл ожидания #
Опрос кнопки через циклы ожидания:
// второй пин кнопки подключен к GND
#define BTN_PIN 3
void setup() {
Serial.begin(115200);
pinMode(BTN_PIN, INPUT_PULLUP);
}
void checkButton() {
while (digitalRead(BTN_PIN)); // ждать, пока кнопка не нажата
Serial.println("Кнопка нажата");
while (!digitalRead(BTN_PIN)); // ждать, пока кнопка нажата
Serial.println("Кнопка отпущена");
}
void loop() {
checkButton();
}
Сложные вычисления #
Начнём с простого примера - будем увеличивать число с шагом 1 и выводить его в порт:
void setup() {
Serial.begin(115200);
}
int x = 0;
void loop() {
x++;
Serial.println(x);
}
Если открыть монитор порта, то можно увидеть, как с огромной скоростью появляются числа. В данном случае вывод в порт будет занимать больше времени, чем увеличение числа. Теперь давайте представим, что нам нужно 500 раз вычислить и просуммировать синус. Чтобы компилятор не оптимизировал вычисления - считать будем синус случайного числа:
void setup() {
Serial.begin(115200);
}
void calcSin() {
float x = 0;
for (int i = 0; i < 500; i++) {
x += sin(random(100));
}
Serial.println(x);
}
void loop() {
calcSin();
}
Запустите данный пример - новые значения будут появляться в мониторе буквально пару раз в секунду - вычисления занимают очень много времени!
Многозадачность #
Я специально оформил примеры блокирующих конструкций в виде функций-задач. Дело в том, что если в программе только одна такая задача, как в примерах выше - программа будет отлично работать, т.к. задача никому не мешает и выполняется со своей скоростью. Более того, блокирующие конструкции обычно очень простые и понятные, программу с ними легко писать и читать, особенно новичку. Проблемы начинаются, когда мы запустим две и более таких задач одновременно.
Здесь программа сразу зависнет в ожидании нажатия кнопки, светодиод мигать не будет:
void loop() {
checkButton();
blinkLED();
}
Здесь светодиод будет мигать медленнее, точнее будет дольше находиться в выключенном состоянии - его будет тормозить задача по вычислению синусов:
void loop() {
blinkLED();
calcSin();
}
И так далее. Блокирующим задачам не место в суперцикле! Рассмотрим основные подходы к организации программы в таких условиях.
Зачем ждать, если можно проверить #
Основная природа блокирующего кода - ожидание события. В примерах выше мы ждём, когда выйдет время в задержке delay(), когда юзер нажмёт или отпустит кнопку, когда посчитаются все 500 синусов. Таких конструкций можно придумать бесконечное количество, но все они "исправляются" одной простой идеей - зачем ждать, если можно проверить... в следующий раз? Тикер вызывается десятки тысяч раз в секунду, то есть мы попадём в этот же код буквально через мгновение. Достаточно запоминать старое "состояние" и сравнивать его с новым. На этой идее работают:
- Программный таймер - позволяет избавиться от задержек и ожидания времени
- Машина состояний и флаги - позволяет описать логику практически любой сложности, смену режимов работы, меню с настройками...
- Урок про переключение режимов
Прерывания #
У МК есть очень мощный механизм - прерывания. Он позволяет обрабатывать аппаратные события непосредственно в момент их наступления. При наступлении прерывания программа переходит к функции-обработчику (там будет наш код), выполняет её и возвращается обратно. Прерывание может наступить в любой момент, например внутри цикла с суммированием синусов - программа всё равно мгновенно переключится на обработку события. Прерывания обычно можно настроить на изменение сигнала с пина, на отправку-приём байта по интерфейсу связи, а также на другие события периферии МК. Это позволяет делать некоторые вещи "в фоне", практически параллельно выполнению основной программы, без ожиданий и проверок условий. Подробнее - в уроке про прерывания.
Диспетчер задач #
Для организации крупного проекта, в котором выполняется множество задач с разными периодами времени и/или режимами энергосбережения, можно использовать простой диспетчер задач, который будет вызывать функции-задачи. Это уже некая "библиотека", которая поможет более лаконично организовать программу, избегать дублирования кода и иметь возможность удобно управлять задачами из одной "точки входа". Примеры:
- GyverOS - простой диспетчер задач, вызывает функции с указанным для них периодом
- Looper - очень мощный диспетчер-фреймворк с автоматической регистрацией задач, поддержкой событий и корутин
Операционная система #
Операционная система - более сложный вариант диспетчера задач, в котором задачи могут иметь приоритеты и выполняться "параллельно" друг другу. Здесь каждая задача - это независимый суперцикл, который выполняется параллельно остальным, даже если содержит блокирующий код. Писать проект с таким подходом гораздо проще, ведь можно буквально наваливать delay() в каждую задачу и не усложнять код машинами состояний.
В рамках данного раздела уроков мы не рассматриваем популярные ОС (например FreeRTOS), т.к. они довольно тяжёлые для слабых МК, требуют знания дополнительной сложной теории многопоточных систем и обладания навыками работы с суперциклом и событиями - так что первым делом всё равно нужно освоить их.
Выбор подхода (шуточный) #
Видео #
Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])
- Поддержать автора за работу над уроками
