Escritura de gestores de interrupciones

En hardware adecuado, MicroPython ofrece la capacidad de escribir gestores de interrupciones en Python. Los gestores de interrupciones, también conocidos como rutinas de servicio de interrupción (ISR, por sus siglas en inglés), se definen como funciones de retorno (callback). Estas se ejecutan en respuesta a un evento como el disparo de un temporizador o un cambio de tensión en un pin. Tales eventos pueden ocurrir en cualquier punto de la ejecución del código del programa. Esto conlleva consecuencias significativas, algunas específicas del lenguaje MicroPython. Otras son comunes a todos los sistemas capaces de responder a eventos en tiempo real. Este documento cubre primero los aspectos específicos del lenguaje, seguidos de una breve introducción a la programación en tiempo real para quienes son nuevos en ella.

Esta introducción utiliza términos imprecisos como «lento» o «lo más rápido posible». Esto es deliberado, ya que las velocidades dependen de la aplicación. Las duraciones aceptables para una ISR dependen de la frecuencia con la que ocurren las interrupciones, de la naturaleza del programa principal y de la presencia de otros eventos concurrentes.

Cuestiones de MicroPython

El búfer de excepciones de emergencia

Si ocurre un error en una ISR, MicroPython no puede producir un informe de error a menos que se cree un búfer especial para ese propósito. La depuración se simplifica si se incluye el siguiente código en cualquier programa que use interrupciones.

import micropython

micropython.alloc_emergency_exception_buf(100)

El búfer de excepciones de emergencia solo puede contener una traza de pila de excepción. Esto significa que si se lanza una segunda excepción durante el manejo de una excepción mientras el montículo está bloqueado, la traza de pila de esa segunda excepción reemplazará a la original, incluso si la segunda excepción se maneja limpiamente. Esto puede llevar a mensajes de excepción confusos si el búfer se imprime posteriormente.

Simplicidad

Por diversas razones es importante mantener el código de las ISR tan corto y simple como sea posible. Solo debe hacer lo que se tenga que hacer inmediatamente después del evento que la causó: las operaciones que se puedan aplazar deben delegarse al bucle del programa principal. Normalmente una ISR se ocupará del dispositivo de hardware que causó la interrupción, dejándolo listo para que ocurra la siguiente interrupción. Se comunicará con el bucle principal actualizando datos compartidos para indicar que la interrupción ha ocurrido, y retornará. Una ISR debe devolver el control al bucle principal tan rápido como sea posible. Esto no es una cuestión específica de MicroPython, por lo que se cubre con más detalle más abajo.

Comunicación entre una ISR y el programa principal

Normalmente una ISR necesita comunicarse con el programa principal. La forma más simple de hacerlo es mediante uno o más objetos de datos compartidos, ya sea declarados como globales o compartidos a través de una clase (consulte más abajo). Existen varias restricciones y peligros en torno a esto, que se cubren con más detalle a continuación. Los enteros y los objetos bytes y bytearray se usan comúnmente para este propósito, junto con los arreglos (del módulo array) que pueden almacenar varios tipos de datos.

El uso de métodos de objeto como funciones de retorno (callbacks)

MicroPython admite esta potente técnica que permite a una ISR compartir variables de instancia con el código subyacente. También permite que una clase que implementa un controlador de dispositivo admita múltiples instancias de dispositivo. El siguiente ejemplo hace que dos LED parpadeen a diferentes velocidades.

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

En este ejemplo, la instancia red controla el LED rojo desde un temporizador virtual de 1 Hz: cada vez que el temporizador se dispara se llama a red.cb(), alternando el LED rojo. La instancia green opera de forma similar con un temporizador de 0,8 Hz que alterna el LED verde. El uso de métodos de instancia aporta dos beneficios. En primer lugar, una sola clase permite compartir código entre múltiples instancias de hardware. En segundo lugar, al ser un método ligado, el primer argumento de la función de retorno es self. Esto permite que la función de retorno acceda a los datos de instancia y guarde el estado entre llamadas sucesivas. Por ejemplo, si la clase anterior tuviera una variable self.count puesta a cero en el constructor, cb() podría incrementar el contador. Las instancias red y green mantendrían entonces recuentos independientes del número de veces que cada LED ha cambiado de estado.

