MicroPython pe microcontrolere

MicroPython este conceput pentru a putea rula pe microcontrolere. Acestea au limitări hardware care pot fi nefamiliare programatorilor obișnuiți mai degrabă cu calculatoarele convenționale. În special, cantitatea de RAM și de memorie nevolatilă de tip „disc” (memorie flash) este limitată. Acest tutorial oferă modalități de a profita la maximum de resursele limitate. Deoarece MicroPython rulează pe controlere bazate pe o varietate de arhitecturi, metodele prezentate sunt generice: în unele cazuri va fi necesar să obțineți informații detaliate din documentația specifică platformei.

Memorie flash

Pe camerele OpenMV Cam, modalitatea simplă de a aborda capacitatea limitată este montarea unui card micro SD. În unele cazuri acest lucru nu este practic, fie pentru că dispozitivul nu are slot pentru card SD, fie din motive de cost sau de consum de energie; prin urmare trebuie folosită memoria flash de pe cip. Firmware-ul, inclusiv subsistemul MicroPython, este stocat în memoria flash încorporată. Capacitatea rămasă este disponibilă pentru utilizare. Din motive legate de arhitectura fizică a memoriei flash, o parte din această capacitate poate fi inaccesibilă ca sistem de fișiere. În astfel de cazuri, acest spațiu poate fi folosit prin încorporarea modulelor utilizatorului într-o compilare de firmware care este apoi scrisă în memoria flash a dispozitivului.

Există două modalități de a realiza acest lucru: module înghețate și bytecode înghețat. Modulele înghețate stochează sursa Python împreună cu firmware-ul. Bytecode-ul înghețat folosește compilatorul încrucișat pentru a converti sursa în bytecode, care este apoi stocat împreună cu firmware-ul. În oricare dintre cazuri, modulul poate fi accesat cu o instrucțiune import:

import mymodule

Procedura de producere a modulelor înghețate și a bytecode-ului depinde de platformă; instrucțiunile pentru compilarea firmware-ului pot fi găsite în fișierele README din partea relevantă a arborelui sursă.

În termeni generali, pașii sunt următorii:

  • Clonați depozitul MicroPython.

  • Procurați-vă lanțul de instrumente (specific platformei) pentru a compila firmware-ul.

  • Compilați compilatorul încrucișat.

  • Plasați modulele care urmează să fie înghețate într-un director specificat (în funcție de faptul dacă modulul urmează să fie înghețat ca sursă sau ca bytecode).

  • Compilați firmware-ul. Poate fi necesară o comandă specifică pentru a compila cod înghețat de oricare dintre tipuri - consultați documentația platformei.

  • Scrieți firmware-ul în memoria flash a dispozitivului.

RAM

Când reduceți utilizarea RAM, există două faze de luat în considerare: compilarea și execuția. Pe lângă consumul de memorie, există și o problemă cunoscută sub numele de fragmentare a heap-ului. În termeni generali, este cel mai bine să minimizați crearea și distrugerea repetată a obiectelor. Motivul pentru aceasta este tratat în secțiunea referitoare la heap.

Faza de compilare

Când un modul este importat, MicroPython compilează codul în bytecode, care este apoi executat de mașina virtuală MicroPython (VM). Bytecode-ul este stocat în RAM. Compilatorul în sine necesită RAM, dar aceasta devine disponibilă pentru utilizare după ce compilarea s-a încheiat.

Dacă au fost deja importate o serie de module, poate apărea situația în care nu există suficientă RAM pentru a rula compilatorul. În acest caz, instrucțiunea import va produce o excepție de memorie.

Dacă un modul instanțiază obiecte globale la import, va consuma RAM în momentul importului, care devine apoi indisponibilă pentru a fi folosită de compilator la importurile ulterioare. În general, este cel mai bine să evitați codul care rulează la import; o abordare mai bună este să aveți cod de inițializare care este rulat de aplicație după ce toate modulele au fost importate. Acest lucru maximizează RAM-ul disponibil pentru compilator.

