MicroPython på mikrokontroller

MicroPython är utformat för att kunna köras på mikrokontroller. Dessa har hårdvarubegränsningar som kan vara obekanta för programmerare som är vana vid konventionella datorer. I synnerhet är mängden RAM och icke-flyktig ”disk”-lagring (flashminne) begränsad. Den här handledningen visar sätt att få ut mesta möjliga av de begränsade resurserna. Eftersom MicroPython körs på kontroller baserade på en mängd olika arkitekturer är de presenterade metoderna generiska: i vissa fall blir det nödvändigt att hämta detaljerad information från plattformsspecifik dokumentation.

Flashminne

På OpenMV Cams är det enkla sättet att hantera den begränsade kapaciteten att sätta i ett micro SD-kort. I vissa fall är detta opraktiskt, antingen för att enheten inte har någon SD-kortplats eller av kostnads- eller strömförbrukningsskäl; därför måste det inbyggda flashminnet användas. Den fasta programvaran inklusive MicroPython-undersystemet lagras i det inbyggda flashminnet. Den återstående kapaciteten är tillgänglig för användning. Av skäl som hänger samman med flashminnets fysiska arkitektur kan en del av denna kapacitet vara otillgänglig som ett filsystem. I sådana fall kan detta utrymme användas genom att integrera användarmoduler i en firmwarebyggnad som sedan flashas till enheten.

Det finns två sätt att åstadkomma detta: frysta moduler och fryst bytekod. Frysta moduler lagrar Python-källkoden tillsammans med den fasta programvaran. Fryst bytekod använder korskompilatorn för att konvertera källkoden till bytekod som sedan lagras tillsammans med den fasta programvaran. I båda fallen kan modulen nås med en import-sats:

import mymodule

Förfarandet för att producera frysta moduler och bytekod är plattformsberoende; instruktioner för att bygga den fasta programvaran finns i README-filerna i den relevanta delen av källkodsträdet.

I allmänna ordalag är stegen följande:

  • Klona MicroPython-repositoryt.

  • Skaffa den (plattformsspecifika) verktygskedjan för att bygga den fasta programvaran.

  • Bygg korskompilatorn.

  • Placera modulerna som ska frysas i en angiven katalog (beroende på om modulen ska frysas som källkod eller som bytekod).

  • Bygg den fasta programvaran. Ett specifikt kommando kan krävas för att bygga fryst kod av endera typen - se plattformsdokumentationen.

  • Flasha den fasta programvaran till enheten.

RAM

När man minskar RAM-användningen finns det två faser att beakta: kompilering och exekvering. Förutom minnesförbrukning finns det också ett problem som kallas heap-fragmentering. I allmänna ordalag är det bäst att minimera den upprepade skapelsen och förstörelsen av objekt. Anledningen till detta behandlas i avsnittet om heap.

Kompileringsfasen

När en modul importeras kompilerar MicroPython koden till bytekod som sedan exekveras av MicroPython-den virtuella maskinen (VM). Bytekoden lagras i RAM. Kompilatorn själv kräver RAM, men detta blir tillgängligt för användning när kompileringen har slutförts.

Om ett antal moduler redan har importerats kan situationen uppstå där det inte finns tillräckligt med RAM för att köra kompilatorn. I det här fallet kommer import-satsen att ge ett minnesundantag.

Om en modul instansierar globala objekt vid import kommer den att förbruka RAM vid importtillfället, vilket sedan är otillgängligt för kompilatorn att använda vid efterföljande importer. I allmänhet är det bäst att undvika kod som körs vid import; ett bättre tillvägagångssätt är att ha initialiseringskod som körs av applikationen efter att alla moduler har importerats. Detta maximerar det RAM som är tillgängligt för kompilatorn.

Om RAM fortfarande är otillräckligt för att kompilera alla moduler är en lösning att förkompilera moduler. MicroPython har en korskompilator som kan kompilera Python-moduler till bytekod (se README i mpy-cross-katalogen). Den resulterande bytekodfilen har filändelsen .mpy; den kan kopieras till filsystemet och importeras på vanligt sätt. Alternativt kan några eller alla moduler implementeras som fryst bytekod: på de flesta plattformar sparar detta ännu mer RAM eftersom bytekoden körs direkt från flashminnet i stället för att lagras i RAM.

Exekveringsfasen

Det finns ett antal kodningstekniker för att minska RAM-användningen.

Konstanter

