MicroPython em microcontroladores

O MicroPython foi concebido para ser capaz de correr em microcontroladores. Estes têm limitações de hardware que podem ser desconhecidas para programadores mais familiarizados com computadores convencionais. Em particular, a quantidade de RAM e de armazenamento não volátil em «disco» (memória flash) é limitada. Este tutorial oferece formas de tirar o máximo partido dos recursos limitados. Como o MicroPython corre em controladores baseados em várias arquiteturas, os métodos apresentados são genéricos: em alguns casos será necessário obter informação detalhada na documentação específica da plataforma.

Memória flash

Nas câmaras OpenMV Cams, a forma mais simples de resolver a capacidade limitada é instalar um cartão micro SD. Em alguns casos isso não é prático, seja porque o dispositivo não tem ranhura para cartão SD, seja por razões de custo ou consumo de energia; por isso, deve ser utilizada a flash integrada no chip. O firmware, incluindo o subsistema MicroPython, é armazenado na flash integrada. A capacidade restante fica disponível para utilização. Por razões relacionadas com a arquitetura física da memória flash, parte desta capacidade pode ser inacessível como sistema de ficheiros. Nesses casos, este espaço pode ser utilizado incorporando módulos do utilizador numa compilação do firmware que é depois gravada no dispositivo.

Existem duas formas de o conseguir: módulos congelados e bytecode congelado. Os módulos congelados armazenam o código-fonte Python juntamente com o firmware. O bytecode congelado utiliza o compilador cruzado para converter o código-fonte em bytecode, que é depois armazenado com o firmware. Em ambos os casos, o módulo pode ser acedido com uma instrução de importação:

import mymodule

O procedimento para produzir módulos e bytecode congelados depende da plataforma; as instruções para compilar o firmware podem ser encontradas nos ficheiros README na parte relevante da árvore de código-fonte.

Em termos gerais, os passos são os seguintes:

  • Clone o repositório do MicroPython.

  • Obtenha a cadeia de ferramentas (específica da plataforma) para compilar o firmware.

  • Compile o compilador cruzado.

  • Coloque os módulos a congelar num diretório especificado (dependendo de o módulo ser congelado como código-fonte ou como bytecode).

  • Compile o firmware. Pode ser necessário um comando específico para compilar código congelado de qualquer tipo — consulte a documentação da plataforma.

  • Grave o firmware no dispositivo.

RAM

Ao reduzir o uso de RAM, há duas fases a considerar: compilação e execução. Para além do consumo de memória, existe também um problema conhecido como fragmentação do heap. Em termos gerais, o melhor é minimizar a criação e destruição repetidas de objetos. O motivo é abordado na secção sobre o heap.

Fase de compilação

Quando um módulo é importado, o MicroPython compila o código em bytecode, que é depois executado pela máquina virtual (VM) do MicroPython. O bytecode é armazenado em RAM. O próprio compilador requer RAM, mas esta fica disponível para utilização quando a compilação termina.

Se já foram importados vários módulos, pode acontecer que não haja RAM suficiente para executar o compilador. Nesse caso, a instrução de importação produzirá uma exceção de memória.

Se um módulo instancia objetos globais aquando da importação, consumirá RAM no momento da importação, que fica então indisponível para o compilador nas importações subsequentes. Em geral, o melhor é evitar código que seja executado na importação; uma abordagem melhor é ter código de inicialização que seja executado pela aplicação depois de todos os módulos terem sido importados. Isto maximiza a RAM disponível para o compilador.

Se a RAM ainda for insuficiente para compilar todos os módulos, uma solução é pré-compilar os módulos. O MicroPython dispõe de um compilador cruzado capaz de compilar módulos Python em bytecode (consulte o README no diretório mpy-cross). O ficheiro de bytecode resultante tem a extensão .mpy; pode ser copiado para o sistema de ficheiros e importado da forma habitual. Em alternativa, alguns ou todos os módulos podem ser implementados como bytecode congelado: na maioria das plataformas, isto poupa ainda mais RAM, pois o bytecode é executado diretamente a partir da flash em vez de ser armazenado em RAM.

Fase de execução

Existem várias técnicas de programação para reduzir o uso de RAM.

Constantes

O MicroPython fornece uma palavra-chave const que pode ser utilizada da seguinte forma:

from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS

Em ambos os casos em que a constante é atribuída a uma variável, o compilador evitará a codificação de uma pesquisa pelo nome da constante, substituindo-a pelo seu valor literal. Isto poupa bytecode e, consequentemente, RAM. No entanto, o valor ROWS ocupará pelo menos duas palavras de máquina, uma para a chave e outra para o valor no dicionário global. A presença no dicionário é necessária porque outro módulo pode importá-la ou utilizá-la. Esta RAM pode ser poupada precedendo o nome com um sublinhado como em _COLS: este símbolo não é visível fora do módulo, pelo que não ocupará RAM.

O argumento de const() pode ser qualquer coisa que, em tempo de compilação, avalie para uma constante, por exemplo 0x100, 1 << 8 ou (True, "string", b"bytes") (consulte a secção abaixo para mais detalhes). Pode até incluir outros símbolos const já definidos, por exemplo 1 << BIT.

Estruturas de dados constantes

Quando existe um volume substancial de dados constantes e a plataforma suporta execução a partir da Flash, a RAM pode ser poupada da seguinte forma. Os dados devem ser colocados em módulos Python e congelados como bytecode. Os dados devem ser definidos como objetos bytes. O compilador «sabe» que os objetos bytes são imutáveis e garante que os objetos permanecem na memória flash em vez de serem copiados para RAM. O módulo struct pode ajudar na conversão entre tipos bytes e outros tipos incorporados do Python.

Ao considerar as implicações do bytecode congelado, note que em Python, strings, floats, bytes, inteiros, números complexos e tuplos são imutáveis. Consequentemente, estes serão congelados na flash (para tuplos, apenas se todos os seus elementos forem imutáveis). Assim, na linha

mystring = "The quick brown fox"

a string real «The quick brown fox» residirá na flash. Em tempo de execução, uma referência à string é atribuída à variável mystring. A referência ocupa uma única palavra de máquina. Em princípio, um inteiro longo poderia ser utilizado para armazenar dados constantes:

bar = 0xDEADBEEF0000DEADBEEF

Tal como no exemplo da string, em tempo de execução uma referência ao inteiro arbitrariamente grande é atribuída à variável bar. Essa referência ocupa uma única palavra de máquina.

Tuplos de objetos constantes são eles próprios constantes. Esses tuplos constantes são otimizados pelo compilador, pelo que não precisam de ser criados em tempo de execução cada vez que são utilizados. Por exemplo:

foo = (1, 2, 3, 4, 5, 6, 100000, ("string", b"bytes", False, True))

Este tuplo completo existirá como um único objeto (potencialmente na flash se o código estiver congelado) e será referenciado cada vez que for necessário.

Criação desnecessária de objetos

Existem várias situações em que os objetos podem ser criados e destruídos inadvertidamente. Isto pode reduzir a usabilidade da RAM através da fragmentação. As secções seguintes abordam exemplos deste fenómeno.

Concatenação de strings

Considere os seguintes fragmentos de código que visam produzir strings constantes:

var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""

Cada um produz o mesmo resultado; no entanto, o primeiro cria desnecessariamente dois objetos string em tempo de execução, aloca mais RAM para a concatenação antes de produzir o terceiro. Os outros realizam a concatenação em tempo de compilação, o que é mais eficiente, reduzindo a fragmentação.

Quando as strings têm de ser criadas dinamicamente antes de serem enviadas para um fluxo, como um ficheiro, poupará RAM se isso for feito de forma faseada. Em vez de criar um objeto string grande, crie uma substring e envie-a para o fluxo antes de tratar a seguinte.

A melhor forma de criar strings dinâmicas é através do método format() da string:

var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)

Buffers

Ao aceder a dispositivos como instâncias de interfaces UART, I2C e SPI, a utilização de buffers pré-alocados evita a criação de objetos desnecessários. Considere estes dois ciclos:

while True:
    var = spi.read(100)
    # process data

buf = bytearray(100)
while True:
    spi.readinto(buf)
    # process data in buf

O primeiro cria um buffer em cada iteração, enquanto o segundo reutiliza um buffer pré-alocado; isto é simultaneamente mais rápido e mais eficiente em termos de fragmentação de memória.

Bytes são menores que ints

Na maioria das plataformas, um inteiro consome quatro bytes. Considere as três chamadas à função foo():

def foo(bar):
    for x in bar:
        print(x)
foo([1, 2, 0xff])
foo((1, 2, 0xff))
foo(b'\1\2\xff')

Na primeira chamada, é criada uma list de inteiros em RAM cada vez que o código é executado. A segunda chamada cria um objeto tuple constante (um tuple contendo apenas objetos constantes) durante a fase de compilação, pelo que é criado apenas uma vez e é mais eficiente que a list. A terceira chamada cria eficientemente um objeto bytes consumindo a quantidade mínima de RAM. Se o módulo fosse congelado como bytecode, tanto o tuple como o objeto bytes residiriam na flash.