Creación de objetos Python

Las ISR no pueden crear instancias de objetos Python. Esto se debe a que MicroPython necesita asignar memoria para el objeto desde un almacén de bloques de memoria libre llamado el heap. Esto no se permite en un gestor de interrupciones porque la asignación en el montículo no es reentrante. En otras palabras, la interrupción podría ocurrir cuando el programa principal está a mitad de realizar una asignación; para mantener la integridad del montículo, el intérprete no permite asignaciones de memoria en el código de las ISR.

Una consecuencia de esto es que las ISR no pueden usar aritmética de punto flotante; esto se debe a que los flotantes son objetos Python. De forma similar, una ISR no puede añadir un elemento a una lista. En la práctica puede ser difícil determinar exactamente qué construcciones de código intentarán realizar una asignación de memoria y provocar un mensaje de error: otra razón para mantener el código de las ISR corto y simple.

Una forma de evitar este problema es que la ISR use búferes preasignados. Por ejemplo, el constructor de una clase crea una instancia de bytearray y una bandera booleana. El método de la ISR asigna datos a posiciones del búfer y activa la bandera. La asignación de memoria ocurre en el código del programa principal cuando se instancia el objeto en lugar de en la ISR.

Los métodos de E/S de la biblioteca de MicroPython suelen ofrecer una opción para usar un búfer preasignado. Por ejemplo, machine.I2C.readfrom_into() lee en un búfer mutable proporcionado por el llamador: esto permite su uso en una ISR.

Una forma de crear un objeto sin emplear una clase ni globales es la siguiente:

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

El compilador instancia el argumento buf predeterminado cuando la función se carga por primera vez (normalmente cuando se importa el módulo en el que se encuentra).

Una instancia de creación de objeto ocurre cuando se crea una referencia a un método ligado. Esto significa que una ISR no puede pasar un método ligado a una función. Una solución es crear una referencia al método ligado en el constructor de la clase y pasar esa referencia en la ISR. Por ejemplo:

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)

Otras técnicas consisten en definir e instanciar el método en el constructor o en pasar Foo.bar() con el argumento self.

Uso de objetos Python

Surge una restricción adicional sobre los objetos debido a la forma en que funciona Python. Cuando se ejecuta una sentencia import, el código Python se compila a bytecode, donde una línea de código normalmente se corresponde con múltiples bytecodes. Cuando el código se ejecuta, el intérprete lee cada bytecode y lo ejecuta como una serie de instrucciones de código máquina. Dado que una interrupción puede ocurrir en cualquier momento entre instrucciones de código máquina, la línea original de código Python puede haberse ejecutado solo parcialmente. En consecuencia, un objeto Python como un conjunto, lista o diccionario modificado en el bucle principal puede carecer de consistencia interna en el momento en que ocurre la interrupción.

Un resultado típico es el siguiente. En raras ocasiones la ISR se ejecutará justo en el momento preciso en que el objeto está parcialmente actualizado. Cuando la ISR intenta leer el objeto, se produce un fallo. Debido a que tales problemas normalmente ocurren en ocasiones raras y aleatorias, pueden ser difíciles de diagnosticar. Existen formas de sortear este problema, descritas en Secciones críticas más abajo.

Es importante tener claro qué constituye la modificación de un objeto. Alterar el contenido de un array o bytearray es seguro. Esto se debe a que los bytes o palabras se escriben como una única instrucción de código máquina que no es interrumpible: en la jerga de la programación en tiempo real la escritura es atómica. Lo mismo ocurre con la actualización de un elemento de diccionario, porque los elementos son palabras de máquina, ya sean enteros o punteros a objetos. Un objeto definido por el usuario podría instanciar un array o bytearray. Es válido que tanto el bucle principal como la ISR alteren el contenido de estos.

El peligro surge cuando se altera la estructura de un objeto, especialmente en el caso de los diccionarios. Añadir o eliminar claves puede desencadenar un rehash. Si una ISR de tipo «hard» se ejecuta mientras un rehash está en curso e intenta acceder a un elemento, puede producirse un fallo. Internamente, las globales se implementan como un diccionario. En consecuencia, el programa principal debe crear todas las globales necesarias antes de iniciar un proceso que genere interrupciones de tipo «hard». El código de la aplicación también debe evitar eliminar globales.

