Написання обробників переривань

На відповідному апаратному забезпеченні MicroPython надає можливість писати обробники переривань мовою Python. Обробники переривань — також відомі як підпрограми обслуговування переривань (ISR) — визначаються як функції зворотного виклику. Вони виконуються у відповідь на подію, наприклад спрацьовування таймера або зміну напруги на виводі. Такі події можуть виникати в будь-який момент під час виконання програмного коду. Це має суттєві наслідки, частина з яких є специфічною для мови MicroPython, тоді як інші є спільними для всіх систем, здатних реагувати на події реального часу. Цей документ спочатку розглядає мовно-специфічні питання, після чого містить короткий вступ до програмування в режимі реального часу для тих, хто з ним не знайомий.

У цьому вступі використовуються розмиті поняття, як-от «повільно» або «якнайшвидше». Це зроблено навмисно, оскільки швидкість залежить від конкретного застосування. Допустима тривалість виконання ISR залежить від частоти виникнення переривань, характеру основної програми та наявності інших паралельних подій.

Питання MicroPython

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

Якщо в ISR виникає помилка, MicroPython не може сформувати звіт про помилку, якщо для цього не створено спеціальний буфер. Налагодження спрощується, якщо наступний код включено в будь-яку програму, що використовує переривання.

import micropython

micropython.alloc_emergency_exception_buf(100)

Буфер екстреного виключення може зберігати лише один стек виклику виключення. Це означає, що якщо під час обробки виключення при заблокованій купі буде викинуто друге виключення, стек виклику другого виключення замінить вихідний — навіть якщо друге виключення було чисто оброблено. Це може призводити до заплутаних повідомлень про виключення, якщо буфер буде надруковано пізніше.

Простота

З різних причин важливо тримати код ISR якомога коротшим і простим. Він повинен виконувати лише те, що потрібно зробити негайно після події, яка його спричинила: операції, які можна відкласти, слід делегувати головному циклу програми. Як правило, ISR взаємодіє з апаратним пристроєм, який спричинив переривання, готуючи його до наступного переривання. Він обмінюється з головним циклом оновленням спільних даних, сигналізуючи про виникнення переривання, і повертається. ISR повинен якнайшвидше повертати управління головному циклу. Це не є специфічним питанням MicroPython, тому розглядається детальніше нижче.

Зв’язок між ISR та основною програмою

Зазвичай ISR потребує зв’язку з основною програмою. Найпростіший спосіб — через один або кілька спільних об’єктів даних, які оголошені як глобальні або спільні через клас (див. нижче). Існують різні обмеження та небезпеки при цьому, які розглядаються докладніше нижче. Для цієї мети зазвичай використовуються цілі числа, об’єкти bytes та bytearray, а також масиви (з модуля array), які можуть зберігати різні типи даних.

Використання методів об’єктів як зворотних викликів

MicroPython підтримує цю потужну техніку, яка дозволяє ISR спільно використовувати змінні екземпляра з базовим кодом. Вона також дозволяє класу, що реалізує драйвер пристрою, підтримувати кілька екземплярів пристрою. Наступний приклад змушує два світлодіоди блимати з різною частотою.

import machine
import micropython

micropython.alloc_emergency_exception_buf(100)


class Foo(object):
    def __init__(self, freq, led):
        self.led = led
        self.timer = machine.Timer(-1, freq=freq, callback=self.cb, hard=True)

    def cb(self, tim):
        self.led.toggle()


red = Foo(1, machine.LED("LED_RED"))
green = Foo(0.8, machine.LED("LED_GREEN"))

У цьому прикладі екземпляр red керує червоним світлодіодом від 1 Гц віртуального таймера: кожного разу, коли таймер спрацьовує, викликається red.cb(), перемикаючи червоний світлодіод. Екземпляр green працює аналогічно з таймером 0.8 Гц, перемикаючи зелений світлодіод. Використання методів екземпляра дає дві переваги. По-перше, один клас дозволяє спільно використовувати код між кількома апаратними екземплярами. По-друге, як прив’язаний метод, перший аргумент функції зворотного виклику — self. Це дозволяє зворотному виклику звертатися до даних екземпляра та зберігати стан між послідовними викликами. Наприклад, якби клас мав змінну self.count, встановлену в нуль у конструкторі, cb() міг би збільшувати лічильник. Екземпляри red та green тоді підтримували б незалежні лічильники кількості змін стану кожного світлодіода.

