Escrevendo manipuladores de interrupção¶
Em hardware adequado, o MicroPython oferece a capacidade de escrever manipuladores de interrupção em Python. Os manipuladores de interrupção - também conhecidos como rotinas de serviço de interrupção (ISRs) - são definidos como funções de callback. Eles são executados em resposta a um evento, como o disparo de um timer ou uma mudança de tensão em um pino. Tais eventos podem ocorrer em qualquer ponto da execução do código do programa. Isso traz consequências significativas, algumas específicas da linguagem MicroPython. Outras são comuns a todos os sistemas capazes de responder a eventos em tempo real. Este documento aborda primeiro as questões específicas da linguagem, seguidas de uma breve introdução à programação em tempo real para quem é iniciante no assunto.
Esta introdução usa termos vagos como “lento” ou “o mais rápido possível”. Isso é deliberado, pois as velocidades dependem da aplicação. As durações aceitáveis para uma ISR dependem da taxa com que as interrupções ocorrem, da natureza do programa principal e da presença de outros eventos concorrentes.
Dicas e práticas recomendadas¶
Isto resume os pontos detalhados abaixo e lista as principais recomendações para o código de manipuladores de interrupção.
Mantenha o código o mais curto e simples possível.
Evite alocação de memória: nada de anexar a listas ou inserir em dicionários, nada de ponto flutuante.
Considere usar
micropython.schedulepara contornar a restrição acima.Quando uma ISR retornar múltiplos bytes, use um
bytearraypré-alocado. Se múltiplos inteiros precisarem ser compartilhados entre uma ISR e o programa principal, considere usar um array (array.array).Quando dados são compartilhados entre o programa principal e uma ISR, considere desabilitar as interrupções antes de acessar os dados no programa principal e reabilitá-las imediatamente em seguida (veja Seções Críticas).
Aloque um buffer de exceção de emergência (veja abaixo).
Questões do MicroPython¶
O buffer de exceção de emergência¶
Se ocorrer um erro em uma ISR, o MicroPython não consegue produzir um relatório de erro, a menos que um buffer especial seja criado para esse propósito. A depuração é simplificada se o código a seguir for incluído em qualquer programa que use interrupções.
import micropython
micropython.alloc_emergency_exception_buf(100)
O buffer de exceção de emergência só pode conter um único rastreamento de pilha de exceção. Isso significa que, se uma segunda exceção for lançada durante o tratamento de uma exceção enquanto o heap estiver bloqueado, o rastreamento de pilha dessa segunda exceção substituirá o original - mesmo que a segunda exceção seja tratada corretamente. Isso pode levar a mensagens de exceção confusas se o buffer for impresso posteriormente.
Simplicidade¶
Por diversas razões, é importante manter o código da ISR o mais curto e simples possível. Ele deve fazer apenas o que precisa ser feito imediatamente após o evento que o causou: operações que podem ser adiadas devem ser delegadas ao laço principal do programa. Tipicamente, uma ISR lida com o dispositivo de hardware que causou a interrupção, deixando-o pronto para a próxima interrupção ocorrer. Ela se comunica com o laço principal atualizando dados compartilhados para indicar que a interrupção ocorreu, e então retorna. Uma ISR deve devolver o controle ao laço principal o mais rápido possível. Isso não é uma questão específica do MicroPython, então é abordado com mais detalhes abaixo.
Comunicação entre uma ISR e o programa principal¶
Normalmente, uma ISR precisa se comunicar com o programa principal. O meio mais simples de fazer isso é por meio de um ou mais objetos de dados compartilhados, declarados como globais ou compartilhados por meio de uma classe (veja abaixo). Há várias restrições e riscos envolvidos nisso, que são abordados com mais detalhes abaixo. Objetos inteiros, bytes e bytearray são comumente usados para esse propósito, juntamente com arrays (do módulo array) que podem armazenar diversos tipos de dados.
O uso de métodos de objeto como callbacks¶
O MicroPython suporta essa técnica poderosa que permite a uma ISR compartilhar variáveis de instância com o código subjacente. Ela também permite que uma classe que implementa um driver de dispositivo suporte múltiplas instâncias de dispositivo. O exemplo a seguir faz com que dois LEDs pisquem em ritmos diferentes.
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 aciona o LED vermelho a partir de um timer virtual de 1 Hz: cada vez que o timer dispara, red.cb() é chamado, alternando o LED vermelho. A instância green opera de forma semelhante com um timer de 0,8 Hz alternando o LED verde. O uso de métodos de instância confere dois benefícios. Primeiro, uma única classe permite que o código seja compartilhado entre múltiplas instâncias de hardware. Segundo, como um método vinculado, o primeiro argumento da função de callback é self. Isso permite que o callback acesse os dados da instância e salve estado entre chamadas sucessivas. Por exemplo, se a classe acima tivesse uma variável self.count inicializada em 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¶
ISRs não podem criar instâncias de objetos Python. Isso ocorre porque o MicroPython precisa alocar memória para o objeto a partir de um conjunto de blocos de memória livre chamado heap. Isso não é permitido em um manipulador de interrupção porque a alocação de heap não é reentrante. Em outras palavras, a interrupção pode ocorrer enquanto o programa principal está no meio de uma alocação - para manter a integridade do heap, o interpretador não permite alocações de memória em código de ISR.
Uma consequência disso é que ISRs não podem usar aritmética de ponto flutuante; isso ocorre porque floats são objetos Python. Da mesma forma, uma ISR não pode anexar um item a uma lista. Na prática, pode ser difícil determinar exatamente quais construções de código tentarão realizar alocação de memória e provocar uma mensagem de erro: mais uma razão para manter o código da ISR curto e simples.
Uma maneira de evitar esse problema é a ISR usar buffers pré-alocados. Por exemplo, um construtor de classe cria uma instância de bytearray e um sinalizador booleano. O método da ISR atribui dados a posições no buffer e define o sinalizador. 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 E/S da biblioteca do MicroPython geralmente fornecem uma opção para usar um buffer pré-alocado. Por exemplo, machine.I2C.readfrom_into() lê dados em um buffer mutável fornecido pelo chamador: isso permite seu uso em uma ISR.
Um meio de criar um objeto sem empregar uma classe ou variáveis globais é o 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 padrão buf quando a função é carregada pela primeira vez (normalmente quando o módulo em que ela se encontra é importado).
Uma instância de criação de objeto ocorre quando uma referência a um método vinculado é criada. Isso significa que uma ISR não pode passar um método vinculado para uma função. Uma solução é criar uma referência ao método vinculado 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 são definir e instanciar o método no construtor ou passar Foo.bar() com o argumento self.
Uso 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 em bytecode, com uma linha de código normalmente mapeando para múltiplos bytecodes. Quando o código é executado, o interpretador lê cada bytecode e o executa como uma série de instruções de código de máquina. Dado que uma interrupção pode ocorrer a qualquer momento entre as instruções de código de 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 laço 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 exatamente no momento em que o objeto está parcialmente atualizado. Quando a ISR tenta ler o objeto, ocorre uma falha. Como tais problemas tipicamente ocorrem em ocasiões raras e aleatórias, eles podem ser difíceis de diagnosticar. Existem maneiras de contornar esse problema, descritas em Seções Críticas abaixo.
É importante ter clareza sobre o que constitui a modificação de um objeto. Alterar o conteúdo de um array ou bytearray é seguro. Isso ocorre porque bytes ou palavras são escritos como uma única instrução de código de máquina que não pode ser interrompida: no jargão da programação em tempo real, a escrita é atômica. O mesmo vale para a 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 usuário pode instanciar um array ou bytearray. É válido tanto para o laço principal quanto para a ISR alterar o conteúdo deles.
O risco surge quando a estrutura de um objeto é alterada, notavelmente no caso de dicionários. Adicionar ou excluir chaves pode disparar um rehash. Se uma ISR rígida (hard) for executada enquanto um rehash está em andamento e tentar acessar um item, pode ocorrer uma falha. 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 rígidas (hard). O código da aplicação também deve evitar excluir variáveis globais.
O MicroPython suporta inteiros de precisão arbitrária. Valores entre 230 -1 e -230 serão armazenados em uma única palavra de máquina. Valores maiores são armazenados como objetos Python. Consequentemente, as alterações em inteiros longos não podem ser consideradas atômicas. O uso de inteiros longos em ISRs é inseguro porque pode ser tentada uma alocação de memória à medida que o valor da variável muda.
Superando a limitação de ponto flutuante¶
Em geral, é melhor evitar o uso de floats em código de ISR: os dispositivos de hardware normalmente lidam com inteiros, e a conversão para floats é normalmente feita no laço principal. No entanto, há alguns algoritmos de DSP que requerem ponto flutuante. Em plataformas com ponto flutuante por hardware (como as OpenMV Cams baseadas em STM32), o assembler ARM Thumb inline pode ser usado para contornar essa limitação. Isso ocorre porque o processador armazena valores float em uma palavra de máquina; os valores podem ser compartilhados entre a ISR e o código do programa principal por meio de um array de floats.
Usando micropython.schedule¶
Esta função permite que uma ISR agende um callback para execução “muito em breve”. O callback é enfileirado para execução, que ocorrerá em um momento em que o heap não estiver bloqueado. Assim, ele pode criar objetos Python e usar floats. O callback também tem garantia de ser executado em um momento em que o programa principal tenha concluído qualquer atualização de objetos Python, de modo que o callback não encontrará objetos parcialmente atualizados.
O uso típico é para lidar com hardware de sensores. A ISR adquire dados do hardware e o habilita a emitir outra interrupção. Em seguida, ela agenda um callback para processar os dados.
Callbacks agendados devem cumprir os princípios de projeto de manipuladores de interrupção descritos abaixo. Isso evita problemas decorrentes de atividade de E/S e da modificação de dados compartilhados, que podem surgir em qualquer código que interrompa o laço principal do programa.
O tempo de execução precisa ser considerado em relação à frequência com que as interrupções podem ocorrer. Se uma interrupção ocorrer enquanto o callback anterior está em execução, uma instância adicional do callback será enfileirada para execução; ela será executada após a conclusão da instância atual. Uma taxa de repetição de interrupções consistentemente alta, portanto, traz o risco de crescimento descontrolado da fila e eventual falha com um RuntimeError.
Se o callback a ser passado para schedule() for um método vinculado, considere a nota em “Criação de objetos Python”.
Exceções¶
Se uma ISR lançar uma exceção, ela não se propagará para o laço principal. A interrupção será desabilitada, a menos que a exceção seja tratada pelo código da ISR.
Interface com asyncio¶
Quando uma ISR é executada, ela pode interromper (preempt) o agendador do asyncio. Se a ISR realizar uma operação de asyncio, o funcionamento do agendador pode ser perturbado. Isso se aplica quer a interrupção seja rígida (hard) ou suave (soft), e também se aplica se a ISR tiver passado a execução para outra função via micropython.schedule. Em particular, criar ou cancelar tarefas é inválido em um contexto de ISR. A maneira segura de interagir com o asyncio é implementar uma corrotina com sincronização realizada por asyncio.ThreadSafeFlag. O fragmento a seguir 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(). Isso é inerente ao agendamento cooperativo. A latência máxima depende da aplicação e da plataforma, mas tipicamente pode ser medida em dezenas de ms.
Questões gerais¶
Esta é apenas uma breve introdução ao assunto da programação em tempo real. Os iniciantes devem notar que erros de projeto em programas de tempo real podem levar a falhas particularmente difíceis de diagnosticar. Isso ocorre porque elas podem acontecer raramente e em intervalos essencialmente aleatórios. É crucial acertar o projeto inicial e antecipar problemas antes que eles surjam. Tanto os manipuladores de interrupção quanto o programa principal precisam ser projetados com uma compreensão das seguintes questões.
Projeto de manipuladores de interrupção¶
Como mencionado acima, as ISRs devem ser projetadas para serem o mais simples possível. Elas devem sempre retornar em um período de tempo curto e previsível. Isso é importante porque, enquanto a ISR está em execução, o laço principal não está: inevitavelmente, o laço principal sofre pausas em sua execução em pontos aleatórios do código. Tais pausas podem ser uma fonte de bugs difíceis de diagnosticar, particularmente se sua duração for longa ou variável. Para entender as implicações do tempo de execução de uma ISR, é necessário um entendimento básico das prioridades de interrupção.
As interrupções são organizadas de acordo com um esquema de prioridades. O próprio código da ISR pode ser interrompido por uma interrupção de prioridade mais alta. Isso tem implicações se as duas interrupções compartilharem dados (veja Seções Críticas abaixo). Se tal interrupção ocorrer, ela introduz um atraso no código da ISR. Se uma interrupção de prioridade mais baixa ocorrer enquanto a ISR está em execução, ela será adiada até que a ISR seja concluída: se o atraso for muito longo, a interrupção de prioridade mais baixa pode falhar. Outra questão com ISRs lentas é o caso em que uma segunda interrupção do mesmo tipo ocorre durante sua execução. A segunda interrupção será tratada ao término da primeira. No entanto, se a taxa de interrupções recebidas exceder consistentemente a capacidade da ISR de atendê-las, o resultado não será nada agradável.
Consequentemente, construções de laço devem ser evitadas ou minimizadas. E/S para dispositivos que não sejam o dispositivo que gerou a interrupção normalmente deve ser evitada: E/S como acesso a disco, instruções print e acesso à UART é relativamente lenta, e sua duração pode variar. Outra questão aqui é que as funções de sistema de arquivos não são reentrantes: usar E/S de sistema de arquivos em uma ISR e no programa principal seria arriscado. Crucialmente, o código da ISR não deve esperar por um evento. A E/S é aceitável se o código tiver garantia de retornar em um período previsível, por exemplo, alternar um pino ou LED. Acessar o dispositivo que gerou a interrupção via I2C ou SPI pode ser necessário, mas o tempo gasto em tais acessos deve ser calculado ou medido, e seu impacto na aplicação avaliado.
Geralmente há a necessidade de compartilhar dados entre a ISR e o laço principal. Isso pode ser feito por meio de variáveis globais ou de variáveis de classe ou de instância. As variáveis são tipicamente do tipo inteiro ou booleano, ou arrays de inteiros ou bytes (um array de inteiros pré-alocado oferece acesso mais rápido 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 acessou alguns, mas não todos, os valores. Isso pode levar a inconsistências.
Considere o seguinte projeto. Uma ISR armazena os dados recebidos em um bytearray e, em seguida, soma 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, em seguida, zera o número de bytes prontos. Isso funcionará até que uma interrupção ocorra logo após o programa principal ter lido o número de bytes. A ISR coloca os dados adicionais no buffer e atualiza o número recebido, mas o programa principal já leu o número, então processa os dados originalmente recebidos. Os bytes recém-chegados são perdidos.
Há várias maneiras de evitar esse risco, sendo a mais simples usar um buffer circular. Se não for possível usar uma estrutura com segurança de thread inerente, outras maneiras são descritas abaixo.
Reentrância¶
Um risco potencial pode ocorrer se uma função ou método for compartilhado entre o programa principal e uma ou mais ISRs, ou entre múltiplas ISRs. A questão aqui é que a própria função pode ser interrompida e uma instância adicional dessa função pode ser executada. Se isso vier a ocorrer, a função deve ser projetada para ser reentrante. Como isso é feito é um tópico avançado que está além do escopo deste tutorial.
Seções críticas¶
Um exemplo de uma seção crítica de código é aquela que acessa mais de uma variável que pode ser afetada por uma ISR. Se a interrupção ocorrer entre os acessos às variáveis individuais, seus valores ficarão inconsistentes. Esta é uma instância de um risco conhecido como condição de corrida: a ISR e o laço principal do programa competem para alterar as variáveis. Para evitar inconsistência, deve-se empregar um meio de garantir que a ISR não altere os valores durante a seção crítica. Uma maneira de conseguir isso é executar machine.disable_irq() antes do início da seção e machine.enable_irq() no final. Aqui está um exemplo dessa 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 seção crítica pode consistir em uma única linha de código e uma única variável. Considere o fragmento de código a seguir.
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 sutil de bugs. A linha count += 1 no laço principal carrega um risco específico de condição de corrida conhecido como leitura-modificação-escrita (read-modify-write). Esta é uma causa clássica de bugs em sistemas de tempo real. No laço principal, o MicroPython lê o valor de count, soma 1 a ele e o escreve de volta. Em raras ocasiões, a interrupção ocorre após a leitura e antes da escrita. A interrupção modifica count, mas sua alteração é sobrescrita pelo laço principal quando a ISR retorna. Em um sistema real, isso poderia levar a falhas raras e imprevisíveis.
Como mencionado acima, deve-se ter cuidado se uma instância de um tipo embutido do Python for modificada no código principal e essa instância for acessada em uma ISR. O código que realiza a modificação deve ser tratado como uma seção crítica para garantir que a instância esteja em um estado válido quando a ISR for executada.
Cuidado especial precisa ser tomado se um conjunto de dados for compartilhado entre diferentes ISRs. O risco aqui é que a interrupção de prioridade mais alta possa ocorrer quando a de prioridade mais baixa tiver atualizado parcialmente os dados compartilhados. Lidar com essa situação é um tópico avançado que está além do escopo desta introdução, exceto para notar que os objetos mutex descritos abaixo podem às vezes ser usados.
Desabilitar as interrupções durante uma seção crítica é a maneira usual e mais simples de proceder, mas isso desabilita todas as interrupções, e não apenas aquela com potencial de causar problemas. Geralmente é indesejável desabilitar uma interrupção por muito tempo. No caso de interrupções de timer, isso introduz variabilidade no momento em que um callback ocorre. No caso de interrupções de dispositivo, pode levar a que o dispositivo seja atendido tarde demais, com possível perda de dados ou erros de estouro (overrun) no hardware do dispositivo. Assim como as ISRs, uma seção crítica no código principal deve ter uma duração curta e previsível.
Uma abordagem para lidar com seções críticas que reduz radicalmente o tempo durante o qual as interrupções ficam desabilitadas é usar um objeto chamado mutex (nome derivado da noção de exclusão mútua, do inglês mutual exclusion). O programa principal trava o mutex antes de executar a seção crítica e o destrava ao final. A ISR testa se o mutex está travado. Se estiver, ela evita a seção crítica e retorna. O desafio de projeto é definir o que a ISR deve fazer caso o acesso às variáveis críticas seja negado. Um exemplo simples de mutex pode ser encontrado aqui. Observe que o código do mutex de fato desabilita as interrupções, mas apenas pela duração de oito instruções de máquina: o benefício dessa abordagem é que outras interrupções ficam praticamente inalteradas.
Interrupções e o REPL¶
Manipuladores de interrupção, como os associados a timers, podem continuar a ser executados após o término de um programa. Isso pode produzir resultados inesperados onde você poderia esperar que o objeto que gera o callback tivesse saído de escopo. Por exemplo, em uma OpenMV Cam:
def bar():
foo = machine.Timer(-1, freq=4, callback=lambda t: print('.', end=''), hard=True)
bar()
Isso continua a ser executado até que o timer seja explicitamente desabilitado ou a placa seja reiniciada com Ctrl-D.