Scrittura dei gestori di interrupt

Su hardware adatto MicroPython offre la possibilità di scrivere gestori di interrupt in Python. I gestori di interrupt - noti anche come routine di servizio degli interrupt (ISR, interrupt service routines) - sono definiti come funzioni di callback. Vengono eseguiti in risposta a un evento come lo scatto di un timer o una variazione di tensione su un pin. Tali eventi possono verificarsi in qualsiasi momento dell’esecuzione del codice del programma. Ciò comporta conseguenze significative, alcune specifiche del linguaggio MicroPython. Altre sono comuni a tutti i sistemi in grado di rispondere a eventi in tempo reale. Questo documento tratta prima le questioni specifiche del linguaggio, seguite da una breve introduzione alla programmazione in tempo reale per chi vi si avvicina per la prima volta.

Questa introduzione usa termini vaghi come «lento» o «il più velocemente possibile». Ciò è intenzionale, poiché le velocità dipendono dall’applicazione. Le durate accettabili per una ISR dipendono dalla frequenza con cui si verificano gli interrupt, dalla natura del programma principale e dalla presenza di altri eventi concorrenti.

Questioni relative a MicroPython

Il buffer di emergenza per le eccezioni

Se si verifica un errore in una ISR, MicroPython non è in grado di produrre un resoconto dell’errore a meno che non sia stato creato un buffer apposito a tale scopo. Il debug è semplificato se il codice seguente è incluso in qualsiasi programma che usa gli interrupt.

import micropython

micropython.alloc_emergency_exception_buf(100)

Il buffer di emergenza per le eccezioni può contenere una sola traccia dello stack di un’eccezione. Ciò significa che se una seconda eccezione viene sollevata durante la gestione di un’eccezione mentre lo heap è bloccato, la traccia dello stack di quella seconda eccezione sostituirà quella originale - anche se la seconda eccezione viene gestita correttamente. Questo può portare a messaggi di eccezione confusi se il buffer viene stampato successivamente.

Semplicità

Per svariate ragioni è importante mantenere il codice della ISR il più breve e semplice possibile. Dovrebbe fare solo ciò che deve essere fatto immediatamente dopo l’evento che l’ha causata: le operazioni che possono essere differite dovrebbero essere delegate al loop del programma principale. Tipicamente una ISR si occupa del dispositivo hardware che ha causato l’interrupt, predisponendolo per l’interrupt successivo. Comunicherà con il loop principale aggiornando dati condivisi per indicare che l’interrupt si è verificato, quindi terminerà. Una ISR dovrebbe restituire il controllo al loop principale il più rapidamente possibile. Questo non è un problema specifico di MicroPython, quindi è trattato in maggior dettaglio più avanti.

Comunicazione tra una ISR e il programma principale

Normalmente una ISR ha bisogno di comunicare con il programma principale. Il mezzo più semplice per farlo è tramite uno o più oggetti di dati condivisi, dichiarati come globali oppure condivisi tramite una classe (vedi sotto). Esistono varie restrizioni e insidie legate a questa pratica, trattate in maggior dettaglio di seguito. Interi, oggetti bytes e bytearray sono comunemente usati a questo scopo insieme agli array (del modulo array) che possono memorizzare vari tipi di dati.

L’uso di metodi di oggetti come callback

MicroPython supporta questa potente tecnica che consente a una ISR di condividere le variabili di istanza con il codice sottostante. Consente inoltre a una classe che implementa un driver di dispositivo di supportare più istanze del dispositivo. L’esempio seguente fa lampeggiare due LED a frequenze diverse.

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

In questo esempio l’istanza red pilota il LED rosso da un timer virtuale a 1 Hz: ogni volta che il timer scatta viene chiamato red.cb(), commutando il LED rosso. L’istanza green opera in modo analogo con un timer a 0,8 Hz che commuta il LED verde. L’uso di metodi di istanza conferisce due vantaggi. In primo luogo una singola classe consente di condividere il codice tra più istanze hardware. In secondo luogo, essendo un metodo associato (bound), il primo argomento della funzione di callback è self. Ciò consente al callback di accedere ai dati di istanza e di salvare lo stato tra chiamate successive. Per esempio, se la classe sopra avesse una variabile self.count impostata a zero nel costruttore, cb() potrebbe incrementare il contatore. Le istanze red e green manterrebbero quindi conteggi indipendenti del numero di volte in cui ciascun LED ha cambiato stato.

