Перейти к основному содержимому

Урок 3. Прерывания и таймеры

Введение

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

Знакомство с прерываниями

Прерывание – это сигнал, сообщающий процессору о наступлении какого-либо события, которое требует незамедлительного внимания. Процессор должен отреагировать на этот сигнал, прервав выполнение текущих инструкций и передав управление обработчику прерывания (ISR, Interrupt Service Routine). Обработчик – это обычная функция, которую мы пишем сами и помещаем туда тот код, который должен отреагировать на событие.

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

interrupt

Рис. 1. Принцип работы прерывания.

Для лучшего понимания разберем прерывания на нескольких бытовых примерах. Важно понять, что прерывания – это не просто, когда вы передумали и начали делать что-то другое. Например:

  • Вы перестали смотреть телевизор и приняли ванну;
  • Вы читал книгу, а потом пришли гости, и вы сели с ними за стол;
  • Пользователь нажал красную кнопку, чтобы робот остановился.

В представленных примерах делается что-то вместо того, что делалось до этого. Рассмотрим другие примеры:

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

Прерывания можно разделить на несколько видов:

  • Аппаратные прерывания. Прерывание на уровне микропроцессорной архитектуры. Само событие может произойти в определенный момент от внешнего устройства – например, нажатие кнопки на клавиатуре, движение компьютерной мыши и т.п.
  • Программные прерывания. Запускаются внутри программы с помощью специальной инструкции. Используются, чтобы вызвать обработчик прерываний.
  • Внутренние (синхронные) прерывания. Внутреннее прерывание возникает в результате изменения или нарушения в исполнении программы (например, при обращении к недопустимому адресу, недопустимый код операции и др.).

В этом уроке мы разберем только аппаратные прерывания. Сами по себе прерывания полезны в программах МК, так как помогают решать проблемы синхронизации. Например, при работе с UART прерывания позволяют не отслеживать поступление каждого символа. Внешнее аппаратное устройство подает сигнал прерывания, процессор сразу же вызывает обработчик прерывания, который вовремя захватывает символ. Это позволяет экономить процессорное время, которое без прерываний тратилось бы на проверку статуса UART, вместо этого все необходимые действия выполняются обработчиком прерывания, не затрагивая главную программу.

Основными причинами, по которым необходимо вызвать прерывание, могут быть:

  • Определение изменения состояния вывода;
  • Прерывание по таймеру;
  • Прерывания по интерфейсам передачи данных (наприме, SPI, I2C, UART, которые мы подробнее изучим в уроке 5);
  • Аналогово-цифровое преобразование;
  • Готовность использовать EEPROM (энергонезависимая память).

Прерывание по изменению состояния вывода

В первую очередь рассмотрим прерывания по изменению состояния вывода. Они возникают в ответ на внешнее событие и исходят от внешнего аппаратного устройства. В STM32 представлены 4 типа таких прерываний. Все они различаются поведением сигнала на контакте прерывания и в программе определяются следующим образом:

  • LOW: Контакт притянут к земле. Обработчик прерывания исполняется до тех пор, пока на пине прерывания будет сигнал LOW.
  • CHANGE: Изменение сигнала на контакте. В таком случае МК выполняет обработчик прерывания, когда на пине прерывания происходит изменение сигнала от LOW к HIGH, или наоборот, от HIGH к LOW.
  • RISING (фронт): Изменение сигнала от LOW к HIGH на контакте – при изменении с низкого сигнала на высокий будет исполняться обработчик прерывания.
  • FALLING (спад): Изменение сигнала от HIGH к LOW на контакте – при изменении с высокого сигнала на низкий будет исполняться обработчик прерывания.
к сведению

Иногда RISING называют передним фронтом, а FALLING - задним фронтом.

interrupt

Рис. 2. Поведение сигнала.

Такое разнообразие прерываний даёт возможность выбирать именно тот режим, который подходит для разрабатываемого приложения. Каждый режим полезен в определенной ситуации.

Теперь давайте приступим к практической части. Напишем следующую программу:

  • при нажатии на кнопку USR_BTN должен загораться светодиод LED2;
  • при отпускании кнопки USR_BTN светодиод LED2 гаснет;
  • светодиод LED1 в это же самое время должен постоянно мигать с частотой 1 Гц, независимо от нажатия на кнопку.

