Psaní obslužných rutin přerušení

Na vhodném hardwaru nabízí MicroPython možnost psát obslužné rutiny přerušení v Pythonu. Obslužné rutiny přerušení – známé také jako interrupt service routines (ISR) – jsou definovány jako callback funkce. Ty se spouštějí v reakci na událost, jako je spuštění časovače nebo změna napětí na pinu. K takovým událostem může dojít kdykoli během vykonávání kódu programu. To má významné důsledky, z nichž některé jsou specifické pro jazyk MicroPython. Jiné jsou společné pro všechny systémy schopné reagovat na události v reálném čase. Tento dokument se nejprve zabývá problémy specifickými pro jazyk a poté následuje stručný úvod do programování v reálném čase pro ty, kdo s ním začínají.

Tento úvod používá vágní pojmy jako „pomalý“ nebo „co nejrychleji“. Je to záměrné, protože rychlosti závisí na konkrétní aplikaci. Přijatelné doby trvání pro ISR závisí na rychlosti, s jakou přerušení nastávají, na povaze hlavního programu a na přítomnosti dalších souběžných událostí.

Problémy specifické pro MicroPython

Nouzový buffer pro výjimky

Pokud v ISR dojde k chybě, MicroPython není schopen vytvořit chybové hlášení, pokud pro tento účel není vytvořen speciální buffer. Ladění se zjednoduší, pokud je do každého programu používajícího přerušení zahrnut následující kód.

import micropython

micropython.alloc_emergency_exception_buf(100)

Nouzový buffer pro výjimky může uchovávat pouze jeden zásobníkový výpis výjimky (stack trace). To znamená, že pokud je během zpracování výjimky vyvolána druhá výjimka v době, kdy je halda uzamčena, zásobníkový výpis této druhé výjimky nahradí původní – i když je druhá výjimka korektně ošetřena. To může vést k matoucím chybovým hlášením, pokud je buffer později vypsán.

Jednoduchost

Z různých důvodů je důležité udržovat kód ISR co nejkratší a nejjednodušší. Měl by dělat pouze to, co je nutné provést bezprostředně po události, která jej vyvolala: operace, které lze odložit, by měly být delegovány na hlavní programovou smyčku. Typicky ISR obslouží hardwarové zařízení, které přerušení způsobilo, a připraví jej na další přerušení. S hlavní smyčkou komunikuje aktualizací sdílených dat, čímž signalizuje, že k přerušení došlo, a poté se vrátí. ISR by měl vrátit řízení hlavní smyčce co nejrychleji. Nejedná se o specifický problém MicroPythonu, proto je podrobněji popsán níže.

Komunikace mezi ISR a hlavním programem

Normálně musí ISR komunikovat s hlavním programem. Nejjednodušším způsobem, jak to udělat, je prostřednictvím jednoho nebo více sdílených datových objektů, deklarovaných buď jako globální, nebo sdílených prostřednictvím třídy (viz níže). S tímto postupem souvisí různá omezení a rizika, která jsou podrobněji popsána níže. K tomuto účelu se běžně používají celá čísla, objekty bytes a bytearray spolu s poli (z modulu array), která mohou ukládat různé datové typy.

Použití metod objektů jako callbacků

MicroPython podporuje tuto výkonnou techniku, která umožňuje ISR sdílet instanční proměnné s podkladovým kódem. Rovněž umožňuje třídě implementující ovladač zařízení podporovat více instancí zařízení. Následující příklad způsobí blikání dvou LED diod různou rychlostí.

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"))

V tomto příkladu instance red řídí červenou LED z virtuálního časovače s frekvencí 1 Hz: pokaždé, když časovač spustí, je zavolána metoda red.cb(), která přepne stav červené LED. Instance green funguje podobně s časovačem s frekvencí 0,8 Hz přepínajícím zelenou LED. Použití instančních metod přináší dvě výhody. Za prvé jediná třída umožňuje sdílení kódu mezi více hardwarovými instancemi. Za druhé, jelikož se jedná o vázanou metodu, prvním argumentem callback funkce je self. To umožňuje callbacku přistupovat k instančním datům a uchovávat stav mezi po sobě jdoucími voláními. Pokud by například výše uvedená třída měla proměnnou self.count nastavenou v konstruktoru na nulu, mohla by metoda cb() čítač inkrementovat. Instance red a green by pak udržovaly nezávislé počty, kolikrát každá LED změnila svůj stav.