MicroPython tillhandahåller ett const-nyckelord som kan användas på följande sätt:

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

I båda fallen där konstanten tilldelas en variabel kommer kompilatorn att undvika att koda en uppslagning av konstantens namn genom att ersätta det med dess literala värde. Detta sparar bytekod och därmed RAM. Värdet ROWS kommer dock att uppta minst två maskinord, ett vardera för nyckeln och värdet i den globala ordboken. Förekomsten i ordboken är nödvändig eftersom en annan modul kan importera eller använda den. Detta RAM kan sparas genom att inleda namnet med ett understreck som i _COLS: denna symbol är inte synlig utanför modulen och kommer därför inte att uppta RAM.

Argumentet till const() kan vara vad som helst som vid kompileringstillfället evalueras till en konstant, t.ex. 0x100, 1 << 8 eller (True, "string", b"bytes") (se avsnittet nedan för detaljer). Det kan till och med inkludera andra const-symboler som redan har definierats, t.ex. 1 << BIT.

Konstanta datastrukturer

Där det finns en betydande mängd konstant data och plattformen stöder exekvering från flashminne kan RAM sparas på följande sätt. Datan bör placeras i Python-moduler och frysas som bytekod. Datan måste definieras som bytes-objekt. Kompilatorn ”vet” att bytes-objekt är oföränderliga och säkerställer att objekten förblir i flashminnet i stället för att kopieras till RAM. Modulen struct kan hjälpa till att konvertera mellan bytes-typer och andra inbyggda Python-typer.

När man överväger konsekvenserna av fryst bytekod ska man notera att i Python är strängar, flyttal, bytes, heltal, komplexa tal och tupler oföränderliga. Följaktligen kommer dessa att frysas in i flashminnet (för tupler, endast om alla deras element är oföränderliga). Således, på raden

mystring = "The quick brown fox"

kommer den faktiska strängen ”The quick brown fox” att finnas i flashminnet. Vid körning tilldelas en referens till strängen till variabeln mystring. Referensen upptar ett enda maskinord. I princip skulle ett långt heltal kunna användas för att lagra konstant data:

bar = 0xDEADBEEF0000DEADBEEF

Liksom i strängexemplet tilldelas vid körning en referens till det godtyckligt stora heltalet till variabeln bar. Den referensen upptar ett enda maskinord.

Tupler av konstanta objekt är själva konstanta. Sådana konstanta tupler optimeras av kompilatorn så att de inte behöver skapas vid körning varje gång de används. Till exempel:

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

Hela denna tupel kommer att existera som ett enda objekt (potentiellt i flashminnet om koden är fryst) och refereras varje gång det behövs.

Onödig objektskapelse

Det finns ett antal situationer där objekt omedvetet kan skapas och förstöras. Detta kan minska användbarheten av RAM genom fragmentering. Följande avsnitt diskuterar exempel på detta.

Strängsammanfogning

Betrakta följande kodfragment som syftar till att producera konstanta strängar:

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

Var och en ger samma resultat, men den första skapar i onödan två strängobjekt vid körning och allokerar mer RAM för sammanfogningen innan den tredje produceras. De andra utför sammanfogningen vid kompileringstillfället vilket är effektivare och minskar fragmenteringen.

Där strängar måste skapas dynamiskt innan de matas till en ström, såsom en fil, sparar det RAM om detta görs styckevis. I stället för att skapa ett stort strängobjekt, skapa en delsträng och mata den till strömmen innan du hanterar nästa.

Det bästa sättet att skapa dynamiska strängar är med hjälp av strängens metod format():

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

Buffertar

Vid åtkomst till enheter såsom instanser av gränssnitten UART, I2C och SPI undviker användning av förallokerade buffertar skapandet av onödiga objekt. Betrakta dessa två loopar:

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

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

Den första skapar en buffert vid varje varv medan den andra återanvänder en förallokerad buffert; detta är både snabbare och effektivare när det gäller minnesfragmentering.

Bytes är mindre än ints

På de flesta plattformar förbrukar ett heltal fyra byte. Betrakta de tre anropen till funktionen foo():

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

I det första anropet skapas en list med heltal i RAM varje gång koden exekveras. Det andra anropet skapar ett konstant tuple-objekt (en tuple som endast innehåller konstanta objekt) som en del av kompileringsfasen, så det skapas bara en gång och är effektivare än list. Det tredje anropet skapar effektivt ett bytes-objekt som förbrukar minsta möjliga mängd RAM. Om modulen frystes som bytekod skulle både tuple- och bytes-objektet finnas i flashminnet.

