MicroPython:Основы/Язык MicroPython и его реализация/Написание обработчиков прерываний

Материал из Онлайн справочника
Перейти к навигацииПерейти к поиску

Перевод: Максим Кузьмин
Проверка/Оформление/Редактирование: Мякишев Е.А.


Написание обработчиков прерываний[1]

В MicroPython есть возможность писать обработчики прерываний на Python для плат/модулей, обладающих соответствующим функционалом. Обработчики прерываний – их также называют программами обслуживания прерываний – это, по сути, функции обратного вызова. Они запускаются в ответ на некоторое событие вроде срабатывания таймера или изменения напряжения на контакте. Эти события могут произойти в любой момент работы программного кода, что серьезно влияет не только на подход к MicroPython-программированию, но и на работу со всеми системами, способными реагировать на события, происходящие в реальном времени. В этой статье сначала будут описано то, что касается языка MicroPython, а затем будет краткое введение в программирование в режиме реального времени для тех, кому эта тема в новинку.

Во введении будут встречаться слова и фразы вроде «медленный» или «как можно быстрее». Это сделано нарочно, т.к. скорость зависит от запущенного приложения. Допустимая продолжительность работы обработчика зависит от частоты появления прерываний, от главной программы, а также от того, происходят ли в это время какие-то другие события.

Советы и практические рекомендации

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

  • Старайтесь писать как можно более короткий и простой код.
  • Старайтесь избегать выделения памяти: не используйте добавление в списки или словари, не используйте числа с плавающей точкой.
  • Используйте micropython.schedule, чтобы обойти ограничение в пункте выше.
  • Если обработчик прерывания возвращает несколько байтов, используйте bytearray, память для которого уже была выделена предварительно. Если несколько целых чисел используются одновременно и обработчиком прерывания, и главной программой, советуем воспользоваться массивом array.array.
  • Если у вас есть данные, которые используются одновременно главной программой и обработчиком прерывания, советуем отключить прерывания в том месте главной программы, который предшествует доступу к этим данным, а когда этот фрагмент кода закончится, сразу же их включить (см. раздел «Критические разделы кода» ниже).
  • Создайте буфер для экстренного исключения (см. ниже).

Работа с прерываниями в MicroPython

Буфер для экстренного исключения

Если в обработчике прерывания возникнет ошибка, MicroPython не сможет создать сообщение о ней, разве что вы не создадите специальный буфер для этой цели. Ловить ошибки будет проще, если в программе с прерываниями будет вот этот код:

import micropython
micropython.alloc_emergency_exception_buf(100)

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

Простота

По множеству разных причин код обработчика прерываний должен быть максимально коротким и простым. После возникновения события-триггера он должен сделать ровно то, что нужно, а операции, выполнение которых можно отложить, лучше перенести в код основной программы. Обычно прерывание возникает в результате получения сигнала от некого устройства, и обработка этого прерывания подготавливает устройство к тому, чтобы оно могло отправить новый сигнал (и вызвать новое прерывание). Обработчик прерывания «общается» с главной программой путем обновления общих данных – так он сообщает ей о возникновении прерывания. Обработчик прерывания должен вернуть главной программе контроль как можно быстрее. Эта рекомендация касается не только MicroPython, поэтому ниже о ней будет рассказано более подробно (см. раздел «Создание обработчика прерываний»).

Коммуникация между обработчиком прерывания и главной программой

Как правило, обработчику прерываний нужно «общаться» с главной программой. Один из самых простых способов организовать это «общение» – это создать один или несколько объектов с данными, которые будут общими для главной программы и обработчика прерываний: либо при помощи глобальных переменных, либо с помощью класса (более подробно читайте ниже). Впрочем, в использовании этих методов есть некоторые риски и ограничения. Обычно для этих целей используются целые числа, объекты bytes и bytearray, а также массивы (из модуля array), которые могут хранить разные типы данных.

Использование методов объекта как функций обратного вызова

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

import pyb, micropython
micropython.alloc_emergency_exception_buf(100)
class Foo(object):
    def __init__(self, timer, led):
        self.led = led
        timer.callback(self.cb)
    def cb(self, tim):
        self.led.toggle()

red = Foo(pyb.Timer(4, freq=1), pyb.LED(1))
green = Foo(pyb.Timer(2, freq=0.8), pyb.LED(2))