MicroPython admite enteros de precisión arbitraria. Los valores entre 230 -1 y -230 se almacenarán en una sola palabra de máquina. Los valores mayores se almacenan como objetos Python. En consecuencia, los cambios en enteros largos no pueden considerarse atómicos. El uso de enteros largos en las ISR no es seguro porque puede intentarse una asignación de memoria a medida que cambia el valor de la variable.

Cómo superar la limitación de los flotantes

En general, lo mejor es evitar el uso de flotantes en el código de las ISR: los dispositivos de hardware normalmente manejan enteros y la conversión a flotantes normalmente se realiza en el bucle principal. Sin embargo, hay algunos algoritmos de DSP que requieren punto flotante. En plataformas con punto flotante por hardware (como las OpenMV Cam basadas en STM32), el ensamblador en línea ARM Thumb puede usarse para sortear esta limitación. Esto se debe a que el procesador almacena los valores flotantes en una palabra de máquina; los valores pueden compartirse entre la ISR y el código del programa principal a través de un arreglo de flotantes.

Uso de micropython.schedule

Esta función permite a una ISR programar una función de retorno para su ejecución «muy pronto». La función de retorno se pone en cola para su ejecución, que tendrá lugar en un momento en que el montículo no esté bloqueado. Por lo tanto, puede crear objetos Python y usar flotantes. La función de retorno también tiene garantizado ejecutarse en un momento en que el programa principal haya completado cualquier actualización de objetos Python, por lo que la función de retorno no encontrará objetos parcialmente actualizados.

El uso típico es manejar el hardware de un sensor. La ISR adquiere datos del hardware y le permite emitir una interrupción posterior. Luego programa una función de retorno para procesar los datos.

Las funciones de retorno programadas deben cumplir con los principios de diseño de gestores de interrupciones que se describen a continuación. Esto es para evitar problemas derivados de la actividad de E/S y de la modificación de datos compartidos que pueden surgir en cualquier código que prevalezca sobre el bucle del programa principal.

El tiempo de ejecución debe considerarse en relación con la frecuencia con la que pueden ocurrir las interrupciones. Si ocurre una interrupción mientras se está ejecutando la función de retorno anterior, se pondrá en cola otra instancia de la función de retorno; esta se ejecutará después de que la instancia actual haya finalizado. Por lo tanto, una tasa de repetición de interrupciones alta y sostenida conlleva un riesgo de crecimiento descontrolado de la cola y un fallo eventual con un RuntimeError.

Si la función de retorno que se va a pasar a schedule() es un método ligado, considere la nota en «Creación de objetos Python».

Excepciones

Si una ISR lanza una excepción, esta no se propagará al bucle principal. La interrupción se deshabilitará a menos que la excepción sea manejada por el código de la ISR.

Interfaz con asyncio

Cuando una ISR se ejecuta, puede prevalecer sobre el planificador de asyncio. Si la ISR realiza una operación de asyncio, el funcionamiento del planificador puede verse interrumpido. Esto se aplica tanto si la interrupción es de tipo «hard» como «soft» y también se aplica si la ISR ha pasado la ejecución a otra función a través de micropython.schedule. En particular, crear o cancelar tareas no es válido en un contexto de ISR. La forma segura de interactuar con asyncio es implementar una corrutina con la sincronización realizada mediante asyncio.ThreadSafeFlag. El siguiente fragmento ilustra la creación de una tarea en respuesta a una interrupción:

tsf = asyncio.ThreadSafeFlag()


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


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

En este ejemplo habrá una cantidad variable de latencia entre la ejecución de la ISR y la ejecución de foo(). Esto es inherente a la planificación cooperativa. La latencia máxima depende de la aplicación y de la plataforma, pero normalmente puede medirse en decenas de ms.

Cuestiones generales