Створення об’єктів Python

ISR не можуть створювати екземпляри об’єктів Python. Це пов’язано з тим, що MicroPython потребує виділення пам’яті для об’єкта зі сховища вільних блоків пам’яті, яке називається heap. Це не дозволяється в обробнику переривань, оскільки виділення купи не є реентрантним. Іншими словами, переривання може виникнути тоді, коли основна програма перебуває в процесі виділення пам’яті — для підтримки цілісності купи інтерпретатор забороняє виділення пам’яті в коді ISR.

Наслідком цього є те, що ISR не можуть використовувати арифметику з плаваючою комою, оскільки числа з плаваючою комою є об’єктами Python. Аналогічно, ISR не може додавати елемент до списку. На практиці буває важко точно визначити, які конструкції коду намагатимуться виділити пам’ять і спровокують повідомлення про помилку: це ще одна причина тримати код ISR коротким і простим.

Один зі способів уникнути цієї проблеми — використовувати в ISR заздалегідь виділені буфери. Наприклад, конструктор класу створює екземпляр bytearray та булевий прапорець. Метод ISR записує дані до комірок буфера та встановлює прапорець. Виділення пам’яті відбувається в коді основної програми при створенні екземпляра об’єкта, а не в ISR.

Методи вводу/виводу бібліотеки MicroPython зазвичай надають можливість використовувати заздалегідь виділений буфер. Наприклад, machine.I2C.readfrom_into() зчитує у змінний буфер, наданий викликаючою стороною: це дозволяє використовувати його в ISR.

Спосіб створення об’єкта без використання класу або глобальних змінних такий:

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

Компілятор створює екземпляр аргументу за замовчуванням buf під час першого завантаження функції (зазвичай під час імпортування модуля, в якому вона знаходиться).

Екземпляр об’єкта створюється, коли виникає посилання на прив’язаний метод. Це означає, що ISR не може передавати прив’язаний метод функції. Одне з рішень — створити посилання на прив’язаний метод у конструкторі класу та передавати це посилання в ISR. Наприклад:

class Foo():
    def __init__(self):
        self.bar_ref = self.bar  # Allocation occurs here
        self.x = 0.1
        self.tim = machine.Timer(-1, freq=2, callback=self.cb, hard=True)

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

    def cb(self, t):
        # Passing self.bar would cause allocation.
        micropython.schedule(self.bar_ref, 0)

Інші техніки — визначити та створити екземпляр методу в конструкторі або передати Foo.bar() з аргументом self.

Використання об’єктів Python

Додаткове обмеження на об’єкти виникає через особливості роботи Python. Під час виконання оператора import код Python компілюється в bytecode, де один рядок коду, як правило, відповідає кільком байткодам. Під час виконання коду інтерпретатор зчитує кожен байткод і виконує його як послідовність інструкцій машинного коду. Оскільки переривання може виникнути в будь-який момент між інструкціями машинного коду, вихідний рядок Python-коду може бути виконаний лише частково. Відповідно, такий об’єкт Python, як множина, список або словник, що змінюється в головному циклі, може не мати внутрішньої узгодженості в момент виникнення переривання.

Типовий результат такий. У рідкісних випадках ISR запускається саме в той момент, коли об’єкт частково оновлений. Коли ISR намагається прочитати об’єкт, відбувається збій. Оскільки такі проблеми зазвичай виникають рідко і у випадкові моменти, їх важко діагностувати. Існують способи обійти цю проблему, описані в Critical Sections нижче.

Важливо чітко розуміти, що є модифікацією об’єкта. Зміна вмісту масиву або bytearray є безпечною. Це тому, що байти або слова записуються однією інструкцією машинного коду, яка не може бути перервана: у термінах програмування реального часу запис є атомарним. Те ж саме стосується оновлення елемента словника, оскільки елементи є словами машинного рівня — цілими числами або покажчиками на об’єкти. Об’єкт, визначений користувачем, може містити масив або bytearray. Допустимо, щоб і головний цикл, і ISR змінювали вміст цих структур.

Небезпека виникає, коли змінюється структура об’єкта, зокрема словників. Додавання або видалення ключів може спричинити перегешування. Якщо жорсткий ISR запускається під час перегешування та намагається звернутися до елемента, може виникнути збій. Внутрішньо глобальні змінні реалізовані як словник. Тому основна програма повинна створити всі необхідні глобальні змінні до запуску процесу, що генерує жорсткі переривання. Код застосунку також повинен уникати видалення глобальних змінних.

