Internowanie łańcuchów znaków w MicroPython

MicroPython używa string interning, aby oszczędzać zarówno pamięć RAM, jak i ROM. Pozwala to uniknąć przechowywania duplikatów tego samego łańcucha znaków. Dotyczy to przede wszystkim identyfikatorów w Twoim kodzie, ponieważ coś takiego jak nazwa funkcji lub zmiennej z dużym prawdopodobieństwem pojawia się w wielu miejscach w kodzie. W MicroPython internowany łańcuch znaków nazywany jest QSTR (uniQue STRing).

Wartość QSTR (typu qstr) jest indeksem w liście powiązanej pul QSTR. QSTR przechowują swoją długość oraz hash swojej zawartości w celu szybkiego porównywania podczas procesu usuwania duplikatów. Wszystkie operacje na kodzie bajtowym, które działają na łańcuchach znaków, używają argumentu QSTR.

Generowanie QSTR w czasie kompilacji

W kodzie C MicroPython wszystkie łańcuchy znaków, które powinny być internowane w końcowym oprogramowaniu układowym, są zapisywane jako MP_QSTR_Foo. W czasie kompilacji zostanie to obliczone do wartości qstr, która wskazuje na indeks "Foo" w puli QSTR.

Wieloetapowy proces w pliku Makefile sprawia, że to działa. W skrócie proces ten składa się z trzech części:

  1. Znalezienie wszystkich tokenów MP_QSTR_Foo w kodzie.

  2. Wygenerowanie statycznej puli QSTR zawierającej wszystkie dane łańcuchów znaków (w tym długości i hashe).

  3. Zastąpienie wszystkich MP_QSTR_Foo (za pomocą preprocesora) ich odpowiednimi indeksami.

Tokeny MP_QSTR_Foo są wyszukiwane w dwóch źródłach:

  1. Wszystkie pliki przywoływane w $(SRC_QSTR). Jest to cały kod C (tzn. py, extmod, ports/stm32), ale bez kodu firm trzecich, takiego jak lib.

  2. Dodatkowe $(QSTR_GLOBAL_DEPENDENCIES) (które obejmują mpconfig*.h).

Uwaga: frozen_mpy.c (generowany przez mpy-tool.py) ma własne generowanie QSTR oraz własną pulę.

Niektóre dodatkowe łańcuchy znaków, których nie da się wyrazić za pomocą składni MP_QSTR_Foo (np. zawierające znaki niealfanumeryczne), są podawane jawnie w plikach qstrdefs.h i qstrdefsport.h za pośrednictwem zmiennej $(QSTR_DEFS).

Przetwarzanie odbywa się w następujących etapach:

  1. qstr.i.last jest wynikiem konkatenacji wszystkich pojedynczych plików wejściowych przepuszczonych przez preprocesor C. Oznacza to, że cały kod warunkowo wyłączony zostanie usunięty, a makra rozwinięte. Dzięki temu nie dodajemy do puli łańcuchów znaków, które nie będą używane w końcowym oprogramowaniu układowym. Ponieważ na tym etapie (dzięki makru NO_QSTR dodanemu przez QSTR_GEN_CFLAGS) nie ma definicji dla MP_QSTR_Foo, przechodzi on przez ten etap bez zmian. Plik ten zawiera również komentarze pochodzące z preprocesora, które zawierają informacje o numerach wierszy. Należy zauważyć, że ten krok wykorzystuje tylko pliki, które uległy zmianie, co oznacza, że qstr.i.last będzie zawierać dane wyłącznie z plików zmienionych od ostatniej kompilacji.

  2. qstr.split jest pustym plikiem tworzonym po uruchomieniu makeqstrdefs.py split na qstr.i.last. Służy on po prostu jako zależność wskazująca, że dany krok został wykonany. Skrypt ten generuje jeden plik na każdy wejściowy plik C, genhdr/qstr/...file.c.qstr, który zawiera wyłącznie dopasowane QSTR. Każdy QSTR jest wypisywany jako Q(Foo). Ten krok jest niezbędny, aby połączyć istniejące pliki z nowymi danymi wygenerowanymi z przyrostowej aktualizacji w qstr.i.last.

  3. qstrdefs.collected.h jest wynikiem konkatenacji genhdr/qstr/* za pomocą makeqstrdefs.py cat. Jest to teraz pełny zbiór MP_QSTR_Foo znalezionych w kodzie, sformatowanych obecnie jako Q(Foo), po jednym w wierszu, z duplikatami. Plik ten jest aktualizowany tylko wtedy, gdy zbiór qstr uległ zmianie. Hash danych QSTR jest zapisywany w innym pliku (qstrdefs.collected.h.hash), co pozwala śledzić zmiany pomiędzy kolejnymi kompilacjami.

  4. Wygenerowanie enumeracji, której każdy wpis odwzorowuje MP_QSTR_Foo na odpowiadający mu indeks. Konkatenuje ona qstrdefs.collected.h z qstrdefs*.h, a następnie przekształca każdy wiersz z Q(Foo) na "Q(Foo)", tak aby przeszły przez preprocesor bez zmian. Następnie preprocesor jest używany do obsługi wszelkiej kompilacji warunkowej w qstrdefs*.h. Potem przekształcenie jest cofane z powrotem do Q(Foo) i zapisywane jako qstrdefs.preprocessed.h.

  5. qstrdefs.generated.h jest wynikiem działania makeqstrdata.py. Dla każdego Q(Foo) w qstrdefs.preprocessed.h (oraz kilku dodatkowych, zakodowanych na stałe) generuje on QDEF(MP_QSTR_Foo, (const byte*)"hash" "Foo").

Następnie w głównej kompilacji z qstrdefs.generated.h dzieją się dwie rzeczy:

  1. W qstr.h każdy QDEF staje się wpisem w enumeracji, co sprawia, że MP_QSTR_Foo jest dostępny dla kodu i równy indeksowi tego łańcucha znaków w tablicy QSTR.

  2. W qstr.c generowana jest właściwa tablica danych QSTR jako elementy mp_qstr_const_pool->qstrs.

Generowanie QSTR w czasie wykonywania

Dodatkowe pule QSTR mogą być tworzone w czasie wykonywania, tak aby można było dodawać do nich łańcuchy znaków. Na przykład kod:

foo[x] = 3

Będzie musiał utworzyć QSTR dla wartości x, aby mógł on zostać użyty przez kod bajtowy „load attr”.

Ponadto, podczas kompilacji kodu Python, dla identyfikatorów i literałów muszą zostać utworzone QSTR. Uwaga: tylko literały krótsze niż 10 znaków stają się QSTR. Dzieje się tak, ponieważ zwykły łańcuch znaków na stercie zawsze zajmuje co najmniej 16 bajtów (jeden blok GC), podczas gdy QSTR pozwalają na ich bardziej efektywne upakowanie w puli.

Pule QSTR (oraz leżące u ich podstaw „chunki” przechowujące dane łańcuchów znaków) są alokowane na żądanie na stercie z minimalnym rozmiarem.