Vytváření objektů Pythonu

ISR nemohou vytvářet instance objektů Pythonu. Je to proto, že MicroPython potřebuje pro objekt alokovat paměť z úložiště volných paměťových bloků nazývaného heap. To není v obslužné rutině přerušení povoleno, protože alokace na haldě není reentrantní. Jinými slovy, přerušení může nastat v okamžiku, kdy hlavní program právě provádí alokaci – aby byla zachována integrita haldy, interpret v kódu ISR alokace paměti zakazuje.

Důsledkem toho je, že ISR nemohou používat aritmetiku s plovoucí desetinnou čárkou; je to proto, že floaty jsou objekty Pythonu. Podobně ISR nemůže přidat položku do seznamu. V praxi může být obtížné přesně určit, které konstrukce kódu se pokusí provést alokaci paměti a vyvolat chybové hlášení: další důvod, proč udržovat kód ISR krátký a jednoduchý.

Jedním ze způsobů, jak se tomuto problému vyhnout, je, aby ISR používal předem alokované buffery. Například konstruktor třídy vytvoří instanci bytearray a logický příznak. Metoda ISR přiřadí data na pozice v bufferu a nastaví příznak. K alokaci paměti dojde v kódu hlavního programu při vytváření instance objektu, nikoli v ISR.

Vstupně/výstupní metody knihovny MicroPythonu obvykle poskytují možnost použít předem alokovaný buffer. Například machine.I2C.readfrom_into() čte do měnitelného bufferu dodaného volajícím: to umožňuje jeho použití v ISR.

Způsob, jak vytvořit objekt bez použití třídy nebo globálních proměnných, je následující:

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

Kompilátor vytvoří instanci výchozího argumentu buf při prvním načtení funkce (obvykle při importu modulu, ve kterém se nachází).

K instanci vytvoření objektu dochází při vytvoření odkazu na vázanou metodu. To znamená, že ISR nemůže předat funkci vázanou metodu. Jedním řešením je vytvořit odkaz na vázanou metodu v konstruktoru třídy a tento odkaz v ISR předat. Například:

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)

Další techniky spočívají v definování a vytvoření instance metody v konstruktoru nebo v předání Foo.bar() s argumentem self.

Použití objektů Pythonu

Další omezení týkající se objektů vyplývá ze způsobu, jakým Python funguje. Když je vykonán příkaz import, kód Pythonu je zkompilován do bytecode, přičemž jeden řádek kódu typicky odpovídá více bytecodům. Když kód běží, interpret čte každý bytecode a vykonává jej jako sérii instrukcí strojového kódu. Vzhledem k tomu, že přerušení může nastat kdykoli mezi instrukcemi strojového kódu, může být původní řádek kódu Pythonu vykonán jen částečně. V důsledku toho může objektu Pythonu, jako je množina, seznam nebo slovník modifikovaný v hlavní smyčce, v okamžiku přerušení chybět vnitřní konzistence.

Typický důsledek je následující. Ve vzácných případech ISR poběží přesně v okamžiku, kdy je objekt částečně aktualizován. Když se ISR pokusí objekt přečíst, dojde k pádu. Protože k takovým problémům typicky dochází vzácně a náhodně, je obtížné je diagnostikovat. Existují způsoby, jak tento problém obejít, popsané v části Kritické sekce níže.

Je důležité mít jasno v tom, co představuje modifikaci objektu. Změna obsahu pole nebo bytearray je bezpečná. Je to proto, že bajty nebo slova jsou zapisována jako jediná instrukce strojového kódu, kterou nelze přerušit: v terminologii programování v reálném čase je tento zápis atomický. Totéž platí pro aktualizaci položky slovníku, protože položky jsou strojová slova, jelikož se jedná o celá čísla nebo ukazatele na objekty. Uživatelsky definovaný objekt může vytvořit instanci pole nebo bytearray. Obsah těchto struktur může platně měnit jak hlavní smyčka, tak ISR.

Riziko nastává při změně struktury objektu, zejména v případě slovníků. Přidání nebo odstranění klíčů může vyvolat přehašování (rehash). Pokud běží tvrdé (hard) ISR během probíhajícího přehašování a pokusí se přistoupit k položce, může dojít k pádu. Interně jsou globální proměnné implementovány jako slovník. Hlavní program by proto měl vytvořit všechny potřebné globální proměnné před spuštěním procesu, který generuje tvrdá přerušení. Kód aplikace by se měl rovněž vyhnout mazání globálních proměnných.