Strängar kontra bytes

Python3 introducerade stöd för Unicode. Detta införde en skillnad mellan en sträng och en array av bytes. MicroPython säkerställer att Unicode-strängar inte tar något ytterligare utrymme så länge alla tecken i strängen är ASCII (dvs. har ett värde < 128). Om värden i hela 8-bitarsintervallet krävs kan bytes- och bytearray-objekt användas för att säkerställa att inget ytterligare utrymme behövs. Observera att de flesta strängmetoder (t.ex. str.strip()) även gäller för bytes-instanser så processen att eliminera Unicode kan vara smärtfri.

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

Där det är nödvändigt att konvertera mellan strängar och bytes kan metoderna str.encode() och bytes.decode() användas. Observera att både strängar och bytes är oföränderliga. Varje operation som tar ett sådant objekt som indata och producerar ett annat innebär minst en RAM-allokering för att producera resultatet. På den andra raden nedan allokeras ett nytt bytes-objekt. Detta skulle också inträffa om foo vore en sträng.

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

Körning av kompilatorn vid körtid

Python-funktionerna eval och exec anropar kompilatorn vid körtid, vilket kräver betydande mängder RAM. Observera att biblioteket pickle från micropython-lib använder exec. Det kan vara mer RAM-effektivt att använda biblioteket json för objektserialisering.

Lagring av strängar i flashminne

Python-strängar är oföränderliga och har därför potentialen att lagras i läsminne. Kompilatorn kan placera strängar som definieras i Python-kod i flashminnet. Liksom med frysta moduler är det nödvändigt att ha en kopia av källkodsträdet på datorn och verktygskedjan för att bygga den fasta programvaran. Förfarandet fungerar även om modulerna inte har felsökts fullständigt, så länge de kan importeras och köras.

Efter att ha importerat modulerna, kör:

micropython.qstr_info(1)

Kopiera och klistra sedan in alla Q(xxx)-rader i en textredigerare. Kontrollera och ta bort rader som uppenbart är ogiltiga. Öppna filen qstrdefsport.h som finns i ports/stm32 (eller motsvarande katalog för den arkitektur som används). Kopiera och klistra in de korrigerade raderna i slutet av filen. Spara filen, bygg om och flasha den fasta programvaran. Resultatet kan kontrolleras genom att importera modulerna och åter utfärda:

micropython.qstr_info(1)

Raderna Q(xxx) bör vara borta.

Heapen

När ett program som körs instansierar ett objekt allokeras det nödvändiga RAM-minnet från en pool av fast storlek som kallas heapen. När objektet går ur räckvidd (med andra ord blir otillgängligt för koden) kallas det överflödiga objektet ”skräp”. En process som kallas ”skräpinsamling” (GC) återvinner det minnet och returnerar det till den fria heapen. Denna process körs automatiskt, men den kan anropas direkt genom att utfärda gc.collect().

Diskussionen om detta är något komplicerad. För en ”snabb lösning”, utfärda följande periodiskt:

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

För mer information, se nedan och dokumentationen för den inbyggda modulen gc.

För detaljer ur MicroPythons interna/utvecklarperspektiv, se även Minneshantering.

Fragmentering

Säg att ett program skapar ett objekt foo, sedan ett objekt bar. Därefter går foo ur räckvidd men bar förblir. Det RAM som används av foo kommer att återvinnas av GC. Men om bar allokerades till en högre adress kommer det RAM som återvunnits från foo endast att vara användbart för objekt som inte är större än foo. I ett komplext eller långkörande program kan heapen bli fragmenterad: trots att det finns en betydande mängd RAM tillgängligt finns det inte tillräckligt med sammanhängande utrymme för att allokera ett visst objekt, och programmet misslyckas med ett minnesfel.

De ovan beskrivna teknikerna syftar till att minimera detta. Där stora permanenta buffertar eller andra objekt krävs är det bäst att instansiera dessa tidigt i programexekveringsprocessen innan fragmentering kan uppstå. Ytterligare förbättringar kan göras genom att övervaka heapens tillstånd och genom att styra GC; dessa beskrivs nedan.

Rapportering

Ett antal biblioteksfunktioner finns tillgängliga för att rapportera om minnesallokering och för att styra GC. Dessa finns i modulerna gc och micropython. Följande exempel kan klistras in vid REPL (Ctrl-E för att gå in i inklistringsläge, Ctrl-D för att köra det).

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)