Strings Versus Bytes

O Python 3 introduziu suporte a Unicode. Isto introduziu uma distinção entre uma string e um array de bytes. O MicroPython garante que as strings Unicode não ocupam espaço adicional, desde que todos os caracteres na string sejam ASCII (ou seja, tenham um valor < 128). Se forem necessários valores no intervalo completo de 8 bits, os objetos bytes e bytearray podem ser utilizados para garantir que não será necessário espaço adicional. Note que a maioria dos métodos de string (por exemplo, str.strip()) aplica-se também a instâncias de bytes, pelo que o processo de eliminar Unicode pode ser indolor.

s = 'the quick brown fox'   # A string instance
b = b'the quick brown fox'  # A bytes instance

Quando é necessário converter entre strings e bytes, podem ser utilizados os métodos str.encode() e bytes.decode(). Note que tanto as strings como os bytes são imutáveis. Qualquer operação que tome como entrada um tal objeto e produza outro implica pelo menos uma alocação de RAM para produzir o resultado. Na segunda linha abaixo, é alocado um novo objeto bytes. O mesmo ocorreria se foo fosse uma string.

foo = b'   empty whitespace'
foo = foo.lstrip()

Execução do compilador em tempo de execução

As funções Python eval e exec invocam o compilador em tempo de execução, o que requer quantidades significativas de RAM. Note que a biblioteca pickle de micropython-lib utiliza exec. Pode ser mais eficiente em termos de RAM utilizar a biblioteca json para serialização de objetos.

Armazenar strings na flash

As strings Python são imutáveis, tendo portanto a possibilidade de ser armazenadas em memória apenas de leitura. O compilador pode colocar na flash strings definidas em código Python. Tal como com os módulos congelados, é necessário ter uma cópia da árvore de código-fonte no PC e a cadeia de ferramentas para compilar o firmware. O procedimento funcionará mesmo que os módulos não tenham sido completamente depurados, desde que possam ser importados e executados.

Após importar os módulos, execute:

micropython.qstr_info(1)

Em seguida, copie e cole todas as linhas Q(xxx) num editor de texto. Verifique e remova as linhas que são obviamente inválidas. Abra o ficheiro qstrdefsport.h, que se encontra em ports/stm32 (ou no diretório equivalente para a arquitetura em uso). Copie e cole as linhas corrigidas no final do ficheiro. Guarde o ficheiro, recompile e grave o firmware. O resultado pode ser verificado importando os módulos e emitindo novamente:

micropython.qstr_info(1)

As linhas Q(xxx) devem ter desaparecido.

O heap

Quando um programa em execução instancia um objeto, a RAM necessária é alocada a partir de um conjunto de tamanho fixo conhecido como heap. Quando o objeto sai do âmbito (por outras palavras, torna-se inacessível ao código), o objeto redundante é conhecido como «lixo». Um processo conhecido como «recolha de lixo» (GC) recupera essa memória, devolvendo-a ao heap livre. Este processo corre automaticamente, mas pode ser invocado diretamente emitindo gc.collect().

A discussão sobre este assunto é algo extensa. Para uma «correção rápida», emita periodicamente o seguinte:

gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())

Para mais informações, consulte abaixo e a documentação para o módulo incorporado gc.

Para detalhes sobre os internos do MicroPython/perspetiva do programador, consulte também Gestão de Memória.

Fragmentação

Suponha que um programa cria um objeto foo, depois um objeto bar. Subsequentemente, foo sai do âmbito mas bar permanece. A RAM utilizada por foo será recuperada pelo GC. No entanto, se bar foi alocado a um endereço superior, a RAM recuperada de foo só será utilizável para objetos não maiores que foo. Num programa complexo ou de longa duração, o heap pode tornar-se fragmentado: apesar de haver uma quantidade substancial de RAM disponível, não há espaço contíguo suficiente para alocar um determinado objeto, e o programa falha com um erro de memória.

As técnicas descritas acima visam minimizar este problema. Quando são necessários grandes buffers permanentes ou outros objetos, o melhor é instanciá-los cedo no processo de execução do programa, antes que a fragmentação possa ocorrer. Podem ser feitas melhorias adicionais monitorizando o estado do heap e controlando o GC; estas são descritas abaixo.

Relatórios

Estão disponíveis várias funções de biblioteca para reportar a alocação de memória e para controlar o GC. Estas encontram-se nos módulos gc e micropython. O exemplo seguinte pode ser colado no REPL (Ctrl-E para entrar em modo de colagem, Ctrl-D para executar).