Creazione di oggetti Python

Le ISR non possono creare istanze di oggetti Python. Questo perché MicroPython ha bisogno di allocare memoria per l’oggetto da una riserva di blocchi di memoria libera chiamata heap. Ciò non è consentito in un gestore di interrupt perché l’allocazione nello heap non è rientrante. In altre parole l’interrupt potrebbe verificarsi mentre il programma principale è a metà di un’allocazione - per mantenere l’integrità dello heap l’interprete vieta le allocazioni di memoria nel codice delle ISR.

Una conseguenza di ciò è che le ISR non possono usare l’aritmetica in virgola mobile; questo perché i float sono oggetti Python. Analogamente una ISR non può aggiungere un elemento a una lista. In pratica può essere difficile determinare con esattezza quali costrutti di codice tenteranno di eseguire un’allocazione di memoria e provocheranno un messaggio di errore: un ulteriore motivo per mantenere il codice della ISR breve e semplice.

Un modo per evitare questo problema è che la ISR usi buffer preallocati. Per esempio un costruttore di classe crea un’istanza di bytearray e un flag booleano. Il metodo della ISR assegna i dati a posizioni nel buffer e imposta il flag. L’allocazione di memoria avviene nel codice del programma principale quando l’oggetto viene istanziato anziché nella ISR.

I metodi di I/O della libreria di MicroPython di solito offrono un’opzione per usare un buffer preallocato. Per esempio machine.I2C.readfrom_into() legge in un buffer mutabile fornito dal chiamante: ciò ne consente l’uso in una ISR.

Un modo per creare un oggetto senza impiegare una classe o variabili globali è il seguente:

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

Il compilatore istanzia l’argomento predefinito buf quando la funzione viene caricata per la prima volta (di solito quando il modulo in cui si trova viene importato).

Un caso di creazione di oggetti si verifica quando viene creato un riferimento a un metodo associato (bound). Ciò significa che una ISR non può passare un metodo associato a una funzione. Una soluzione è creare un riferimento al metodo associato nel costruttore della classe e passare quel riferimento nella ISR. Per esempio:

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)

Altre tecniche consistono nel definire e istanziare il metodo nel costruttore o nel passare Foo.bar() con l’argomento self.

Uso degli oggetti Python

Un’ulteriore restrizione sugli oggetti deriva dal modo in cui funziona Python. Quando viene eseguita un’istruzione import il codice Python viene compilato in bytecode, con una riga di codice che tipicamente corrisponde a più bytecode. Quando il codice viene eseguito l’interprete legge ciascun bytecode e lo esegue come una serie di istruzioni in codice macchina. Dato che un interrupt può verificarsi in qualsiasi momento tra le istruzioni in codice macchina, la riga originale di codice Python potrebbe essere eseguita solo parzialmente. Di conseguenza un oggetto Python come un set, una lista o un dizionario modificato nel loop principale potrebbe mancare di coerenza interna nel momento in cui si verifica l’interrupt.

Un risultato tipico è il seguente. In rare occasioni la ISR verrà eseguita esattamente nel momento in cui l’oggetto è parzialmente aggiornato. Quando la ISR tenta di leggere l’oggetto, ne risulta un crash. Poiché tali problemi si verificano tipicamente in occasioni rare e casuali possono essere difficili da diagnosticare. Esistono modi per aggirare questo problema, descritti nelle Sezioni critiche più avanti.

È importante essere chiari su cosa costituisca la modifica di un oggetto. Alterare il contenuto di un array o di un bytearray è sicuro. Questo perché i byte o le parole vengono scritti come una singola istruzione in codice macchina che non è interrompibile: nel gergo della programmazione in tempo reale la scrittura è atomica. Lo stesso vale per l’aggiornamento di un elemento di un dizionario perché gli elementi sono parole macchina, essendo interi o puntatori a oggetti. Un oggetto definito dall’utente potrebbe istanziare un array o un bytearray. È valido sia per il loop principale sia per la ISR alterare il contenuto di questi.

