Arduino. Как мигать светодиодом без delay

Данная статья представляет собой краткий курс по основам программирования микроконтроллера Arduino для создания задержек без использования функции delay(). Тема эта, с одной стороны, простая, с другой стороны, как и всё в программировании микроконтроллеров, она требует двигаться от простого к сложному.

Мигать светодиодом - казалось бы, что может быть проще, но на практике индикация состояний - задача отнюдь не тривиальная. Внешняя простота действия не должна вас обманывать, мигать светодиодом - это целое искусство. Ведь, что такое программирование микроконтроллеров, как не переключение транзисторов, реле и светодиодов, а также реакция на внешние события, будь то нажатие кнопки или поворот потенциометра, или же сообщение, полученное по последовательному интерфейсу.

Рассмотрим пример Blink

Пример Blink состоит из двух функций: setup() и loop(). Работает этот пример следующим образом:

Диаграмма примера Blink Arduino

После того, как микроконтроллер был сброшен (передний фронт импульса на выводе 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 миллисекунду заданное число раз:

Блок-схема функции _delay_ms()

Как видно из блок-схемы, функция ожидает наступления события - истечения заданного временного интервала, после чего завершает свою работу. Поэтому и нам следует изменить функцию 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() выглядит так:

Блок-схема новой версии функции 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>