MicroPython podporuje celá čísla libovolné přesnosti. Hodnoty mezi 230 -1 a -230 budou uloženy v jediném strojovém slově. Větší hodnoty jsou uloženy jako objekty Pythonu. V důsledku toho nelze změny dlouhých celých čísel považovat za atomické. Použití dlouhých celých čísel v ISR je nebezpečné, protože při změně hodnoty proměnné může být provedena alokace paměti.

Překonání omezení plovoucí desetinné čárky

Obecně je nejlepší vyhnout se používání floatů v kódu ISR: hardwarová zařízení normálně pracují s celými čísly a převod na floaty se obvykle provádí v hlavní smyčce. Existuje však několik algoritmů DSP, které vyžadují plovoucí desetinnou čárku. Na platformách s hardwarovou podporou plovoucí desetinné čárky (jako jsou OpenMV Cam založené na STM32) lze toto omezení obejít pomocí inline assembleru ARM Thumb. Je to proto, že procesor ukládá hodnoty float do strojového slova; hodnoty lze mezi ISR a kódem hlavního programu sdílet prostřednictvím pole floatů.

Použití micropython.schedule

Tato funkce umožňuje ISR naplánovat callback k vykonání „velmi brzy“. Callback je zařazen do fronty k vykonání, které proběhne v okamžiku, kdy halda není uzamčena. Může tedy vytvářet objekty Pythonu a používat floaty. Callback je rovněž zaručeně spuštěn v okamžiku, kdy hlavní program dokončil veškeré aktualizace objektů Pythonu, takže callback nenarazí na částečně aktualizované objekty.

Typickým použitím je obsluha senzorového hardwaru. ISR získá data z hardwaru a umožní mu vyvolat další přerušení. Poté naplánuje callback ke zpracování dat.

Naplánované callbacky by měly dodržovat zásady návrhu obslužných rutin přerušení uvedené níže. Tím se předejde problémům vyplývajícím ze vstupně/výstupní činnosti a modifikace sdílených dat, které mohou nastat v jakémkoli kódu, jenž předbíhá hlavní programovou smyčku.

Dobu vykonávání je třeba zvažovat ve vztahu k frekvenci, s jakou mohou přerušení nastávat. Pokud přerušení nastane během vykonávání předchozího callbacku, bude do fronty k vykonání zařazena další instance callbacku; ta poběží po dokončení aktuální instance. Trvale vysoká frekvence opakování přerušení proto nese riziko neomezeného růstu fronty a případného selhání s chybou RuntimeError.

Pokud je callback předávaný funkci schedule() vázanou metodou, zvažte poznámku v části „Vytváření objektů Pythonu“.

Výjimky

Pokud ISR vyvolá výjimku, nebude se propagovat do hlavní smyčky. Přerušení bude zakázáno, pokud výjimku neošetří kód ISR.

Propojení s asyncio

Když ISR běží, může předběhnout plánovač asyncio. Pokud ISR provede operaci asyncio, může být činnost plánovače narušena. To platí bez ohledu na to, zda je přerušení tvrdé (hard) nebo měkké (soft), a platí to i v případě, že ISR předal řízení jiné funkci prostřednictvím micropython.schedule. Zejména vytváření nebo rušení úloh je v kontextu ISR neplatné. Bezpečný způsob, jak interagovat s asyncio, je implementovat korutinu se synchronizací prováděnou pomocí asyncio.ThreadSafeFlag. Následující úryvek ilustruje vytvoření úlohy v reakci na přerušení:

tsf = asyncio.ThreadSafeFlag()


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


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

V tomto příkladu bude mezi vykonáním ISR a vykonáním foo() proměnlivá latence. To je vlastní kooperativnímu plánování. Maximální latence závisí na aplikaci a platformě, ale typicky se může pohybovat v desítkách ms.

Obecné problémy

Toto je pouze stručný úvod do tématu programování v reálném čase. Začátečníci by si měli uvědomit, že chyby v návrhu programů reálného času mohou vést k poruchám, které jsou obzvláště obtížné diagnostikovat. Je to proto, že mohou nastávat vzácně a v intervalech, které jsou v podstatě náhodné. Je klíčové dostat počáteční návrh správně a předvídat problémy dříve, než nastanou. Jak obslužné rutiny přerušení, tak hlavní program je třeba navrhovat s ohledem na následující problémy.