MicroPython підтримує цілі числа довільної точності. Значення від -230 до 230 -1 зберігаються в одному машинному слові. Більші значення зберігаються як об’єкти Python. Отже, зміни довгих цілих чисел не можна вважати атомарними. Використання довгих цілих чисел в ISR є небезпечним, оскільки під час зміни значення змінної може бути спроба виділення пам’яті.

Подолання обмеження для чисел з плаваючою комою

Загалом, найкраще уникати використання чисел з плаваючою комою в коді ISR: апаратні пристрої зазвичай оперують цілими числами, а перетворення в числа з плаваючою комою зазвичай виконується в головному циклі. Однак деякі алгоритми DSP потребують обчислень з плаваючою комою. На платформах з апаратним модулем плаваючої коми (таких як OpenMV Cam на базі STM32) вбудований асемблер ARM Thumb можна використовувати для обходу цього обмеження. Це пов’язано з тим, що процесор зберігає значення з плаваючою комою в машинному слові; значення можна спільно використовувати між ISR та кодом основної програми через масив чисел з плаваючою комою.

Використання micropython.schedule

Ця функція дозволяє ISR запланувати зворотний виклик для виконання «дуже скоро». Зворотний виклик ставиться в чергу на виконання, яке відбудеться тоді, коли купа не заблокована. Отже, він може створювати об’єкти Python і використовувати числа з плаваючою комою. Також гарантується, що зворотний виклик запуститься тоді, коли основна програма завершила всі оновлення об’єктів Python, тому зворотний виклик не зустрінеться з частково оновленими об’єктами.

Типове використання — обробка апаратного датчика. ISR отримує дані від апаратного забезпечення та дозволяє йому видати наступне переривання. Потім він планує зворотний виклик для обробки даних.

Заплановані зворотні виклики повинні відповідати принципам проектування обробника переривань, описаним нижче. Це дозволяє уникнути проблем, пов’язаних з операціями вводу/виводу та модифікацією спільних даних, що можуть виникати в будь-якому коді, який перериває головний цикл програми.

Час виконання потрібно розглядати у зв’язку з частотою виникнення переривань. Якщо переривання виникає, поки виконується попередній зворотний виклик, до черги додається ще один екземпляр зворотного виклику; він запуститься після завершення поточного. Тому тривале надходження переривань з високою частотою несе ризик неконтрольованого зростання черги та зрештою призведе до відмови з RuntimeError.

Якщо зворотний виклик, що передається до schedule(), є прив’язаним методом, зверніть увагу на примітку в розділі «Створення об’єктів Python».

Виключення

Якщо ISR викидає виключення, воно не поширюється на головний цикл. Переривання буде вимкнено, якщо виключення не обробляється кодом ISR.

Інтерфейс з asyncio

Коли ISR запускається, він може перервати планувальник asyncio. Якщо ISR виконує операцію asyncio, робота планувальника може бути порушена. Це стосується як жорстких, так і м’яких переривань, а також застосовується, якщо ISR передав виконання іншій функції через micropython.schedule. Зокрема, створення або скасування задач є неприпустимим в контексті ISR. Безпечний спосіб взаємодії з asyncio — реалізувати корутину з синхронізацією за допомогою asyncio.ThreadSafeFlag. Наступний фрагмент ілюструє створення задачі у відповідь на переривання:

tsf = asyncio.ThreadSafeFlag()


def isr(_):  # Interrupt handler
    tsf.set()


async def foo():
    while True:
        await tsf.wait()
        asyncio.create_task(bar())

У цьому прикладі між виконанням ISR та виконанням foo() буде змінна затримка. Це є невід’ємною властивістю кооперативного планування. Максимальна затримка залежить від застосунку та платформи, але зазвичай вимірюється десятками мілісекунд.

Загальні питання

Це лише короткий вступ до теми програмування реального часу. Початківцям слід зауважити, що помилки проектування в програмах реального часу можуть призводити до несправностей, які особливо важко діагностувати. Це пов’язано з тим, що вони можуть виникати рідко і з випадковими інтервалами. Вкрай важливо від початку правильно розробити дизайн та передбачити проблеми до їх виникнення. Як обробники переривань, так і основна програма повинні бути розроблені з урахуванням наступних питань.