В этом коде экземпляр red привязывает таймер 4 к светодиоду 1 – в результате, когда таймер 4 запускает прерывание, вызывается метод red.cb(), заставляющий светодиод 1 поменять свое состояние. Экземпляр green работает похожим образом: прерывание на таймере 2 ведет к вызову метода green.cb(), который переключает светодиод 2. Использование методов экземпляра имеет два плюса. Во-первых, использование одного класса позволяет использовать один и тот же код в разных экземплярах. Во-вторых, функция обратного вызова – это связанный метод, поэтому ее первым аргументом будет self. Это позволяет функции обратного вызова получать доступ к данным экземпляра и сохранять данные в промежутке между успешными вызовами. К примеру, если бы в конструкторе класса Foo выше была переменная self.count со значением «0», то метод cb() мог бы увеличить значение в этом счетчике. Таким образом, экземпляры red и green могли бы использовать независимые счетчики того, сколько раз их светодиоды меняли свое состояние.

Создание Python-объектов

Обработчики прерываний не могут создавать экземпляры Python-объектов. Причина в том, что MicroPython необходимо выделять память под эти объекты из хранилища свободной памяти, которое называется «кучей». В обработчике прерываний этого делать не разрешается, потому что выделение памяти в куче не реентерабельно. Другими словами, прерывание может произойти прямо в процессе выделения памяти главной программой – поэтому, чтобы сохранить целостность кучи, интерпретатор не разрешает выделять память в коде обработчика прерывания.

Из-за этого обработчики событий не могут выполнять вычисления, где присутствуют числа с плавающей точкой, потому что это Python-объекты. По той же причине обработчики событий не могут добавлять новые элементы в списки. На практике трудно определить, какие именно конструкции попытаются выполнить выделение памяти и спровоцировать ошибку – это еще одна причина для того, чтобы делать код обработчика событий как можно более коротким и простым.

Один из способов избежать эту проблему – это воспользоваться буферами с предварительно выделенной памятью. Например, можно создать в конструкторе класса экземпляр bytearray и булев флаг. Метод обработчика прерываний присвоит данные позициям в буфере и задаст этому флагу значение «1». В результате при инстанцинировании объекта выделение памяти будет происходить в коде главной программы, а не обработчика прерываний.

Как правило, методы чтения/записи в библиотеке MicroPython могут использовать буферы с предварительно выделенной памятью. К примеру, в первом аргументе метода pyb.i2c.recv() можно задать изменяемый буфер, что позволит использовать этот метод в обработчике прерывания.

Кроме того, вы можете создать объект без использования класса и глобальных переменных. Это делается следующим образом:

def set_volume(t, buf=bytearray(3)):
    buf[0] = 0xa5
    buf[1] = t >> 4
    buf[2] = 0x5a
    return buf

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

Создание экземпляра объекта происходит при создании указателя на связанный метод. Это значит, что обработчик прерывания не сможет передать связанный метод в функцию. Один из способов решить эту проблему – это создать указатель на связанный метод в конструкторе класса и передать этот указатель обработчику прерываний. Например:

class Foo():
    def __init__(self):
        self.bar_ref = self.bar  # выделение памяти происходит тут 
        self.x = 0.1
        tim = pyb.Timer(4)
        tim.init(freq=2)
        tim.callback(self.cb)

    def bar(self, _):
        self.x *= 1.2
        print(self.x)

    def cb(self, t):
        # передача self.bar запустит выделение памяти:
        micropython.schedule(self.bar_ref, 0)

Но есть и другие способы: можно задать и инстанцинировать метод в конструкторе класса или передать Foo.bar() с аргументом self.

Использование объектов Python

Еще одно ограничение, связанное с объектами, произрастает из особенностей работы языка Python. При выполнении оператора import код Python компилируется в байт-код, причем одна строчка кода, как правило, преобразуется в несколько инструкций байт-кода. Когда код запускается, интерпретатор считывает все инструкции байт-кода и выполняет их как серию инструкций машинного кода. Но поскольку прерывание может произойти в любой момент – в том числе между выполнениями инструкций машинного кода – исходная строчка Python-кода может быть выполнена только частично. Соответственно, в момент прерывания в таких Python-объектах как множества, списки или словари, модифицируемых в главной программе, может нарушиться внутренняя целостность.