Návrh obslužné rutiny přerušení

Jak bylo uvedeno výše, ISR by měly být navrženy tak, aby byly co nejjednodušší. Měly by se vždy vrátit v krátkém, předvídatelném čase. To je důležité, protože když ISR běží, hlavní smyčka neběží: hlavní smyčka nevyhnutelně zažívá pauzy ve svém vykonávání na náhodných místech v kódu. Takové pauzy mohou být zdrojem obtížně diagnostikovatelných chyb, zejména pokud je jejich doba trvání dlouhá nebo proměnlivá. K pochopení důsledků doby běhu ISR je nutné základní porozumění prioritám přerušení.

Přerušení jsou organizována podle prioritního schématu. Kód ISR může být sám přerušen přerušením s vyšší prioritou. To má důsledky, pokud obě přerušení sdílejí data (viz Kritické sekce níže). Pokud k takovému přerušení dojde, vloží do kódu ISR zpoždění. Pokud během běhu ISR nastane přerušení s nižší prioritou, bude zpožděno až do dokončení ISR: pokud je zpoždění příliš dlouhé, přerušení s nižší prioritou může selhat. Dalším problémem pomalých ISR je případ, kdy během jeho vykonávání nastane druhé přerušení stejného typu. Druhé přerušení bude obslouženo po ukončení prvního. Pokud však rychlost příchozích přerušení trvale překračuje schopnost ISR je obsluhovat, výsledek nebude příjemný.

V důsledku toho by se cyklické konstrukce měly omezit nebo se jim vyhnout. Vstupně/výstupním operacím vůči jiným zařízením než přerušujícímu zařízení je třeba se obvykle vyhnout: vstup/výstup, jako je přístup k disku, příkazy print a přístup k UART, je relativně pomalý a jeho doba trvání se může lišit. Dalším problémem zde je, že funkce souborového systému nejsou reentrantní: použití vstupu/výstupu souborového systému v ISR a hlavním programu by bylo nebezpečné. Zásadní je, že kód ISR by neměl čekat na událost. Vstup/výstup je přijatelný, pokud lze zaručit, že se kód vrátí v předvídatelném čase, například přepnutí pinu nebo LED. Přístup k přerušujícímu zařízení přes I2C nebo SPI může být nezbytný, ale čas potřebný pro takové přístupy by měl být vypočítán nebo změřen a jeho dopad na aplikaci posouzen.

Obvykle je potřeba sdílet data mezi ISR a hlavní smyčkou. To lze provést buď prostřednictvím globálních proměnných, nebo prostřednictvím proměnných třídy či instance. Proměnné jsou typicky celočíselného nebo logického typu, případně celočíselná nebo bajtová pole (předem alokované celočíselné pole nabízí rychlejší přístup než seznam). Pokud ISR modifikuje více hodnot, je nutné uvažovat případ, kdy přerušení nastane v okamžiku, kdy hlavní program přistoupil k některým, ale ne ke všem hodnotám. To může vést k nekonzistencím.

Zvažte následující návrh. ISR ukládá příchozí data do bytearray a poté přidá počet přijatých bajtů k celému číslu představujícímu celkový počet bajtů připravených ke zpracování. Hlavní program přečte počet bajtů, zpracuje bajty a poté vynuluje počet bajtů připravených ke zpracování. To bude fungovat, dokud přerušení nenastane těsně poté, co hlavní program přečetl počet bajtů. ISR vloží přidaná data do bufferu a aktualizuje počet přijatých bajtů, ale hlavní program již počet přečetl, takže zpracuje původně přijatá data. Nově příchozí bajty jsou ztraceny.

Existují různé způsoby, jak se tomuto riziku vyhnout, přičemž nejjednodušší je použít kruhový buffer. Pokud není možné použít strukturu s inherentní vláknovou bezpečností, jsou níže popsány jiné způsoby.

Reentrance

Potenciální riziko může nastat, pokud je funkce nebo metoda sdílena mezi hlavním programem a jedním nebo více ISR, nebo mezi více ISR. Problémem zde je, že funkce může být sama přerušena a spuštěna další instance této funkce. Má-li k tomu dojít, musí být funkce navržena jako reentrantní. Jak se to dělá, je pokročilé téma, které přesahuje rámec tohoto návodu.