Esto no es más que una breve introducción al tema de la programación en tiempo real. Los principiantes deben tener en cuenta que los errores de diseño en los programas en tiempo real pueden conducir a fallos que son particularmente difíciles de diagnosticar. Esto se debe a que pueden ocurrir raramente y a intervalos que son esencialmente aleatorios. Es crucial acertar con el diseño inicial y anticipar los problemas antes de que surjan. Tanto los gestores de interrupciones como el programa principal deben diseñarse con conocimiento de las siguientes cuestiones.

Diseño de gestores de interrupciones

Como se mencionó anteriormente, las ISR deben diseñarse para ser lo más simples posible. Siempre deben retornar en un período de tiempo corto y predecible. Esto es importante porque cuando la ISR se está ejecutando, el bucle principal no lo está: inevitablemente el bucle principal experimenta pausas en su ejecución en puntos aleatorios del código. Tales pausas pueden ser una fuente de errores difíciles de diagnosticar, particularmente si su duración es larga o variable. Para entender las implicaciones del tiempo de ejecución de las ISR, se requiere una comprensión básica de las prioridades de interrupción.

Las interrupciones se organizan según un esquema de prioridades. El código de una ISR puede a su vez ser interrumpido por una interrupción de mayor prioridad. Esto tiene implicaciones si las dos interrupciones comparten datos (consulte Secciones críticas más abajo). Si ocurre tal interrupción, interpone un retraso en el código de la ISR. Si ocurre una interrupción de menor prioridad mientras la ISR se está ejecutando, se retrasará hasta que la ISR esté completa: si el retraso es demasiado largo, la interrupción de menor prioridad puede fallar. Otra cuestión con las ISR lentas es el caso en que ocurre una segunda interrupción del mismo tipo durante su ejecución. La segunda interrupción se manejará al finalizar la primera. Sin embargo, si la tasa de interrupciones entrantes excede de forma consistente la capacidad de la ISR para atenderlas, el resultado no será nada feliz.

En consecuencia, las construcciones de bucle deben evitarse o minimizarse. La E/S a dispositivos distintos del dispositivo que interrumpe normalmente debe evitarse: la E/S como el acceso a disco, las sentencias print y el acceso a UART es relativamente lenta, y su duración puede variar. Otra cuestión aquí es que las funciones del sistema de archivos no son reentrantes: usar E/S del sistema de archivos en una ISR y en el programa principal sería peligroso. De forma crucial, el código de la ISR no debe esperar a un evento. La E/S es aceptable si se puede garantizar que el código retorna en un período predecible, por ejemplo alternar un pin o un LED. Acceder al dispositivo que interrumpe a través de I2C o SPI puede ser necesario, pero el tiempo que toman tales accesos debe calcularse o medirse y debe evaluarse su impacto en la aplicación.

Normalmente hay necesidad de compartir datos entre la ISR y el bucle principal. Esto puede hacerse ya sea a través de variables globales o mediante variables de clase o de instancia. Las variables suelen ser de tipo entero o booleano, o arreglos de enteros o de bytes (un arreglo de enteros preasignado ofrece un acceso más rápido que una lista). Cuando la ISR modifica múltiples valores, es necesario considerar el caso en que la interrupción ocurre en un momento en que el programa principal ha accedido a algunos, pero no a todos, los valores. Esto puede conducir a inconsistencias.

Considere el siguiente diseño. Una ISR almacena los datos entrantes en un bytearray, luego suma el número de bytes recibidos a un entero que representa el total de bytes listos para procesar. El programa principal lee el número de bytes, procesa los bytes y luego pone a cero el número de bytes listos. Esto funcionará hasta que ocurra una interrupción justo después de que el programa principal haya leído el número de bytes. La ISR pone los datos añadidos en el búfer y actualiza el número recibido, pero el programa principal ya ha leído el número, por lo que procesa los datos recibidos originalmente. Los bytes recién llegados se pierden.

Existen varias formas de evitar este peligro, siendo la más simple usar un búfer circular. Si no es posible usar una estructura con seguridad de hilos inherente, se describen otras formas a continuación.

Reentrancia

Puede surgir un peligro potencial si una función o método se comparte entre el programa principal y una o más ISR, o entre múltiples ISR. La cuestión aquí es que la función puede ser interrumpida ella misma y ejecutarse otra instancia de esa función. Si esto va a ocurrir, la función debe diseñarse para ser reentrante. Cómo se hace esto es un tema avanzado que está fuera del alcance de este tutorial.

