Escrita de tratadores de interrupções¶
Em hardware adequado, o MicroPython oferece a possibilidade de escrever tratadores de interrupções em Python. Os tratadores de interrupções — também conhecidos como rotinas de serviço de interrupção (ISR) — são definidos como funções de callback. Estas são executadas em resposta a um evento, como o disparo de um temporizador ou uma alteração de tensão num pino. Tais eventos podem ocorrer em qualquer momento durante a execução do código do programa. Isto acarreta consequências significativas, algumas específicas da linguagem MicroPython, outras comuns a todos os sistemas capazes de responder a eventos em tempo real. Este documento aborda primeiro as questões específicas da linguagem, seguindo-se uma breve introdução à programação em tempo real para quem ainda não está familiarizado com ela.
Esta introdução utiliza termos vagos como «lento» ou «o mais rápido possível». Isso é intencional, pois as velocidades dependem da aplicação. As durações aceitáveis para uma ISR dependem da frequência com que as interrupções ocorrem, da natureza do programa principal e da presença de outros eventos concorrentes.
Dicas e práticas recomendadas¶
Este ponto resume os detalhes apresentados abaixo e lista as principais recomendações para o código de tratadores de interrupções.
Mantenha o código o mais curto e simples possível.
Evite a alocação de memória: sem acrescentar a listas ou inserir em dicionários, sem vírgula flutuante.
Considere utilizar
micropython.schedulepara contornar a restrição acima.Quando uma ISR devolve múltiplos bytes, utilize um
bytearraypré-alocado. Se vários inteiros forem partilhados entre uma ISR e o programa principal, considere utilizar um array (array.array).Quando os dados são partilhados entre o programa principal e uma ISR, considere desativar as interrupções antes de aceder aos dados no programa principal e reativá-las imediatamente a seguir (consulte Secções Críticas).
Aloque um buffer de exceção de emergência (ver abaixo).
Questões do MicroPython¶
O buffer de exceção de emergência¶
Se ocorrer um erro numa ISR, o MicroPython não consegue produzir um relatório de erro a não ser que seja criado um buffer especial para esse fim. A depuração é simplificada se o seguinte código for incluído em qualquer programa que utilize interrupções.
import micropython
micropython.alloc_emergency_exception_buf(100)
O buffer de exceção de emergência só pode conter um rastreio de pilha de exceção. Isto significa que se for lançada uma segunda exceção durante o tratamento de uma exceção enquanto o heap está bloqueado, o rastreio de pilha dessa segunda exceção substituirá o original — mesmo que a segunda exceção seja tratada corretamente. Isto pode levar a mensagens de exceção confusas se o buffer for impresso mais tarde.
Simplicidade¶
Por várias razões, é importante manter o código das ISR o mais curto e simples possível. Deve fazer apenas o que tem de ser feito imediatamente após o evento que a originou: as operações que podem ser adiadas devem ser delegadas ao ciclo principal do programa. Tipicamente, uma ISR lidará com o dispositivo de hardware que originou a interrupção, preparando-o para a próxima interrupção. Comunicará com o ciclo principal atualizando dados partilhados para indicar que a interrupção ocorreu, e depois retornará. Uma ISR deve devolver o controlo ao ciclo principal o mais rapidamente possível. Esta não é uma questão específica do MicroPython, pelo que é abordada com mais detalhe abaixo.
Comunicação entre uma ISR e o programa principal¶
Normalmente, uma ISR precisa de comunicar com o programa principal. O meio mais simples de o fazer é através de um ou mais objetos de dados partilhados, declarados como globais ou partilhados através de uma classe (ver abaixo). Existem várias restrições e riscos associados a este procedimento, que são abordados com mais detalhe abaixo. Os objetos bytes e bytearray, bem como os arrays (do módulo array) que podem armazenar vários tipos de dados, são habitualmente utilizados para este fim.
A utilização de métodos de objetos como callbacks¶
O MicroPython suporta esta técnica poderosa que permite a uma ISR partilhar variáveis de instância com o código subjacente. Permite também que uma classe que implementa um driver de dispositivo suporte múltiplas instâncias do dispositivo. O exemplo seguinte faz piscar dois LEDs a diferentes frequências.
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"))
Neste exemplo, a instância red controla o LED vermelho a partir de um temporizador virtual de 1 Hz: cada vez que o temporizador dispara, red.cb() é chamada, alternando o estado do LED vermelho. A instância green funciona de forma semelhante com um temporizador de 0,8 Hz que alterna o estado do LED verde. A utilização de métodos de instância confere dois benefícios. Em primeiro lugar, uma única classe permite que o código seja partilhado entre múltiplas instâncias de hardware. Em segundo lugar, como método ligado, o primeiro argumento da função de callback é self. Isto permite à callback aceder a dados de instância e guardar o estado entre chamadas sucessivas. Por exemplo, se a classe acima tivesse uma variável self.count inicializada a zero no construtor, cb() poderia incrementar o contador. As instâncias red e green manteriam então contagens independentes do número de vezes que cada LED mudou de estado.
Criação de objetos Python¶
As ISR não podem criar instâncias de objetos Python. Isto deve-se ao facto de o MicroPython precisar de alocar memória para o objeto a partir de um conjunto de blocos de memória livre denominado heap. Isto não é permitido num tratador de interrupções porque a alocação no heap não é reentrante. Por outras palavras, a interrupção pode ocorrer quando o programa principal está a meio de realizar uma alocação — para manter a integridade do heap, o interpretador não permite alocações de memória no código ISR.
Uma consequência disto é que as ISR não podem utilizar aritmética de vírgula flutuante; isto porque os floats são objetos Python. Da mesma forma, uma ISR não pode acrescentar um item a uma lista. Na prática, pode ser difícil determinar exatamente quais as construções de código que tentarão efetuar alocações de memória e provocarão uma mensagem de erro: mais uma razão para manter o código ISR curto e simples.
Uma forma de evitar este problema é fazer com que a ISR utilize buffers pré-alocados. Por exemplo, um construtor de classe cria uma instância bytearray e um indicador booleano. O método ISR atribui dados a posições no buffer e define o indicador. A alocação de memória ocorre no código do programa principal quando o objeto é instanciado, e não na ISR.
Os métodos de I/O da biblioteca MicroPython geralmente oferecem uma opção para utilizar um buffer pré-alocado. Por exemplo, machine.I2C.readfrom_into() lê para um buffer mutável fornecido pelo chamador: isto permite a sua utilização numa ISR.
Uma forma de criar um objeto sem utilizar uma classe ou variáveis globais é a seguinte:
def set_volume(t, buf=bytearray(3)):
buf[0] = 0xa5
buf[1] = t >> 4
buf[2] = 0x5a
return buf
O compilador instancia o argumento buf predefinido quando a função é carregada pela primeira vez (normalmente quando o módulo em que se encontra é importado).
Uma instância de criação de objetos ocorre quando é criada uma referência a um método ligado. Isto significa que uma ISR não pode passar um método ligado a uma função. Uma solução é criar uma referência ao método ligado no construtor da classe e passar essa referência na ISR. Por exemplo:
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)
Outras técnicas consistem em definir e instanciar o método no construtor ou em passar Foo.bar() com o argumento self.
Utilização de objetos Python¶
Uma restrição adicional aos objetos surge devido à forma como o Python funciona. Quando uma instrução import é executada, o código Python é compilado para bytecode, com uma linha de código a mapear tipicamente para múltiplos bytecodes. Quando o código é executado, o interpretador lê cada bytecode e executa-o como uma série de instruções em código máquina. Dado que uma interrupção pode ocorrer em qualquer momento entre instruções de código máquina, a linha original de código Python pode ter sido apenas parcialmente executada. Consequentemente, um objeto Python como um conjunto, lista ou dicionário modificado no ciclo principal pode carecer de consistência interna no momento em que a interrupção ocorre.
Um resultado típico é o seguinte. Em raras ocasiões, a ISR será executada no momento exato em que o objeto se encontra parcialmente atualizado. Quando a ISR tenta ler o objeto, ocorre um crash. Como tais problemas ocorrem tipicamente em ocasiões raras e aleatórias, podem ser difíceis de diagnosticar. Existem formas de contornar este problema, descritas em Secções Críticas abaixo.
É importante ter claro o que constitui a modificação de um objeto. Alterar o conteúdo de um array ou bytearray é seguro. Isto porque os bytes ou palavras são escritos como uma única instrução de código máquina que não é interrompível: no jargão da programação em tempo real, a escrita é atómica. O mesmo se aplica à atualização de um item de dicionário, porque os itens são palavras de máquina, sendo inteiros ou ponteiros para objetos. Um objeto definido pelo utilizador pode instanciar um array ou bytearray. É válido tanto para o ciclo principal como para a ISR alterar o conteúdo destes.
O perigo surge quando a estrutura de um objeto é alterada, nomeadamente no caso dos dicionários. Adicionar ou remover chaves pode desencadear um rehash. Se uma ISR hard for executada enquanto um rehash está em curso e tentar aceder a um item, pode ocorrer um crash. Internamente, as variáveis globais são implementadas como um dicionário. Consequentemente, o programa principal deve criar todas as variáveis globais necessárias antes de iniciar um processo que gere interrupções hard. O código da aplicação também deve evitar eliminar variáveis globais.
O MicroPython suporta inteiros de precisão arbitrária. Os valores entre 230 -1 e -230 serão armazenados numa única palavra de máquina. Os valores maiores são armazenados como objetos Python. Consequentemente, as alterações a inteiros longos não podem ser consideradas atómicas. A utilização de inteiros longos em ISR é insegura porque pode ser tentada uma alocação de memória à medida que o valor da variável muda.
Superar a limitação dos floats¶
Em geral, é preferível evitar a utilização de floats no código ISR: os dispositivos de hardware normalmente tratam inteiros e a conversão para floats é normalmente feita no ciclo principal. No entanto, existem alguns algoritmos DSP que requerem vírgula flutuante. Em plataformas com vírgula flutuante em hardware (como as OpenMV Cams baseadas em STM32), o assembler inline ARM Thumb pode ser utilizado para contornar esta limitação. Isto deve-se ao facto de o processador armazenar valores float numa palavra de máquina; os valores podem ser partilhados entre a ISR e o código do programa principal através de um array de floats.
Utilização de micropython.schedule¶
Esta função permite a uma ISR agendar uma callback para execução «muito em breve». A callback é colocada em fila para execução, que terá lugar num momento em que o heap não está bloqueado. Assim, pode criar objetos Python e utilizar floats. A callback também tem garantia de ser executada num momento em que o programa principal concluiu qualquer atualização de objetos Python, pelo que a callback não encontrará objetos parcialmente atualizados.
A utilização típica é para tratar hardware de sensor. A ISR adquire dados do hardware e permite-lhe emitir uma interrupção adicional. De seguida, agenda uma callback para processar os dados.
As callbacks agendadas devem cumprir os princípios de conceção de tratadores de interrupções descritos abaixo. Isto serve para evitar problemas resultantes de atividade de I/O e da modificação de dados partilhados, que podem surgir em qualquer código que preempta o ciclo principal do programa.
O tempo de execução deve ser considerado em relação à frequência com que as interrupções podem ocorrer. Se uma interrupção ocorrer enquanto a callback anterior está a ser executada, outra instância da callback será colocada em fila para execução; esta será executada após a conclusão da instância atual. Uma taxa elevada e sustentada de repetição de interrupções comporta, por isso, o risco de crescimento ilimitado da fila e eventual falha com um RuntimeError.
Se a callback a ser passada a schedule() for um método ligado, consulte a nota em «Criação de objetos Python».
Exceções¶
Se uma ISR lançar uma exceção, esta não se propagará para o ciclo principal. A interrupção será desativada, a não ser que a exceção seja tratada pelo código ISR.
Integração com asyncio¶
Quando uma ISR é executada, pode preempta o escalonador asyncio. Se a ISR realizar uma operação asyncio, o funcionamento do escalonador pode ser perturbado. Isto aplica-se quer a interrupção seja hard ou soft, e também se aplica se a ISR tiver passado a execução para outra função através de micropython.schedule. Em particular, criar ou cancelar tarefas é inválido num contexto ISR. A forma segura de interagir com asyncio é implementar uma corrotina com sincronização realizada por asyncio.ThreadSafeFlag. O fragmento seguinte ilustra a criação de uma tarefa em resposta a uma interrupção:
tsf = asyncio.ThreadSafeFlag()
def isr(_): # Interrupt handler
tsf.set()
async def foo():
while True:
await tsf.wait()
asyncio.create_task(bar())
Neste exemplo, haverá uma quantidade variável de latência entre a execução da ISR e a execução de foo(). Isto é inerente ao escalonamento cooperativo. A latência máxima depende da aplicação e da plataforma, mas pode tipicamente ser medida em dezenas de ms.
Questões gerais¶
Esta é apenas uma breve introdução ao tema da programação em tempo real. Os principiantes devem ter em atenção que erros de conceção em programas em tempo real podem levar a falhas particularmente difíceis de diagnosticar. Isto porque podem ocorrer raramente e em intervalos essencialmente aleatórios. É crucial acertar na conceção inicial e antecipar os problemas antes de surgirem. Tanto os tratadores de interrupções como o programa principal precisam de ser concebidos tendo em conta os seguintes problemas.
Conceção de tratadores de interrupções¶
Como mencionado acima, as ISR devem ser concebidas para serem o mais simples possível. Devem terminar sempre num período de tempo curto e previsível. Isto é importante porque quando a ISR está a ser executada, o ciclo principal não o está: inevitavelmente, o ciclo principal experimenta pausas na sua execução em pontos aleatórios do código. Tais pausas podem ser uma fonte de erros difíceis de diagnosticar, especialmente se a sua duração for longa ou variável. Para compreender as implicações do tempo de execução das ISR, é necessária uma compreensão básica das prioridades de interrupção.
As interrupções são organizadas de acordo com um esquema de prioridades. O código ISR pode por si mesmo ser interrompido por uma interrupção de prioridade mais elevada. Isto tem implicações se as duas interrupções partilharem dados (consulte Secções Críticas abaixo). Se tal interrupção ocorrer, introduz um atraso no código ISR. Se uma interrupção de prioridade inferior ocorrer enquanto a ISR está a ser executada, será adiada até que a ISR esteja concluída: se o atraso for demasiado longo, a interrupção de prioridade inferior pode falhar. Uma questão adicional com ISR lentas é o caso em que uma segunda interrupção do mesmo tipo ocorre durante a sua execução. A segunda interrupção será tratada no término da primeira. No entanto, se a taxa de interrupções recebidas exceder consistentemente a capacidade da ISR de as processar, o resultado não será satisfatório.
Consequentemente, as estruturas de ciclos devem ser evitadas ou minimizadas. O I/O para dispositivos que não sejam o dispositivo que originou a interrupção deve ser normalmente evitado: o I/O como acesso a disco, instruções print e acesso a UART é relativamente lento e a sua duração pode variar. Uma questão adicional aqui é que as funções do sistema de ficheiros não são reentrantes: utilizar I/O do sistema de ficheiros numa ISR e no programa principal seria perigoso. De forma crucial, o código ISR não deve aguardar por um evento. O I/O é aceitável se for possível garantir que o código retorna num período previsível, por exemplo, alternando o estado de um pino ou LED. O acesso ao dispositivo que originou a interrupção via I2C ou SPI pode ser necessário, mas o tempo gasto em tais acessos deve ser calculado ou medido e o seu impacto na aplicação avaliado.
Normalmente existe a necessidade de partilhar dados entre a ISR e o ciclo principal. Isto pode ser feito através de variáveis globais ou via variáveis de classe ou de instância. As variáveis são tipicamente de tipo inteiro ou booleano, ou arrays de inteiros ou bytes (um array de inteiros pré-alocado oferece acesso mais rápido do que uma lista). Quando múltiplos valores são modificados pela ISR, é necessário considerar o caso em que a interrupção ocorre num momento em que o programa principal acedeu a alguns, mas não a todos, os valores. Isto pode levar a inconsistências.
Considere a seguinte conceção. Uma ISR armazena os dados recebidos num bytearray e, de seguida, adiciona o número de bytes recebidos a um inteiro que representa o total de bytes prontos para processamento. O programa principal lê o número de bytes, processa os bytes e depois limpa o número de bytes prontos. Isto funcionará até que uma interrupção ocorra imediatamente após o programa principal ter lido o número de bytes. A ISR coloca os dados adicionados no buffer e atualiza o número recebido, mas o programa principal já leu o número, pelo que processa os dados recebidos originalmente. Os bytes recém-chegados são perdidos.
Existem várias formas de evitar este perigo, sendo a mais simples utilizar um buffer circular. Se não for possível utilizar uma estrutura com segurança de thread inerente, outras formas são descritas abaixo.
Reentrância¶
Pode ocorrer um perigo potencial se uma função ou método for partilhado entre o programa principal e uma ou mais ISR, ou entre múltiplas ISR. O problema aqui é que a função pode por si mesma ser interrompida e outra instância dessa função ser executada. Se isto ocorrer, a função deve ser concebida para ser reentrante. A forma como isto é feito é um tópico avançado que vai além do âmbito deste tutorial.
Secções críticas¶
Um exemplo de uma secção crítica do código é aquela que acede a mais do que uma variável que pode ser afetada por uma ISR. Se a interrupção ocorrer entre os acessos às variáveis individuais, os seus valores serão inconsistentes. Esta é uma instância de um perigo conhecido como condição de corrida: a ISR e o ciclo principal do programa competem para alterar as variáveis. Para evitar inconsistências, deve ser empregue um meio para garantir que a ISR não altera os valores durante a duração da secção crítica. Uma forma de o conseguir é emitir machine.disable_irq() antes do início da secção, e machine.enable_irq() no fim. Aqui está um exemplo desta abordagem:
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()
Uma secção crítica pode compreender uma única linha de código e uma única variável. Considere o seguinte 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 exemplo ilustra uma fonte subtil de erros. A linha count += 1 no ciclo principal comporta uma condição de corrida específica conhecida como leitura-modificação-escrita. Esta é uma causa clássica de erros em sistemas de tempo real. No ciclo principal, o MicroPython lê o valor de count, adiciona 1 a ele e escreve-o de volta. Em raras ocasiões, a interrupção ocorre após a leitura e antes da escrita. A interrupção modifica count, mas a sua alteração é sobrescrita pelo ciclo principal quando a ISR retorna. Num sistema real, isto poderia levar a falhas raras e imprevisíveis.
Como mencionado acima, deve ter-se cuidado se uma instância de um tipo built-in do Python for modificada no código principal e essa instância for acedida numa ISR. O código que realiza a modificação deve ser considerado uma secção crítica para garantir que a instância está num estado válido quando a ISR é executada.
É necessário ter particular cuidado se um conjunto de dados for partilhado entre diferentes ISR. O perigo aqui é que a interrupção de prioridade mais elevada pode ocorrer quando a de prioridade inferior atualizou parcialmente os dados partilhados. Lidar com esta situação é um tópico avançado que vai além do âmbito desta introdução, exceto para notar que os objetos mutex descritos abaixo podem ser utilizados em alguns casos.
Desativar as interrupções durante a duração de uma secção crítica é a forma habitual e mais simples de proceder, mas desativa todas as interrupções em vez de apenas aquela com potencial para causar problemas. Em geral, é indesejável desativar uma interrupção por muito tempo. No caso das interrupções de temporizador, introduz variabilidade no momento em que uma callback ocorre. No caso das interrupções de dispositivo, pode levar a que o dispositivo seja servido demasiado tarde com possível perda de dados ou erros de sobrefluxo no hardware do dispositivo. Tal como as ISR, uma secção crítica no código principal deve ter uma duração curta e previsível.
Uma abordagem para lidar com secções críticas que reduz radicalmente o tempo durante o qual as interrupções são desativadas é utilizar um objeto denominado mutex (nome derivado da noção de exclusão mútua). O programa principal bloqueia o mutex antes de executar a secção crítica e desbloqueia-o no fim. A ISR testa se o mutex está bloqueado. Se estiver, evita a secção crítica e retorna. O desafio de conceção é definir o que a ISR deve fazer no caso de o acesso às variáveis críticas ser negado. Um exemplo simples de um mutex pode ser encontrado aqui. Note que o código mutex desativa as interrupções, mas apenas durante a duração de oito instruções de máquina: o benefício desta abordagem é que outras interrupções são praticamente inalteradas.
Interrupções e o REPL¶
Os tratadores de interrupções, como os associados a temporizadores, podem continuar a ser executados após o término de um programa. Isto pode produzir resultados inesperados quando se poderia esperar que o objeto que origina a callback tivesse saído do âmbito. Por exemplo, numa OpenMV Cam:
def bar():
foo = machine.Timer(-1, freq=4, callback=lambda t: print('.', end=''), hard=True)
bar()
Isto continua a ser executado até que o temporizador seja explicitamente desativado ou a placa seja reiniciada com Ctrl-D.