Dacă RAM-ul este în continuare insuficient pentru a compila toate modulele, o soluție este precompilarea modulelor. MicroPython are un compilator încrucișat capabil să compileze module Python în bytecode (consultați fișierul README din directorul mpy-cross). Fișierul bytecode rezultat are extensia .mpy; el poate fi copiat în sistemul de fișiere și importat în mod obișnuit. Alternativ, unele sau toate modulele pot fi implementate ca bytecode înghețat: pe majoritatea platformelor acest lucru economisește și mai multă RAM, deoarece bytecode-ul este rulat direct din memoria flash în loc să fie stocat în RAM.

Faza de execuție

Există o serie de tehnici de programare pentru reducerea utilizării RAM.

Constante

MicroPython oferă un cuvânt cheie const care poate fi folosit după cum urmează:

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

În ambele cazuri în care constanta este atribuită unei variabile, compilatorul va evita generarea unei căutări a numelui constantei, înlocuind-o cu valoarea sa literală. Acest lucru economisește bytecode și, prin urmare, RAM. Totuși, valoarea ROWS va ocupa cel puțin două cuvinte mașină, câte unul pentru cheie și pentru valoare în dicționarul de globale. Prezența în dicționar este necesară deoarece un alt modul ar putea să o importe sau să o folosească. Această RAM poate fi economisită prefixând numele cu un underscore, ca în _COLS: acest simbol nu este vizibil în afara modulului, deci nu va ocupa RAM.

Argumentul pentru const() poate fi orice se evaluează, la momentul compilării, la o constantă, de exemplu 0x100, 1 << 8 sau (True, "string", b"bytes") (consultați secțiunea de mai jos pentru detalii). Poate include chiar și alte simboluri const care au fost deja definite, de exemplu 1 << BIT.

Structuri de date constante

Acolo unde există un volum substanțial de date constante și platforma acceptă execuția din memoria flash, RAM-ul poate fi economisit după cum urmează. Datele ar trebui plasate în module Python și înghețate ca bytecode. Datele trebuie definite ca obiecte bytes. Compilatorul „știe” că obiectele bytes sunt imuabile și asigură că obiectele rămân în memoria flash în loc să fie copiate în RAM. Modulul struct poate ajuta la conversia între tipurile bytes și alte tipuri încorporate Python.

Când luați în considerare implicațiile bytecode-ului înghețat, rețineți că în Python șirurile de caractere, numerele în virgulă mobilă, octeții, numerele întregi, numerele complexe și tuplurile sunt imuabile. În consecință, acestea vor fi înghețate în memoria flash (pentru tupluri, doar dacă toate elementele lor sunt imuabile). Astfel, în linia

mystring = "The quick brown fox"

șirul de caractere efectiv „The quick brown fox” va rezida în memoria flash. La execuție, o referință către șirul de caractere este atribuită variabilei mystring. Referința ocupă un singur cuvânt mașină. În principiu, un număr întreg mare ar putea fi folosit pentru a stoca date constante:

bar = 0xDEADBEEF0000DEADBEEF

La fel ca în exemplul cu șirul de caractere, la execuție o referință către numărul întreg arbitrar de mare este atribuită variabilei bar. Acea referință ocupă un singur cuvânt mașină.

Tuplurile de obiecte constante sunt ele însele constante. Astfel de tupluri constante sunt optimizate de compilator, astfel încât nu trebuie create la execuție de fiecare dată când sunt folosite. De exemplu:

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

Acest întreg tuplu va exista ca un singur obiect (potențial în memoria flash dacă codul este înghețat) și va fi referit de fiecare dată când este nevoie de el.

Crearea inutilă de obiecte

Există o serie de situații în care obiectele pot fi create și distruse fără intenție. Acest lucru poate reduce gradul de utilizare a RAM-ului prin fragmentare. Secțiunile următoare discută instanțe ale acestui fenomen.

Concatenarea șirurilor de caractere