Secciones críticas

Un ejemplo de una sección crítica de código es una que accede a más de una variable que puede verse afectada por una ISR. Si la interrupción ocurre entre los accesos a las variables individuales, sus valores serán inconsistentes. Esta es una instancia de un peligro conocido como condición de carrera: la ISR y el bucle del programa principal compiten por alterar las variables. Para evitar la inconsistencia debe emplearse un medio para garantizar que la ISR no altere los valores durante la sección crítica. Una forma de lograr esto es emitir machine.disable_irq() antes del inicio de la sección, y machine.enable_irq() al final. He aquí un ejemplo de este enfoque:

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 sección crítica puede comprender una sola línea de código y una sola variable. Considere el siguiente fragmento de código.

count = 0


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


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

Este ejemplo ilustra una fuente sutil de errores. La línea count += 1 en el bucle principal conlleva un peligro específico de condición de carrera conocido como lectura-modificación-escritura. Esta es una causa clásica de errores en sistemas en tiempo real. En el bucle principal, MicroPython lee el valor de count, le suma 1 y lo vuelve a escribir. En raras ocasiones la interrupción ocurre después de la lectura y antes de la escritura. La interrupción modifica count pero su cambio es sobrescrito por el bucle principal cuando la ISR retorna. En un sistema real esto podría conducir a fallos raros e impredecibles.

Como se mencionó anteriormente, debe tenerse cuidado si una instancia de un tipo incorporado de Python se modifica en el código principal y se accede a esa instancia en una ISR. El código que realiza la modificación debe considerarse como una sección crítica para garantizar que la instancia esté en un estado válido cuando se ejecute la ISR.

Debe tenerse especial cuidado si un conjunto de datos se comparte entre diferentes ISR. El peligro aquí es que la interrupción de mayor prioridad puede ocurrir cuando la de menor prioridad ha actualizado parcialmente los datos compartidos. Lidiar con esta situación es un tema avanzado que está fuera del alcance de esta introducción, salvo señalar que los objetos mutex descritos a continuación a veces pueden usarse.

Deshabilitar las interrupciones durante una sección crítica es la forma habitual y más simple de proceder, pero deshabilita todas las interrupciones en lugar de únicamente la que tiene el potencial de causar problemas. Generalmente no es deseable deshabilitar una interrupción durante mucho tiempo. En el caso de las interrupciones de temporizador, introduce variabilidad en el momento en que ocurre una función de retorno. En el caso de las interrupciones de dispositivo, puede llevar a que el dispositivo se atienda demasiado tarde, con posible pérdida de datos o errores de desbordamiento en el hardware del dispositivo. Como las ISR, una sección crítica en el código principal debe tener una duración corta y predecible.

Un enfoque para lidiar con las secciones críticas que reduce radicalmente el tiempo durante el cual las interrupciones están deshabilitadas es usar un objeto denominado mutex (nombre derivado de la noción de exclusión mutua). El programa principal bloquea el mutex antes de ejecutar la sección crítica y lo desbloquea al final. La ISR comprueba si el mutex está bloqueado. Si lo está, evita la sección crítica y retorna. El desafío de diseño es definir qué debe hacer la ISR en caso de que se le deniegue el acceso a las variables críticas. Puede encontrarse un ejemplo simple de un mutex aquí. Tenga en cuenta que el código del mutex sí deshabilita las interrupciones, pero solo durante la duración de ocho instrucciones de máquina: el beneficio de este enfoque es que otras interrupciones quedan prácticamente inalteradas.

Las interrupciones y el REPL

Los gestores de interrupciones, como los asociados a los temporizadores, pueden continuar ejecutándose después de que un programa termine. Esto puede producir resultados inesperados donde quizás esperaba que el objeto que genera la función de retorno hubiera quedado fuera de ámbito. Por ejemplo, en una OpenMV Cam:

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

bar()

Esto continúa ejecutándose hasta que el temporizador se deshabilita explícitamente o se reinicia la placa con Ctrl-D.