В результате обработчик прерывания может запуститься как раз в тот момент, когда объект обновлен лишь частично, и когда обработчик прерывания попытается прочесть этот объект, произойдет вылет программы. Как правило, такие проблемы происходят редко и в случайные моменты, поэтому диагностировать их трудно. Их, впрочем, можно обойти, и о способах сделать это рассказывается в разделе «Критические разделы кода» ниже.

Тут важно прояснить о модификации объектов. Если изменение данных во встроенном типе данных вроде словаря проблематично, то изменение данных в массиве или массиве байтов – нет. Причина в том, что байты/слова сохраняются в одну непрерываемую инструкцию машинного кода: в терминологии программирования в реальном времени она называется «атомарной». Объект, созданный пользователем, может инстанцинировать целое число, массив или массив байтов. Их содержимое могут использовать и главная программа, и обработчик прерываний.

В MicroPython поддерживается использование целых чисел произвольной точности. Значения в диапазоне между 2**30 -1 и -2**30 будут храниться в одном машинном слове. Более крупные значения хранятся в Python-объектах. Соответственно, изменения в длинных целых числах нельзя считать атомарными. Пользоваться длинными целыми числами в обработчике прерываний небезопасно, т.к. выделение памяти может не удаться из-за изменения значения в переменной.

Как обойти ограничение с числами с плавающей точкой

Как правило, в обработчике прерываний числа с плавающей точкой лучше не использовать: устройства, как правило, работают с целыми числами, а манипуляции с числами с плавающей точкой происходят, как правило, в коде главной программы. Но существуют DSP-алгоритмы (от англ. «digital signal processing», что можно перевести как «алгоритм цифровой обработки сигналов»), которым требуются именно числа с плавающей точкой. На платформах, где вычисления с числами с плавающей точкой реализованы аппаратно (вроде Pyboard), для обхода этого ограничения можно использовать встроенный ассемблер ARM Thumb. Это возможно, потому что такие процессоры хранят числа с плавающей точкой в машинных словах: обработчик прерываний и код главной программы могут пользоваться общими данными при помощи массивов чисел с плавающей точкой.

Использование micropython.schedule()

Эта функция позволяет обработчику событий «очень быстро» выполнять функцию обратного вызова. Она ставит функцию обратного вызова в очередь на выполнение – и это выполнение произойдет, когда куча не будет заблокирована.

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

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

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

Кроме того, выполнение функции обратного вызова должно быть относительно быстрым, чтобы поспевать за частотой, с которой происходят прерывания. Если прерывание произойдет в момент выполнения предыдущей функции обратного вызова, новый экземпляр функции обратного вызова будет поставлен в очередь – его выполнение произойдет только после того, как завершится выполнение текущего экземпляра. Таким образом, если прерывания возникают очень часто, это чревато появлением огромной очереди и в итоге сбоем с ошибкой «RuntimeError».

Если функция обратного вызова, передаваемая в schedule() – это связанный метод, советуем почитать раздел «Создание Python-объектов» выше.

Исключения

Если обработчик прерывания выдает исключение, оно не будет передано в главную программу. Прерывания будут отключены, пока это исключение не будет обработано кодом обработчика прерываний.

Общие вопросы по работе с прерываниями

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

Создание обработчика прерываний

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

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

Следовательно, циклические конструкции нужно либо минимизировать, либо вовсе их избегать. Необходимо избегать операций ввода/вывода (кроме тех, что касаются работы с устройством, ставшим причиной прерывания) – такие операции ввода/вывода как доступ к диску, операторы print и доступ к UART-порту, во-первых, выполняются относительно медленно, и во-вторых, их продолжительность может варьироваться. Еще одна проблема в том, что функции файловой системы не реентерабельны: использование операций ввода/вывода файловой системы в обработчике прерываний и главном коде могут быть неприятные последствия. Важно, чтобы код обработчика прерываний не ждал никакого события. Операциями ввода/вывода можно пользоваться, если код гарантированно вернет результат через предсказуемый промежуток времени – например, если это переключение контакта или светодиода. Вам, возможно, нужно будет воспользоваться интерфейсами I2C или SPI, чтобы получить доступ к устройству, сгенерировавшему прерывание, но вы должны рассчитать и определить время, требуемое для этого, а также то, как это повлияет на работу приложения.

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

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

Есть несколько способов обойти эту проблему, и самый простой – это воспользоваться кольцевым буфером. Если у вас нет возможности воспользоваться структурой с внутренней потоковой безопасностью, есть и другие способы, описанные ниже.