Kritické sekce

Příkladem kritické sekce kódu je sekce, která přistupuje k více než jedné proměnné, jež může být ovlivněna ISR. Pokud přerušení nastane mezi přístupy k jednotlivým proměnným, jejich hodnoty budou nekonzistentní. Jedná se o případ rizika známého jako souběh (race condition): ISR a hlavní programová smyčka soupeří o změnu proměnných. Aby se předešlo nekonzistenci, je nutné použít prostředek zajišťující, že ISR po dobu trvání kritické sekce hodnoty nezmění. Jedním ze způsobů, jak toho dosáhnout, je vydat machine.disable_irq() před začátkem sekce a machine.enable_irq() na jejím konci. Zde je příklad tohoto přístupu:

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()

Kritická sekce může sestávat z jediného řádku kódu a jediné proměnné. Zvažte následující úryvek kódu.

count = 0


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


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

Tento příklad ilustruje nenápadný zdroj chyb. Řádek count += 1 v hlavní smyčce nese specifické riziko souběhu známé jako čtení-modifikace-zápis (read-modify-write). To je klasická příčina chyb v systémech reálného času. V hlavní smyčce MicroPython přečte hodnotu count, přičte k ní 1 a zapíše ji zpět. Ve vzácných případech nastane přerušení po čtení a před zápisem. Přerušení modifikuje count, ale jeho změna je při návratu z ISR přepsána hlavní smyčkou. V reálném systému by to mohlo vést ke vzácným, nepředvídatelným selháním.

Jak bylo uvedeno výše, je třeba být opatrný, pokud je instance vestavěného typu Pythonu modifikována v hlavním kódu a tato instance je zpřístupněna v ISR. Kód provádějící modifikaci by měl být považován za kritickou sekci, aby bylo zajištěno, že instance je v platném stavu, když ISR běží.

Zvláštní opatrnost je třeba věnovat případu, kdy je datová sada sdílena mezi různými ISR. Riziko zde spočívá v tom, že přerušení s vyšší prioritou může nastat, když přerušení s nižší prioritou částečně aktualizovalo sdílená data. Řešení této situace je pokročilé téma, které přesahuje rámec tohoto úvodu, kromě poznámky, že mutexové objekty popsané níže lze někdy použít.

Zakázání přerušení po dobu trvání kritické sekce je obvyklý a nejjednodušší způsob, ale zakáže všechna přerušení, nikoli pouze to, které by mohlo způsobovat problémy. Obecně je nežádoucí zakazovat přerušení na dlouhou dobu. V případě přerušení časovače to zavádí proměnlivost do okamžiku, kdy callback nastane. V případě přerušení zařízení to může vést k tomu, že zařízení bude obslouženo příliš pozdě s možnou ztrátou dat nebo chybami přetečení v hardwaru zařízení. Stejně jako ISR by i kritická sekce v hlavním kódu měla mít krátkou, předvídatelnou dobu trvání.

Přístupem k řešení kritických sekcí, který radikálně snižuje dobu, po kterou jsou přerušení zakázána, je použití objektu nazývaného mutex (název je odvozen z pojmu mutual exclusion, vzájemné vyloučení). Hlavní program před spuštěním kritické sekce mutex uzamkne a na konci jej odemkne. ISR testuje, zda je mutex uzamčen. Pokud ano, vyhne se kritické sekci a vrátí se. Výzvou návrhu je definovat, co má ISR udělat v případě, že je přístup ke kritickým proměnným odepřen. Jednoduchý příklad mutexu lze nalézt zde. Všimněte si, že kód mutexu přerušení zakazuje, ale pouze po dobu trvání osmi strojových instrukcí: výhodou tohoto přístupu je, že ostatní přerušení jsou prakticky neovlivněna.

Přerušení a REPL

Obslužné rutiny přerušení, jako jsou ty spojené s časovači, mohou pokračovat v běhu i po ukončení programu. To může vést k neočekávaným výsledkům tam, kde byste očekávali, že objekt vyvolávající callback již přestal existovat (vyšel ze scope). Například na OpenMV Cam:

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

bar()

To pokračuje v běhu, dokud není časovač explicitně zakázán nebo dokud není deska resetována pomocí Ctrl-D.