Gestion de la mémoire

Contrairement aux langages de programmation tels que le C/C++, MicroPython masque les détails de la gestion de la mémoire au développeur en prenant en charge une gestion automatique de la mémoire. La gestion automatique de la mémoire est une technique utilisée par les systèmes d’exploitation ou les applications pour gérer automatiquement l’allocation et la libération de la mémoire. Cela élimine des difficultés telles que l’oubli de libérer la mémoire allouée à un objet. La gestion automatique de la mémoire évite également le problème critique consistant à utiliser de la mémoire déjà libérée. La gestion automatique de la mémoire prend de nombreuses formes, l’une d’elles étant le ramasse-miettes (GC, pour « garbage collection »).

Le ramasse-miettes a généralement deux responsabilités ;

  1. Allouer de nouveaux objets dans la mémoire disponible.

  2. Libérer la mémoire inutilisée.

Il existe de nombreux algorithmes de GC, mais MicroPython utilise la politique Mark and Sweep pour gérer la mémoire. Cet algorithme comporte une phase de marquage qui parcourt le tas en marquant tous les objets vivants, tandis que la phase de balayage parcourt le tas pour récupérer tous les objets non marqués.

La fonctionnalité de ramasse-miettes dans MicroPython est disponible via le module intégré gc :

>>> x = 5
>>> x
5
>>> import gc
>>> gc.enable()
>>> gc.mem_alloc()
1312
>>> gc.mem_free()
2071392
>>> gc.collect()
19
>>> gc.disable()
>>>

Même lorsque gc.disable() est invoqué, la collecte peut être déclenchée avec gc.collect().

La mémoire MicroPython depuis le code C

Une connaissance du ramasse-miettes est nécessaire lors de l’écriture de code C qui alloue de la mémoire à partir du « tas Python » (c’est-à-dire les fonctions m_malloc(), m_malloc0(), m_free(), etc.).

La phase de marquage du ramasse-miettes recherche les pointeurs vivants vers la mémoire du tas en partant des racines suivantes :

  • La pile de l’environnement d’exécution principal de Python (ou du REPL).

  • Les piles de chaque « thread Python », pour les ports qui implémentent les threads Python par-dessus les threads ou tâches natifs du système d’exploitation.

  • Les « pointeurs racines » définis dans le code C à l’aide de la macro MP_REGISTER_ROOT_POINTER. Il s’agit de la manière recommandée d’avoir des pointeurs à portée statique vers le tas Python.

  • Les allocations suivies effectuées avec les fonctions m_tracked_calloc(), m_tracked_realloc et m_tracked_free(). Ces fonctions spéciales permettent d’allouer un bloc de mémoire qui est toujours considéré comme vivant par le ramasse-miettes. À l’instar de l’allocation de mémoire en C, cette mémoire n’est libérée qu’en appelant m_tracked_free() ou par une réinitialisation logicielle. Chaque allocation suivie entraîne une légère consommation de mémoire et une légère surcharge d’exécution. Cette fonctionnalité n’est pas activée par défaut sur tous les ports.

Le ramasse-miettes analyse ensuite récursivement et marque toute la mémoire pointée par les pointeurs racines, jusqu’à épuisement de toutes les adresses. Cela suffit à trouver tous les objets Python encore utilisés par l’environnement d’exécution de MicroPython.

Cependant, la mémoire suivante ne sera pas analysée par le ramasse-miettes et pourrait être libérée prématurément :

  • Les variables C statiques ou globales qui contiennent des pointeurs vers la mémoire du tas.

  • Les pointeurs qui ne pointent pas vers le « début » d’un tampon alloué (c’est-à-dire vers l’adresse exacte renvoyée par m_malloc()), mais plutôt vers une adresse à l’intérieur du tampon alloué (par exemple, un pointeur vers une structure imbriquée). Pour des raisons de performances, le ramasse-miettes ne marque pas le tampon englobant dans ces cas.

  • La pile de tout thread ou tâche RTOS qui n’exécute pas de code Python ou n’est pas manuellement enregistré comme « thread Python » (pour les ports qui prennent en charge les threads ou tâches natifs).

Moyens d’éviter l’utilisation après libération (use-after-free) dans ces scénarios :

  • Utiliser l’API d’allocation suivie m_tracked_calloc(), m_tracked_realloc() et m_tracked_free().

  • Enregistrer un pointeur racine (voir ci-dessus), au lieu de stocker un pointeur dans une variable statique.

  • Restructurer le code, par exemple en disposant d’une API où le code Python initialise un objet Python singleton (implémenté en C) qui contient tous les pointeurs pertinents au lieu de les avoir dans des variables statiques.

Note