Для начала реализуем программу, которая пытается это делать без использования прерываний. Её код представлен ниже.

// Светодиод загорается при нажатии на кнопку, когда постоянно в цикле идет проверка состояния кнопки. 
// При этом другой светодиод постоянно моргает с интервалом в 500 мс.

#include <VBCoreG4_arduino_system.h>

// задать обозначения для пинов
#define BTN_PIN PC13
#define LED1_PIN PD2
#define LED2_PIN PA5

int buttonState = LOW;

void setup() {
// инициализировать пин PD2 в режим выхода (LED1)
pinMode(LED1_PIN, OUTPUT);
// инициализировать пин PA5 в режим выхода (LED2)
pinMode(LED2_PIN, OUTPUT);
// инициализировать пин PC13 как вход с подтягивающим резистором
pinMode(BTN_PIN, INPUT_PULLUP);
}

void loop() {
// считывание входного пина
buttonState = digitalRead(BTN_PIN);

// если кнопка не нажата, то светодиод LED2 не горит
if (buttonState == HIGH) {
digitalWrite(LED2_PIN, LOW);
}
// если кнопка нажата, то светодиод LED2 горит
if (buttonState == LOW) {
digitalWrite(LED2_PIN, HIGH);
}

// мигаем светодиодом LED1
digitalWrite(LED1_PIN, HIGH);
delay(500);
digitalWrite(LED1_PIN, LOW);
delay(500);
}

Загрузите этот код на МК. После загрузки вы увидите, что LED1 действительно моргает с частотой 1 Гц. Можете даже проверить это осциллографом. Но вот если нажать кнопку USR_BTN, то вы увидите, что светодиод LED1 иногда загорает, иногда нет, а иногда делает это с задержкой. Это связано с использованием функции delay(). Когда контроллер вызывает эту функцию, он делает паузу и больше ничего не выполняет до того момента, пока не отсчитает 300 мс. Из-за этого он может пропускать некоторые нажатия на кнопку.

Исправим эту проблему с помощью прерывания. Для установки прерываний используется функция attachInterrupt(digitalPinToInterrupt(pin), function, mode) со следующими аргументами:

  • pin – номер пина, по которому отслеживается прерывание;
  • function – название вызываемой функции при прерывании (важно – функция не должна ни принимать, ни возвращать какие-либо значения);
  • mode – условие срабатывания прерывания: RISING, FALLING, CHANGE или LOW.

При работе с прерываниями нужно обязательно учитывать следующие важные ограничения:

  • Функция–обработчик (ISR) не должна выполняться слишком долго. Все дело в том, что МК не может обрабатывать несколько прерываний одновременно. Пока выполняется ваша функция-обработчик, все остальные прерывания останутся без внимания, и вы можете пропустить важные события. Если надо делать что-то большое – просто передавайте обработку событий в основном цикле loop(). В обработчике лучше всего лишь устанавливать флаг события, а в loop() – проверять флаг и обрабатывать его.
  • Если у вас есть какие-либо переменные в функции-обработчике, перед ними должно стоять ключевое слово volatile. Объявление переменной как volatile предотвращает ее оптимизацию компилятором. В противном случае значение переменной может быть неточным. Ключевое слово volatile гарантирует, что значение переменной обновится, если оно будет изменена в другой части кода.
  • Не рекомендуется использовать большое количество прерываний (старайтесь использовать не более 6-8). Большое количество разнообразных событий требует серьезного усложнения кода, а, значит, ведет к потенциальным ошибкам. К тому же надо понимать, что ни о какой временной точности исполнения в системах с большим количеством прерываний речи быть не может – вы никогда точно не поймете, каков промежуток между вызовами важных для вас команд.
  • В обработчиках категорически нельзя использовать delay(). Механизм определения интервала задержки использует таймеры, а они тоже работают на прерываниях, которые заблокирует ваш обработчик. В итоге все будут ждать всех, и программа зависнет

Теперь приступим к написанию самой программы:

// Светодиод загорается когда срабатывает прерывание по кнопке

#include <VBCoreG4_arduino_system.h>

// задать обозначения для пинов
#define BTN_PIN PC13
#define LED1_PIN PD2
#define LED2_PIN PA5

volatile int buttonState = LOW;