Luați în considerare următoarele fragmente de cod care urmăresc să producă șiruri de caractere constante:

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

Fiecare produce același rezultat, însă primul creează inutil două obiecte de tip șir de caractere la execuție, alocând mai multă RAM pentru concatenare înainte de a produce al treilea. Celelalte efectuează concatenarea la momentul compilării, ceea ce este mai eficient, reducând fragmentarea.

Acolo unde șirurile de caractere trebuie create dinamic înainte de a fi transmise unui flux precum un fișier, se va economisi RAM dacă acest lucru se face în mod fragmentat. În loc să creați un obiect mare de tip șir de caractere, creați un subșir și transmiteți-l fluxului înainte de a vă ocupa de următorul.

Cea mai bună modalitate de a crea șiruri de caractere dinamice este prin intermediul metodei format() a șirurilor de caractere:

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

Tampoane (buffers)

Când accesați dispozitive precum instanțe ale interfețelor UART, I2C și SPI, utilizarea unor tampoane (buffers) prealocate evită crearea de obiecte inutile. Luați în considerare aceste două bucle:

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

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

Prima creează un tampon (buffer) la fiecare trecere, în timp ce a doua reutilizează un tampon (buffer) prealocat; acest lucru este atât mai rapid, cât și mai eficient în ceea ce privește fragmentarea memoriei.

Octeții sunt mai mici decât numerele întregi

Pe majoritatea platformelor, un număr întreg consumă patru octeți. Luați în considerare cele trei apeluri către funcția foo():

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

La primul apel, o list de numere întregi este creată în RAM de fiecare dată când codul este executat. Al doilea apel creează un obiect tuple constant (un tuple care conține doar obiecte constante) ca parte a fazei de compilare, deci este creat o singură dată și este mai eficient decât list. Al treilea apel creează eficient un obiect bytes care consumă cantitatea minimă de RAM. Dacă modulul ar fi înghețat ca bytecode, atât obiectul tuple, cât și obiectul bytes ar rezida în memoria flash.

Șiruri de caractere versus octeți

Python3 a introdus suportul pentru Unicode. Acest lucru a introdus o distincție între un șir de caractere și un tablou de octeți. MicroPython asigură că șirurile de caractere Unicode nu ocupă spațiu suplimentar atâta timp cât toate caracterele din șir sunt ASCII (adică au o valoare < 128). Dacă sunt necesare valori în întreaga gamă de 8 biți, pot fi folosite obiecte bytes și bytearray pentru a asigura că nu va fi necesar spațiu suplimentar. Rețineți că majoritatea metodelor pentru șiruri de caractere (de exemplu str.strip()) se aplică și instanțelor bytes, deci procesul de eliminare a Unicode poate fi fără probleme.

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

Acolo unde este necesară conversia între șiruri de caractere și octeți, pot fi folosite metodele str.encode() și bytes.decode(). Rețineți că atât șirurile de caractere, cât și octeții sunt imuabili. Orice operație care primește ca intrare un astfel de obiect și produce altul implică cel puțin o alocare de RAM pentru a produce rezultatul. În a doua linie de mai jos, un nou obiect bytes este alocat. Acest lucru s-ar întâmpla și dacă foo ar fi un șir de caractere.

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

Execuția compilatorului la runtime

Funcțiile Python eval și exec invocă compilatorul la runtime, ceea ce necesită cantități semnificative de RAM. Rețineți că biblioteca pickle din micropython-lib folosește exec. Poate fi mai eficient din punct de vedere al RAM-ului să folosiți biblioteca json pentru serializarea obiectelor.

Stocarea șirurilor de caractere în memoria flash

Șirurile de caractere Python sunt imuabile, prin urmare au potențialul de a fi stocate în memorie doar pentru citire. Compilatorul poate plasa în memoria flash șiruri de caractere definite în cod Python. La fel ca în cazul modulelor înghețate, este necesar să aveți o copie a arborelui sursă pe PC și lanțul de instrumente pentru a compila firmware-ul. Procedura va funcționa chiar dacă modulele nu au fost depanate complet, atâta timp cât pot fi importate și rulate.