Réinitialisation logicielle (soft reset) efface toujours le tas Python et libère toute la mémoire. Il est important de ne conserver aucun pointeur vers le tas après une réinitialisation logicielle, car ils deviendront des pointeurs pendants vers de la mémoire libérée.

Certains ports prennent également en charge un « tas C » (voir Allocation dynamique de mémoire en C), auquel cas vous pouvez allouer de la mémoire qui restera valide au-delà d’une réinitialisation logicielle en appelant les fonctions C standard malloc, etc.

Le modèle objet

Tous les objets MicroPython sont désignés par le type de données mp_obj_t. Celui-ci a généralement la taille d’un mot machine (c’est-à-dire la même taille qu’un pointeur sur l’architecture cible), et peut typiquement être de 32 bits (STM32, RP2, nRF, Unix x86) ou de 64 bits (Unix x64). Il peut aussi être plus grand qu’un mot pour certaines représentations d’objets ; par exemple, OBJ_REPR_D a un mp_obj_t de 64 bits sur une architecture 32 bits.

Un mp_obj_t représente un objet MicroPython, par exemple un entier, un flottant, un type, un dictionnaire ou une instance de classe. Certains objets, comme les booléens et les petits entiers, ont leur valeur stockée directement dans la valeur mp_obj_t et ne nécessitent pas de mémoire supplémentaire. D’autres objets ont leur valeur stockée ailleurs en mémoire (par exemple sur le tas géré par le ramasse-miettes) et leur mp_obj_t contient un pointeur vers cette mémoire. Une partie de mp_obj_t est l’étiquette (tag) qui indique de quel type d’objet il s’agit.

Voir py/mpconfig.h pour les détails spécifiques des représentations disponibles.

Étiquetage de pointeur (pointer tagging)

Comme les pointeurs sont alignés sur un mot, lorsqu’ils sont stockés dans un mp_obj_t, les bits de poids faible de ce handle d’objet sont à zéro. Par exemple, sur une architecture 32 bits, les 2 bits de poids faible seront à zéro :

********|********|********|******00

Ces bits sont réservés au stockage d’une étiquette (tag). L’étiquette stocke des informations supplémentaires plutôt que d’introduire un nouveau champ pour stocker ces informations dans l’objet, ce qui pourrait être inefficace. Dans MicroPython, l’étiquette indique si nous avons affaire à un petit entier, à une chaîne (petite) internée ou à un objet concret, et des sémantiques différentes s’appliquent à chacun d’eux.

Pour les petits entiers, l’association est la suivante :

********|********|********|*******1

Où les astérisques contiennent la valeur entière réelle. Pour une chaîne internée ou un objet immédiat (par exemple True), la disposition de la valeur mp_obj_t est, respectivement :

********|********|********|*****010

********|********|********|*****110

Tandis qu’un objet concret qui n’est aucun des cas ci-dessus prend la forme :

********|********|********|******00

Les astérisques correspondent ici à l’adresse de l’objet concret en mémoire.

Allocation des objets

La valeur d’un petit entier est stockée directement dans le mp_obj_t et sera allouée sur place, et non sur le tas ou ailleurs. Ainsi, la création de petits entiers n’affecte pas le tas. Il en va de même pour les chaînes internées dont les données textuelles sont déjà stockées ailleurs, et pour les valeurs immédiates comme None, False et True.

Tout le reste, qui est un objet concret, est alloué sur le tas et sa structure d’objet est telle qu’un champ est réservé dans l’en-tête de l’objet pour stocker le type de l’objet.

+++++++++++
+         +
+ type    + object header
+         +
+++++++++++
+         + object items
+         +
+         +
+++++++++++

La plus petite unité d’allocation du tas est un bloc, qui a une taille de quatre mots machine (16 octets sur une machine 32 bits, 32 octets sur une machine 64 bits). Une autre structure, également allouée sur le tas, suit l’allocation des objets dans chaque bloc. Cette structure est appelée bitmap.

../_images/bitmap.png

Le bitmap suit l’état « libre » ou « utilisé » d’un bloc et utilise deux bits pour suivre cet état pour chaque bloc.

Le ramasse-miettes de type mark-sweep gère les objets alloués sur le tas, et utilise également le bitmap pour marquer les objets encore utilisés. Voir py/gc.c pour l’implémentation complète de ces détails.

Allocation : disposition du tas

Le tas est organisé de telle sorte qu’il est constitué de blocs regroupés en pools. Un bloc peut avoir différentes propriétés :

  • ATB (allocation table byte, octet de table d’allocation) : S’il est défini, alors le bloc est un bloc normal

  • FREE : Bloc libre

  • HEAD : Tête d’une chaîne de blocs

  • TAIL : Dans la queue d’une chaîne de blocs

  • MARK : Bloc de tête marqué

  • FTB (finaliser table byte, octet de table de finaliseur) : S’il est défini, alors le bloc possède un finaliseur