MicroPython op microcontrollers¶
MicroPython is ontworpen om te kunnen draaien op microcontrollers. Deze hebben hardwarebeperkingen die mogelijk onbekend zijn voor programmeurs die meer vertrouwd zijn met conventionele computers. In het bijzonder is de hoeveelheid RAM en niet-vluchtige “schijf”-opslag (flashgeheugen) beperkt. Deze tutorial biedt manieren om het meeste uit de beperkte middelen te halen. Omdat MicroPython draait op controllers gebaseerd op uiteenlopende architecturen, zijn de gepresenteerde methoden generiek: in sommige gevallen zal het nodig zijn om gedetailleerde informatie te halen uit platformspecifieke documentatie.
Flashgeheugen¶
Op OpenMV Cams is de eenvoudige manier om de beperkte capaciteit aan te pakken het plaatsen van een micro SD-kaart. In sommige gevallen is dit onpraktisch, omdat het apparaat geen SD-kaartslot heeft of vanwege kosten of stroomverbruik; daarom moet het flashgeheugen op de chip worden gebruikt. De firmware inclusief het MicroPython-subsysteem wordt opgeslagen in het ingebouwde flashgeheugen. De resterende capaciteit is beschikbaar voor gebruik. Om redenen die samenhangen met de fysieke architectuur van het flashgeheugen kan een deel van deze capaciteit ontoegankelijk zijn als bestandssysteem. In dergelijke gevallen kan deze ruimte worden benut door gebruikersmodules op te nemen in een firmware-build die vervolgens naar het apparaat wordt geflasht.
Er zijn twee manieren om dit te bereiken: frozen modules en frozen bytecode. Frozen modules slaan de Python-broncode samen met de firmware op. Frozen bytecode gebruikt de cross-compiler om de broncode om te zetten naar bytecode, die vervolgens samen met de firmware wordt opgeslagen. In beide gevallen kan de module worden benaderd met een import-statement:
import mymodule
De procedure voor het produceren van frozen modules en bytecode is platformafhankelijk; instructies voor het bouwen van de firmware zijn te vinden in de README-bestanden in het relevante deel van de broncode-boom.
In algemene termen zijn de stappen als volgt:
Kloon de MicroPython-repository.
Verkrijg de (platformspecifieke) toolchain om de firmware te bouwen.
Bouw de cross-compiler.
Plaats de te bevriezen modules in een opgegeven map (afhankelijk van of de module als broncode of als bytecode moet worden bevroren).
Bouw de firmware. Mogelijk is een specifiek commando nodig om frozen code van beide types te bouwen - zie de platformdocumentatie.
Flash de firmware naar het apparaat.
RAM¶
Bij het verminderen van RAM-gebruik zijn er twee fasen om rekening mee te houden: compilatie en uitvoering. Naast geheugenverbruik is er ook een probleem dat bekendstaat als heap-fragmentatie. In het algemeen is het het beste om het herhaaldelijk creëren en vernietigen van objecten te minimaliseren. De reden hiervoor wordt behandeld in het gedeelte over de heap.
Compilatiefase¶
Wanneer een module wordt geïmporteerd, compileert MicroPython de code naar bytecode, die vervolgens wordt uitgevoerd door de MicroPython virtual machine (VM). De bytecode wordt opgeslagen in RAM. De compiler zelf vereist RAM, maar dit komt beschikbaar voor gebruik wanneer de compilatie is voltooid.
Als er al een aantal modules zijn geïmporteerd, kan de situatie ontstaan waarin er onvoldoende RAM is om de compiler te draaien. In dat geval zal het import-statement een geheugenuitzondering veroorzaken.
Als een module bij import globale objecten instantieert, verbruikt deze RAM op het moment van import, dat vervolgens niet beschikbaar is voor de compiler om te gebruiken bij volgende imports. In het algemeen is het het beste om code te vermijden die bij import wordt uitgevoerd; een betere aanpak is om initialisatiecode te hebben die door de applicatie wordt uitgevoerd nadat alle modules zijn geïmporteerd. Dit maximaliseert de RAM die beschikbaar is voor de compiler.
Als er nog steeds onvoldoende RAM is om alle modules te compileren, is een oplossing het voorcompileren van modules. MicroPython heeft een cross-compiler die in staat is Python-modules naar bytecode te compileren (zie de README in de map mpy-cross). Het resulterende bytecode-bestand heeft een .mpy-extensie; het kan naar het bestandssysteem worden gekopieerd en op de gebruikelijke manier worden geïmporteerd. Als alternatief kunnen sommige of alle modules worden geïmplementeerd als frozen bytecode: op de meeste platformen bespaart dit nog meer RAM omdat de bytecode rechtstreeks vanuit flashgeheugen wordt uitgevoerd in plaats van in RAM te worden opgeslagen.
Uitvoeringsfase¶
Er zijn een aantal codeertechnieken om het RAM-gebruik te verminderen.
Constanten
MicroPython biedt een const-trefwoord dat als volgt kan worden gebruikt:
from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS
In beide gevallen waar de constante aan een variabele wordt toegewezen, vermijdt de compiler het coderen van een opzoeking naar de naam van de constante door de letterlijke waarde ervan te substitueren. Dit bespaart bytecode en dus RAM. De waarde ROWS neemt echter ten minste twee machinewoorden in beslag, één elk voor de sleutel en de waarde in het globals-woordenboek. De aanwezigheid in het woordenboek is noodzakelijk omdat een andere module het zou kunnen importeren of gebruiken. Deze RAM kan worden bespaard door de naam te laten voorafgaan door een underscore, zoals in _COLS: dit symbool is niet zichtbaar buiten de module en zal dus geen RAM in beslag nemen.
Het argument voor const() mag alles zijn dat tijdens het compileren tot een constante evalueert, bijv. 0x100, 1 << 8 of (True, "string", b"bytes") (zie het gedeelte hieronder voor details). Het kan zelfs andere const-symbolen bevatten die al gedefinieerd zijn, bijv. 1 << BIT.
Constante datastructuren
Waar er een aanzienlijke hoeveelheid constante data is en het platform uitvoering vanuit Flash ondersteunt, kan als volgt RAM worden bespaard. De data moet worden ondergebracht in Python-modules en bevroren als bytecode. De data moet worden gedefinieerd als bytes-objecten. De compiler ‘weet’ dat bytes-objecten onveranderlijk zijn en zorgt ervoor dat de objecten in flashgeheugen blijven in plaats van naar RAM te worden gekopieerd. De struct-module kan helpen bij het converteren tussen bytes-types en andere ingebouwde Python-types.
Wanneer je de implicaties van frozen bytecode overweegt, let er dan op dat in Python strings, floats, bytes, gehele getallen, complexe getallen en tuples onveranderlijk zijn. Daarom worden deze in flash bevroren (voor tuples alleen als al hun elementen onveranderlijk zijn). Dus in de regel
mystring = "The quick brown fox"
zal de eigenlijke string “The quick brown fox” zich in flash bevinden. Tijdens runtime wordt een verwijzing naar de string toegewezen aan de variabele mystring. De verwijzing neemt één enkel machinewoord in beslag. In principe kan een long integer worden gebruikt om constante data op te slaan:
bar = 0xDEADBEEF0000DEADBEEF
Zoals in het stringvoorbeeld wordt tijdens runtime een verwijzing naar het willekeurig grote gehele getal toegewezen aan de variabele bar. Die verwijzing neemt één enkel machinewoord in beslag.
Tuples van constante objecten zijn zelf constant. Dergelijke constante tuples worden door de compiler geoptimaliseerd zodat ze niet tijdens runtime hoeven te worden aangemaakt telkens wanneer ze worden gebruikt. Bijvoorbeeld:
foo = (1, 2, 3, 4, 5, 6, 100000, ("string", b"bytes", False, True))
Deze hele tuple bestaat als één enkel object (mogelijk in flash als de code bevroren is) en wordt aangeroepen telkens wanneer het nodig is.
Onnodige objectcreatie
Er zijn een aantal situaties waarin objecten onbedoeld kunnen worden aangemaakt en vernietigd. Dit kan de bruikbaarheid van RAM verminderen door fragmentatie. De volgende gedeelten bespreken voorbeelden hiervan.
String-concatenatie
Beschouw de volgende codefragmenten die tot doel hebben constante strings te produceren:
var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""
Elk produceert hetzelfde resultaat, maar het eerste creëert onnodig twee stringobjecten tijdens runtime en wijst meer RAM toe voor concatenatie voordat het derde wordt geproduceerd. De andere voeren de concatenatie tijdens het compileren uit, wat efficiënter is en de fragmentatie vermindert.
Waar strings dynamisch moeten worden aangemaakt voordat ze worden doorgegeven aan een stream zoals een bestand, bespaart het RAM als dit stuk voor stuk gebeurt. In plaats van een groot stringobject aan te maken, maak je een substring aan en geef je deze door aan de stream voordat je de volgende behandelt.
De beste manier om dynamische strings te maken is door middel van de string-methode format():
var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)
Buffers
Bij het benaderen van apparaten zoals instanties van UART-, I2C- en SPI-interfaces vermijdt het gebruik van voorgealloceerde buffers het aanmaken van onnodige objecten. Beschouw deze twee lussen:
while True:
var = spi.read(100)
# process data
buf = bytearray(100)
while True:
spi.readinto(buf)
# process data in buf
De eerste creëert een buffer bij elke doorgang, terwijl de tweede een voorgealloceerde buffer hergebruikt; dit is zowel sneller als efficiënter wat betreft geheugenfragmentatie.
Bytes zijn kleiner dan ints
Op de meeste platformen verbruikt een geheel getal vier bytes. Beschouw de drie aanroepen van de functie foo():
def foo(bar):
for x in bar:
print(x)
foo([1, 2, 0xff])
foo((1, 2, 0xff))
foo(b'\1\2\xff')
Bij de eerste aanroep wordt een list van gehele getallen telkens in RAM aangemaakt wanneer de code wordt uitgevoerd. De tweede aanroep creëert een constant tuple-object (een tuple die alleen constante objecten bevat) als onderdeel van de compilatiefase, dus het wordt slechts één keer aangemaakt en is efficiënter dan de list. De derde aanroep creëert efficiënt een bytes-object dat de minimale hoeveelheid RAM verbruikt. Als de module als bytecode zou worden bevroren, zouden zowel het tuple- als het bytes-object zich in flash bevinden.
Strings versus Bytes
Python3 introduceerde Unicode-ondersteuning. Dit introduceerde een onderscheid tussen een string en een array van bytes. MicroPython zorgt ervoor dat Unicode-strings geen extra ruimte innemen zolang alle tekens in de string ASCII zijn (d.w.z. een waarde < 128 hebben). Als waarden in het volledige 8-bits bereik nodig zijn, kunnen bytes- en bytearray-objecten worden gebruikt om ervoor te zorgen dat er geen extra ruimte nodig is. Merk op dat de meeste string-methoden (bijv. str.strip()) ook van toepassing zijn op bytes-instanties, zodat het proces van het elimineren van Unicode pijnloos kan zijn.
s = 'the quick brown fox' # A string instance
b = b'the quick brown fox' # A bytes instance
Waar het nodig is om tussen strings en bytes te converteren, kunnen de methoden str.encode() en bytes.decode() worden gebruikt. Merk op dat zowel strings als bytes onveranderlijk zijn. Elke bewerking die zo’n object als invoer neemt en een ander produceert, impliceert ten minste één RAM-allocatie om het resultaat te produceren. In de tweede regel hieronder wordt een nieuw bytes-object gealloceerd. Dit zou ook gebeuren als foo een string was.
foo = b' empty whitespace'
foo = foo.lstrip()
Compileruitvoering tijdens runtime
De Python-functies eval en exec roepen de compiler tijdens runtime aan, wat aanzienlijke hoeveelheden RAM vereist. Merk op dat de pickle-bibliotheek van micropython-lib exec gebruikt. Het kan RAM-efficiënter zijn om de json-bibliotheek te gebruiken voor objectserialisatie.
Strings opslaan in flash
Python-strings zijn onveranderlijk en hebben daarom het potentieel om te worden opgeslagen in alleen-lezen geheugen. De compiler kan strings die in Python-code zijn gedefinieerd in flash plaatsen. Net als bij frozen modules is het nodig om een kopie van de broncode-boom op de pc te hebben en de toolchain om de firmware te bouwen. De procedure werkt zelfs als de modules niet volledig zijn gedebugd, zolang ze maar kunnen worden geïmporteerd en uitgevoerd.
Voer na het importeren van de modules uit:
micropython.qstr_info(1)
Kopieer en plak vervolgens alle Q(xxx)-regels in een teksteditor. Controleer op en verwijder regels die overduidelijk ongeldig zijn. Open het bestand qstrdefsport.h dat te vinden is in ports/stm32 (of de equivalente map voor de gebruikte architectuur). Kopieer en plak de gecorrigeerde regels aan het einde van het bestand. Sla het bestand op, herbouw de firmware en flash deze. De uitkomst kan worden gecontroleerd door de modules te importeren en opnieuw uit te voeren:
micropython.qstr_info(1)
De Q(xxx)-regels zouden verdwenen moeten zijn.
De heap¶
Wanneer een draaiend programma een object instantieert, wordt de benodigde RAM gealloceerd uit een pool van vaste grootte die bekendstaat als de heap. Wanneer het object buiten bereik raakt (met andere woorden ontoegankelijk wordt voor code) staat het overbodige object bekend als “garbage”. Een proces dat bekendstaat als “garbage collection” (GC) wint dat geheugen terug en geeft het terug aan de vrije heap. Dit proces verloopt automatisch, maar het kan rechtstreeks worden aangeroepen door gc.collect() uit te voeren.
Het betoog hierover is enigszins ingewikkeld. Voor een ‘snelle oplossing’ voer je het volgende periodiek uit:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Zie hieronder en de documentatie voor de ingebouwde module gc voor meer informatie.
Voor details vanuit het perspectief van de MicroPython-interne werking/ontwikkelaar, zie ook Geheugenbeheer.
Fragmentatie¶
Stel dat een programma een object foo aanmaakt, dan een object bar. Vervolgens raakt foo buiten bereik maar blijft bar bestaan. De RAM die door foo wordt gebruikt, wordt teruggewonnen door GC. Maar als bar aan een hoger adres is gealloceerd, is de van foo teruggewonnen RAM alleen bruikbaar voor objecten die niet groter zijn dan foo. In een complex of langlopend programma kan de heap gefragmenteerd raken: ondanks dat er een aanzienlijke hoeveelheid RAM beschikbaar is, is er onvoldoende aaneengesloten ruimte om een bepaald object te alloceren, en faalt het programma met een geheugenfout.
De hierboven geschetste technieken hebben tot doel dit te minimaliseren. Waar grote permanente buffers of andere objecten nodig zijn, is het het beste om deze vroeg in het proces van programma-uitvoering te instantiëren, voordat fragmentatie kan optreden. Verdere verbeteringen kunnen worden aangebracht door de toestand van de heap te monitoren en door GC te besturen; deze worden hieronder geschetst.
Rapportage¶
Er zijn een aantal bibliotheekfuncties beschikbaar om te rapporteren over geheugenallocatie en om GC te besturen. Deze zijn te vinden in de modules gc en micropython. Het volgende voorbeeld kan worden geplakt in de REPL (Ctrl-E om de plakmodus te activeren, Ctrl-D om het uit te voeren).
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)
Hierboven gebruikte methoden:
gc.collect()Forceer een garbage collection. Zie voetnoot.micropython.mem_info()Print een samenvatting van het RAM-gebruik.gc.mem_free()Geef de vrije heap-grootte in bytes terug.gc.mem_alloc()Geef het aantal momenteel gealloceerde bytes terug.micropython.mem_info(1)Print een tabel van het heap-gebruik (hieronder gedetailleerd).
De geproduceerde getallen zijn afhankelijk van het platform, maar het is te zien dat het declareren van de functie een kleine hoeveelheid RAM gebruikt in de vorm van bytecode die door de compiler is uitgezonden (de door de compiler gebruikte RAM is teruggewonnen). Het draaien van de functie gebruikt meer dan 10KiB, maar bij terugkeer is a garbage omdat het buiten bereik is en niet kan worden gerefereerd. De laatste gc.collect() herstelt dat geheugen.
De uiteindelijke uitvoer geproduceerd door micropython.mem_info(1) zal in detail variëren maar kan als volgt worden geïnterpreteerd:
Symbool |
Betekenis |
|---|---|
. |
vrij blok |
h |
kopblok |
= |
staartblok |
m |
gemarkeerd kopblok |
T |
tuple |
L |
list |
D |
dict |
F |
float |
B |
byte code |
M |
module |
S |
string of bytes |
A |
bytearray |
Elke letter vertegenwoordigt één enkel geheugenblok, waarbij een blok 16 bytes is. Elke regel van de heap-dump vertegenwoordigt dus 0x400 bytes of 1KiB RAM.
Besturing van garbage collection¶
Een GC kan op elk moment worden geëist door gc.collect() uit te voeren. Het is voordelig om dit met tussenpozen te doen, ten eerste om fragmentatie te voorkomen en ten tweede voor de prestaties. Een GC kan enkele milliseconden duren maar is sneller wanneer er weinig werk te doen is (ongeveer 1ms op een OpenMV Cam). Een expliciete aanroep kan die vertraging minimaliseren terwijl wordt gegarandeerd dat deze plaatsvindt op punten in het programma waar dit acceptabel is.
Automatische GC wordt onder de volgende omstandigheden uitgelokt. Wanneer een poging tot allocatie mislukt, wordt een GC uitgevoerd en de allocatie opnieuw geprobeerd. Alleen als dit mislukt, wordt een uitzondering opgeworpen. Ten tweede wordt een automatische GC geactiveerd als de hoeveelheid vrije RAM onder een drempelwaarde daalt. Deze drempelwaarde kan worden aangepast naarmate de uitvoering vordert:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Dit zal een GC uitlokken wanneer meer dan 25% van de momenteel vrije heap bezet raakt.
In het algemeen moeten modules data-objecten tijdens runtime instantiëren met behulp van constructors of andere initialisatiefuncties. De reden is dat als dit bij initialisatie gebeurt, de compiler mogelijk RAM tekortkomt wanneer volgende modules worden geïmporteerd. Als modules wel data bij import instantiëren, dan zal een gc.collect() die na de import wordt uitgevoerd het probleem verlichten.
String-bewerkingen¶
MicroPython behandelt strings op een efficiënte manier en het begrijpen hiervan kan helpen bij het ontwerpen van applicaties die op microcontrollers draaien. Wanneer een module wordt gecompileerd, worden strings die meerdere keren voorkomen slechts één keer opgeslagen, een proces dat bekendstaat als string interning. In MicroPython staat een interned string bekend als een qstr. In een normaal geïmporteerde module bevindt die enkele instantie zich in RAM, maar zoals hierboven beschreven, bevindt deze zich in flash in modules die als bytecode zijn bevroren.
Stringvergelijkingen worden ook efficiënt uitgevoerd met behulp van hashing in plaats van teken voor teken. De prijs voor het gebruiken van strings in plaats van gehele getallen kan dus klein zijn, zowel wat betreft prestaties als RAM-gebruik - een feit dat voor C-programmeurs als een verrassing kan komen.
Naschrift¶
MicroPython geeft objecten door, retourneert ze en kopieert ze (standaard) per verwijzing. Een verwijzing neemt één enkel machinewoord in beslag, dus deze processen zijn efficiënt wat betreft RAM-gebruik en snelheid.
Waar variabelen nodig zijn waarvan de grootte noch een byte noch een machinewoord is, zijn er standaardbibliotheken die kunnen helpen bij het efficiënt opslaan hiervan en bij het uitvoeren van conversies. Zie de modules array, struct en uctypes.
Voetnoot: gc.collect() retourwaarde¶
Op Unix- en Windows-platformen retourneert de methode gc.collect() een geheel getal dat het aantal afzonderlijke geheugenregio’s aangeeft dat bij de collectie is teruggewonnen (preciezer gezegd, het aantal heads dat in frees is omgezet). Om efficiëntieredenen retourneren bare-metal ports deze waarde niet.