L’insidia sorge quando viene alterata la struttura di un oggetto, in particolare nel caso dei dizionari. Aggiungere o eliminare chiavi può innescare un rehash. Se una ISR hard viene eseguita mentre è in corso un rehash e tenta di accedere a un elemento, può verificarsi un crash. Internamente le variabili globali sono implementate come un dizionario. Di conseguenza il programma principale dovrebbe creare tutte le variabili globali necessarie prima di avviare un processo che genera interrupt hard. Il codice dell’applicazione dovrebbe inoltre evitare di eliminare le variabili globali.

MicroPython supporta interi di precisione arbitraria. I valori compresi tra 230 -1 e -230 verranno memorizzati in una singola parola macchina. I valori più grandi vengono memorizzati come oggetti Python. Di conseguenza le modifiche agli interi lunghi non possono essere considerate atomiche. L’uso di interi lunghi nelle ISR non è sicuro perché potrebbe essere tentata un’allocazione di memoria al variare del valore della variabile.

Superare la limitazione sui float

In generale è meglio evitare l’uso dei float nel codice delle ISR: i dispositivi hardware normalmente gestiscono interi e la conversione in float viene normalmente effettuata nel loop principale. Tuttavia esistono alcuni algoritmi DSP che richiedono la virgola mobile. Su piattaforme con virgola mobile hardware (come le OpenMV Cam basate su STM32) si può usare l’assembler inline ARM Thumb per aggirare questa limitazione. Questo perché il processore memorizza i valori float in una parola macchina; i valori possono essere condivisi tra la ISR e il codice del programma principale tramite un array di float.

Uso di micropython.schedule

Questa funzione consente a una ISR di pianificare un callback per l’esecuzione «molto presto». Il callback viene accodato per l’esecuzione, che avverrà in un momento in cui lo heap non è bloccato. Quindi può creare oggetti Python e usare i float. È inoltre garantito che il callback venga eseguito in un momento in cui il programma principale ha completato qualsiasi aggiornamento di oggetti Python, perciò il callback non incontrerà oggetti parzialmente aggiornati.

L’uso tipico è la gestione dell’hardware dei sensori. La ISR acquisisce i dati dall’hardware e gli consente di emettere un ulteriore interrupt. Quindi pianifica un callback per elaborare i dati.

I callback pianificati dovrebbero rispettare i principi di progettazione dei gestori di interrupt descritti di seguito. Questo per evitare i problemi derivanti dall’attività di I/O e dalla modifica di dati condivisi che possono sorgere in qualsiasi codice che precede il loop del programma principale.

Il tempo di esecuzione deve essere considerato in relazione alla frequenza con cui possono verificarsi gli interrupt. Se un interrupt si verifica mentre il callback precedente è in esecuzione, un’ulteriore istanza del callback verrà accodata per l’esecuzione; questa verrà eseguita dopo il completamento dell’istanza corrente. Una frequenza di ripetizione degli interrupt elevata e prolungata comporta quindi un rischio di crescita incontrollata della coda e di eventuale fallimento con un RuntimeError.

Se il callback da passare a schedule() è un metodo associato (bound), considera la nota in «Creazione di oggetti Python».

Eccezioni

Se una ISR solleva un’eccezione questa non si propagherà al loop principale. L’interrupt verrà disabilitato a meno che l’eccezione non venga gestita dal codice della ISR.

Interfacciamento con asyncio

Quando una ISR viene eseguita può precedere lo scheduler di asyncio. Se la ISR esegue un’operazione di asyncio il funzionamento dello scheduler può essere disturbato. Ciò vale indipendentemente dal fatto che l’interrupt sia hard o soft e vale anche se la ISR ha passato l’esecuzione a un’altra funzione tramite micropython.schedule. In particolare la creazione o la cancellazione di task non è valida in un contesto di ISR. Il modo sicuro per interagire con asyncio è implementare una coroutine con la sincronizzazione effettuata da asyncio.ThreadSafeFlag. Il frammento seguente illustra la creazione di un task in risposta a un interrupt:

tsf = asyncio.ThreadSafeFlag()


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


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

In questo esempio ci sarà una quantità variabile di latenza tra l’esecuzione della ISR e l’esecuzione di foo(). Ciò è insito nello scheduling cooperativo. La latenza massima dipende dall’applicazione e dalla piattaforma ma tipicamente può essere misurata in decine di ms.

Questioni generali