void btnInterrupt() {
buttonState = digitalRead(BTN_PIN); // Считываем состояние кнопки
if (buttonState == HIGH) { // Если кнопка не нажата
digitalWrite(LED2_PIN, LOW); // светодиод выключен
}
if (buttonState == LOW) { // Если кнопка нажата
digitalWrite(LED2_PIN, HIGH); // светодиод включен
}
}

void setup() {
// инициализировать пин PD2 в режим выхода (LED1)
pinMode(LED1_PIN, OUTPUT);
// инициализировать пин PA5 в режим выхода (LED2)
pinMode(LED2_PIN, OUTPUT);
// инициализировать пин PC13 как вход с подтягивающим резистором
pinMode(BTN_PIN, INPUT_PULLUP);

// Установка внешнего прерывания
attachInterrupt(digitalPinToInterrupt(BTN_PIN), btnInterrupt, CHANGE);
}

void loop() {
// мигаем светодиодом LED1
digitalWrite(LED1_PIN, HIGH);
delay(500);
digitalWrite(LED1_PIN, LOW);
delay(500);
}

В приведенном коде есть функция ISR для нажатия кнопки, называемая btnInterrupt(). Внутри ISR-функции находится код, который считывает состояние кнопки и включает или выключает желтый светодиод. Обратите внимание, что так как переменная buttonState используется в ISR-функции, то она объявлена с ключевым словом volatile.

Рассмотрим другой пример, где в прерывании считаются нажатия кнопки USR_BTN, а затем значение счётчика передаётся по Serial. Код программы представлен ниже.

// Счетчик по кнопке без гашения дребезга контактов.

#include <VBCoreG4_arduino_system.h>

// задать обозначения для пинов
#define BTN_PIN PC13

volatile int counter = 0;

void btnInterrupt() {
counter++;
Serial.println(counter);
}

void setup() {
// инициализировать serial
Serial.begin(115200);
// инициализировать пин PC13 как вход с подтягивающим резистором
pinMode(BTN_PIN, INPUT_PULLUP);
// Установка внешнего прерывания
attachInterrupt(digitalPinToInterrupt(BTN_PIN), btnInterrupt, RISING);
}

void loop() {

}

Загрузите код на микроконтроллер и протестируйте работу программы. Если вы нажимали несколько раз на кнопку, то скорее всего обратили внимание, что иногда по одному нажатию счётчик инкрементируется два и более раз. Логической ошибки в коде нет. Проблема здесь связана с так называемым дребезгом контактов. Давайте подробнее разберемся с этим явлением.

Дребезг контактов

В основе работы любой кнопки лежит механический способ смыкания-размыкания контактов. Нажимая на любую, даже самую простую тактовую кнопку, мы формируем определенное давление на внутренние механизмы (пластины или пружины), в результате чего происходит сближение или расхождение металлических пластин.

В момент нажатия на кнопку в месте соединения контакты не соприкасаются мгновенно – микронеровности на поверхности не позволяют пластинам мгновенно соединиться. Из-за этого в короткий промежуток времени на границе пластинок меняется и сопротивление, и взаимная емкость, из-за чего возникают масса разнообразных изменений уровня тока и напряжения. Другими словами, возникают очень интересные, хотя и не очень приятные процессы, которые в электротехнике называют переходными.

Переходный процесс в электротехнике — это кратковременное изменение токов и напряжений в электрической цепи при её переключении из одного устойчивого состояния в другое. Переходные процессы протекают очень быстро и исчезают за доли миллисекунд. Поэтому мы редко их замечаем, например, когда включаем свет в комнате, хотя на самом деле они есть. Лампа накаливания не может менять свою яркость мгновенно, а наш мозг при этом не может реагировать на свет на столько быстро, чтобы увидеть эти изменения яркости. Но, обрабатывая сигнал от кнопки на таком быстром устройстве, как микроконтроллер, мы вполне можем столкнуться с подобными переходными эффектами и должны их учитывать при программировании.

В идеальном мире форма сигнала после нажатия на кнопку должна быть строго прямоугольная. В реальных же условиях вместе резкого перехода мы видим множество пиков и спадов.

interrupt

Рис. 3. Поведение сигнала при наличии дребезга контактов.

interrupt

Рис. 4. Осциллограмма, показывающая наличие дребезга контактов.

Как отразится дребезг в нашем проект? Мы будем получать срабатывание прерывания каждый раз, когда на пине PC13 есть восходящий фронт (т.е. переход из LOW в HIGH) и, соответственно, будем лишний раз производить инкремент счетчика.

