Arduino. Как мигать светодиодом без delay
Данная статья представляет собой краткий курс по основам программирования микроконтроллера Arduino для создания задержек без использования функции delay(). Тема эта, с одной стороны, простая, с другой стороны, как и всё в программировании микроконтроллеров, она требует двигаться от простого к сложному.
Мигать светодиодом - казалось бы, что может быть проще, но на практике индикация состояний - задача отнюдь не тривиальная. Внешняя простота действия не должна вас обманывать, мигать светодиодом - это целое искусство. Ведь, что такое программирование микроконтроллеров, как не переключение транзисторов, реле и светодиодов, а также реакция на внешние события, будь то нажатие кнопки или поворот потенциометра, или же сообщение, полученное по последовательному интерфейсу.
Рассмотрим пример Blink
Пример Blink состоит из двух функций: setup() и loop(). Работает этот пример следующим образом:
После того, как микроконтроллер был сброшен (передний фронт импульса на выводе NRESET), осуществляется инициализация глобальных переменных, затем управление передаётся функции setup(), после чего в бесконечном цикле вызывается функция loop().
Заглянем в код функции loop():
// the loop function runs over and over again forever
void loop() {
digitalWrite(LED_BUILTIN, HIGH); // turn the LED on (HIGH is the voltage level)
delay(1000); // wait for a second
digitalWrite(LED_BUILTIN, LOW); // turn the LED off by making the voltage LOW
delay(1000); // wait for a second
}
В ней мы видим дубль из вызовов. Сперва вызывается функция digitalWrite(), затем вызывается функция delay(). Этот код повторяется. В данном примере это сделано для простоты самого примера, но на самом деле здесь нарушен принцип инкапсуляции. Мы должны оформить дублирующийся код в виде функции. Назовём её blinkDelay():
// Переключить светодиод и подождать
void blinkDelay(int state)
{
digitalWrite(LED_BUILTIN, state); // turn the LED on (HIGH is the voltage level)
delay(1000); // wait for a second
}
Функцию loop() изменим следующим образом:
// the loop function runs over and over again forever
void loop()
{
blinkDelay(HIGH); // Включить светодиод
blinkDelay(LOW); // Выключить светодиод
}
Теперь покопаемся в исходниках Arduino и изучим, как работает функция delay().
Как работает функция delay()
А функция delay() устроена довольно просто. В качестве входного параметра она принимает число миллисекунд. Поэтому она просто вызывает функцию библиотеки avr-libc, которая называется _delay_ms().
Функция _delay_ms() выполняет задержку в 1 миллисекунду заданное число раз:
Как видно из блок-схемы, функция ожидает наступления события - истечения заданного временного интервала, после чего завершает свою работу. Поэтому и нам следует изменить функцию blinkDelay() таким образом, чтобы она сперва ожидала наступления события, а уже затем переключала состояние светодиода. Ведь так было бы логичнее, не правда ли?
// Подождать и переключить светодиод
void blinkDelay(int state)
{
delay(1000); // wait for a second
digitalWrite(LED_BUILTIN, state); // turn the LED on (HIGH is the voltage level)
}
Но, что если и нам не ждать 1000 миллисекунд, а завести счётчик и раз в миллисекунду уменьшать его на единицу и проверять, истёк уже интервал или нет, а потом переключать состояние светодиода?
Чтобы знать его текущее состояние, заведём переменную ledState. Тогда можно избавиться от функции blinkDelay() и весь код разместить в функции loop().
Новая версия функции loop()
Вот, что у нас получилось:
int delayCounter = 0;
int state = LOW;
// the setup function runs once when you press reset or power the board
void setup() {
// initialize digital pin LED_BUILTIN as an output.
pinMode(LED_BUILTIN, OUTPUT);
}
// the loop function runs over and over again forever
void loop()
{
if (delayCounter == 0) {
if (state == LOW)
state = HIGH;
else
state = LOW;
digitalWrite(LED_BUILTIN, state);
delayCounter = 1000;
}
delay(1); // wait for a millisecond
--delayCounter; // Уменьшить счётчик на единицу
}
Блок-схема новой версии функции loop() выглядит так:
Заметим, что мы по-прежнему можем задать разные интервалы для состояний включенного и выключенного светодиода. Просто нужно немного видоизменить алгоритм:
// the loop function runs over and over again forever
void loop()
{
if (delayCounter == 0) {
if (state == LOW) {
state = HIGH;
delayCounter = 150; // Светить 150 мс
}
else {
state = LOW;
delayCounter = 350; // Не светить 350 мс
}
digitalWrite(LED_BUILTIN, state);
}
delay(1); // wait for a millisecond
--delayCounter; // Уменьшить счётчик на единицу
}
Здесь я задал мигание с интервалом в пол-секунды, короткими импульсами по 150 миллисекунд.
Использование таймера-счётчика 2
Теперь, когда у нас всё так изящно инкапсулировано, мы можем спросить себя: а чего мы ждём в строке 15, вызывая функцию delay()?
Правильно, мы ждём, когда истечёт 1 миллисекунда времени.
Так что нам мешает ждать этого события от таймера-счётчика?
Правильно, ничего не мешает. Так что давайте запрограммируем таймер-счётчик №2, чтобы он раз в 1 миллисекунду подавал нам сигнал: миллисекунда прошла.
Так и поступим.
Чтобы использовать таймер-счётчик 2, воспользуемся библиотекой VEduino. Для этого скачаем её с сервера SourceForge. Затем установим библиотеку VEduino в папку libraries.
Если вы ещё не устанавливали библиотеки в Arduino вручную, то см. пункт Manual Installation на странице Arduino - Libraries. Файл с библиотекой VEduino нужно распаковать из архива и после копирования папки переименовать её в VEduino.
После установки библиотеки можно скомпилировать скетч (скачать код скетча). Рассмотрим скетч подробнее:
#include <ve_avr.h>
Эта строка сообщает компилятору, что будет использоваться библиотека VEduino.
int delayCounter = 0;
int state = LOW;
volatile bool timer2compaInterrupt = false;
Это наши глобальные переменные: счётчик временной задержки, состояние светодиода и флаг прерывания.
Переменная флага прерывания timer2compaInterrupt объявлена как изменяемая внезапно (volatile), потому что её значение изменяется внутри обработчика вектора прерывания. Поэтому компилятор не будет сохранять её значение в регистре, а будет каждый раз при обращении к ней брать её текущее значение из памяти.
В функцию setup() мы добавили код инициализации таймера-счётчика 2:
DEV_TIMER2.setClockSelect(Prescaler2::Prescaler_64);
DEV_TIMER2.setWaveGenMode(Timer2::CTC);
DEV_TIMER2.setOutputCompareA(249);
DEV_TICTRL2.outCompIntEnableA();
interrupts();
Мы задаём предделитель /64, то есть таймер-счётчик будет тактироваться с частотой Fclk / 64. Для Arduino это
16e6 / 64 = 250 кГц.
То есть каждый такт таймера будет происходить 1 раз в 4 микросекунды.
Наше прерывание должно вызываться раз в 1 миллисекунду, поэтому в регистр сравнения OCR2A мы записываем значение 249. Если 1 мс разделить на одну 250 килогерцовую, то есть на 4 микросекунды, то мы получаем число 250. Однако таймер-счётчик считает от нуля, то есть мы отнимаем единицу от этого значения и получаем 249.
Мы выбираем режим генерации волны CTC, при котором таймер-счётчик считает до совпадения значения с регистром OCR2A. Затем счёт возобновляется с нуля, то есть прерывание TIMER2_COMPA_vect будет вызываться 1 раз в милисекунду, что задаётся значением регистра OCR2A.
Далее мы разрешаем прерывание TIMER2_COMPA_vect, и разрешаем прерывания вызовом функции interrupts().
Мы добавляем обработчик вектора прерывания. Его код прост, здесь просто устанавливается флаг, что произошло прерывание:
ISR(TIMER2_COMPA_vect)
{
timer2compaInterrupt = true;
}
В функции loop() мы проверяем, произошло ли прерывание, и уменьшилось ли значение счётчика до 0:
void loop()
{
if (timer2compaInterrupt) {
timer2compaInterrupt = false;
if (delayCounter == 0) {
if (state == LOW) {
state = HIGH;
delayCounter = 150; // Светить 150 мс
}
else {
state = LOW;
delayCounter = 350; // Не светить 350 мс
}
digitalWrite(LED_BUILTIN, state);
}
else
--delayCounter; // Уменьшить счётчик на единицу
}
}
Оптимизируем скетч
Отлично. Всё работает, но я вижу здесь не оптимальное решение. Мы проверяем, произошло ли прерывание каждый раз, и каждый раз, когда оно происходит, выполняем уменьшение счётчика временной задержки.
Логичнее поместить код счётчика в обработчик прерывания, а в функции loop() проверять, закончился ли отсчёт временной задержки.
Объявим счётчик временной задержки как volatile.
volatile int delayCounter = 0;
Флаг прерывания уберём. Обработчик прерывания изменим следующим образом:
ISR(TIMER2_COMPA_vect)
{
if (delayCounter != 0) {
--delayCounter; // Уменьшить счётчик на единицу
}
}
И код функции loop() заметно упростится:
void loop()
{
if (delayCounter == 0) {
if (state == LOW) {
state = HIGH;
delayCounter = 150; // Светить 150 мс
}
else {
state = LOW;
delayCounter = 350; // Не светить 350 мс
}
digitalWrite(LED_BUILTIN, state);
}
}
Эту версию тоже можно скачать.
Особенности программирования прерываний
Всё бы было прекрасно, если бы не одно обстоятельство - микроконтроллер Arduino построен на 8-битном ядре AVR8, которому для работы с чтением-записью 16-разрядных переменных типа int требуются две команды на чтение и две команды на запись. Более того - и при сравнении таких переменных выполняются две команды.
А это значит, что прерывание может произойти в тот момент, когда первая команда уже выполнена, а вторая ещё нет. То есть мы сравниваем нашу переменную delayCounter с нулём, а она в середине этой операции может измениться, что и происходит - достаточно посмотреть, как работает оптимизированный скетч на практике.
Мигает он очень странно, совсем не так он должен мигать, как он мигает. И причина одна - мы имеем дело с так называемыми небезопасными с точки зрения прерываний операциями.
Выход один - использовать 8-битный флаг типа bool, чтобы сообщать основному циклу из прерывания, что отсчёт завершён, и из основного цикла уведомлять прерывание, что можно начать работать с переменной-счётчиком.
Итак, мы добавляем волатильную переменную-флаг counted, с помощью которой можно будет работать с прерываниями безопасно. Потому что она 8-разрядная, как и например, тип char. И мы могли бы использовать тип char для переменной-счётчика, но при этом мы были бы ограничены максимальным значением задержки в 255 миллисекунд.
Если бы мы перешли от миллисекундных задержек к другой системе, например, к 4 миллисекундным задержкам, используя предделитель не /64, а /256, то и предыдущая версия скетча могла бы работать с максимальным временем задержки в 1020 милисекунд, но мы ведь пишем серьёзный скетч, а не что-то такое, красивого алгоритма ради.
volatile bool counted = true;
В обработчике прерывания мы проверяем наш флаг, нужно ли вести отсчёт, уменьшая значение счётчика. Если счётчик уменьшился до нуля, то здесь же мы и выключаем отсчёт, устанавливая переменную-флаг в значение true.
ISR(TIMER2_COMPA_vect)
{
if (counted == false) {
--delayCounter; // Уменьшить счётчик на единицу
if (delayCounter == 0)
counted = true;
}
}
Код же функции loop() выглядит не намного более громоздким, благодаря использованию логической переменной.
void loop()
{
if (counted) {
if (state == LOW) {
state = HIGH;
delayCounter = 150; // Светить 150 мс
}
else {
state = LOW;
delayCounter = 350; // Не светить 350 мс
}
counted = false;
digitalWrite(LED_BUILTIN, state);
}
}
Пусть мигает сам
Посмотрев на то, что получилось свежим взором, я спросил себя: а зачем вообще использовать функцию loop()? Ведь всё это можно делать в обработчике прерывания. За исключением вызова функции digitalWrite().
Дело в том, что компилятор AVR-GCC использует соглашение "вызывающий сохраняет регистры", поэтому из прерываний нельзя вызывать функции, потому что тогда в код обработчика прерывания будет добавлен код, сохраняющий и восстанавливающий все регистры, которых у AVR8 32. Это очень замедлит обработку прерываний, а в некоторых случаях может привести к тому, что микроконтроллер будет не успевать обрабатывать события.
Но мы используем библиотеку VEduino, которая написана таким образом, что все её функции компилятор преобразует в команды непосредственной работы с регистрами, поэтому её функции мы можем использовать в коде обработчиков прерываний.
Однако есть одно но. Функция digitalWrite() не обращается к светодиоду явно. Мы не указываем ей, что нужно зажечь светодиод, подключенный к выводу 5 порта B (в случае использования ATmega328P). Мы используем мнемоническую ссылку LED_BUILTIN (встроенный светодиод).
Таким образом код примера Blink получается переносимым. Он одинаково работает на любой плате Arduino.
При непосредственной работе с регистрами ввода-вывода мы теряем переносимость, и должны под конкретную модель Arduino указывать нужный порт и номер пина.
В случае с Arduino UNO это будет PORTB и пин номер 5.
Определим для них мнемонические ссылки:
#define LED_GPIO DEV_GPIOB
#define LED_PIN 5
Поскольку у нас используются 16-битные переменные, определяющие время задержки, то мы теперь не сможем их безопасно устанавливать, нам потребуются для них буферные переменные и флаг, сообщающий обработчику прерывания, что нужно обновить значения времени задержки из буфера:
int delayCounter;
int highDelay, lowDelay;
int highDelayBuf, lowDelayBuf;
volatile bool bufUpdated;
Добавим функцию, которая устанавливает время задержки по всем правилам:
void setLedDelays(int high, int low)
{
bufUpdated = false;
highDelayBuf = high;
lowDelayBuf = low;
bufUpdated = true;
}
Настройку светодиода и таймера тоже оформим в виде функции:
void setupLed()
{
// initialize digital pin LED_BUILTIN as an output.
LED_GPIO.setMode(LED_PIN, GPIO::MODE_OUTPUT);
LED_GPIO.setLow(LED_PIN);
delayCounter = 1;
DEV_TIMER2.setClockSelect(Prescaler2::Prescaler_64);
DEV_TIMER2.setWaveGenMode(Timer2::CTC);
DEV_TIMER2.setOutputCompareA(249);
DEV_TICTRL2.outCompIntEnableA();
interrupts();
}
Обработчик прерывания:
ISR(TIMER2_COMPA_vect)
{
if (--delayCounter == 0) {
if (bufUpdated) {
highDelay = highDelayBuf;
lowDelay = lowDelayBuf;
bufUpdated = false;
}
if (LED_GPIO.isLow(LED_PIN)) {
LED_GPIO.setHigh(LED_PIN);
delayCounter = highDelay;
}
else {
LED_GPIO.setLow(LED_PIN);
delayCounter = lowDelay;
}
}
}
Обратите внимание: первое, что делает обработчик прерывания - это уменьшает счётчик задержки. Поэтому, чтобы он корректно отработал самое первое прерывание, в функции setupLed() мы задаём значение счётчика задержки равным единице. Так сказать, немножко параметрического программирования.
И вот, как теперь выглядят наши функции setup() и loop():
// the setup function runs once when you press reset or power the board
void setup()
{
setupLed();
setLedDelays(150, 350);
}
// the loop function runs over and over again forever
void loop()
{
}
Мы можем поместить в loop() любой новый код, а светодиод так и будет себе мигать как ни в чём не бывало. Если это не многозадачность, то что такое многозадачность?
Автор: Андрей Шаройко <vanyamboe@gmail.com>