MicroPython sui microcontrollori

MicroPython è progettato per poter funzionare sui microcontrollori. Questi presentano limitazioni hardware che potrebbero risultare poco familiari ai programmatori più abituati ai computer convenzionali. In particolare, la quantità di RAM e di memoria «disco» non volatile (memoria flash) è limitata. Questo tutorial illustra alcuni modi per sfruttare al meglio le risorse limitate. Poiché MicroPython funziona su controllori basati su una varietà di architetture, i metodi presentati sono generici: in alcuni casi sarà necessario reperire informazioni dettagliate nella documentazione specifica della piattaforma.

Memoria flash

Sulle OpenMV Cam il modo più semplice per ovviare alla capacità limitata è inserire una scheda micro SD. In alcuni casi questo è poco pratico, sia perché il dispositivo non dispone di uno slot per schede SD, sia per ragioni di costo o di consumo energetico; di conseguenza occorre utilizzare la flash integrata. Il firmware, incluso il sottosistema MicroPython, è memorizzato nella flash a bordo. La capacità rimanente è disponibile per l’uso. Per motivi legati all’architettura fisica della memoria flash, parte di questa capacità potrebbe non essere accessibile come filesystem. In tali casi questo spazio può essere impiegato incorporando i moduli utente in una build del firmware, che viene poi flashata sul dispositivo.

Esistono due modi per ottenere questo risultato: i moduli congelati (frozen modules) e il bytecode congelato (frozen bytecode). I moduli congelati memorizzano il sorgente Python insieme al firmware. Il bytecode congelato utilizza il cross compiler per convertire il sorgente in bytecode, che viene poi memorizzato insieme al firmware. In entrambi i casi il modulo può essere richiamato con un’istruzione import:

import mymodule

La procedura per produrre moduli e bytecode congelati dipende dalla piattaforma; le istruzioni per compilare il firmware si trovano nei file README nella parte pertinente dell’albero dei sorgenti.

In termini generali i passaggi sono i seguenti:

  • Clonare il repository di MicroPython.

  • Procurarsi la toolchain (specifica della piattaforma) per compilare il firmware.

  • Compilare il cross compiler.

  • Collocare i moduli da congelare in una directory specifica (a seconda che il modulo debba essere congelato come sorgente o come bytecode).

  • Compilare il firmware. Potrebbe essere richiesto un comando specifico per compilare il codice congelato di entrambi i tipi: consultare la documentazione della piattaforma.

  • Flashare il firmware sul dispositivo.

RAM

Quando si riduce l’utilizzo della RAM occorre considerare due fasi: la compilazione e l’esecuzione. Oltre al consumo di memoria, esiste anche un problema noto come frammentazione dell’heap. In termini generali è meglio ridurre al minimo la ripetuta creazione e distruzione di oggetti. La ragione di ciò è trattata nella sezione dedicata all”heap.

Fase di compilazione

Quando un modulo viene importato, MicroPython compila il codice in bytecode, che viene poi eseguito dalla macchina virtuale di MicroPython (VM). Il bytecode è memorizzato nella RAM. Il compilatore stesso richiede RAM, ma questa torna disponibile per l’uso al termine della compilazione.

Se sono già stati importati diversi moduli, può verificarsi la situazione in cui non vi sia RAM sufficiente per eseguire il compilatore. In questo caso l’istruzione import genererà un’eccezione di memoria.

Se un modulo istanzia oggetti globali al momento dell’import, consumerà RAM in quel momento, che diventa quindi non disponibile per il compilatore nelle importazioni successive. In generale è meglio evitare codice che viene eseguito durante l’import; un approccio migliore consiste nell’avere codice di inizializzazione eseguito dall’applicazione dopo che tutti i moduli sono stati importati. Ciò massimizza la RAM disponibile per il compilatore.

Se la RAM è ancora insufficiente per compilare tutti i moduli, una soluzione è precompilare i moduli. MicroPython dispone di un cross compiler in grado di compilare i moduli Python in bytecode (vedere il README nella directory mpy-cross). Il file di bytecode risultante ha estensione .mpy; può essere copiato nel filesystem e importato nel modo consueto. In alternativa, alcuni o tutti i moduli possono essere implementati come bytecode congelato: sulla maggior parte delle piattaforme questo consente di risparmiare ancora più RAM, poiché il bytecode viene eseguito direttamente dalla flash anziché essere memorizzato nella RAM.

Fase di esecuzione

Esistono diverse tecniche di programmazione per ridurre l’utilizzo della RAM.

Costanti

MicroPython fornisce una parola chiave const che può essere utilizzata come segue:

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