Metoder som används ovan:

  • gc.collect() Framtvinga en skräpinsamling. Se fotnot.

  • micropython.mem_info() Skriv ut en sammanfattning av RAM-utnyttjandet.

  • gc.mem_free() Returnera den fria heapstorleken i byte.

  • gc.mem_alloc() Returnera antalet byte som för närvarande är allokerade.

  • micropython.mem_info(1) Skriv ut en tabell över heaputnyttjandet (beskrivs nedan).

De producerade siffrorna är beroende av plattformen, men det kan ses att deklarering av funktionen använder en liten mängd RAM i form av bytekod som kompilatorn genererar (det RAM som kompilatorn använde har återvunnits). Att köra funktionen använder över 10 KiB, men vid återkomst är a skräp eftersom det är ur räckvidd och inte kan refereras. Det slutliga gc.collect() återvinner det minnet.

Den slutliga utdatan som produceras av micropython.mem_info(1) kommer att variera i detalj men kan tolkas på följande sätt:

Symbol

Betydelse

.

ledigt block

h

huvudblock

=

svansblock

m

markerat huvudblock

T

tupel

L

lista

D

ordbok

F

flyttal

B

bytekod

M

modul

S

sträng eller bytes

A

bytearray

Varje bokstav representerar ett enda minnesblock, där ett block är 16 byte. Så varje rad i heapdumpen representerar 0x400 byte eller 1 KiB RAM.

Styrning av skräpinsamling

En GC kan begäras när som helst genom att utfärda gc.collect(). Det är fördelaktigt att göra detta med jämna mellanrum, dels för att förekomma fragmentering och dels för prestandans skull. En GC kan ta flera millisekunder men går snabbare när det finns lite arbete att göra (omkring 1 ms på en OpenMV Cam). Ett explicit anrop kan minimera den fördröjningen samtidigt som det säkerställer att den inträffar vid punkter i programmet då det är acceptabelt.

Automatisk GC framkallas under följande omständigheter. När ett försök till allokering misslyckas utförs en GC och allokeringen försöks igen. Endast om detta misslyckas utlöses ett undantag. För det andra utlöses en automatisk GC om mängden fritt RAM faller under ett tröskelvärde. Detta tröskelvärde kan anpassas allteftersom exekveringen fortskrider:

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

Detta kommer att framkalla en GC när mer än 25 % av den för närvarande lediga heapen blir upptagen.

I allmänhet bör moduler instansiera dataobjekt vid körning med hjälp av konstruktorer eller andra initialiseringsfunktioner. Anledningen är att om detta sker vid initialisering kan kompilatorn berövas RAM när efterföljande moduler importeras. Om moduler ändå instansierar data vid import kommer ett gc.collect() som utfärdas efter importen att lindra problemet.

Strängoperationer

MicroPython hanterar strängar på ett effektivt sätt och att förstå detta kan hjälpa till vid utformning av applikationer som ska köras på mikrokontroller. När en modul kompileras lagras strängar som förekommer flera gånger endast en gång, en process som kallas stränginternering. I MicroPython kallas en internerad sträng en qstr. I en modul som importeras normalt kommer den enda instansen att finnas i RAM, men som beskrivits ovan kommer den i moduler som frysts som bytekod att finnas i flashminnet.

Strängjämförelser utförs också effektivt med hjälp av hashning i stället för tecken för tecken. Straffet för att använda strängar i stället för heltal kan därför vara litet både när det gäller prestanda och RAM-användning - ett faktum som kan komma som en överraskning för C-programmerare.

Efterskrift

MicroPython skickar, returnerar och (som standard) kopierar objekt via referens. En referens upptar ett enda maskinord så dessa processer är effektiva när det gäller RAM-användning och hastighet.

Där variabler krävs vars storlek varken är en byte eller ett maskinord finns det standardbibliotek som kan hjälpa till att lagra dessa effektivt och att utföra konverteringar. Se modulerna array, struct och uctypes.

Fotnot: returvärdet för gc.collect()

På Unix- och Windows-plattformar returnerar metoden gc.collect() ett heltal som anger antalet distinkta minnesregioner som återvanns vid insamlingen (mer exakt, antalet huvuden som omvandlades till lediga). Av effektivitetsskäl returnerar bare metal-portar inte detta värde.