Продолжаем изучение одной из самых самых сложных тем - прерываний. В предыдущей статье
Микроконтроллеры для начинающих. Часть 57. Прерывания
мы рассмотрели несколько самых общих вопросов и коснулись аппаратной части контроллеров прерываний. Но каким бы сложным и функциональным ни был контроллер прерываний, без программной поддержки и программной же обработки, пользы от него будет не много.
Вот это и будет темой сегодняшней статьи. Причем рассматривать вопрос сегодня мы будем безотносительно конкретной реализации в конкретном микроконтроллере. Есть очень много общих моментов в обработке прерываний не требующих погружения в детали реализации. Кроме того, это позволит избежать повторов при дальнейшем изучении различных семейств микроконтроллеров.
Общее, частное, тонкости
Обработка прерываний лежит за границами традиционного прикладного программирования. Хотя некоторые параллели можно провести и с обработкой исключительных ситуаций, и с параллельно выполняющимися и взаимодействующими потоками, и с обработкой сигналов (Unix, С).
При этом нет принципиальной разницы между обработкой программных и обработкой аппаратных прерываний. За исключением того, что обработка аппаратных требует взаимодействия с контроллером прерываний, но это лишь малая часть кода обработчика.
Поскольку языки программирования высокого уровня, в большинстве своем, не предусматривают работу с прерываниями, во многих компиляторах предусматриваются расширения стандарта языка. Поэтому программы с обработкой прерываний являются не только аппаратно-зависимыми, но зачастую и компиляторо-зависимыми.
Что бы не утонуть в деталях различных реализаций я сегодня буду использовать С-подобный синтаксис и обобщенное описание процедуры обработчика
__interrupt ISR_proc(VECT_NUM, PRI) { ... }
здесь __interrupt это модификатор, который и указывает компилятору, что функция ISR_proc, кстати, имя может быть любым на ваш вкус, является не просто функцией, а именно обработчиком прерывания. И компилятор будет обрабатывать специальным образом.
Хорошо видно, что функция не возвращает, да и не может возвращать, значения. Зато имеет два параметра, которые параметрами вызова на самом деле не являются. И оба параметра могут отсутствовать.
Первый параметр VECT_NUM определяет номер прерывания, или номер вектора, что одно и тоже. Именно номер, не адрес. Это важный момент. Этот параметр используется компилятором для размещения вызова функции по правильному адресу в таблице векторов прерываний. В собственно вызове он не используется и в функцию не передается.
Второй параметр PRI определяет приоритет обработчика. Он используется если процессор или контроллер прерываний поддерживают работу с изменяемыми приоритетами прерываний. Гибкое управление приоритетами, если поддерживается аппаратно, позволяет задавать приоритет обработчика не связанным жестко с номером вектора.
Теперь мы можем перейти к более подробному рассмотрению обработки прерываний. И, самое главное, к тем тонкостям, которые обязательно требуется учитывать.
Что в векторе тебе моем?
Про векторы прерываний я говорил уже много раз. А в статье
"Микроконтроллеры для начинающих. Часть 26. Команды вызова подпрограмм и прерываний и возврата из них"
есть иллюстрация показывающая использование векторов. Я повторю эту иллюстрацию здесь
Поскольку в рассматриваемых микроконтроллерах обычно векторы прерываний реализованы именно так, я не буду уделять внимание другим вариантам.
Итак, вектор прерывания это небольшой фрагмент памяти программ, который обеспечивает передачу управления непосредственно обработчику. Обычно размер этого фрагмента небольшой, например, 4 или 8 команд. А его начальный адрес однозначно связан с номером прерывания. Как может формироваться адрес из номера я показал в предыдущей статье.
Поскольку команда перехода к обработчику прерывания может быть довольно длинной, например, переход по адресу внешней памяти программ, размер вектора обычно задается разработчиком микроконтроллера с некоторым запасом.
Таблица векторов прерываний обычно размещается в начале памяти программ. Причина проста, для нее должно найтись место даже в микроконтроллерах с минимальным объемом памяти.
Во многих случаях не требуется размещать таблицу максимального размера, достаточно учесть максимальный номер используемых прерываний. Это позволяет сэкономить небольшой объем памяти. Однако, если "не ожидаемое" прерывание все таки произойдет, то результат будет катастрофическим. И такие случаи действительно иногда случаются!
Поэтому рекомендуется всегда задавать обработчики для всех прерываний, а не только реально используемых в программе. А что бы компилятор не вздумал своевольничать достаточно описать обработчик, даже пустой, для прерывания с максимальным номером возможным для конкретного используемого микроконтроллера.
В данном случае компилятор разместил в векторах, для которых программист не определил в явном виде обработчики прерываний, команды возврата из прерывания. Это позволит, хотя бы, избежать катастрофы с передачей управления в неподходящее для этого место.
Такой способ приходится применять, так как в языке высокого уровня нет возможности формировать таблицу векторов вручную. Да, мы можем задать адрес начала процедуры (через расширение языка). Но мы не можем разместить код вне функции. А при описании функции мы неизбежно получаем добавление компилятором пролога и эпилога к коду функции, что не позволит втиснуть ее в вектор.
В некоторых случаях формирование пролога/эпилога можно избежать с помощью модификатора naked, если он поддерживается компилятором. Таким способом мы можем сформировать именованные фрагменты кода, которые полноценными функциями не являются. Но зато можно задать адрес из размещения. То есть, мы можем сформировать таблицу векторов вручную.
Что еще от нас скрывает компилятор. Сохранение контекста
Точнее не совсем скрывает, а просто старается не обременять лишней информацией тех, кому это не очень нужно. Но иногда настолько успешно, что даже в документации не найти требуемой информации и приходится лезть в сгенерированный код.
Сразу скажу, что касается это в большей части тех, кто использует возможность прямой вставки ассемблерного кода в текст программы на языке высокого уровня. И тех, кто использует специальные возможности компилятора и расширения языка для ручной оптимизации кода. Например, если для перебора элементов массива используется не переменная, а непосредственно индексный регистр.
Вспомните, что я говорил раньше. Программа не должна знать, что прерывание возникло и обработано, если это не предусмотрено программистом в явном виде. А асинхронное прерывание может возникнуть в любой момент времени, даже в середине выполнения машинной команды. Но вот обработка прерывания начнется только после завершения выполнения текущей команды. Выполнение команды прервано быть не может.
А теперь представьте, что выполнялась команда сравнения, результат выполнения которой изменил состояние флагов в байте (слове) состояния процессора. И следующая команда является командой условного перехода. Но вот между сравнением и переходом обрабатывается прерывание.
Выполнение команд обработчика прерывания тоже может изменить флаги состояния процессора. А значит, когда управление вернется команде перехода, состояние флагов будет отличаться от ожидаемого. А это является проблемой.
Поэтому перед обработкой прерывания надо сохранить не только адрес возврата, как показано на первой иллюстрации, но и все состояние процессора. То есть, несколько дополнительных, но важных, регистров. Процессор может оказывать для этого некоторую аппаратную поддержку. Но может и не оказывать.
Но состояние процессора это еще не все. Компилятор использует регистры процессора по своему усмотрению, по своим правилам. И вот эти то правила и приходится иногда искать. Например, компилятор может использовать часть доступных регистров для хранения промежуточных результатов вычислений. Если обработчик прерываний использует те же регистры, то надо сохранять и их. Может потребоваться сохранять указатель стека, если обработчику нужен доступ к переменным размещенным в стеке до возникновения прерывания.
Состояние процессора и состояние общих регистров используемых компилятором для внутренних целей (состояние программы) часто называется одним термином - контекст. Вот сохранение этого контекста перед обработкой прерывания и его восстановление перед возвратом управления и добавляется автоматически компилятором вместо стандартных пролога и эпилога для функций.
В большинстве случаев какого-либо вмешательства программиста не требуется. Компилятор отлично справляется сам. Но если обработчик прерывания написан на ассемблере, или где то в тексте программы используются ассемблерные вставки, компилятор может просто не знать, что нужно сохранять какие то дополнительные регистры. А значит, об этом должен позаботиться сам программист.
Синим цветом я показал стандартные пролог и эпилог обработчика прерывания. В предположении, что аппаратно сохраняется только адрес возврата и запрещаются/разрешаются прерывания. Видно, что в прологе сохраняется состояние процессора, аккумулятора, указателя стека. Причем указатель стека устанавливается на использование отдельного стека. Эпилог восстанавливает сохраненные прологом регистры и выполняет команду возврата из прерывания.
Предположим, что и в основной программе, и в обработчике прерывания, мы используем индексный регистр IREG_X, например, как указатель в некотором буфере. Причем компилятор в этом не участвует (ассемблер, как пример).
Это и приводит к тому, что мы сохраняем/восстанавливаем этот регистр вручную. Будем считать, что в стандартной библиотеке компилятора есть функции push и pop. Причем записи в стек обязательно должно соответствовать извлечение их стека.
Изменчивость и атомарность
Асинхронные прерывания возникают независимо от хода выполнения программы, а их обработчики, в некоторой степени, работают параллельно (псевдо-параллельно) основной программе. А это заставляет нас задуматься о способах работы с переменными, доступ к которым осуществляется и из основной программы, но и из обработчика прерывания.
Прежде всего, давайте вспомним о таком компоненте компилятора, как оптимизатор. Он изменяет сгенерированный код для уменьшения его размера и времени выполнения. И если наша переменная, используемая и программой и обработчиком, входит в какое либо вычисляемое выражение, то оптимизатор запросто может использовать не саму переменную а ее копию во внутреннем регистре процессора.
Но ведь прерывание может возникнуть в любой момент. А оптимизатор имеет право посчитать, что вот в таком случае
for(int i=0; i<=10; i++) {
some_var+=shared_var;
}
переменная shared_var является неизменной. И результат будет отличаться от ожидаемого. Что бы компилятор использовал именно переменную, а не ее временную копию, в языке С используется модификатор volatile.
Все переменные, которые используются и в основной программе, и в обработчике прерывания, должны быть описаны как volatile. Это даст нам уверенность, что компилятор не будет оптимизировать код, в котором переменная используется.
Но и этого иногда оказывается недостаточно. Дело в том, что программы часто работают с переменными, разрядность которых превышает разрядность процессора. И тут возникает еще одна проблема, которую трудно заметить только в терминах языка высокого уровня.
uint16_t var1;
volatile uint16_t var2;
var1=var2;
Что тут может пойти не так? Обратите внимание, что var2 объявлена как volatile, то есть, мы используем ее и в прерывании. Но ведь разрядность var1 и var2 равна 16 битам, а мы рассматриваем 8-битные микроконтроллеры. А значит, операция присваивания будет состоять из нескольких команд в сгененрированном коде. И копирование будет идти по одному байту. А если здесь произойдет прерывание? Например, между копированием младшего и старшего байта. И в этом прерывании значение var2 будет изменено.
Мы получим совершенно ошибочное значение в var1. Один байт будет от старого значения var2, а другой из нового. Значит, нам надо как то объединить команды сгенерированны для операции присваивания в единый неделимый блок. То есть, обеспечить атомарность доступа. Нет, транзакция тут не подойдет. Это не тот случай. Нам подойдет временный запрет прерываний.
uint16_t var1;
volatile uint16_t var2;
DisableInterrupt();
var1=var2;
Enable_interrupt();
Теперь возникшее во время присваивания прерывание будет обработано только после завершения присваивания. И результат операции всегда будет верным. Необходимость обеспечения атомарности легко упустить из виду при разработке программы, особенно, если разрядность была увеличена уже после написания некоторого объема кода. Но такая забывчивость приводит к очень трудно уловимым ошибкам. Отладчиком такие ошибки не отловить.
А что можно сказать про атомарность доступа в обработчике прерывания? Если обработчик сам не может быть прерван (у прерываний нет приоритетов), то дополнительной предосторожности не требуется. Если же прерывания более высокого приоритета возможны, для обеспечения атомарности их тоже следует временно запрещать.
Влияние размещения переменных
Стек позволяет достаточно просто обрабатывать вложенные области видимости переменных. Однако, при обработке прерываний это может быть совсем не так. Независимо от того, используется обработчиком отдельный стек, или фрейм формируется в общем стеке, прерывания могут нарушать иерархию вложенностей.
А значит, локальные переменные функции, или ее вложенных блоков, могут оказаться недоступными обработчику. Тоже самое касается статических переменных объявленных внутри функции, но тут уже влияют ограничения видимости.
Статические переменные объявленные в исходном файле вне функций могут использоваться только обработчиком и функциями расположенными в том же самом файле. Этот момент мы ранее подробно рассматривали.
А вот глобальные переменные могут использоваться в любом случае. Поэтому для новичков, которые с прерываниями еще не работали, я бы посоветовал размещать общие переменные именно как глобальные. Пока новички не освоятся с особенностями работы с прерываниями и для своего контроллера, и для своего компилятора.
Повторная входимость это важно
Вопрос повторной входимости мы уже рассматривали ранее. Но тогда далеко не все оценили важность этого. Сейчас пришло время посмотреть, как этот вопрос обретает реальную значимость при использовании прерываний.
Необходимость повторной входимости возникает тогда, когда какая-либо функция может вызываться во время своего выполнения. Например, когда вы вызываете некую функцию и из основной программы, и из обработчика прерывания. Вот эта вызываемая функция и должна быть повторно входимой. Так как прерывание может возникнуть и во время ее выполнения (при вызове из основной программы).
Я не буду повторять описание условий повторной входимости, это уже было подробно рассмотрено. Но покажу, как мы можем просто не заметить, что требование повторной входимости возникло.
a=b/c;
Что тут может быть не так? Мы же ничего не вызываем. Это не всегда так. Микроконтроллер может не иметь аппаратной команды деления. Команда деления может не поддерживать необходимую разрядность операндов/результата. Тип данных (например, float) может не иметь соответствующей команды деления.
В результате, компилятор или вставит фрагмент кода выполняющий деление прямо вместо отсутствующей команды, или использует вызов библиотечной процедуры. И вот эта библиотечная процедeра должна быть повторно входимой, если деление используется и в прерывании и в основной программе. Хотя мы ничего в явном виде и не вызываем.
Что бы понять, насколько это критично, нужно читать документацию на компилятор. Вполне возможна ситуация, что стандартная библиотека собрана без повторной входимости и нужно использовать специальный экземпляр библиотеки. А возможно использовать и специальные ключи компилятора.
Влияние на параметры времени
Обработка прерываний требует времени. И это дополнительное время может потребоваться в любой момент, в любой точке выполнения программы. И это необходимо учитывать.
Выполнение критичных по времени фрагментов программы нужно обрамлять запретом/разрешением прерываний даже если не требуется обеспечение атомарности.
Кроме того, необходимо стремиться сделать обработчик прерывания как можно более коротким и быстрым. Иначе мы можем и просто пропустить событие.
Как я показал в предыдущей статье, запрос прерывания сохраняется во время запрета прерываний и обрабатывается позднее. Но для запросов прерываний нет какого либо дополнительного хранилища. Если во время запрета прерываний будет сформировано два запроса прерывания от одного и того же источника, то мы после разрешения прерываний сможем увидеть только один, последний. А значит, мы пропустим, как минимум, одно событие.
Заключение
Сегодня я коснулся лишь некоторых важных моментов, которые нужно учитывать при разработке программ использующих прерывания. Как все это применяется на практике мы увидим в наших учебных проектах.
Прерывания это действительно не простая тема. И допущенные ошибки чрезвычайно трудно искать, даже с отладчиком. А отладка программ с прерываниями сама по себе не самая тривиальная.
Тем не менее, прерываний не стоит бояться. Просто они непривычны тем, кто писал лишь прикладные программы. Причем далекие от аппаратурных тонкостей. И вы, без сомнения, научитесь с ними справляться.
На этом тема прерываний не закончена. Мы переходим к изучению особенностей реализации прерываний в различных семействах микроконтроллеров.