In entrambi i casi in cui la costante viene assegnata a una variabile, il compilatore eviterà di codificare una ricerca del nome della costante sostituendolo con il suo valore letterale. Ciò consente di risparmiare bytecode e quindi RAM. Tuttavia il valore ROWS occuperà almeno due parole macchina, una per la chiave e una per il valore nel dizionario globale. La presenza nel dizionario è necessaria perché un altro modulo potrebbe importarlo o utilizzarlo. Questa RAM può essere risparmiata anteponendo al nome un trattino basso, come in _COLS: questo simbolo non è visibile al di fuori del modulo e quindi non occuperà RAM.

L’argomento di const() può essere qualsiasi cosa che, al momento della compilazione, sia valutata come una costante, ad esempio 0x100, 1 << 8 o (True, "string", b"bytes") (vedere la sezione seguente per i dettagli). Può persino includere altri simboli const già definiti, ad esempio 1 << BIT.

Strutture dati costanti

Quando è presente un volume sostanziale di dati costanti e la piattaforma supporta l’esecuzione dalla flash, è possibile risparmiare RAM nel modo seguente. I dati dovrebbero essere collocati in moduli Python e congelati come bytecode. I dati devono essere definiti come oggetti bytes. Il compilatore «sa» che gli oggetti bytes sono immutabili e garantisce che gli oggetti rimangano nella memoria flash anziché essere copiati nella RAM. Il modulo struct può aiutare nella conversione tra i tipi bytes e altri tipi built-in di Python.

Quando si considerano le implicazioni del bytecode congelato, si noti che in Python le stringhe, i float, i bytes, gli interi, i numeri complessi e le tuple sono immutabili. Di conseguenza questi verranno congelati nella flash (per le tuple, solo se tutti i loro elementi sono immutabili). Pertanto, nella riga

mystring = "The quick brown fox"

la stringa effettiva «The quick brown fox» risiederà nella flash. In fase di esecuzione un riferimento alla stringa viene assegnato alla variabile mystring. Il riferimento occupa una singola parola macchina. In linea di principio si potrebbe utilizzare un intero lungo per memorizzare dati costanti:

bar = 0xDEADBEEF0000DEADBEEF

Come nell’esempio della stringa, in fase di esecuzione un riferimento all’intero arbitrariamente grande viene assegnato alla variabile bar. Tale riferimento occupa una singola parola macchina.

Le tuple di oggetti costanti sono esse stesse costanti. Tali tuple costanti vengono ottimizzate dal compilatore in modo da non dover essere create in fase di esecuzione ogni volta che vengono utilizzate. Ad esempio:

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

Questa intera tupla esisterà come un singolo oggetto (potenzialmente nella flash se il codice è congelato) e verrà referenziata ogni volta che è necessaria.

Creazione superflua di oggetti

Esistono diverse situazioni in cui gli oggetti possono essere creati e distrutti inavvertitamente. Ciò può ridurre l’usabilità della RAM a causa della frammentazione. Le sezioni seguenti illustrano alcuni esempi di questo fenomeno.

Concatenazione di stringhe

Si considerino i seguenti frammenti di codice il cui scopo è produrre stringhe costanti:

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

Ciascuno produce lo stesso risultato, tuttavia il primo crea inutilmente due oggetti stringa in fase di esecuzione, allocando ulteriore RAM per la concatenazione prima di produrre il terzo. Gli altri eseguono la concatenazione in fase di compilazione, il che è più efficiente e riduce la frammentazione.

Quando le stringhe devono essere create dinamicamente prima di essere inviate a uno stream come un file, si risparmia RAM se questo viene fatto in modo frammentato. Anziché creare un grande oggetto stringa, conviene creare una sottostringa e inviarla allo stream prima di gestire la successiva.

Il modo migliore per creare stringhe dinamiche è tramite il metodo format() della stringa:

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

Buffer

Quando si accede a dispositivi come istanze di interfacce UART, I2C e SPI, l’utilizzo di buffer pre-allocati evita la creazione di oggetti superflui. Si considerino questi due cicli:

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

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

Il primo crea un buffer a ogni passaggio, mentre il secondo riutilizza un buffer pre-allocato; ciò è più veloce e più efficiente in termini di frammentazione della memoria.

I bytes sono più piccoli degli int

Sulla maggior parte delle piattaforme un intero consuma quattro byte. Si considerino le tre chiamate alla funzione foo():

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

Nella prima chiamata viene creata in RAM una list di interi ogni volta che il codice viene eseguito. La seconda chiamata crea un oggetto tuple costante (una tuple contenente solo oggetti costanti) come parte della fase di compilazione, quindi viene creato una sola volta ed è più efficiente della list. La terza chiamata crea efficientemente un oggetto bytes che consuma la quantità minima di RAM. Se il modulo fosse congelato come bytecode, sia l’oggetto tuple sia l’oggetto bytes risiederebbero nella flash.

