вторник, 21 мая 2019 г.

Прерывания в Ардуино. Часть 1

Прерывания в Ардуино. Внешние прерывания

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

Содержание



Что такое прерывание

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

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

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


Внешние прерывания

Следует сразу отметить, что AVR микроконтроллеры имеют две разновидности внешних прерываний. Прерывания первого типа, которым и посвящена эта статья, генерируются при появлении на входе внешнего прерывания заданного сигнала. Как правило, говоря о внешних прерываниях, подразумевают именно этот тип. Прерывания второго типа генерируются при любом изменении состояния определенных выводов микроконтроллера, отсюда их название - Pin Change Interrupt. Они будут рассмотрены в следующей публикации.

Большинство плат Ардуино (а конкретно построенные на базе ATmega328/P) имеют два входа внешних прерываний: INT0 и INT1. Для того чтобы микроконтроллер начал реагировать на поступающие от них запросы прерываний необходимо выполнить следующие действия:
  1. Написать функцию-обработчик прерывания. Она должна быть без параметров и не возвращать никаких значений.
  2. Разрешить внешнее прерывание и задать для него обработчик. Для этих целей используется функция attachInterrupt.


attachInterrupt

Функция attachInterrupt сообщает микроконтроллеру, на какие события он должен реагировать и какой обработчик им соответствует. Функция имеет следующий синтаксис:
attachInterrupt(interrupt, function, mode)
  • interrupt - номер внешнего прерывания. Его можно указать явно (таблица соответствия номеров прерываний выводам Ардуино приведена ниже) или воспользоваться функцией digitalPinToInterrupt(pin) - она вернет номер прерывания по номеру пина.
  • function - функция-обработчик прерывания. Эта функция будет вызываться каждый раз при появлении запроса прерывания.
  • mode - определяет события какого типа будут рассматриваться как запрос прерывания. Для данного параметра предусмотрены следующие значения:
    • LOW - генерировать запрос прерывания при наличии сигнала низкого уровня;
    • RISING - генерировать запрос прерывания при изменении уровня сигнала от низкого к высокому;
    • FALLING - генерировать запрос прерывания при изменении уровня сигнала от высокому к низкого;
    • CHANGE - генерировать запрос прерывания при любом изменении уровня сигнала;
    • для DUE и Zero также доступно значение HIGH.

Таблица соответствия номеров прерываний выводам Ардуино:

Плата INT0 INT1 INT2 INT3 INT4 INT5
UNO и другие на базе ATmega328/P 2 3
Leonardo 3 2 0 1 7
Mega2560 2 3 21 20 19 16

Плата Arduino DUE поддерживает прерывания на всех линиях ввода-вывода. Для нее можно не использовать функцию digitalPinToInterrupt и указывать номер вывода прямо в параметрах attachInterrupt.

Каждый новый вызов функции attachInterrupt привязывает новый обработчик прерывания, то есть если ранее была привязана другая функция-обработчик, то она уже не будет вызываться. Аналогичная ситуация с параметром mode, определяющим тип событий, на которые должен реагировать микроконтроллер. Другими словами нельзя, например, задать отдельные обработчики для FALLING и RISING для одного входа внешнего прерывания.

Разберем пример работы с внешними прерываниями с использованием описанных функций:

#define ledPin 13
#define interruptPin 2 // Кнопка между цифровым пином 2 и GND
volatile byte state = LOW;

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(interruptPin, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(interruptPin), blink, FALLING);
}

void loop() {
  digitalWrite(ledPin, state);
}

void blink() {
  state = !state;
}

В функции setup мы настраиваем тринадцатый пин на вывод, чтобы управлять встроенным светодиодом. Второй пин подтягиваем к питанию, это обеспечит на нем сигнал высокого уровня. Далее функцией attachInterrupt задаем функцию-обработчик blink, которая должна вызываться при изменении сигнала на втором пине от высокого уровня к низкому (FALLING). Внутри функции blink мы изменяем значение переменной state, что впоследствии приводит к включению и выключению светодиода в функции loop. Таким образом можно управлять светодиодом, не опрашивая кнопку в основной программе.

Загрузите этот скетч в Ардуино и проверьте его работу, добавив кнопку между вторым цифровым выводом и землей (или просто замыкая их проводом). В целом скетч будет работать как и задумывалось, но вы заметите, что иногда светодиод не реагирует на нажатие кнопки. Такое поведение вызвано дребезгом контактов кнопки: многократное изменение сигнала на цифровом входе 2 приводит к повторным вызовам обработчика. Как следствие значение переменной state может остаться не измененным. К этой проблеме мы вернемся чуть позже, а пока продолжим разбор примера. В нем остался еще один момент, требующий пояснения - ключевое слово volatile.