Реентерабельность

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

Критические разделы кода

Примером критического раздела кода может служить код, который получает доступ к более чем одной переменной, к которой также может получить доступ обработчик прерываний. Если прерывание произойдет в промежутке между доступами к отдельным переменным, их значения станут неконсистентными. Эта проблема известна как «состояние гонки»: обработчик прерываний и главная программа будут соревноваться за изменение этих переменных. Чтобы избежать этого, нужно сделать так, чтобы обработчик прерываний не смог поменять эти значения в этом критическом разделе кода. Это можно сделать, во-первых, если до критического раздела поместить pyb.disable_irq(), а после него – pyb.enable_irq(). Вот пример использования этого подхода:

import pyb, micropython, array
micropython.alloc_emergency_exception_buf(100)

class BoundsException(Exception):
    pass

ARRAYSIZE = const(20)
index = 0
data = array.array('i', 0 for x in range(ARRAYSIZE))

def callback1(t):
    global data, index
    for x in range(5):
        data[index] = pyb.rng() # симулируем ввод данных
        index += 1
        if index >= ARRAYSIZE:
            raise BoundsException('Превышены границы массива')

tim4 = pyb.Timer(4, freq=100, callback=callback1)

for loop in range(1000):
    if index > 0:
        irq_state = pyb.disable_irq() # начало критического раздела 
        for x in range(index):
            print(data[x])
        index = 0
        pyb.enable_irq(irq_state) # конец критического раздела
        print('loop {}'.format(loop))
    pyb.delay(1)

tim4.callback(None)

Критический раздел может содержать одну строчку кода и одну переменную. Взгляните на этот фрагмент:

count = 0
def cb(): # функция обратного вызова из обработчика прерываний
    count +=1
def main():
    # код, позволяющий перескочить 
    # функцию обратного вызова из обработчика прерываний:
    while True:
        count += 1

В этом фрагменте проиллюстрирован труднодиагностируемый источник багов. Строчка count += 1 в главной программе может стать причиной особого подвида состояния гонки, известного как «чтение-модификация-запись». Это классический источник багов в системах реального времени. В главном коде считывается значение t.counter, затем к нему прибавляется единица, после чего результат сохраняется (записывается). В редких случаях прерывание происходит после операции чтения и до операции записи. В результате прерывание модифицирует t.counter, но когда обработчик прерывания возвращает свое значение, это изменение перезаписывается главной программой. На практике это может приводить к редким, непредсказуемым сбоям.

Выше говорилось, что нужно быть осторожнее в ситуациях, когда главная программа может модифицировать объект Python, но доступ к нему также есть у обработчика прерываний. Код, выполняющий эту модификацию, должен считаться критическим разделом – чтобы гарантировать, что этот экземпляр будет находиться в корректном состоянии при запуске обработчика прерываний. Кроме того, нужно быть особенно быть осторожным, если какие-то данные используются одновременно в разных обработчиках прерываний. Опасность в том, что прерывание с более высоким приоритетом может возникнуть в тот момент, когда прерывание с меньшим приоритетом лишь частично обновило эти общие данные. Решение этой проблемы – это сложная тема, выходящая далеко за пределы этого руководства, но здесь можно добавить, что в той ситуации можно иногда использовать мьютексы (см. ниже).

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

Чтобы значительно снизить время отключения прерываний, можно воспользоваться объектом, который называется «мьютексом» (название произошло от «mutual exclusion» или сокр. «mutex», что можно перевести как «взаимное исключение»). Суть в том, что главная программа блокирует мьютекс до запуска критического раздела, а потом разблокирует его. Обработчик прерываний в это время сверяется с тем, заблокирован ли мьютекс. Если да, он избегает доступа к критическому разделу и завершает свою работу. Трудность здесь в том, чтобы понять, что обработчик прерываний должен делать, когда ему отказано в доступе к этим критическим переменным. Простой пример мьютекса можно найти тут. Помните, что хотя код мьютекса и отключает прерывания, но это отключение длится в течение восьми машинных команд: преимущество такого подхода в том, что это практически никак не влияет на другие прерывания.

Прерывания и REPL

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

def bar():
    foo = pyb.Timer(2, freq=4, callback=lambda t: print('.', end=''))

bar()

Эта функция продолжит работать до тех пор, пока таймер не будет отключен явно или вы не сбросите плату с помощью  Ctrl + D .

См.также

Внешние ссылки