După importarea modulelor, executați:

micropython.qstr_info(1)

Apoi copiați și lipiți toate liniile Q(xxx) într-un editor de text. Verificați și eliminați liniile care sunt în mod evident invalide. Deschideți fișierul qstrdefsport.h care va fi găsit în ports/stm32 (sau directorul echivalent pentru arhitectura utilizată). Copiați și lipiți liniile corectate la sfârșitul fișierului. Salvați fișierul, recompilați și scrieți firmware-ul în memoria flash. Rezultatul poate fi verificat importând modulele și emițând din nou:

micropython.qstr_info(1)

Liniile Q(xxx) ar trebui să fi dispărut.

Heap-ul

Când un program în execuție instanțiază un obiect, RAM-ul necesar este alocat dintr-un fond de mărime fixă cunoscut sub numele de heap. Când obiectul iese din domeniul de vizibilitate (cu alte cuvinte, devine inaccesibil pentru cod), obiectul redundant este cunoscut sub numele de „gunoi”. Un proces cunoscut sub numele de „colectare a gunoiului” (GC) recuperează acea memorie, returnând-o heap-ului liber. Acest proces rulează automat, însă poate fi invocat direct emițând gc.collect().

Discuția pe acest subiect este oarecum complicată. Pentru o „soluție rapidă”, emiteți periodic următoarele:

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

Pentru mai multe informații, consultați mai jos și documentația pentru modulul încorporat gc.

Pentru detalii din perspectiva mecanismelor interne MicroPython / a dezvoltatorului, consultați de asemenea Gestionarea memoriei.

Fragmentarea

Să presupunem că un program creează un obiect foo, apoi un obiect bar. Ulterior foo iese din domeniul de vizibilitate, dar bar rămâne. RAM-ul folosit de foo va fi recuperat de GC. Totuși, dacă bar a fost alocat la o adresă mai mare, RAM-ul recuperat de la foo va fi util doar pentru obiecte nu mai mari decât foo. Într-un program complex sau cu rulare îndelungată, heap-ul poate deveni fragmentat: în ciuda faptului că există o cantitate substanțială de RAM disponibilă, nu există suficient spațiu contiguu pentru a aloca un anumit obiect, iar programul eșuează cu o eroare de memorie.

Tehnicile prezentate mai sus urmăresc să minimizeze acest lucru. Acolo unde sunt necesare tampoane permanente mari sau alte obiecte, este cel mai bine să le instanțiați devreme în procesul de execuție a programului, înainte de a putea apărea fragmentarea. Pot fi aduse îmbunătățiri suplimentare prin monitorizarea stării heap-ului și prin controlarea GC-ului; acestea sunt prezentate mai jos.

Raportare

O serie de funcții de bibliotecă sunt disponibile pentru a raporta alocarea memoriei și pentru a controla GC-ul. Acestea se găsesc în modulele gc și micropython. Exemplul următor poate fi lipit în REPL (Ctrl-E pentru a intra în modul lipire, Ctrl-D pentru a-l rula).

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)

Metode folosite mai sus:

  • gc.collect() Forțează o colectare a gunoiului. Vezi nota de subsol.

  • micropython.mem_info() Afișează un rezumat al utilizării RAM-ului.

  • gc.mem_free() Returnează dimensiunea heap-ului liber în octeți.

  • gc.mem_alloc() Returnează numărul de octeți alocați în prezent.

  • micropython.mem_info(1) Afișează un tabel al utilizării heap-ului (detaliat mai jos).

Numerele produse depind de platformă, dar se poate observa că declararea funcției folosește o cantitate mică de RAM sub forma bytecode-ului emis de compilator (RAM-ul folosit de compilator a fost recuperat). Rularea funcției folosește peste 10KiB, dar la revenire a este gunoi deoarece a ieșit din domeniul de vizibilitate și nu poate fi referit. Apelul final gc.collect() recuperează acea memorie.