import gc
import micropython
gc.collect()
micropython.mem_info()
print('-----------------------------')
print('Initial free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
def func():
    a = bytearray(10000)
gc.collect()
print('Func definition: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
func()
print('Func run free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
gc.collect()
print('Garbage collect free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
print('-----------------------------')
micropython.mem_info(1)

Métodos utilizados acima:

  • gc.collect() Forçar uma recolha de lixo. Consulte a nota de rodapé.

  • micropython.mem_info() Imprimir um resumo da utilização de RAM.

  • gc.mem_free() Retornar o tamanho do heap livre em bytes.

  • gc.mem_alloc() Retornar o número de bytes atualmente alocados.

  • micropython.mem_info(1) Imprimir uma tabela de utilização do heap (detalhada abaixo).

Os números produzidos dependem da plataforma, mas pode-se verificar que declarar a função utiliza uma pequena quantidade de RAM na forma de bytecode emitido pelo compilador (a RAM utilizada pelo compilador foi recuperada). Executar a função utiliza mais de 10 KiB, mas ao retornar, a é lixo porque está fora do âmbito e não pode ser referenciado. O gc.collect() final recupera essa memória.

A saída final produzida por micropython.mem_info(1) variará em detalhe, mas pode ser interpretada da seguinte forma:

Símbolo

Significado

.

bloco livre

h

bloco de cabeçalho

=

bloco de cauda

m

bloco de cabeçalho marcado

T

tuplo

L

lista

D

dicionário

F

float

B

byte code

M

módulo

S

string ou bytes

A

bytearray

Cada letra representa um único bloco de memória, sendo um bloco de 16 bytes. Portanto, cada linha do dump do heap representa 0x400 bytes ou 1 KiB de RAM.

Controlo da recolha de lixo

Um GC pode ser solicitado a qualquer momento emitindo gc.collect(). É vantajoso fazê-lo a intervalos regulares, primeiro para antecipar a fragmentação e segundo por razões de desempenho. Um GC pode demorar vários milissegundos, mas é mais rápido quando há pouco trabalho a fazer (cerca de 1 ms numa OpenMV Cam). Uma chamada explícita pode minimizar esse atraso, garantindo que ocorre em pontos do programa onde é aceitável.

O GC automático é provocado nas seguintes circunstâncias. Quando uma tentativa de alocação falha, é executado um GC e a alocação é tentada novamente. Só se esta também falhar é que é lançada uma exceção. Em segundo lugar, um GC automático será acionado se a quantidade de RAM livre cair abaixo de um limiar. Este limiar pode ser adaptado à medida que a execução avança:

gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())

Isto provocará um GC quando mais de 25% do heap atualmente livre ficar ocupado.

Em geral, os módulos devem instanciar objetos de dados em tempo de execução utilizando construtores ou outras funções de inicialização. O motivo é que se isso ocorrer na inicialização, o compilador pode ficar sem RAM quando módulos subsequentes são importados. Se os módulos instanciarem dados na importação, então gc.collect() emitido após a importação amenizará o problema.

Operações com strings

O MicroPython lida com strings de forma eficiente, e compreender isso pode ajudar a conceber aplicações para correr em microcontroladores. Quando um módulo é compilado, as strings que ocorrem múltiplas vezes são armazenadas apenas uma vez, num processo conhecido como interning de strings. No MicroPython, uma string interned é conhecida como qstr. Num módulo importado normalmente, essa instância única estará localizada em RAM, mas, como descrito acima, em módulos congelados como bytecode estará localizada na flash.

As comparações de strings também são realizadas de forma eficiente utilizando hashing em vez de carácter por carácter. A penalidade por utilizar strings em vez de inteiros pode, portanto, ser pequena tanto em termos de desempenho como de uso de RAM — um facto que pode surpreender programadores de C.

Pós-escrito

O MicroPython passa, retorna e (por padrão) copia objetos por referência. Uma referência ocupa uma única palavra de máquina, pelo que estes processos são eficientes em termos de uso de RAM e velocidade.

Quando são necessárias variáveis cujo tamanho não é nem um byte nem uma palavra de máquina, existem bibliotecas standard que podem ajudar a armazená-las de forma eficiente e a realizar conversões. Consulte os módulos array, struct e uctypes.

Nota de rodapé: valor de retorno de gc.collect()

Nas plataformas Unix e Windows, o método gc.collect() retorna um inteiro que indica o número de regiões de memória distintas que foram recuperadas na recolha (mais precisamente, o número de cabeçalhos que foram convertidos em blocos livres). Por razões de eficiência, os ports de metal nu não retornam este valor.