Проектування обробника переривань

Як зазначалося вище, ISR слід проектувати максимально простими. Вони повинні завжди завершуватися за короткий передбачуваний час. Це важливо, тому що поки виконується ISR, головний цикл не виконується: неминуче головний цикл відчуває паузи у своєму виконанні в довільних точках коду. Такі паузи можуть бути джерелом важкодіагностованих помилок, особливо якщо їх тривалість велика або непостійна. Щоб зрозуміти наслідки часу виконання ISR, необхідно базове розуміння пріоритетів переривань.

Переривання організовані за схемою пріоритетів. Сам код ISR може бути перерваний перериванням вищого пріоритету. Це має наслідки, якщо два переривання спільно використовують дані (див. розділ «Критичні секції» нижче). Якщо таке переривання виникає, воно вносить затримку в код ISR. Якщо переривання нижчого пріоритету виникає під час виконання ISR, воно буде затримано до завершення ISR: якщо затримка занадто велика, переривання нижчого пріоритету може не відпрацювати. Ще одна проблема з повільними ISR — це випадок, коли під час їх виконання виникає друге переривання того ж типу. Друге переривання буде оброблено після завершення першого. Однак якщо швидкість надходження переривань постійно перевищує можливості ISR з їх обслуговування, результат буде невтішним.

Тому слід уникати або мінімізувати конструкції з циклами. Зазвичай слід уникати вводу/виводу з пристроями, відмінними від пристрою, що перериває: такий вввід/вивід, як доступ до диску, оператори print та доступ до UART, є відносно повільним, а його тривалість може змінюватися. Ще одна проблема тут полягає в тому, що функції файлової системи не є реентрантними: використання вводу/виводу файлової системи в ISR і в основній програмі одночасно було б небезпечним. Головне, що код ISR не повинен чекати на подію. Введення/виведення прийнятне, якщо код гарантовано повертається за передбачуваний час, наприклад, перемикання виводу або світлодіода. Доступ до пристрою, що перериває, через I2C або SPI може бути необхідним, але час таких звернень слід розрахувати або виміряти та оцінити їх вплив на застосунок.

Зазвичай виникає потреба спільно використовувати дані між ISR та головним циклом. Це можна зробити через глобальні змінні або через змінні класу чи екземпляра. Змінні, як правило, є цілочисельними або булевими типами, або цілочисельними чи байтовими масивами (заздалегідь виділений цілочисельний масив забезпечує швидший доступ, ніж список). Якщо ISR змінює кілька значень, необхідно врахувати випадок, коли переривання виникає тоді, коли основна програма звернулася до деяких, але не до всіх значень. Це може призводити до суперечностей.

Розглянемо наступний дизайн. ISR зберігає вхідні дані в bytearray, потім додає кількість отриманих байтів до цілого числа, що представляє загальну кількість байтів, готових до обробки. Основна програма зчитує кількість байтів, обробляє байти, а потім скидає кількість готових байтів. Це буде працювати, допоки переривання не виникне відразу після того, як основна програма зчитала кількість байтів. ISR поміщає додані дані в буфер і оновлює кількість отриманих, але основна програма вже зчитала кількість, тому обробляє спочатку отримані дані. Нові байти, що надійшли, втрачаються.

Існують різні способи уникнути цієї небезпеки, найпростіший — використовувати кільцевий буфер. Якщо неможливо використати структуру з вбудованою потокобезпечністю, інші способи описані нижче.

Реентрантність

Потенційна небезпека може виникнути, якщо функція або метод використовується спільно між основною програмою та одним або кількома ISR, або між кількома ISR. Проблема тут полягає в тому, що функція сама може бути перервана і запущений ще один її екземпляр. Якщо це може статися, функція повинна бути спроектована як реентрантна. Опис того, як це зробити, є просунутою темою, що виходить за рамки цього посібника.

Критичні секції

Прикладом критичної секції коду є така, що звертається до більш ніж однієї змінної, яка може бути змінена ISR. Якщо переривання виникає між зверненнями до окремих змінних, їх значення будуть суперечливими. Це є прикладом небезпеки, відомої як стан гонки: ISR та головний цикл програми змагаються за зміну змінних. Щоб уникнути суперечливості, необхідно забезпечити, щоб ISR не змінював значення протягом тривалості критичної секції. Один зі способів досягти цього — видати machine.disable_irq() перед початком секції та machine.enable_irq() в кінці. Ось приклад такого підходу:

