MicroPython em microcontroladores¶
O MicroPython foi projetado para ser capaz de rodar em microcontroladores. Estes possuem limitações de hardware que podem ser desconhecidas para programadores mais familiarizados com computadores convencionais. Em particular, a quantidade de RAM e de armazenamento “em disco” não volátil (memória flash) é limitada. Este tutorial oferece maneiras de tirar o máximo proveito dos recursos limitados. Como o MicroPython roda em controladores baseados em uma variedade de arquiteturas, os métodos apresentados são genéricos: em alguns casos será necessário obter informações detalhadas a partir da documentação específica da plataforma.
Memória flash¶
Nas OpenMV Cams, a maneira simples de lidar com a capacidade limitada é instalar um cartão micro SD. Em alguns casos isso é impraticável, seja porque o dispositivo não possui um slot para cartão SD ou por razões de custo ou consumo de energia; portanto, a flash interna do chip deve ser usada. O firmware, incluindo o subsistema MicroPython, é armazenado na flash embarcada. A capacidade restante fica disponível para uso. Por razões relacionadas à arquitetura física da memória flash, parte dessa capacidade pode ficar inacessível como sistema de arquivos. Nesses casos, esse espaço pode ser aproveitado incorporando módulos do usuário a uma compilação de firmware que é então gravada no dispositivo.
Há duas maneiras de conseguir isso: módulos congelados (frozen modules) e bytecode congelado (frozen bytecode). Os módulos congelados armazenam o código-fonte Python junto com o firmware. O bytecode congelado usa o compilador cruzado para converter o código-fonte em bytecode, que é então armazenado junto com o firmware. Em qualquer um dos casos, o módulo pode ser acessado com uma instrução import:
import mymodule
O procedimento para produzir módulos congelados e bytecode congelado depende da plataforma; instruções para compilar o firmware podem ser encontradas nos arquivos 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 toolchain (específica da plataforma) para compilar o firmware.
Compile o compilador cruzado.
Coloque os módulos a serem congelados em um diretório especificado (dependendo se o módulo deve ser congelado como código-fonte ou como bytecode).
Compile o firmware. Um comando específico pode ser necessário para compilar código congelado de qualquer um dos tipos - 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. Além do consumo de memória, há também um problema conhecido como fragmentação do heap. Em termos gerais, é melhor minimizar a criação e destruição repetidas de objetos. A razão para isso é abordada na seção que trata do heap.
Fase de compilação¶
Quando um módulo é importado, o MicroPython compila o código em bytecode, que é então executado pela máquina virtual (VM) do MicroPython. O bytecode é armazenado na RAM. O próprio compilador requer RAM, mas ela fica disponível para uso quando a compilação é concluída.
Se vários módulos já tiverem sido importados, pode surgir a situação em que não há RAM suficiente para rodar o compilador. Nesse caso, a instrução import produzirá uma exceção de memória.
Se um módulo instancia objetos globais ao ser importado, ele consumirá RAM no momento da importação, que então fica indisponível para o compilador usar em importações subsequentes. Em geral, é melhor evitar código que roda durante a importação; uma abordagem melhor é ter código de inicialização que seja executado pela aplicação depois que todos os módulos tiverem sido importados. Isso 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 possui um compilador cruzado capaz de compilar módulos Python em bytecode (consulte o README no diretório mpy-cross). O arquivo de bytecode resultante tem a extensão .mpy; ele pode ser copiado para o sistema de arquivos e importado da maneira usual. Alternativamente, alguns ou todos os módulos podem ser implementados como bytecode congelado: na maioria das plataformas isso economiza ainda mais RAM, pois o bytecode é executado diretamente da flash em vez de ser armazenado na RAM.
Fase de execução¶
Existem várias técnicas de codificação para reduzir o uso de RAM.
Constantes
O MicroPython fornece uma palavra-chave const que pode ser usada 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 evita gerar uma busca pelo nome da constante, substituindo-a por seu valor literal. Isso economiza bytecode e, portanto, 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 de globais. A presença no dicionário é necessária porque outro módulo poderia importá-lo ou usá-lo. Essa RAM pode ser economizada prefixando o nome com um sublinhado, como em _COLS: esse símbolo não é visível fora do módulo e, portanto, não ocupará RAM.
O argumento para const() pode ser qualquer coisa que, em tempo de compilação, seja avaliada como uma constante, por exemplo 0x100, 1 << 8 ou (True, "string", b"bytes") (consulte a seção abaixo para detalhes). Ele pode até incluir outros símbolos const que já tenham sido definidos, por exemplo 1 << BIT.
Estruturas de dados constantes
Onde há um volume substancial de dados constantes e a plataforma oferece suporte à execução a partir da flash, a RAM pode ser economizada da seguinte forma. Os dados devem ser localizados em módulos Python e congelados como bytecode. Os dados devem ser definidos como objetos bytes. O compilador ‘sabe’ que objetos bytes são imutáveis e garante que os objetos permaneçam na memória flash em vez de serem copiados para a RAM. O módulo struct pode ajudar na conversão entre tipos bytes e outros tipos embutidos do Python.
Ao considerar as implicações do bytecode congelado, observe que em Python strings, floats, bytes, inteiros, números complexos e tuplas são imutáveis. Consequentemente, esses serão congelados na flash (no caso das tuplas, somente 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 usado para armazenar dados constantes:
bar = 0xDEADBEEF0000DEADBEEF
Assim 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.
Tuplas de objetos constantes são, elas próprias, constantes. Tais tuplas constantes são otimizadas pelo compilador, de modo que não precisam ser criadas em tempo de execução cada vez que são usadas. Por exemplo:
foo = (1, 2, 3, 4, 5, 6, 100000, ("string", b"bytes", False, True))
Essa tupla inteira existirá como um único objeto (potencialmente na flash, se o código for congelado) e será referenciada cada vez que for necessária.
Criação desnecessária de objetos
Existem várias situações em que objetos podem ser criados e destruídos inadvertidamente. Isso pode reduzir a usabilidade da RAM por meio da fragmentação. As seções a seguir discutem casos disso.
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, alocando 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.
Onde strings precisam ser criadas dinamicamente antes de serem enviadas a um fluxo, como um arquivo, economiza-se RAM se isso for feito de forma fragmentada. Em vez de criar um grande objeto string, crie uma substring e a envie ao fluxo antes de lidar com a próxima.
A melhor maneira de criar strings dinâmicas é por meio do método format() da string:
var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)
Buffers
Ao acessar dispositivos como instâncias de interfaces UART, I2C e SPI, usar buffers pré-alocados evita a criação de objetos desnecessários. Considere estes dois laços:
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 a cada passagem, enquanto o segundo reutiliza um buffer pré-alocado; isso é 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, uma list de inteiros é criada na RAM cada vez que o código é executado. A segunda chamada cria um objeto tuple constante (uma tuple contendo apenas objetos constantes) como parte da fase de compilação, de modo que ele é criado apenas uma vez e é mais eficiente do que a list. A terceira chamada cria eficientemente um objeto bytes consumindo a menor quantidade de RAM. Se o módulo fosse congelado como bytecode, tanto o objeto tuple quanto o objeto bytes residiriam na flash.
Strings versus Bytes
O Python3 introduziu suporte a Unicode. Isso introduziu uma distinção entre uma string e um array de bytes. O MicroPython garante que strings Unicode não ocupem espaço adicional desde que todos os caracteres da string sejam ASCII (ou seja, tenham um valor < 128). Se forem necessários valores em toda a faixa de 8 bits, objetos bytes e bytearray podem ser usados para garantir que nenhum espaço adicional seja necessário. Observe que a maioria dos métodos de string (por exemplo, str.strip()) também se aplica a instâncias de bytes, de modo 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
Onde for necessário converter entre strings e bytes, os métodos str.encode() e bytes.decode() podem ser usados. Observe que tanto strings quanto bytes são imutáveis. Qualquer operação que receba como entrada tal objeto e produza outro implica pelo menos uma alocação de RAM para produzir o resultado. Na segunda linha abaixo, um novo objeto bytes é alocado. Isso também 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. Observe que a biblioteca pickle do micropython-lib emprega exec. Pode ser mais eficiente em termos de RAM usar a biblioteca json para serialização de objetos.
Armazenando strings na flash
Strings Python são imutáveis e, portanto, têm o potencial de serem armazenadas em memória somente leitura. O compilador pode colocar na flash strings definidas em código Python. Assim como acontece com os módulos congelados, é necessário ter uma cópia da árvore de código-fonte no PC e a toolchain para compilar o firmware. O procedimento funcionará mesmo que os módulos não tenham sido totalmente 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) em um editor de texto. Verifique e remova as linhas que são obviamente inválidas. Abra o arquivo qstrdefsport.h, que será encontrado em ports/stm32 (ou no diretório equivalente para a arquitetura em uso). Copie e cole as linhas corrigidas no final do arquivo. Salve o arquivo, 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 de um conjunto de tamanho fixo conhecido como heap. Quando o objeto sai de escopo (em outras palavras, torna-se inacessível ao código), o objeto redundante é conhecido como “lixo” (garbage). Um processo conhecido como “coleta de lixo” (garbage collection, GC) recupera essa memória, devolvendo-a ao heap livre. Esse processo roda automaticamente, porém pode ser invocado diretamente emitindo gc.collect().
A discussão sobre isso é um tanto complexa. Para uma ‘solução rápida’, emita o seguinte periodicamente:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Para mais informações, consulte abaixo e a documentação do módulo embutido gc.
Para detalhes a partir da perspectiva interna/do desenvolvedor do MicroPython, consulte também Gerenciamento de Memória.
Fragmentação¶
Digamos que um programa crie um objeto foo, depois um objeto bar. Posteriormente, foo sai de escopo, mas bar permanece. A RAM usada por foo será recuperada pelo GC. No entanto, se bar tiver sido alocado em um endereço mais alto, a RAM recuperada de foo só será útil para objetos não maiores que foo. Em um programa complexo ou de longa execução, o heap pode se tornar fragmentado: apesar de haver uma quantidade substancial de RAM disponível, não há espaço contíguo suficiente para alocar um objeto específico, e o programa falha com um erro de memória.
As técnicas descritas acima visam minimizar isso. Onde grandes buffers permanentes ou outros objetos são necessários, é melhor instanciá-los cedo no processo de execução do programa, antes que a fragmentação possa ocorrer. Melhorias adicionais podem ser feitas monitorando o estado do heap e controlando o GC; estas são descritas abaixo.
Relatórios¶
Há várias funções de biblioteca disponíveis para relatar a alocação de memória e controlar o GC. Estas são encontradas nos módulos gc e micropython. O exemplo a seguir pode ser colado no REPL (Ctrl-E para entrar no modo de colagem, Ctrl-D para executá-lo).
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 empregados acima:
gc.collect()Força uma coleta de lixo. Veja a nota de rodapé.micropython.mem_info()Imprime um resumo da utilização de RAM.gc.mem_free()Retorna o tamanho do heap livre em bytes.gc.mem_alloc()Retorna o número de bytes atualmente alocados.micropython.mem_info(1)Imprime uma tabela de utilização do heap (detalhada abaixo).
Os números produzidos dependem da plataforma, mas pode-se ver que declarar a função usa uma pequena quantidade de RAM na forma de bytecode emitido pelo compilador (a RAM usada pelo compilador foi recuperada). Executar a função usa mais de 10KiB, mas no retorno a é lixo, pois está fora de escopo 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 detalhes, mas pode ser interpretada da seguinte forma:
Símbolo |
Significado |
|---|---|
. |
bloco livre |
h |
bloco de cabeça |
= |
bloco de cauda |
m |
bloco de cabeça marcado |
T |
tupla |
L |
lista |
D |
dict |
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 igual a 16 bytes. Assim, cada linha do dump do heap representa 0x400 bytes ou 1KiB de RAM.
Controle da coleta de lixo¶
Um GC pode ser exigido a qualquer momento emitindo gc.collect(). É vantajoso fazer isso em intervalos, primeiro para prevenir a fragmentação e segundo por desempenho. Um GC pode levar vários milissegundos, mas é mais rápido quando há pouco trabalho a fazer (cerca de 1ms em uma OpenMV Cam). Uma chamada explícita pode minimizar esse atraso, ao mesmo tempo em que garante que ele ocorra em pontos do programa em que seja aceitável.
O GC automático é provocado nas seguintes circunstâncias. Quando uma tentativa de alocação falha, um GC é realizado e a alocação é tentada novamente. Somente se isso falhar é que uma exceção é levantada. Em segundo lugar, um GC automático será disparado se a quantidade de RAM livre cair abaixo de um limiar. Esse limiar pode ser adaptado conforme a execução avança:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Isso provocará um GC quando mais de 25% do heap atualmente livre passar a estar ocupado.
Em geral, os módulos devem instanciar objetos de dados em tempo de execução usando construtores ou outras funções de inicialização. A razão é que, se isso ocorrer na inicialização, o compilador pode ficar sem RAM quando os módulos subsequentes forem importados. Se os módulos realmente instanciarem dados durante a importação, então gc.collect() emitido após a importação atenuará o problema.
Operações com strings¶
O MicroPython lida com strings de maneira eficiente, e entender isso pode ajudar a projetar aplicações para rodar em microcontroladores. Quando um módulo é compilado, strings que ocorrem várias vezes são armazenadas apenas uma vez, um processo conhecido como interning de strings. No MicroPython, uma string internada (interned) é conhecida como qstr. Em um módulo importado normalmente, essa única instância ficará localizada na RAM, mas, conforme descrito acima, em módulos congelados como bytecode ela ficará localizada na flash.
Comparações de strings também são realizadas de forma eficiente usando hashing em vez de caractere por caractere. A penalidade por usar strings em vez de inteiros pode, portanto, ser pequena tanto em termos de desempenho quanto de uso de RAM - um fato que pode surpreender programadores de C.
Posfácio¶
O MicroPython passa, retorna e (por padrão) copia objetos por referência. Uma referência ocupa uma única palavra de máquina, de modo que esses processos são eficientes em uso de RAM e velocidade.
Onde são necessárias variáveis cujo tamanho não seja nem um byte nem uma palavra de máquina, há bibliotecas padrão 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 coleta (mais precisamente, o número de cabeças que foram transformadas em blocos livres). Por razões de eficiência, as portas bare metal não retornam esse valor.