L’internement de chaînes de MicroPython

MicroPython utilise l”string interning pour économiser à la fois de la RAM et de la ROM. Cela évite d’avoir à stocker des copies en double de la même chaîne. Cela s’applique principalement aux identifiants de votre code, car un élément tel qu’un nom de fonction ou de variable est très susceptible d’apparaître à plusieurs endroits du code. Dans MicroPython, une chaîne internée est appelée un QSTR (uniQue STRing).

Une valeur QSTR (de type qstr) est un index dans une liste chaînée de pools de QSTR. Les QSTR stockent leur longueur et un hash de leur contenu pour une comparaison rapide lors du processus de déduplication. Toutes les opérations de bytecode qui manipulent des chaînes utilisent un argument QSTR.

Génération des QSTR à la compilation

Dans le code C de MicroPython, toutes les chaînes qui doivent être internées dans le micrologiciel final sont écrites sous la forme MP_QSTR_Foo. À la compilation, cela donnera une valeur qstr qui pointe vers l’index de "Foo" dans le pool de QSTR.

Un processus en plusieurs étapes dans le Makefile permet d’obtenir ce résultat. En résumé, ce processus comporte trois parties :

  1. Trouver tous les jetons MP_QSTR_Foo dans le code.

  2. Générer un pool de QSTR statique contenant toutes les données des chaînes (y compris les longueurs et les hash).

  3. Remplacer tous les MP_QSTR_Foo (via le préprocesseur) par leur index correspondant.

Les jetons MP_QSTR_Foo sont recherchés dans deux sources :

  1. Tous les fichiers référencés dans $(SRC_QSTR). Il s’agit de tout le code C (c.-à-d. py, extmod, ports/stm32) mais sans inclure le code tiers tel que lib.

  2. Les $(QSTR_GLOBAL_DEPENDENCIES) supplémentaires (qui incluent mpconfig*.h).

Remarque : frozen_mpy.c (généré par mpy-tool.py) possède sa propre génération et son propre pool de QSTR.

Certaines chaînes supplémentaires qui ne peuvent pas être exprimées avec la syntaxe MP_QSTR_Foo (par exemple parce qu’elles contiennent des caractères non alphanumériques) sont fournies explicitement dans qstrdefs.h et qstrdefsport.h via la variable $(QSTR_DEFS).

Le traitement se déroule dans les étapes suivantes :

  1. qstr.i.last est le résultat de la concaténation obtenue en passant chacun des fichiers d’entrée dans le préprocesseur C. Cela signifie que tout code désactivé conditionnellement sera supprimé et que les macros seront expansées. Cela signifie que nous n’ajoutons pas au pool des chaînes qui ne seront pas utilisées dans le micrologiciel final. Parce qu’à ce stade (grâce à la macro NO_QSTR ajoutée par QSTR_GEN_CFLAGS) il n’existe aucune définition pour MP_QSTR_Foo, celui-ci traverse cette étape sans être modifié. Ce fichier inclut également des commentaires du préprocesseur qui contiennent des informations sur les numéros de ligne. Notez que cette étape n’utilise que les fichiers qui ont changé, ce qui signifie que qstr.i.last ne contiendra que les données des fichiers ayant changé depuis la dernière compilation.

  2. qstr.split est un fichier vide créé après l’exécution de makeqstrdefs.py split sur qstr.i.last. Il sert simplement de dépendance pour indiquer que l’étape s’est exécutée. Ce script produit un fichier par fichier C d’entrée, genhdr/qstr/...file.c.qstr, qui ne contient que les QSTR correspondants. Chaque QSTR est imprimé sous la forme Q(Foo). Cette étape est nécessaire pour combiner les fichiers existants avec les nouvelles données générées par la mise à jour incrémentale dans qstr.i.last.

  3. qstrdefs.collected.h est le résultat de la concaténation de genhdr/qstr/* à l’aide de makeqstrdefs.py cat. Il s’agit désormais de l’ensemble complet des MP_QSTR_Foo trouvés dans le code, maintenant formatés sous la forme Q(Foo), un par ligne, avec les doublons. Ce fichier n’est mis à jour que si l’ensemble des qstr a changé. Un hash des données QSTR est écrit dans un autre fichier (qstrdefs.collected.h.hash) qui permet de suivre les changements d’une compilation à l’autre.

  4. Génère une énumération, dont chaque entrée associe un MP_QSTR_Foo à son index correspondant. Elle concatène qstrdefs.collected.h avec qstrdefs*.h, puis transforme chaque ligne de Q(Foo) en "Q(Foo)" afin qu’elles traversent le préprocesseur sans modification. Le préprocesseur est ensuite utilisé pour gérer toute compilation conditionnelle dans qstrdefs*.h. La transformation est ensuite annulée pour revenir à Q(Foo), et le résultat est enregistré sous qstrdefs.preprocessed.h.

  5. qstrdefs.generated.h est le résultat de makeqstrdata.py. Pour chaque Q(Foo) dans qstrdefs.preprocessed.h (plus quelques entrées supplémentaires codées en dur), il produit QDEF(MP_QSTR_Foo, (const byte*)"hash" "Foo").

Ensuite, lors de la compilation principale, deux choses se produisent avec qstrdefs.generated.h :

  1. Dans qstr.h, chaque QDEF devient une entrée d’un enum, ce qui rend MP_QSTR_Foo disponible pour le code et égal à l’index de cette chaîne dans la table QSTR.

  2. Dans qstr.c, la table de données QSTR réelle est générée sous forme d’éléments de mp_qstr_const_pool->qstrs.

Génération des QSTR à l’exécution

Des pools de QSTR supplémentaires peuvent être créés à l’exécution afin que des chaînes puissent y être ajoutées. Par exemple, le code suivant

foo[x] = 3

Devra créer un QSTR pour la valeur de x afin qu’elle puisse être utilisée par le bytecode « load attr ».

De plus, lors de la compilation du code Python, des QSTR doivent être créés pour les identifiants et les littéraux. Remarque : seuls les littéraux de moins de 10 caractères deviennent des QSTR. Cela s’explique par le fait qu’une chaîne ordinaire sur le tas occupe toujours au minimum 16 octets (un bloc GC), tandis que les QSTR permettent de les empaqueter plus efficacement dans le pool.

Les pools de QSTR (et les « chunks » sous-jacents qui stockent les données des chaînes) sont alloués à la demande sur le tas avec une taille minimale.