Бороться с дребезгом можно двумя способами: аппаратно или программно. Аппаратный способ подразумевает припаивание конденсатора к кнопке. Программный способ подразумевает введение задержки при чтении уровня сигнала с кнопки. Сделать это можно при помощи функции millis(). Она позволяет засечь время, прошедшее от первого срабатывания кнопки. Перепишите программу в соответствии с кодом ниже.

// Счетчик с гашением дребезга контактов.

#include <VBCoreG4_arduino_system.h>

// задать обозначения для пинов
#define BTN_PIN PC13

volatile int counter = 0;
unsigned long previousMillis = millis();

void btnInterrupt() {
if (millis() - previousMillis >= 100) {
// запомнить время первого срабатывания
previousMillis = millis();
// инкрементировать счетчик
counter++;
// вывести значение счетчика в монитор
Serial.println(counter);
}
}

void setup() {
// инициализировать serial
Serial.begin(115200);
// инициализировать пин PC13 как вход с подтягивающим резистором
pinMode(BTN_PIN, INPUT_PULLUP);
// установить внешнее прерывание
attachInterrupt(digitalPinToInterrupt(BTN_PIN), btnInterrupt, RISING);
}

void loop() {

}

Снова проверьте работу счетчика. В этот раз ошибка должна исчезнуть.

Вопрос

Как вы думаете, почему мы не воспользовались функцией delay() при написании этой программы?

Прерывание по таймеру

Таймером называется счетчик, который производит счет с некоторой частотой. Прерывание по таймеру подразумевает срабатывание ISR-функции каждый раз, когда происходит отсчет таймера.

Работа с таймером в библиотеке VBCoreG4_arduino_system производится с помощью класса HardwareTimer(timer). В качестве аргумента timer можно передать номер аппаратного таймера от TIM1 до TIM7. Рассмотрим программу, мигающую светодиодом с частотой 0.5 Гц.

// Светодиод моргает при срабатывании таймера.

#include <VBCoreG4_arduino_system.h>

// задать обозначения для пинов
#define LED1_PIN PD2

volatile bool led1_state = false;

void func_timer() // обработчик прерывания
{
led1_state = !led1_state;
digitalWrite(LED1_PIN, led1_state);
}

void setup() {
// инициализировать пин PC13 как вход с подтягивающим резистором
pinMode(LED1_PIN, OUTPUT);

HardwareTimer *timer = new HardwareTimer(TIM3);
timer->pause(); // останавливаем таймер перед настройкой
timer->setOverflow(1, HERTZ_FORMAT); // 1 Hz
timer->attachInterrupt(func_timer); // активируем прерывание
timer->refresh(); // обнулить таймер
timer->resume(); // запускаем таймер
}

void loop() {

}

Обратите внимание, что в качестве аргумента функции setOverflow мы указали 1 Гц, а не 0.5 Гц. Это связано с тем, что в обработчике прерывания мы переключаем состояние светодиода и, следовательно, период моргания получается 2 секунды или, что тоже самое, частота 0.5 Гц.

Также в функции setOverflow(value, format) помимо указания частоты HERTZ_FORMAT, можно указать и другие форматы:

  • TICK_FORMAT: переменная value определяет количество тактов процессора;
  • MICROSEC_FORMAT: переменная value определяет время в микросекундах.

Функция attachInterrupt(foo) в качестве аргумента принимает функцию-обработчик прерывания. Она будет вызываться всякий раз, когда срабатывает таймер.

Сделаем программу, использующую формат MICROSEC_FORMAT. Программа должна через Serial принимать целое число, равное периоду мерцания светодиода в миллисекундах. В это время в функции прерывания по таймеру должно производится переключение состояния этого светодиода. Код программы представлен ниже.

// Настраиваем частоту таймера по команде с Serial.

#include <VBCoreG4_arduino_system.h>

// задать обозначения для пинов
#define LED1_PIN PD2

volatile bool led1_state = false;
int period = 1000;

HardwareTimer *timer = new HardwareTimer(TIM3);

void timer_callback() // обработчик прерывания
{
led1_state = !led1_state;
digitalWrite(LED1_PIN, led1_state);
}