Stringhe contro Bytes

Python3 ha introdotto il supporto Unicode. Ciò ha introdotto una distinzione tra una stringa e un array di byte. MicroPython garantisce che le stringhe Unicode non occupino spazio aggiuntivo, a condizione che tutti i caratteri della stringa siano ASCII (cioè abbiano un valore < 128). Se sono richiesti valori nell’intero intervallo a 8 bit, è possibile utilizzare gli oggetti bytes e bytearray per garantire che non sia richiesto spazio aggiuntivo. Si noti che la maggior parte dei metodi delle stringhe (ad esempio str.strip()) si applica anche alle istanze bytes, quindi il processo di eliminazione dell’Unicode può essere indolore.

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

Quando è necessario convertire tra stringhe e bytes, è possibile utilizzare i metodi str.encode() e bytes.decode(). Si noti che sia le stringhe sia i bytes sono immutabili. Qualsiasi operazione che prende in input un oggetto di questo tipo e ne produce un altro implica almeno un’allocazione di RAM per produrre il risultato. Nella seconda riga sottostante viene allocato un nuovo oggetto bytes. Ciò si verificherebbe anche se foo fosse una stringa.

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

Esecuzione del compilatore in fase di esecuzione

Le funzioni Python eval ed exec invocano il compilatore in fase di esecuzione, il che richiede quantità significative di RAM. Si noti che la libreria pickle di micropython-lib impiega exec. Potrebbe essere più efficiente in termini di RAM utilizzare la libreria json per la serializzazione degli oggetti.

Memorizzazione delle stringhe nella flash

Le stringhe Python sono immutabili e quindi possono potenzialmente essere memorizzate in memoria di sola lettura. Il compilatore può collocare nella flash le stringhe definite nel codice Python. Come per i moduli congelati, è necessario disporre di una copia dell’albero dei sorgenti sul PC e della toolchain per compilare il firmware. La procedura funzionerà anche se i moduli non sono stati completamente debuggati, purché possano essere importati ed eseguiti.

Dopo aver importato i moduli, eseguire:

micropython.qstr_info(1)

Quindi copiare e incollare tutte le righe Q(xxx) in un editor di testo. Verificare e rimuovere le righe palesemente non valide. Aprire il file qstrdefsport.h, che si troverà in ports/stm32 (o nella directory equivalente per l’architettura in uso). Copiare e incollare le righe corrette alla fine del file. Salvare il file, ricompilare e flashare il firmware. Il risultato può essere verificato importando i moduli ed eseguendo nuovamente:

micropython.qstr_info(1)

Le righe Q(xxx) dovrebbero essere scomparse.

L’heap

Quando un programma in esecuzione istanzia un oggetto, la RAM necessaria viene allocata da un pool di dimensione fissa noto come heap. Quando l’oggetto esce dall’ambito (in altre parole diventa inaccessibile al codice), l’oggetto ridondante viene definito «garbage». Un processo noto come «garbage collection» (GC) recupera quella memoria, restituendola all’heap libero. Questo processo viene eseguito automaticamente, tuttavia può essere invocato direttamente eseguendo gc.collect().

La trattazione di questo argomento è alquanto complessa. Per una «soluzione rapida» eseguire periodicamente quanto segue:

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

Per maggiori informazioni, vedere di seguito e la documentazione del modulo built-in gc.

Per i dettagli dal punto di vista interno/sviluppatore di MicroPython, vedere anche Gestione della memoria.

Frammentazione

Supponiamo che un programma crei un oggetto foo, quindi un oggetto bar. Successivamente foo esce dall’ambito ma bar rimane. La RAM utilizzata da foo verrà recuperata dal GC. Tuttavia, se bar è stato allocato a un indirizzo più alto, la RAM recuperata da foo sarà utilizzabile solo per oggetti non più grandi di foo. In un programma complesso o di lunga durata l’heap può frammentarsi: nonostante sia disponibile una quantità sostanziale di RAM, non c’è spazio contiguo sufficiente per allocare un particolare oggetto, e il programma fallisce con un errore di memoria.

Le tecniche illustrate sopra mirano a ridurre al minimo questo fenomeno. Quando sono richiesti grandi buffer permanenti o altri oggetti, è meglio istanziarli all’inizio del processo di esecuzione del programma, prima che possa verificarsi la frammentazione. Ulteriori miglioramenti possono essere ottenuti monitorando lo stato dell’heap e controllando il GC; questi aspetti sono illustrati di seguito.

Reporting

Sono disponibili diverse funzioni di libreria per riportare l’allocazione della memoria e per controllare il GC. Si trovano nei moduli gc e micropython. L’esempio seguente può essere incollato nel REPL (Ctrl-E per entrare in modalità incolla, Ctrl-D per eseguirlo).

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)