volatile

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

byte A = 0;
byte B;

void loop() {
  A++;
  B = A + 1;
}

Переменные A и B - это ячейки в памяти микроконтроллера. Для того чтобы микроконтроллер мог что-то сделать с ними (изменить, сравнить и т.п.)  их значения должны быть загружены из памяти во внутренние регистры. Поэтому при компиляции данного фрагмента будет сгенерирован код вида:

  1. Загрузить из памяти значение A в регистр Р1
  2. Загрузить в регистр Р2 константу 1
  3. Сложить значение Р2 с Р1 (результат в Р2)
  4. Сохранить значение регистра Р2 в памяти по адресу A
  5. Сложить содержимое регистра Р1 с константой 2
  6. Сохранить значение регистра Р1 в памяти по адресу B

Считывание значения переменной А в регистр происходит в самом начале кода. Если на одном из приведенных шагов поступит запрос прерывания, при обработке которого значение переменной A (ячейки памяти) будет изменено, то после возвращения в функцию loop микроконтроллер будет работать с ее неактуальным значением, оставшимся в регистре. Использование квалификатора volatile как раз позволяет избежать подобных ситуаций. При обращении к переменной, объявленной как volatile, микроконтроллер всегда будет считывать ее актуальное значение из памяти, а не использовать считанное ранее (то есть такой код будет генерировать компилятор).

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

Разберем еще один интересный пример:

#define interruptPin 2
volatile byte f = 0;

void setup() {
  pinMode(interruptPin, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(interruptPin), buttonPressed, FALLING);
}

void loop() {
  while (f == 0) {
    // Что-то делаем в ожидании нажатия кнопки
  }
  // Кнопка нажата
}

void buttonPressed() {
  f = 1;
}

Цикл внутри функции loop должен выполняться до тех пор, пока значение переменной f равно нулю. А измениться оно должно в обработчике прерывания при нажатии на кнопку. Если бы мы объявили переменную f без квалификатора volatile, то компилятор, "видя", что значение переменной внутри цикла не изменяется и условие выхода из цикла остается ложным, заменил бы цикл на бесконечный. Так работает оптимизация кода при компиляции. Войдя в такой цикл микроконтроллер просто зависнет. Объявление переменной с квалификатором volatile гарантирует, что эта переменная не получит какой-либо оптимизированный тип доступа.

При работе с прерываниями и совместном использовании переменных обработчиком и основной программой нужно помнить очень важный момент: AVR микроконтроллеры являются 8-битными и для загрузки 16- или 32-разрядного значения из памяти требуется несколько отдельных операций. Соответственно, возможна ситуация, когда микроконтроллер считает из памяти младший байт переменной, после чего поступит запрос прерывания и значение данной переменной будет изменено обработчиком. После возврата в основную программу микроконтроллер считает из памяти  старший байт, получив при этом не просто старое значение, а совершенно другое, что может привести к ошибке работы программы. Для исключения подобных ситуаций можно либо запретить обработку прерываний на время обращения к разделяемым переменным при помощи функций interrupts и noInterrupts, либо поместить обращение к такой переменной в атомарно исполняемый блок кода.


Функции interrupts и noInterrupts

Данные функции служат для разрешения и запрета обработки прерываний соответственно. Они могут быть полезны при обращении к переменной, значение которой изменяется обработчиком прерывания (описанная выше ситуация). Или если код чувствителен к времени выполнения и потому должен выполняться без прерываний. В таком случае код должен быть обрамлен указанными функциями:

  noInterrupts(); // Запрещаем обработку прерываний
  // Критичный к времени выполнения код
  interrupts(); // Разрешаем обработку прерываний

Только учтите, что на прерываниях реализована работа многих модулей: таймеры-счетчики, UART, I2C, SPI, АЦП и другие. Запретив обработку прерываний, вы не сможете, например, использовать класс Serial или функции millis, micros. Поэтому избегайте длительной блокировки прерываний.

Кроме функций interrupts и noInterrupts для этих же целей можно использовать функции  sei и cli - разрешить и запретить прерывания соответственно. Разницы между ними нет, просто последние являются частью набора библиотек AVR Libc, другие же введены разработчиками IDE Arduino в качестве альтернативы, более легкой для запоминания и восприятия в программе. Согласитесь, noInterrupts более говорящее название, чем cli.