Ieșirea finală produsă de micropython.mem_info(1) va varia în detalii, dar poate fi interpretată după cum urmează:

Simbol

Semnificație

.

bloc liber

h

bloc de început

=

bloc de continuare

m

bloc de început marcat

T

tuplu

L

listă

D

dicționar

F

număr în virgulă mobilă

B

bytecode

M

modul

S

șir de caractere sau octeți

A

bytearray

Fiecare literă reprezintă un singur bloc de memorie, un bloc fiind de 16 octeți. Deci fiecare linie a dump-ului heap-ului reprezintă 0x400 octeți sau 1KiB de RAM.

Controlul colectării gunoiului

Un GC poate fi solicitat în orice moment emițând gc.collect(). Este avantajos să faceți acest lucru la intervale regulate, în primul rând pentru a preveni fragmentarea și în al doilea rând pentru performanță. Un GC poate dura câteva milisecunde, dar este mai rapid când are puțin de lucru (aproximativ 1ms pe o cameră OpenMV Cam). Un apel explicit poate minimiza acea întârziere, asigurând în același timp că aceasta apare în puncte ale programului în care este acceptabilă.

GC-ul automat este provocat în următoarele circumstanțe. Când o încercare de alocare eșuează, se efectuează un GC și alocarea este reîncercată. Doar dacă aceasta eșuează se ridică o excepție. În al doilea rând, un GC automat va fi declanșat dacă cantitatea de RAM liberă scade sub un prag. Acest prag poate fi adaptat pe măsură ce execuția progresează:

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

Acest lucru va provoca un GC atunci când mai mult de 25% din heap-ul liber în prezent devine ocupat.

În general, modulele ar trebui să instanțieze obiecte de date la runtime folosind constructori sau alte funcții de inițializare. Motivul este că, dacă acest lucru are loc la inițializare, compilatorul poate fi lipsit de RAM când modulele ulterioare sunt importate. Dacă modulele instanțiază totuși date la import, atunci gc.collect() emis după import va ameliora problema.

Operații cu șiruri de caractere

MicroPython gestionează șirurile de caractere într-o manieră eficientă, iar înțelegerea acestui lucru poate ajuta la proiectarea aplicațiilor care rulează pe microcontrolere. Când un modul este compilat, șirurile de caractere care apar de mai multe ori sunt stocate o singură dată, un proces cunoscut sub numele de internare a șirurilor de caractere. În MicroPython, un șir de caractere internat este cunoscut sub numele de qstr. Într-un modul importat în mod normal, acea instanță unică va fi localizată în RAM, dar, după cum s-a descris mai sus, în modulele înghețate ca bytecode ea va fi localizată în memoria flash.

Comparațiile de șiruri de caractere sunt de asemenea efectuate eficient folosind hashing în loc de caracter cu caracter. Penalizarea pentru utilizarea șirurilor de caractere în loc de numere întregi poate fi astfel mică atât în ceea ce privește performanța, cât și utilizarea RAM-ului - un fapt care poate veni ca o surpriză pentru programatorii C.

Postscriptum

MicroPython transmite, returnează și (în mod implicit) copiază obiectele prin referință. O referință ocupă un singur cuvânt mașină, deci aceste procese sunt eficiente în ceea ce privește utilizarea RAM-ului și viteza.

Acolo unde sunt necesare variabile a căror dimensiune nu este nici un octet, nici un cuvânt mașină, există biblioteci standard care pot ajuta la stocarea eficientă a acestora și la efectuarea conversiilor. Consultați modulele array, struct și uctypes.

Notă de subsol: valoarea returnată de gc.collect()

Pe platformele Unix și Windows, metoda gc.collect() returnează un număr întreg care indică numărul de regiuni de memorie distincte care au fost recuperate în colectare (mai exact, numărul de blocuri de început care au fost transformate în blocuri libere). Din motive de eficiență, porturile bare metal nu returnează această valoare.