Questa è solo una breve introduzione all’argomento della programmazione in tempo reale. I principianti dovrebbero notare che gli errori di progettazione nei programmi in tempo reale possono portare a guasti particolarmente difficili da diagnosticare. Questo perché possono verificarsi raramente e a intervalli essenzialmente casuali. È cruciale impostare correttamente la progettazione iniziale e anticipare i problemi prima che sorgano. Sia i gestori di interrupt sia il programma principale devono essere progettati con la consapevolezza delle seguenti questioni.

Progettazione dei gestori di interrupt

Come accennato sopra, le ISR dovrebbero essere progettate per essere il più semplici possibile. Dovrebbero sempre terminare in un periodo di tempo breve e prevedibile. Questo è importante perché quando la ISR è in esecuzione, il loop principale non lo è: inevitabilmente il loop principale subisce pause nella sua esecuzione in punti casuali del codice. Tali pause possono essere fonte di bug difficili da diagnosticare, in particolare se la loro durata è lunga o variabile. Per comprendere le implicazioni del tempo di esecuzione della ISR, è necessaria una conoscenza di base delle priorità degli interrupt.

Gli interrupt sono organizzati secondo uno schema di priorità. Il codice di una ISR può a sua volta essere interrotto da un interrupt a priorità più alta. Ciò ha implicazioni se i due interrupt condividono dati (vedi Sezioni critiche più avanti). Se tale interrupt si verifica, introduce un ritardo nel codice della ISR. Se un interrupt a priorità più bassa si verifica mentre la ISR è in esecuzione, verrà ritardato fino al completamento della ISR: se il ritardo è troppo lungo, l’interrupt a priorità più bassa può fallire. Un ulteriore problema con le ISR lente è il caso in cui un secondo interrupt dello stesso tipo si verifichi durante la sua esecuzione. Il secondo interrupt verrà gestito al termine del primo. Tuttavia se la frequenza degli interrupt in arrivo supera costantemente la capacità della ISR di servirli l’esito non sarà felice.

Di conseguenza i costrutti di loop dovrebbero essere evitati o ridotti al minimo. L’I/O verso dispositivi diversi dal dispositivo che ha generato l’interrupt dovrebbe normalmente essere evitato: l’I/O come l’accesso al disco, le istruzioni print e l’accesso all’UART è relativamente lento e la sua durata può variare. Un ulteriore problema qui è che le funzioni del filesystem non sono rientranti: usare l’I/O del filesystem in una ISR e nel programma principale sarebbe rischioso. Soprattutto il codice della ISR non dovrebbe attendere un evento. L’I/O è accettabile se è possibile garantire che il codice termini in un periodo prevedibile, per esempio commutando un pin o un LED. L’accesso al dispositivo che ha generato l’interrupt tramite I2C o SPI può essere necessario ma il tempo richiesto per tali accessi dovrebbe essere calcolato o misurato e il suo impatto sull’applicazione valutato.

Di solito è necessario condividere dati tra la ISR e il loop principale. Ciò può essere fatto tramite variabili globali oppure tramite variabili di classe o di istanza. Le variabili sono tipicamente di tipo intero o booleano, oppure array di interi o di byte (un array di interi preallocato offre un accesso più veloce di una lista). Quando più valori vengono modificati dalla ISR è necessario considerare il caso in cui l’interrupt si verifichi in un momento in cui il programma principale ha acceduto ad alcuni, ma non a tutti, i valori. Ciò può portare a incoerenze.

Considera la progettazione seguente. Una ISR memorizza i dati in arrivo in un bytearray, quindi aggiunge il numero di byte ricevuti a un intero che rappresenta il totale dei byte pronti per l’elaborazione. Il programma principale legge il numero di byte, elabora i byte, quindi azzera il numero di byte pronti. Questo funzionerà finché un interrupt non si verifica subito dopo che il programma principale ha letto il numero di byte. La ISR inserisce i dati aggiunti nel buffer e aggiorna il numero ricevuto, ma il programma principale ha già letto il numero, quindi elabora i dati ricevuti originariamente. I byte appena arrivati vengono persi.

Esistono vari modi per evitare questa insidia, il più semplice essendo l’uso di un buffer circolare. Se non è possibile usare una struttura con thread safety intrinseca altri modi sono descritti di seguito.

Rientranza

