MicroPython na mikrokontrolerach¶
MicroPython został zaprojektowany tak, aby mógł działać na mikrokontrolerach. Mają one ograniczenia sprzętowe, które mogą być nieznane programistom przyzwyczajonym do konwencjonalnych komputerów. W szczególności ilość pamięci RAM oraz nieulotnej pamięci masowej „dyskowej” (pamięci flash) jest ograniczona. Ten poradnik przedstawia sposoby na jak najlepsze wykorzystanie ograniczonych zasobów. Ponieważ MicroPython działa na kontrolerach opartych na różnorodnych architekturach, przedstawione metody mają charakter ogólny: w niektórych przypadkach konieczne będzie uzyskanie szczegółowych informacji z dokumentacji dla danej platformy.
Pamięć flash¶
W kamerach OpenMV Cam prostym sposobem na poradzenie sobie z ograniczoną pojemnością jest zamontowanie karty micro SD. W niektórych przypadkach jest to niepraktyczne, ponieważ urządzenie nie ma gniazda karty SD albo ze względu na koszt lub zużycie energii; dlatego trzeba użyć wbudowanej pamięci flash. Oprogramowanie układowe, w tym podsystem MicroPython, jest przechowywane we wbudowanej pamięci flash. Pozostała pojemność jest dostępna do użytku. Z powodów związanych z fizyczną architekturą pamięci flash część tej pojemności może być niedostępna jako system plików. W takich przypadkach przestrzeń tę można wykorzystać poprzez włączenie modułów użytkownika do kompilacji oprogramowania układowego, które następnie jest wgrywane do urządzenia.
Można to osiągnąć na dwa sposoby: za pomocą zamrożonych modułów oraz zamrożonego kodu bajtowego. Zamrożone moduły przechowują kod źródłowy Pythona wraz z oprogramowaniem układowym. Zamrożony kod bajtowy wykorzystuje kompilator skrośny do przekształcenia kodu źródłowego w kod bajtowy, który jest następnie przechowywany wraz z oprogramowaniem układowym. W obu przypadkach do modułu można uzyskać dostęp za pomocą instrukcji import:
import mymodule
Procedura tworzenia zamrożonych modułów i kodu bajtowego zależy od platformy; instrukcje budowania oprogramowania układowego można znaleźć w plikach README w odpowiedniej części drzewa źródłowego.
Ogólnie rzecz biorąc, kroki są następujące:
Sklonuj repozytorium MicroPython.
Pozyskaj (zależny od platformy) łańcuch narzędziowy do budowy oprogramowania układowego.
Zbuduj kompilator skrośny.
Umieść moduły, które mają zostać zamrożone, w określonym katalogu (w zależności od tego, czy moduł ma być zamrożony jako kod źródłowy, czy jako kod bajtowy).
Zbuduj oprogramowanie układowe. Do zbudowania zamrożonego kodu obu typów może być wymagane konkretne polecenie - zobacz dokumentację platformy.
Wgraj oprogramowanie układowe do urządzenia.
RAM¶
Przy ograniczaniu zużycia pamięci RAM należy wziąć pod uwagę dwie fazy: kompilacji i wykonania. Oprócz zużycia pamięci istnieje również problem znany jako fragmentacja sterty. Ogólnie rzecz biorąc, najlepiej jest minimalizować wielokrotne tworzenie i niszczenie obiektów. Powód tego jest omówiony w sekcji dotyczącej heap.
Faza kompilacji¶
Gdy moduł jest importowany, MicroPython kompiluje kod do kodu bajtowego, który jest następnie wykonywany przez maszynę wirtualną MicroPython (VM). Kod bajtowy jest przechowywany w pamięci RAM. Sam kompilator wymaga pamięci RAM, ale staje się ona dostępna do użytku po zakończeniu kompilacji.
Jeśli zaimportowano już pewną liczbę modułów, może wystąpić sytuacja, w której brakuje pamięci RAM do uruchomienia kompilatora. W takim przypadku instrukcja import wygeneruje wyjątek pamięci.
Jeśli moduł tworzy obiekty globalne podczas importu, zużyje pamięć RAM w momencie importu, która następnie jest niedostępna dla kompilatora przy kolejnych importach. Ogólnie najlepiej jest unikać kodu uruchamianego podczas importu; lepszym podejściem jest posiadanie kodu inicjalizującego, który jest uruchamiany przez aplikację po zaimportowaniu wszystkich modułów. Maksymalizuje to ilość pamięci RAM dostępnej dla kompilatora.
Jeśli pamięć RAM nadal jest niewystarczająca do skompilowania wszystkich modułów, jednym z rozwiązań jest wstępna kompilacja modułów. MicroPython posiada kompilator skrośny zdolny do kompilowania modułów Pythona do kodu bajtowego (zobacz plik README w katalogu mpy-cross). Powstały plik kodu bajtowego ma rozszerzenie .mpy; można go skopiować do systemu plików i zaimportować w zwykły sposób. Alternatywnie niektóre lub wszystkie moduły mogą być zaimplementowane jako zamrożony kod bajtowy: na większości platform pozwala to zaoszczędzić jeszcze więcej pamięci RAM, ponieważ kod bajtowy jest uruchamiany bezpośrednio z pamięci flash, zamiast być przechowywanym w pamięci RAM.
Faza wykonania¶
Istnieje wiele technik kodowania pozwalających ograniczyć zużycie pamięci RAM.
Stałe
MicroPython udostępnia słowo kluczowe const, którego można używać w następujący sposób:
from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS
W obu przypadkach, gdy stała jest przypisana do zmiennej, kompilator uniknie zakodowania odwołania do nazwy stałej, podstawiając jej wartość literalną. Oszczędza to kod bajtowy, a tym samym pamięć RAM. Jednak wartość ROWS zajmie co najmniej dwa słowa maszynowe, po jednym dla klucza i wartości w słowniku globalnym. Obecność w słowniku jest konieczna, ponieważ inny moduł może go zaimportować lub użyć. Tę pamięć RAM można zaoszczędzić, poprzedzając nazwę podkreśleniem, jak w _COLS: ten symbol nie jest widoczny poza modułem, więc nie zajmie pamięci RAM.
Argument funkcji const() może być czymkolwiek, co w czasie kompilacji daje w wyniku stałą, np. 0x100, 1 << 8 lub (True, "string", b"bytes") (szczegóły w sekcji poniżej). Może nawet zawierać inne symbole const, które zostały już zdefiniowane, np. 1 << BIT.
Stałe struktury danych
Gdy istnieje znaczna ilość stałych danych, a platforma obsługuje wykonywanie z pamięci flash, pamięć RAM można zaoszczędzić w następujący sposób. Dane powinny być umieszczone w modułach Pythona i zamrożone jako kod bajtowy. Dane muszą być zdefiniowane jako obiekty bytes. Kompilator „wie”, że obiekty bytes są niezmienne, i zapewnia, że obiekty pozostają w pamięci flash, zamiast być kopiowane do pamięci RAM. Moduł struct może pomóc w konwersji między typami bytes a innymi wbudowanymi typami Pythona.
Rozważając implikacje zamrożonego kodu bajtowego, należy zauważyć, że w Pythonie łańcuchy znaków, liczby zmiennoprzecinkowe, bajty, liczby całkowite, liczby zespolone i krotki są niezmienne. W związku z tym zostaną one zamrożone w pamięci flash (w przypadku krotek tylko wtedy, gdy wszystkie ich elementy są niezmienne). Tak więc w wierszu
mystring = "The quick brown fox"
rzeczywisty łańcuch „The quick brown fox” będzie znajdował się w pamięci flash. W czasie wykonania odwołanie do tego łańcucha jest przypisywane do zmiennej mystring. Odwołanie zajmuje pojedyncze słowo maszynowe. Zasadniczo do przechowywania stałych danych można użyć długiej liczby całkowitej:
bar = 0xDEADBEEF0000DEADBEEF
Podobnie jak w przykładzie z łańcuchem znaków, w czasie wykonania odwołanie do dowolnie dużej liczby całkowitej jest przypisywane do zmiennej bar. To odwołanie zajmuje pojedyncze słowo maszynowe.
Krotki stałych obiektów same są stałe. Takie stałe krotki są optymalizowane przez kompilator, więc nie muszą być tworzone w czasie wykonania za każdym razem, gdy są używane. Na przykład:
foo = (1, 2, 3, 4, 5, 6, 100000, ("string", b"bytes", False, True))
Cała ta krotka będzie istnieć jako pojedynczy obiekt (potencjalnie w pamięci flash, jeśli kod jest zamrożony) i będzie wskazywana za każdym razem, gdy jest potrzebna.
Niepotrzebne tworzenie obiektów
Istnieje wiele sytuacji, w których obiekty mogą być nieświadomie tworzone i niszczone. Może to zmniejszać użyteczność pamięci RAM z powodu fragmentacji. W kolejnych sekcjach omówiono przykłady takich sytuacji.
Konkatenacja łańcuchów znaków
Rozważ następujące fragmenty kodu, których celem jest utworzenie stałych łańcuchów znaków:
var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""
Każdy z nich daje ten sam wynik, jednak pierwszy niepotrzebnie tworzy dwa obiekty łańcuchowe w czasie wykonania, alokując więcej pamięci RAM na konkatenację przed utworzeniem trzeciego. Pozostałe wykonują konkatenację w czasie kompilacji, co jest wydajniejsze i ogranicza fragmentację.
Gdy łańcuchy znaków muszą być tworzone dynamicznie przed przekazaniem ich do strumienia, takiego jak plik, można zaoszczędzić pamięć RAM, robiąc to fragmentami. Zamiast tworzyć duży obiekt łańcuchowy, utwórz podłańcuch i przekaż go do strumienia, zanim zajmiesz się kolejnym.
Najlepszym sposobem tworzenia dynamicznych łańcuchów znaków jest użycie metody format() łańcucha:
var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)
Bufory
Podczas dostępu do urządzeń, takich jak instancje interfejsów UART, I2C i SPI, użycie wstępnie zaalokowanych buforów pozwala uniknąć tworzenia niepotrzebnych obiektów. Rozważ te dwie pętle:
while True:
var = spi.read(100)
# process data
buf = bytearray(100)
while True:
spi.readinto(buf)
# process data in buf
Pierwsza tworzy bufor przy każdym przebiegu, podczas gdy druga ponownie wykorzystuje wstępnie zaalokowany bufor; jest to zarówno szybsze, jak i wydajniejsze pod względem fragmentacji pamięci.
Bajty są mniejsze niż liczby całkowite
Na większości platform liczba całkowita zajmuje cztery bajty. Rozważ trzy wywołania funkcji foo():
def foo(bar):
for x in bar:
print(x)
foo([1, 2, 0xff])
foo((1, 2, 0xff))
foo(b'\1\2\xff')
W pierwszym wywołaniu list liczb całkowitych jest tworzona w pamięci RAM za każdym razem, gdy kod jest wykonywany. Drugie wywołanie tworzy stały obiekt tuple (krotkę tuple zawierającą wyłącznie stałe obiekty) jako część fazy kompilacji, więc jest tworzony tylko raz i jest wydajniejszy niż list. Trzecie wywołanie wydajnie tworzy obiekt bytes, zużywając minimalną ilość pamięci RAM. Gdyby moduł został zamrożony jako kod bajtowy, zarówno obiekt tuple, jak i bytes znajdowałyby się w pamięci flash.
Łańcuchy znaków a bajty
Python3 wprowadził obsługę Unicode. Wprowadziło to rozróżnienie między łańcuchem znaków a tablicą bajtów. MicroPython zapewnia, że łańcuchy Unicode nie zajmują dodatkowego miejsca, o ile wszystkie znaki w łańcuchu są znakami ASCII (tj. mają wartość < 128). Jeśli wymagane są wartości z pełnego zakresu 8-bitowego, można użyć obiektów bytes i bytearray, aby zapewnić, że nie będzie potrzebne dodatkowe miejsce. Należy zauważyć, że większość metod łańcuchowych (np. str.strip()) dotyczy również instancji bytes, więc proces eliminacji Unicode może być bezbolesny.
s = 'the quick brown fox' # A string instance
b = b'the quick brown fox' # A bytes instance
Gdy konieczna jest konwersja między łańcuchami znaków a bajtami, można użyć metod str.encode() i bytes.decode(). Należy zauważyć, że zarówno łańcuchy znaków, jak i bajty są niezmienne. Każda operacja, która przyjmuje na wejściu taki obiekt i tworzy kolejny, oznacza co najmniej jedną alokację pamięci RAM w celu utworzenia wyniku. W drugim wierszu poniżej alokowany jest nowy obiekt bytes. Wystąpiłoby to również, gdyby foo był łańcuchem znaków.
foo = b' empty whitespace'
foo = foo.lstrip()
Wykonywanie kompilatora w czasie wykonania
Funkcje Pythona eval i exec wywołują kompilator w czasie wykonania, co wymaga znacznych ilości pamięci RAM. Należy zauważyć, że biblioteka pickle z micropython-lib wykorzystuje exec. Pod względem zużycia pamięci RAM bardziej wydajne może być użycie biblioteki json do serializacji obiektów.
Przechowywanie łańcuchów znaków w pamięci flash
Łańcuchy znaków Pythona są niezmienne, dlatego potencjalnie mogą być przechowywane w pamięci tylko do odczytu. Kompilator może umieścić w pamięci flash łańcuchy zdefiniowane w kodzie Pythona. Podobnie jak w przypadku zamrożonych modułów, konieczne jest posiadanie kopii drzewa źródłowego na komputerze oraz łańcucha narzędziowego do budowy oprogramowania układowego. Procedura zadziała nawet wtedy, gdy moduły nie zostały w pełni zdebugowane, o ile można je zaimportować i uruchomić.
Po zaimportowaniu modułów wykonaj:
micropython.qstr_info(1)
Następnie skopiuj i wklej wszystkie wiersze Q(xxx) do edytora tekstu. Sprawdź i usuń wiersze, które są ewidentnie nieprawidłowe. Otwórz plik qstrdefsport.h, który znajdziesz w ports/stm32 (lub w równoważnym katalogu dla używanej architektury). Skopiuj i wklej poprawione wiersze na końcu pliku. Zapisz plik, ponownie zbuduj i wgraj oprogramowanie układowe. Wynik można sprawdzić, importując moduły i ponownie wykonując:
micropython.qstr_info(1)
Wiersze Q(xxx) powinny zniknąć.
Sterta¶
Gdy działający program tworzy obiekt, niezbędna pamięć RAM jest alokowana z puli o stałym rozmiarze zwanej stertą. Gdy obiekt wychodzi poza zakres (innymi słowy staje się niedostępny dla kodu), zbędny obiekt nazywany jest „śmieciem”. Proces znany jako „odśmiecanie pamięci” (GC) odzyskuje tę pamięć, zwracając ją do wolnej sterty. Proces ten działa automatycznie, można go jednak wywołać bezpośrednio za pomocą gc.collect().
Omówienie tego tematu jest dość złożone. Aby uzyskać szybkie rozwiązanie, wykonuj okresowo następujące polecenie:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Więcej informacji znajdziesz poniżej oraz w dokumentacji wbudowanego modułu gc.
Szczegóły z perspektywy wewnętrznych mechanizmów MicroPython / dewelopera znajdziesz również w Zarządzanie pamięcią.
Fragmentacja¶
Powiedzmy, że program tworzy obiekt foo, a następnie obiekt bar. Później foo wychodzi poza zakres, ale bar pozostaje. Pamięć RAM używana przez foo zostanie odzyskana przez GC. Jednak jeśli bar został zaalokowany pod wyższym adresem, pamięć RAM odzyskana z foo będzie przydatna tylko dla obiektów nie większych niż foo. W złożonym lub długo działającym programie sterta może ulec fragmentacji: mimo że dostępna jest znaczna ilość pamięci RAM, brakuje wystarczająco ciągłej przestrzeni do zaalokowania określonego obiektu, a program kończy się błędem pamięci.
Opisane powyżej techniki mają na celu zminimalizowanie tego problemu. Gdy wymagane są duże trwałe bufory lub inne obiekty, najlepiej tworzyć je wcześnie w procesie wykonywania programu, zanim może dojść do fragmentacji. Dalsze usprawnienia można uzyskać, monitorując stan sterty i kontrolując GC; są one opisane poniżej.
Raportowanie¶
Dostępnych jest wiele funkcji bibliotecznych do raportowania alokacji pamięci i kontrolowania GC. Można je znaleźć w modułach gc i micropython. Poniższy przykład można wkleić w REPL (Ctrl-E, aby przejść do trybu wklejania, Ctrl-D, aby go uruchomić).
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)
Metody zastosowane powyżej:
gc.collect()Wymusza odśmiecanie pamięci. Zobacz przypis.micropython.mem_info()Wyświetla podsumowanie wykorzystania pamięci RAM.gc.mem_free()Zwraca rozmiar wolnej sterty w bajtach.gc.mem_alloc()Zwraca liczbę aktualnie zaalokowanych bajtów.micropython.mem_info(1)Wyświetla tabelę wykorzystania sterty (szczegóły poniżej).
Otrzymywane liczby zależą od platformy, ale można zauważyć, że zadeklarowanie funkcji zużywa niewielką ilość pamięci RAM w postaci kodu bajtowego wyemitowanego przez kompilator (pamięć RAM użyta przez kompilator została odzyskana). Uruchomienie funkcji zużywa ponad 10KiB, ale po powrocie a jest śmieciem, ponieważ jest poza zakresem i nie można się do niej odwołać. Końcowe gc.collect() odzyskuje tę pamięć.
Końcowy wynik tworzony przez micropython.mem_info(1) będzie się różnić w szczegółach, ale można go interpretować w następujący sposób:
Symbol |
Znaczenie |
|---|---|
. |
wolny blok |
h |
blok główny |
= |
blok ogonowy |
m |
oznaczony blok główny |
T |
krotka |
L |
lista |
D |
słownik |
F |
liczba zmiennoprzecinkowa |
B |
kod bajtowy |
M |
moduł |
S |
łańcuch znaków lub bajty |
A |
bytearray |
Każda litera reprezentuje pojedynczy blok pamięci, przy czym blok ma 16 bajtów. Tak więc każdy wiersz zrzutu sterty reprezentuje 0x400 bajtów, czyli 1KiB pamięci RAM.
Kontrola odśmiecania pamięci¶
GC można zażądać w dowolnym momencie za pomocą gc.collect(). Korzystne jest robienie tego w odstępach czasu, po pierwsze, aby zapobiec fragmentacji, a po drugie, ze względu na wydajność. GC może zająć kilka milisekund, ale jest szybsze, gdy jest niewiele pracy do wykonania (około 1ms na kamerze OpenMV Cam). Jawne wywołanie może zminimalizować to opóźnienie, jednocześnie zapewniając, że nastąpi ono w punktach programu, w których jest to akceptowalne.
Automatyczne GC jest wywoływane w następujących okolicznościach. Gdy próba alokacji nie powiedzie się, wykonywane jest GC, a alokacja jest ponawiana. Tylko jeśli to się nie powiedzie, zgłaszany jest wyjątek. Po drugie, automatyczne GC zostanie wyzwolone, jeśli ilość wolnej pamięci RAM spadnie poniżej progu. Próg ten można dostosowywać w miarę postępu wykonania:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Wywoła to GC, gdy zajętych zostanie więcej niż 25% aktualnie wolnej sterty.
Ogólnie moduły powinny tworzyć obiekty danych w czasie wykonania za pomocą konstruktorów lub innych funkcji inicjalizujących. Powodem jest to, że jeśli nastąpi to podczas inicjalizacji, kompilator może zostać pozbawiony pamięci RAM podczas importowania kolejnych modułów. Jeśli moduły jednak tworzą dane podczas importu, wówczas gc.collect() wywołane po imporcie złagodzi ten problem.
Operacje na łańcuchach znaków¶
MicroPython obsługuje łańcuchy znaków w wydajny sposób, a zrozumienie tego może pomóc w projektowaniu aplikacji działających na mikrokontrolerach. Gdy moduł jest kompilowany, łańcuchy znaków występujące wielokrotnie są przechowywane tylko raz, w procesie zwanym internowaniem łańcuchów. W MicroPython internowany łańcuch znaków jest nazywany qstr. W module importowanym normalnie ta pojedyncza instancja będzie znajdować się w pamięci RAM, ale jak opisano powyżej, w modułach zamrożonych jako kod bajtowy będzie znajdować się w pamięci flash.
Porównania łańcuchów znaków są również wykonywane wydajnie przy użyciu haszowania, a nie znak po znaku. Kara za używanie łańcuchów znaków zamiast liczb całkowitych może być zatem niewielka zarówno pod względem wydajności, jak i zużycia pamięci RAM - fakt, który może zaskoczyć programistów języka C.
Postscriptum¶
MicroPython przekazuje, zwraca i (domyślnie) kopiuje obiekty przez odwołanie. Odwołanie zajmuje pojedyncze słowo maszynowe, więc procesy te są wydajne pod względem zużycia pamięci RAM i szybkości.
Gdy wymagane są zmienne, których rozmiar nie jest ani bajtem, ani słowem maszynowym, istnieją standardowe biblioteki, które mogą pomóc w wydajnym ich przechowywaniu i wykonywaniu konwersji. Zobacz moduły array, struct i uctypes.
Przypis: wartość zwracana przez gc.collect()¶
Na platformach Unix i Windows metoda gc.collect() zwraca liczbę całkowitą, która oznacza liczbę odrębnych regionów pamięci odzyskanych podczas odśmiecania (a dokładniej liczbę nagłówków, które zostały zamienione na wolne bloki). Ze względów wydajnościowych porty bare metal nie zwracają tej wartości.