Атомарно исполняемые блоки кода

Конструкции для работы с атомарно и не атомарно исполняемыми блоками кода являются частью AVR Libc. Они позволяют определить блок, который будет выполнен гарантированно атомарно (то есть без прерываний) или не атомарно. Ниже приведен пример использования атомарно исполняемого блока:

#include <util/atomic.h>

#define interruptPin 2
volatile unsigned int counter = 0;

void setup() {
  pinMode(interruptPin, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(interruptPin), myISR, FALLING);
}

void myISR() {
  counter++;
}

void loop() {
  unsigned int counter_copy;  
  ATOMIC_BLOCK(ATOMIC_FORCEON){
    // Атомарный доступ к 2х байтной переменной counter:
    counter_copy = counter;
  }
  // Для дальнейшей работы используем значение counter_copy
  // if (counter_copy == ...
}

Данный пример начинается с подключения заголовочного файла atomic.h, в котором содержатся макросы для поддержки атомарности. Затем в функции loop осуществляется обращение к двухбайтной переменной counter. Обращение помещено в блок ATOMIC_BLOCK, что гарантирует его атомарность. Как и в случае с использованием функций interrupts и noInterrupts атомарность достигается за счет глобального запрета обработки прерываний перед входом в такой блок. После выхода из блока обработка прерываний разрешается, за что отвечает параметр ATOMIC_FORCEON. Если по каким-то причинам перед входом в блок нам не известно, разрешены прерывания или нет, то принудительно разрешать их после выхода из блока будет плохим решением. В таких случаях в макросе ATOMIC_BLOCK должен использоваться параметр ATOMIC_RESTORESTATE - он восстановит прежнее значение флага глобального разрешения прерываний:

ATOMIC_BLOCK(ATOMIC_RESTORESTATE){
  // ...
}

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


detachInterrupt

Если в программе больше не требуется отслеживать внешние прерывания, то их обработку можно отменить функцией detachInterrupt. Ее единственный параметр - это номер прерывания, указанный ранее в функции attachInterrupt:

detachInterrupt(digitalPinToInterrupt(interruptPin));


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

Очевидно, что при подаче на вход внешнего прерывания сигнала, искаженного дребезгом контактов, обработчик прерывания будет выполнен несколько раз. В некоторых случаях дребезг не является проблемой, например, если мы ожидаем нажатия на кнопку для выполнения в программе каких-либо действий: достаточно отключить отслеживание данного прерывания при входе в обработчик, а после выполнения нашего кода снова включить (подразумевается, что время выполнения наших действий превышает длительность дребезга контактов). Таким образом мы пропустим дребезг и обработчик будет выполнен только один раз. Другое дело, когда прерывания используются для регистрации событий. Хороший пример - механический энкодер вращения. В этом случае мы не можем отключить отслеживание прерываний, поскольку рискуем пропустить событие (импульс от энкодера). Принять решение о том, ложный это импульс или нет, обработчик не может, да и просто это не та задача, которой он должен заниматься. Поэтому единственным рациональным решением является применение аппаратных подавителей дребезга. Данной теме посвящены несколько моих публикаций, ссылки на них ниже:


Использование прерываний для выхода из спящего режима

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

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

В статье Режимы энергосбережения Ардуино я описал доступные (для плат на базе ATmega328/P) режимы и типы прерываний, которые могут разбудить микроконтроллер. Также данную информацию вы можете найти в разделе Sleep Modes даташита:

Режимы энергосбережения и прерывания ATmega328/P

Внешние прерывания (как рассмотренные в данной статье INTx, так и прерывания по изменению уровня) способны вывести микроконтроллер из любого режима энергосбережения. Здесь я хочу обратить внимание на неточность в даташите на ATmega328/P, якобы в режимах энергосбережения, когда приостанавливается тактирование подсистемы ввода-вывода (а это все режимы за исключением Idle), обнаружение внешних прерываний INTx по фронту недоступно и для пробуждения микроконтроллера следует использовать прерывание по низкому уровню. Это не так. Обнаружение запроса прерывания и пробуждение микроконтроллера возможны для всех (LOW, RISING, FALLING, CHANGE) типов событий на входах INTx. Ну и в подтверждение моих слов простой пример с уходом в сон и пробуждением по внешнему прерыванию по FALLING:

#include <avr/sleep.h>
#define ledPin 13
#define interruptPin 2 // Кнопка между цифровым пином 2 и GND

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(interruptPin, INPUT_PULLUP);
}

void loop() {
  // Гасим светодиод, разрешаем прерывание по FALLING и уходим в сон:
  digitalWrite(ledPin, 0);
  attachInterrupt(digitalPinToInterrupt(interruptPin), myISR, FALLING);
  set_sleep_mode(SLEEP_MODE_PWR_DOWN);
  sleep_mode();
  
  // После пробуждения первым будет выполнен обработчик. Затем мы вернемся сюда.
  // Запрещаем прерывание, включаем светодиод для индикации пробуждения
  detachInterrupt(digitalPinToInterrupt(interruptPin));
  digitalWrite(ledPin, 1);
  // А здесь может идти код, который должен выполняться при нажатии на кнопку
  delay(2000);
}

void myISR() {
  //Тут пусто
}



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

Подробное рассмотрение темы поступления запросов на прерывание и очередности их обслуживания я оставлю для следующей части публикации. Но сейчас считаю нужным озвучить важный момент. При переходе к обработчику прерывания обработка всех остальных прерываний запрещается. Как если бы мы вызвали функцию noInterrupts. После выполнения обработчика и выхода из него обработка прерываний разрешается вновь. Таким образом прерывание одного обработчика другим исключено (если только не разрешить это специально). По этой причине в обработчиках нельзя обращаться к функциям, работа которых основана на прерываниях.


Общие рекомендации по написанию обработчиков прерываний

В заключение хочу привести несколько рекомендаций по написанию обработчиков прерываний.
  • Во-первых, делайте обработчики предельно короткими. Ведь они прерывают выполнение основной программы, а также блокируют обработку других прерываний. По возможности обработчик должен фиксировать только факт возникновения события, изменяя значение переменной. А сама реакция на событие должна выполняться в основной программе при анализе этой переменной.
  • Как уже было сказано, при входе в обработчик устанавливается глобальный запрет на обработку других прерываний. А это в свою очередь влияет на работу функций, использующих прерывания. Будьте с ними осторожнее. Если не уверены в безопасности их вызова, то лучше откажитесь от их использования в обработчике.
  • Возьмите за правило объявлять разделяемые между основной программой и обработчиком переменные как volatile. И не забывайте, что этого квалификатора недостаточно в случае многобайтных переменных - используйте при работе с ними атомарно исполняемые блоки или interrupts/noInterrupts
На этом я закончу первую часть публикации, она уже получилась довольно объемной. В следующей части я покажу как работать с прерываниями без использования функций Ардуино, а также поясню порядок их обработки микроконтроллером.

7 комментариев:

  1. Отличные у вас статьи, большое спасибо за них! Подскажите пожалуйста, я хочу использовать прерывание для выхода из спящего режима. В кратце: при подключении зарядки юсб устройство должно просыпаться чтоб показывать, что процесс зарядки идет. Хочу использовать сигнал с плюса юсб подключенный к int0 и использовать в таком случае RISING, это должно же заработать? И я так понимаю int0 нужно подтягивать к земле через резистор 10к (чтобы не было ложных срабатываний) ?

    ОтветитьУдалить
    Ответы
    1. Должно получиться. Только использовать надо будет не RISING, а CHANGE, чтобы отслеживать и подключение зарядки, и отсоединение. В обработчике прерывания читать уровень на пине, по нему будет ясно, подключили зарядник или отключили.
      Если речь идет о контроллере зарядки для аккумулятора, то в нем, наверняка, есть вывод Stat, к которому подвешен светодиод. Этот вывод можно использовать и микроконтроллером.

      Удалить
    2. Большое спасибо за ответ. Да, контроллер mcp73832 с выходом stat, его также буду использовать как обычную кнопку, чтоб включать анимацию значка батареи. Хочу использовать два вывода, потому что хочется, чтоб усройство просыпалось при подключении usb независимо от того заряжен аккумулятор или нет.

      Удалить
  2. Интересно! Отлично написано!

    ОтветитьУдалить
  3. Благодарю. Очень помогли в понимании.

    ОтветитьУдалить
  4. Большое спасибо за статью. Хотелось бы больше узнать о том, как подавить дребезг контактов при помощи программы. Пока что как я понял, при помощи блока "noInterrupts" "Interrupts". Правильно я понял?

    ОтветитьУдалить