import machine
import micropython
import array
import random
import time

micropython.alloc_emergency_exception_buf(100)


class BoundsException(Exception):
    pass


ARRAYSIZE = const(20)
index = 0
data = array.array('i', [0] * ARRAYSIZE)


def callback1(t):
    global data, index
    for x in range(5):
        data[index] = random.getrandbits(30)  # simulate input
        index += 1
        if index >= ARRAYSIZE:
            raise BoundsException('Array bounds exceeded')


tim = machine.Timer(-1, freq=100, callback=callback1, hard=True)

for loop in range(1000):
    if index > 0:
        irq_state = machine.disable_irq()  # Start of critical section
        for x in range(index):
            print(data[x])
        index = 0
        machine.enable_irq(irq_state)  # End of critical section
        print('loop {}'.format(loop))
    time.sleep_ms(1)

tim.deinit()

Критична секція може складатися з одного рядка коду та однієї змінної. Розглянемо наступний фрагмент коду.

count = 0


def cb(): # An interrupt callback
    count += 1


def main():
    # Code to set up the interrupt callback omitted
    while True:
        count += 1

Цей приклад ілюструє тонке джерело помилок. Рядок count += 1 в головному циклі несе специфічну небезпеку стану гонки, відому як читання-модифікація-запис. Це класична причина помилок у системах реального часу. В головному циклі MicroPython зчитує значення count, додає до нього 1 і записує назад. У рідкісних випадках переривання виникає після читання і до запису. Переривання змінює count, але його зміна перезаписується головним циклом після повернення з ISR. У реальній системі це може призводити до рідкісних, непередбачуваних збоїв.

Як згадувалося вище, слід бути обережним, якщо екземпляр вбудованого типу Python змінюється в основному коді і цей екземпляр доступний в ISR. Код, що виконує модифікацію, слід розглядати як критичну секцію, щоб гарантувати, що екземпляр перебуває у дійсному стані під час запуску ISR.

Особливу увагу слід приділити, якщо набір даних спільно використовується різними ISR. Небезпека тут полягає в тому, що переривання вищого пріоритету може виникнути, коли переривання нижчого пріоритету частково оновило спільні дані. Розгляд цієї ситуації є просунутою темою, що виходить за рамки цього вступу, крім зауваження, що описані нижче об’єкти mutex іноді можуть бути використані.

Вимкнення переривань на тривалість критичної секції є звичайним і найпростішим способом, але воно вимикає всі переривання, а не лише те, що потенційно може спричинити проблему. Як правило, небажано вимикати переривання надовго. У випадку таймерних переривань це вносить варіативність у час виникнення зворотного виклику. У випадку переривань від пристроїв це може призвести до надто пізнього обслуговування пристрою з можливою втратою даних або помилками переповнення в апаратному забезпеченні пристрою. Як і ISR, критична секція в основному коді повинна мати короткий та передбачуваний час виконання.

Підхід до роботи з критичними секціями, який радикально скорочує час вимкнення переривань, полягає у використанні об’єкта, що називається mutex (назва походить від поняття взаємного виключення). Основна програма блокує mutex перед виконанням критичної секції та розблоковує його в кінці. ISR перевіряє, чи заблокований mutex. Якщо так, він уникає критичної секції та повертається. Завдання проектування полягає у визначенні того, що ISR повинен робити у разі, якщо доступ до критичних змінних заборонений. Простий приклад mutex можна знайти тут. Зауважте, що код mutex дійсно вимикає переривання, але лише на час виконання восьми інструкцій машинного коду: перевага цього підходу полягає в тому, що інші переривання практично не зазнають впливу.

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

Обробники переривань, наприклад пов’язані з таймерами, можуть продовжувати виконуватися після завершення програми. Це може призводити до несподіваних результатів там, де можна було очікувати, що об’єкт, що ініціює зворотний виклик, вийшов за межі видимості. Наприклад, на OpenMV Cam:

def bar():
    foo = machine.Timer(-1, freq=4, callback=lambda t: print('.', end=''), hard=True)

bar()

Це продовжується до тих пір, поки таймер явно не вимкнено або плата не скинута за допомогою Ctrl-D.