void setup() {
Serial.begin(115200);
// инициализировать пин PC13 как вход с подтягивающим резистором
pinMode(LED1_PIN, OUTPUT);

timer->pause(); // останавливаем таймер перед настройкой
timer->setOverflow(1, HERTZ_FORMAT); // 1 Hz
timer->attachInterrupt(timer_callback); // активируем прерывание
timer->refresh(); // обнулить таймер
timer->resume(); // запускаем таймер
}

void loop() {
if (Serial.available() > 0) {
period = Serial.readString().toInt();
if (period >= 1)
{
timer->setOverflow(period*1000/2, MICROSEC_FORMAT);
timer->refresh();
timer->resume();
Serial.println("Period " + String(period) + " ms is set");
}
else
Serial.println("Period must be integer number and greater than or equal to 1");
}
}

Загрузите код на МК, откройте Serial Monitor и протестируйте программу. Обратите внимание, что при вводе нужно использовать целочисленные значения.

Прерывание по наличию данных в буфере интерфейсов передачи данных

Для удобства отслеживания наличия принятых данных по интерфейсам коммуникации также можно использовать прерывания. Мы с вами пока только поверхностно коснулись протокола UART (Serial Port), но в следующих уроках подробно изучим и другие виды протоколов.

Встроенный в библиотеку VBCoreG4_arduino_system класс Serial уже использует внутри себя прерывания и сохраняет принятые байты в буфер. Функция Serial.available() как раз возвращает значение счётчика принятого количества байт. Следовательно, для работы с UART нам не нужно настраивать собственный обработчик прерываний. Достаточно лишь, как мы уже до этого делали, на каждой итерации цикла loop() проверять наличие принятых данных. Порой такая постоянная проверка в цикле делает код громоздким и менее читаемым. В качестве альтернативы можно использовать встроенную функцию serialEvent(), которая вызывается автоматически в конце каждой итерации цикла loop(), если в буфере UART есть данный. За вызов этой функции как раз отвечает встроенный в библиотеку обработчик прерываний.

В качестве примера использования serialEvent() напишем программу «Эхо». Она должна принимать данные с компьютера, а затем тут же отправлять их обратно. Код программы представлен ниже.

// Читаем Serial Port по прерыванию.

#include <VBCoreG4_arduino_system.h>

void setup() {
Serial.begin(115200);
}

void loop() {

}

void serialEvent()
{
String s = Serial.readString();
Serial.print(s);
}

Протестируйте программу и убедитесь, что она работает так, как планировалось.

Заключение

На этом уроке мы познакомились с механизмом прерываний и разобрались, зачем они нужны при программировании микроконтроллеров. На практических примерах было показано, что постоянный опрос входов в основном цикле программы не всегда эффективен и может приводить к ошибкам в работе устройства. Использование прерываний позволяет микроконтроллеру быстро реагировать на важные события, не отвлекаясь от выполнения основной программы.

Мы научились настраивать внешние прерывания по нажатию кнопки, разобрались с разными режимами их срабатывания и увидели, как прерывания помогают корректно обрабатывать нажатия даже тогда, когда в программе используются задержки. Также была рассмотрена проблема дребезга контактов кнопки и показано, почему её необходимо учитывать при работе с реальными электронными компонентами.

Отдельное внимание было уделено прерываниям по таймеру. Мы научились использовать аппаратные таймеры для создания периодических событий, таких как мигание светодиодов, а также менять параметры работы программы во время её выполнения с помощью команд, передаваемых по интерфейсу UART. Это показало, как можно управлять поведением микроконтроллера в реальном времени.

В завершение мы познакомились с функцией serialEvent(), которая упрощает обработку данных, поступающих по последовательному порту, и делает код более понятным и аккуратным.

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

Задачи

  1. Доработайте программу из листинга 6. Сделайте так, чтобы с помощью кнопки USR_BTN можно было выбирать моргающий светодиод.
  2. Сделайте так, чтобы оба светодиода LD1 и LD2 моргали с определенной частотой. Причем частота LD1 должна быть 5 Гц, а частота LD2 – 1 Гц. Для этого вам нужно реализовать два обработчика прерываний по таймерам TIM5 и TIM6.
  3. Соберите проект со светодиодом, подключенному к пину PA9. Пусть он моргает с частотой 2 Гц. При этом должна быть возможность регулировать яркость светодиода по UART от 0 до 255, где 0 соответствует отсутствию напряжения на светодиоде, а 255 – максимальной яркости.