Metodi impiegati sopra:

  • gc.collect() Forza una garbage collection. Vedere la nota a piè di pagina.

  • micropython.mem_info() Stampa un riepilogo dell’utilizzo della RAM.

  • gc.mem_free() Restituisce la dimensione dell’heap libero in byte.

  • gc.mem_alloc() Restituisce il numero di byte attualmente allocati.

  • micropython.mem_info(1) Stampa una tabella dell’utilizzo dell’heap (descritta in dettaglio di seguito).

I numeri prodotti dipendono dalla piattaforma, ma si può notare che la dichiarazione della funzione utilizza una piccola quantità di RAM sotto forma di bytecode emesso dal compilatore (la RAM utilizzata dal compilatore è stata recuperata). L’esecuzione della funzione utilizza oltre 10KiB, ma al ritorno a è garbage perché è fuori ambito e non può essere referenziata. La chiamata finale a gc.collect() recupera quella memoria.

L’output finale prodotto da micropython.mem_info(1) varierà nei dettagli ma può essere interpretato come segue:

Simbolo

Significato

.

blocco libero

h

blocco di testa

=

blocco di coda

m

blocco di testa contrassegnato

T

tupla

L

lista

D

dict

F

float

B

byte code

M

modulo

S

stringa o bytes

A

bytearray

Ogni lettera rappresenta un singolo blocco di memoria, dove un blocco è di 16 byte. Quindi ogni riga del dump dell’heap rappresenta 0x400 byte ovvero 1KiB di RAM.

Controllo della garbage collection

È possibile richiedere un GC in qualsiasi momento eseguendo gc.collect(). È vantaggioso farlo a intervalli, in primo luogo per prevenire la frammentazione e in secondo luogo per le prestazioni. Un GC può richiedere diversi millisecondi, ma è più rapido quando c’è poco lavoro da svolgere (circa 1ms su una OpenMV Cam). Una chiamata esplicita può ridurre al minimo tale ritardo, garantendo al contempo che avvenga in punti del programma in cui è accettabile.

Il GC automatico viene provocato nelle seguenti circostanze. Quando un tentativo di allocazione fallisce, viene eseguito un GC e l’allocazione viene ritentata. Solo se anche questo fallisce viene sollevata un’eccezione. In secondo luogo, un GC automatico viene attivato se la quantità di RAM libera scende al di sotto di una soglia. Questa soglia può essere adattata man mano che l’esecuzione procede:

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

Ciò provocherà un GC quando più del 25% dell’heap attualmente libero risulta occupato.

In generale i moduli dovrebbero istanziare gli oggetti dati in fase di esecuzione utilizzando costruttori o altre funzioni di inizializzazione. Il motivo è che, se ciò avviene in fase di inizializzazione, il compilatore potrebbe trovarsi a corto di RAM quando vengono importati i moduli successivi. Se i moduli istanziano effettivamente dati durante l’import, allora gc.collect() eseguito dopo l’import attenuerà il problema.

Operazioni sulle stringhe

MicroPython gestisce le stringhe in modo efficiente e comprendere questo aspetto può aiutare nella progettazione di applicazioni destinate a essere eseguite sui microcontrollori. Quando un modulo viene compilato, le stringhe che ricorrono più volte vengono memorizzate una sola volta, un processo noto come string interning. In MicroPython una stringa interned è nota come qstr. In un modulo importato normalmente quell’unica istanza sarà collocata nella RAM, ma come descritto sopra, nei moduli congelati come bytecode sarà collocata nella flash.

Anche i confronti tra stringhe vengono eseguiti efficientemente utilizzando l’hashing anziché carattere per carattere. La penalità derivante dall’uso di stringhe anziché interi può quindi essere ridotta sia in termini di prestazioni sia di utilizzo della RAM, un fatto che potrebbe sorprendere i programmatori C.

Postscriptum

MicroPython passa, restituisce e (per impostazione predefinita) copia gli oggetti per riferimento. Un riferimento occupa una singola parola macchina, quindi questi processi sono efficienti in termini di utilizzo della RAM e di velocità.

Quando sono richieste variabili la cui dimensione non è né un byte né una parola macchina, esistono librerie standard che possono aiutare a memorizzarle efficientemente e a eseguire conversioni. Vedere i moduli array, struct e uctypes.

Nota a piè di pagina: valore di ritorno di gc.collect()

Sulle piattaforme Unix e Windows il metodo gc.collect() restituisce un intero che indica il numero di regioni di memoria distinte recuperate nella collection (più precisamente, il numero di teste trasformate in blocchi liberi). Per ragioni di efficienza i port bare metal non restituiscono questo valore.