Un’insidia potenziale può verificarsi se una funzione o un metodo è condiviso tra il programma principale e una o più ISR oppure tra più ISR. Il problema qui è che la funzione può a sua volta essere interrotta e un’ulteriore istanza di tale funzione essere eseguita. Affinché ciò sia possibile, la funzione deve essere progettata per essere rientrante. Come ciò venga fatto è un argomento avanzato che esula dallo scopo di questo tutorial.

Sezioni critiche

Un esempio di sezione critica di codice è quella che accede a più di una variabile che può essere influenzata da una ISR. Se l’interrupt si verifica per caso tra gli accessi alle singole variabili, i loro valori saranno incoerenti. Questo è un caso di un’insidia nota come race condition: la ISR e il loop del programma principale gareggiano per alterare le variabili. Per evitare incoerenze deve essere impiegato un mezzo per garantire che la ISR non alteri i valori per la durata della sezione critica. Un modo per ottenere ciò è emettere machine.disable_irq() prima dell’inizio della sezione e machine.enable_irq() alla fine. Ecco un esempio di questo approccio:

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

Una sezione critica può comprendere una singola riga di codice e una singola variabile. Considera il frammento di codice seguente.

count = 0


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


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

Questo esempio illustra una sottile fonte di bug. La riga count += 1 nel loop principale comporta una specifica insidia di race condition nota come read-modify-write. Questa è una causa classica di bug nei sistemi in tempo reale. Nel loop principale MicroPython legge il valore di count, vi aggiunge 1 e lo riscrive. In rare occasioni l’interrupt si verifica dopo la lettura e prima della scrittura. L’interrupt modifica count ma la sua modifica viene sovrascritta dal loop principale quando la ISR termina. In un sistema reale ciò potrebbe portare a guasti rari e imprevedibili.

Come accennato sopra, occorre prestare attenzione se un’istanza di un tipo built-in di Python viene modificata nel codice principale e tale istanza viene acceduta in una ISR. Il codice che esegue la modifica dovrebbe essere considerato come una sezione critica per garantire che l’istanza sia in uno stato valido quando la ISR viene eseguita.

Particolare attenzione deve essere prestata se un dataset è condiviso tra ISR diverse. L’insidia qui è che l’interrupt a priorità più alta può verificarsi quando quello a priorità più bassa ha parzialmente aggiornato i dati condivisi. Affrontare questa situazione è un argomento avanzato che esula dallo scopo di questa introduzione, se non per notare che gli oggetti mutex descritti di seguito possono a volte essere usati.

Disabilitare gli interrupt per la durata di una sezione critica è il modo usuale e più semplice di procedere, ma disabilita tutti gli interrupt anziché solo quello con il potenziale di causare problemi. È generalmente indesiderabile disabilitare un interrupt a lungo. Nel caso degli interrupt da timer introduce variabilità nel momento in cui si verifica un callback. Nel caso degli interrupt da dispositivo, può portare a un servizio del dispositivo troppo tardivo con possibile perdita di dati o errori di overrun nell’hardware del dispositivo. Come le ISR, una sezione critica nel codice principale dovrebbe avere una durata breve e prevedibile.

Un approccio per affrontare le sezioni critiche che riduce drasticamente il tempo per cui gli interrupt sono disabilitati consiste nell’usare un oggetto chiamato mutex (nome derivato dalla nozione di mutua esclusione). Il programma principale blocca il mutex prima di eseguire la sezione critica e lo sblocca alla fine. La ISR verifica se il mutex è bloccato. Se lo è, evita la sezione critica e termina. La sfida progettuale consiste nel definire cosa la ISR dovrebbe fare nel caso in cui l’accesso alle variabili critiche venga negato. Un semplice esempio di mutex può essere trovato qui. Nota che il codice del mutex disabilita effettivamente gli interrupt, ma solo per la durata di otto istruzioni macchina: il vantaggio di questo approccio è che gli altri interrupt sono praticamente inalterati.

Gli interrupt e il REPL

I gestori di interrupt, come quelli associati ai timer, possono continuare a essere eseguiti dopo la terminazione di un programma. Ciò può produrre risultati inattesi laddove ci si potrebbe aspettare che l’oggetto che solleva il callback sia uscito dall’ambito (scope). Per esempio su una OpenMV Cam:

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

bar()

Questo continua a essere eseguito finché il timer non viene esplicitamente disabilitato o la scheda non viene resettata con Ctrl-D.