MicroPython auf Mikrocontrollern¶
MicroPython ist darauf ausgelegt, auf Mikrocontrollern lauffähig zu sein. Diese unterliegen Hardwarebeschränkungen, die Programmierern, die eher mit herkömmlichen Computern vertraut sind, möglicherweise fremd sind. Insbesondere die Menge an RAM und nichtflüchtigem „Festplatten“-Speicher (Flash-Speicher) ist begrenzt. Dieses Tutorial bietet Möglichkeiten, das Beste aus den begrenzten Ressourcen herauszuholen. Da MicroPython auf Controllern mit unterschiedlichsten Architekturen läuft, sind die vorgestellten Methoden allgemein gehalten: in einigen Fällen ist es nötig, detaillierte Informationen aus der plattformspezifischen Dokumentation zu beziehen.
Flash-Speicher¶
Bei OpenMV Cams ist der einfachste Weg, der begrenzten Kapazität zu begegnen, der Einsatz einer Micro-SD-Karte. In manchen Fällen ist das unpraktisch, entweder weil das Gerät keinen SD-Kartensteckplatz besitzt oder aus Kosten- oder Stromverbrauchsgründen; daher muss der On-Chip-Flash genutzt werden. Die Firmware einschließlich des MicroPython-Subsystems ist im integrierten Flash gespeichert. Die verbleibende Kapazität steht zur Verfügung. Aus Gründen, die mit der physischen Architektur des Flash-Speichers zusammenhängen, kann ein Teil dieser Kapazität als Dateisystem unzugänglich sein. In solchen Fällen lässt sich dieser Speicherplatz nutzen, indem Benutzermodule in einen Firmware-Build eingebunden werden, der anschließend auf das Gerät geflasht wird.
Es gibt zwei Möglichkeiten, dies zu erreichen: eingefrorene Module (frozen modules) und eingefrorener Bytecode (frozen bytecode). Eingefrorene Module speichern den Python-Quellcode zusammen mit der Firmware. Eingefrorener Bytecode nutzt den Cross-Compiler, um den Quellcode in Bytecode umzuwandeln, der dann mit der Firmware gespeichert wird. In beiden Fällen kann auf das Modul mit einer import-Anweisung zugegriffen werden:
import mymodule
Das Verfahren zur Erzeugung eingefrorener Module und eingefrorenen Bytecodes ist plattformabhängig; Anweisungen zum Erstellen der Firmware finden sich in den README-Dateien im jeweiligen Bereich des Quellbaums.
Allgemein gesprochen sind die Schritte folgende:
Klonen Sie das MicroPython-Repository.
Beschaffen Sie sich die (plattformspezifische) Toolchain zum Erstellen der Firmware.
Erstellen Sie den Cross-Compiler.
Legen Sie die einzufrierenden Module in einem bestimmten Verzeichnis ab (abhängig davon, ob das Modul als Quellcode oder als Bytecode eingefroren werden soll).
Erstellen Sie die Firmware. Möglicherweise ist ein spezieller Befehl erforderlich, um eingefrorenen Code eines der beiden Typen zu erstellen - siehe die Plattformdokumentation.
Flashen Sie die Firmware auf das Gerät.
RAM¶
Bei der Reduzierung des RAM-Verbrauchs sind zwei Phasen zu berücksichtigen: Kompilierung und Ausführung. Neben dem Speicherverbrauch gibt es auch ein Problem, das als Heap-Fragmentierung bekannt ist. Allgemein gesprochen ist es am besten, das wiederholte Erstellen und Zerstören von Objekten zu minimieren. Der Grund dafür wird im Abschnitt über den heap behandelt.
Kompilierungsphase¶
Wenn ein Modul importiert wird, kompiliert MicroPython den Code zu Bytecode, der dann von der virtuellen Maschine (VM) von MicroPython ausgeführt wird. Der Bytecode wird im RAM gespeichert. Der Compiler selbst benötigt RAM, der jedoch nach Abschluss der Kompilierung wieder zur Verfügung steht.
Wenn bereits eine Reihe von Modulen importiert wurde, kann die Situation entstehen, dass nicht genügend RAM zum Ausführen des Compilers vorhanden ist. In diesem Fall erzeugt die import-Anweisung eine Speicher-Exception.
Wenn ein Modul beim Import globale Objekte instanziiert, verbraucht es zum Importzeitpunkt RAM, der dann dem Compiler bei nachfolgenden Importen nicht mehr zur Verfügung steht. Im Allgemeinen ist es am besten, Code zu vermeiden, der beim Import ausgeführt wird; ein besserer Ansatz ist es, Initialisierungscode zu haben, der von der Anwendung ausgeführt wird, nachdem alle Module importiert wurden. Dies maximiert den für den Compiler verfügbaren RAM.
Wenn der RAM immer noch nicht ausreicht, um alle Module zu kompilieren, besteht eine Lösung darin, Module vorzukompilieren. MicroPython verfügt über einen Cross-Compiler, der Python-Module zu Bytecode kompilieren kann (siehe die README im Verzeichnis mpy-cross). Die resultierende Bytecode-Datei hat die Endung .mpy; sie kann in das Dateisystem kopiert und auf die übliche Weise importiert werden. Alternativ können einige oder alle Module als eingefrorener Bytecode implementiert werden: auf den meisten Plattformen spart das noch mehr RAM, da der Bytecode direkt aus dem Flash ausgeführt wird, anstatt im RAM gespeichert zu werden.
Ausführungsphase¶
Es gibt eine Reihe von Programmiertechniken zur Reduzierung des RAM-Verbrauchs.
Konstanten
MicroPython stellt ein const-Schlüsselwort bereit, das wie folgt verwendet werden kann:
from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS
In beiden Fällen, in denen die Konstante einer Variablen zugewiesen wird, vermeidet der Compiler das Codieren einer Namensauflösung der Konstante, indem er ihren literalen Wert einsetzt. Dies spart Bytecode und damit RAM. Allerdings belegt der Wert ROWS mindestens zwei Maschinenwörter, jeweils eines für den Schlüssel und den Wert im globals-Dictionary. Das Vorhandensein im Dictionary ist notwendig, weil ein anderes Modul ihn importieren oder verwenden könnte. Dieser RAM kann eingespart werden, indem dem Namen ein Unterstrich vorangestellt wird wie bei _COLS: dieses Symbol ist außerhalb des Moduls nicht sichtbar und belegt daher keinen RAM.
Das Argument von const() kann alles sein, was zur Kompilierzeit zu einer Konstante ausgewertet wird, z. B. 0x100, 1 << 8 oder (True, "string", b"bytes") (Details siehe Abschnitt unten). Es kann sogar andere bereits definierte const-Symbole enthalten, z. B. 1 << BIT.
Konstante Datenstrukturen
Wenn ein erheblicher Umfang an konstanten Daten vorliegt und die Plattform die Ausführung aus dem Flash unterstützt, kann RAM wie folgt eingespart werden. Die Daten sollten in Python-Modulen abgelegt und als Bytecode eingefroren werden. Die Daten müssen als bytes-Objekte definiert werden. Der Compiler ‚weiß‘, dass bytes-Objekte unveränderlich sind, und stellt sicher, dass die Objekte im Flash-Speicher verbleiben, anstatt in den RAM kopiert zu werden. Das struct-Modul kann bei der Umwandlung zwischen bytes-Typen und anderen in Python eingebauten Typen helfen.
Bei der Betrachtung der Auswirkungen von eingefrorenem Bytecode ist zu beachten, dass in Python Strings, Floats, Bytes, Integer, komplexe Zahlen und Tupel unveränderlich sind. Entsprechend werden diese in den Flash eingefroren (bei Tupeln nur, wenn alle ihre Elemente unveränderlich sind). So wird in der Zeile
mystring = "The quick brown fox"
der eigentliche String „The quick brown fox“ im Flash residieren. Zur Laufzeit wird der Variablen mystring eine Referenz auf den String zugewiesen. Die Referenz belegt ein einzelnes Maschinenwort. Im Prinzip könnte ein Long-Integer verwendet werden, um konstante Daten zu speichern:
bar = 0xDEADBEEF0000DEADBEEF
Wie im String-Beispiel wird zur Laufzeit der Variablen bar eine Referenz auf die beliebig große Ganzzahl zugewiesen. Diese Referenz belegt ein einzelnes Maschinenwort.
Tupel aus konstanten Objekten sind selbst konstant. Solche konstanten Tupel werden vom Compiler optimiert, sodass sie nicht zur Laufzeit bei jeder Verwendung neu erstellt werden müssen. Zum Beispiel:
foo = (1, 2, 3, 4, 5, 6, 100000, ("string", b"bytes", False, True))
Dieses gesamte Tupel existiert als ein einziges Objekt (möglicherweise im Flash, wenn der Code eingefroren ist) und wird bei jedem Bedarf referenziert.
Unnötige Objekterstellung
Es gibt eine Reihe von Situationen, in denen Objekte unbeabsichtigt erstellt und zerstört werden können. Dies kann die Nutzbarkeit des RAM durch Fragmentierung verringern. Die folgenden Abschnitte behandeln Beispiele dafür.
String-Verkettung
Betrachten Sie die folgenden Codefragmente, die darauf abzielen, konstante Strings zu erzeugen:
var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""
Jedes erzeugt dasselbe Ergebnis, jedoch erstellt das erste unnötigerweise zur Laufzeit zwei String-Objekte und reserviert vor der Erzeugung des dritten mehr RAM für die Verkettung. Die anderen führen die Verkettung zur Kompilierzeit durch, was effizienter ist und die Fragmentierung verringert.
Wenn Strings dynamisch erstellt werden müssen, bevor sie einem Stream wie einer Datei zugeführt werden, spart es RAM, wenn dies stückweise geschieht. Anstatt ein großes String-Objekt zu erstellen, erstellen Sie einen Teilstring und führen ihn dem Stream zu, bevor Sie den nächsten bearbeiten.
Der beste Weg, dynamische Strings zu erstellen, ist mittels der String-Methode format():
var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)
Puffer
Beim Zugriff auf Geräte wie Instanzen von UART-, I2C- und SPI-Schnittstellen vermeidet die Verwendung vorab reservierter Puffer die Erstellung unnötiger Objekte. Betrachten Sie diese beiden Schleifen:
while True:
var = spi.read(100)
# process data
buf = bytearray(100)
while True:
spi.readinto(buf)
# process data in buf
Die erste erstellt bei jedem Durchlauf einen Puffer, während die zweite einen vorab reservierten Puffer wiederverwendet; dies ist sowohl schneller als auch effizienter in Bezug auf die Speicherfragmentierung.
Bytes sind kleiner als Ints
Auf den meisten Plattformen verbraucht eine Ganzzahl vier Bytes. Betrachten Sie die drei Aufrufe der Funktion foo():
def foo(bar):
for x in bar:
print(x)
foo([1, 2, 0xff])
foo((1, 2, 0xff))
foo(b'\1\2\xff')
Beim ersten Aufruf wird bei jeder Ausführung des Codes eine list von Ganzzahlen im RAM erstellt. Der zweite Aufruf erstellt als Teil der Kompilierungsphase ein konstantes tuple-Objekt (ein tuple, das nur konstante Objekte enthält), sodass es nur einmal erstellt wird und effizienter ist als die list. Der dritte Aufruf erstellt effizient ein bytes-Objekt, das die minimale Menge an RAM verbraucht. Wäre das Modul als Bytecode eingefroren, würden sowohl das tuple- als auch das bytes-Objekt im Flash residieren.
Strings versus Bytes
Python3 führte Unicode-Unterstützung ein. Dadurch entstand eine Unterscheidung zwischen einem String und einem Array von Bytes. MicroPython stellt sicher, dass Unicode-Strings keinen zusätzlichen Platz beanspruchen, solange alle Zeichen im String ASCII sind (d. h. einen Wert < 128 haben). Wenn Werte im vollen 8-Bit-Bereich benötigt werden, können bytes- und bytearray-Objekte verwendet werden, um sicherzustellen, dass kein zusätzlicher Platz erforderlich ist. Beachten Sie, dass die meisten String-Methoden (z. B. str.strip()) auch auf bytes-Instanzen anwendbar sind, sodass das Eliminieren von Unicode schmerzlos sein kann.
s = 'the quick brown fox' # A string instance
b = b'the quick brown fox' # A bytes instance
Wenn eine Umwandlung zwischen Strings und Bytes erforderlich ist, können die Methoden str.encode() und bytes.decode() verwendet werden. Beachten Sie, dass sowohl Strings als auch Bytes unveränderlich sind. Jede Operation, die ein solches Objekt als Eingabe nimmt und ein anderes erzeugt, impliziert mindestens eine RAM-Reservierung, um das Ergebnis zu erzeugen. In der zweiten Zeile unten wird ein neues bytes-Objekt reserviert. Dies würde auch geschehen, wenn foo ein String wäre.
foo = b' empty whitespace'
foo = foo.lstrip()
Compiler-Ausführung zur Laufzeit
Die Python-Funktionen eval und exec rufen den Compiler zur Laufzeit auf, was erhebliche Mengen an RAM erfordert. Beachten Sie, dass die pickle-Bibliothek aus micropython-lib exec verwendet. Es kann RAM-effizienter sein, die json-Bibliothek für die Objektserialisierung zu verwenden.
Strings im Flash speichern
Python-Strings sind unveränderlich und haben daher das Potenzial, im Nur-Lese-Speicher abgelegt zu werden. Der Compiler kann in Python-Code definierte Strings im Flash platzieren. Wie bei eingefrorenen Modulen ist es erforderlich, eine Kopie des Quellbaums auf dem PC und die Toolchain zum Erstellen der Firmware zu haben. Das Verfahren funktioniert auch dann, wenn die Module noch nicht vollständig debuggt wurden, solange sie importiert und ausgeführt werden können.
Führen Sie nach dem Importieren der Module aus:
micropython.qstr_info(1)
Kopieren Sie dann alle Q(xxx)-Zeilen und fügen Sie sie in einen Texteditor ein. Prüfen Sie auf offensichtlich ungültige Zeilen und entfernen Sie diese. Öffnen Sie die Datei qstrdefsport.h, die sich in ports/stm32 befindet (oder im entsprechenden Verzeichnis der verwendeten Architektur). Kopieren Sie die korrigierten Zeilen und fügen Sie sie am Ende der Datei ein. Speichern Sie die Datei, erstellen Sie die Firmware neu und flashen Sie sie. Das Ergebnis kann überprüft werden, indem die Module importiert und erneut ausgeführt wird:
micropython.qstr_info(1)
Die Q(xxx)-Zeilen sollten verschwunden sein.
Der Heap¶
Wenn ein laufendes Programm ein Objekt instanziiert, wird der erforderliche RAM aus einem Pool fester Größe reserviert, der als Heap bekannt ist. Wenn das Objekt seinen Gültigkeitsbereich verlässt (mit anderen Worten für Code unzugänglich wird), wird das überflüssige Objekt als „Müll“ (garbage) bezeichnet. Ein Prozess, der als „Garbage Collection“ (GC) bekannt ist, gibt diesen Speicher zurück und führt ihn dem freien Heap wieder zu. Dieser Prozess läuft automatisch ab, kann aber auch direkt durch Aufruf von gc.collect() angestoßen werden.
Die Ausführungen dazu sind etwas komplex. Für eine ‚schnelle Lösung‘ führen Sie periodisch Folgendes aus:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Weitere Informationen finden Sie unten und in der Dokumentation des eingebauten Moduls gc.
Für Details aus der Perspektive der MicroPython-Interna/Entwickler siehe auch Speicherverwaltung.
Fragmentierung¶
Angenommen, ein Programm erstellt ein Objekt foo, dann ein Objekt bar. Anschließend verlässt foo den Gültigkeitsbereich, während bar bestehen bleibt. Der von foo belegte RAM wird durch GC zurückgewonnen. Wurde bar jedoch an einer höheren Adresse reserviert, ist der von foo zurückgewonnene RAM nur für Objekte nutzbar, die nicht größer als foo sind. In einem komplexen oder lang laufenden Programm kann der Heap fragmentiert werden: Obwohl eine erhebliche Menge RAM verfügbar ist, gibt es nicht genügend zusammenhängenden Platz, um ein bestimmtes Objekt zu reservieren, und das Programm schlägt mit einem Speicherfehler fehl.
Die oben dargestellten Techniken zielen darauf ab, dies zu minimieren. Wenn große permanente Puffer oder andere Objekte benötigt werden, ist es am besten, diese früh im Verlauf der Programmausführung zu instanziieren, bevor Fragmentierung auftreten kann. Weitere Verbesserungen lassen sich durch Überwachung des Heap-Zustands und durch Steuerung der GC erzielen; diese werden unten dargestellt.
Berichterstattung¶
Es stehen eine Reihe von Bibliotheksfunktionen zur Verfügung, um über die Speicherreservierung zu berichten und die GC zu steuern. Diese finden sich in den Modulen gc und micropython. Das folgende Beispiel kann in der REPL eingefügt werden (Ctrl-E zum Aktivieren des Einfügemodus, Ctrl-D zum Ausführen).
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)
Oben verwendete Methoden:
gc.collect()Erzwingt eine Garbage Collection. Siehe Fußnote.micropython.mem_info()Gibt eine Zusammenfassung der RAM-Auslastung aus.gc.mem_free()Gibt die freie Heap-Größe in Bytes zurück.gc.mem_alloc()Gibt die Anzahl der aktuell reservierten Bytes zurück.micropython.mem_info(1)Gibt eine Tabelle der Heap-Auslastung aus (unten detailliert).
Die erzeugten Zahlen sind plattformabhängig, aber es ist zu erkennen, dass die Deklaration der Funktion eine kleine Menge RAM in Form des vom Compiler emittierten Bytecodes verbraucht (der vom Compiler verwendete RAM wurde zurückgewonnen). Die Ausführung der Funktion verbraucht über 10 KiB, aber bei der Rückkehr ist a Müll, weil es außerhalb des Gültigkeitsbereichs liegt und nicht referenziert werden kann. Der abschließende gc.collect() gibt diesen Speicher zurück.
Die abschließende Ausgabe, die von micropython.mem_info(1) erzeugt wird, variiert im Detail, kann aber wie folgt interpretiert werden:
Symbol |
Bedeutung |
|---|---|
. |
freier Block |
h |
Kopfblock (head block) |
= |
Schwanzblock (tail block) |
m |
markierter Kopfblock |
T |
Tupel |
L |
Liste |
D |
Dict |
F |
Float |
B |
Bytecode |
M |
Modul |
S |
String oder Bytes |
A |
Bytearray |
Jeder Buchstabe steht für einen einzelnen Speicherblock, wobei ein Block 16 Bytes umfasst. Jede Zeile des Heap-Dumps repräsentiert also 0x400 Bytes bzw. 1 KiB RAM.
Steuerung der Garbage Collection¶
Eine GC kann jederzeit durch Aufruf von gc.collect() angefordert werden. Es ist vorteilhaft, dies in Intervallen zu tun, erstens um Fragmentierung vorzubeugen und zweitens aus Performance-Gründen. Eine GC kann mehrere Millisekunden dauern, ist aber schneller, wenn wenig zu tun ist (etwa 1 ms auf einer OpenMV Cam). Ein expliziter Aufruf kann diese Verzögerung minimieren und gleichzeitig sicherstellen, dass sie an Stellen im Programm erfolgt, an denen sie akzeptabel ist.
Eine automatische GC wird unter folgenden Umständen ausgelöst. Wenn ein Reservierungsversuch fehlschlägt, wird eine GC durchgeführt und die Reservierung erneut versucht. Nur wenn auch dies fehlschlägt, wird eine Exception ausgelöst. Zweitens wird eine automatische GC ausgelöst, wenn die Menge an freiem RAM unter einen Schwellenwert fällt. Dieser Schwellenwert kann im Verlauf der Ausführung angepasst werden:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Dies löst eine GC aus, wenn mehr als 25 % des aktuell freien Heaps belegt sind.
Im Allgemeinen sollten Module Datenobjekte zur Laufzeit über Konstruktoren oder andere Initialisierungsfunktionen instanziieren. Der Grund ist, dass dem Compiler, wenn dies bei der Initialisierung geschieht, RAM fehlen könnte, wenn nachfolgende Module importiert werden. Wenn Module beim Import doch Daten instanziieren, dann lindert ein nach dem Import ausgeführtes gc.collect() das Problem.
String-Operationen¶
MicroPython behandelt Strings auf effiziente Weise, und das Verständnis davon kann beim Entwurf von Anwendungen helfen, die auf Mikrocontrollern laufen sollen. Wenn ein Modul kompiliert wird, werden Strings, die mehrfach vorkommen, nur einmal gespeichert, ein Prozess, der als String-Interning bekannt ist. In MicroPython wird ein internierter String als qstr bezeichnet. In einem normal importierten Modul befindet sich diese einzelne Instanz im RAM, aber wie oben beschrieben befindet sie sich in als Bytecode eingefrorenen Modulen im Flash.
Auch String-Vergleiche werden effizient durchgeführt, indem Hashing anstelle eines zeichenweisen Vergleichs verwendet wird. Der Nachteil der Verwendung von Strings anstelle von Ganzzahlen kann daher sowohl in Bezug auf Performance als auch RAM-Verbrauch gering sein - eine Tatsache, die C-Programmierer überraschen mag.
Nachschrift¶
MicroPython übergibt, gibt zurück und kopiert (standardmäßig) Objekte per Referenz. Eine Referenz belegt ein einzelnes Maschinenwort, sodass diese Vorgänge in Bezug auf RAM-Verbrauch und Geschwindigkeit effizient sind.
Wenn Variablen benötigt werden, deren Größe weder ein Byte noch ein Maschinenwort ist, gibt es Standardbibliotheken, die beim effizienten Speichern und bei der Durchführung von Umwandlungen helfen können. Siehe die Module array, struct und uctypes.
Fußnote: Rückgabewert von gc.collect()¶
Auf Unix- und Windows-Plattformen gibt die Methode gc.collect() eine Ganzzahl zurück, die die Anzahl der bei der Sammlung zurückgewonnenen, voneinander getrennten Speicherbereiche angibt (genauer gesagt die Anzahl der Heads, die in Frees umgewandelt wurden). Aus Effizienzgründen geben Bare-Metal-Ports diesen Wert nicht zurück.