MicroPython sur microcontrôleurs¶
MicroPython est conçu pour pouvoir s’exécuter sur des microcontrôleurs. Ceux-ci présentent des limitations matérielles qui peuvent être inhabituelles pour les programmeurs plus habitués aux ordinateurs classiques. En particulier, la quantité de RAM et de stockage « disque » non volatile (mémoire flash) est limitée. Ce tutoriel propose des moyens de tirer le meilleur parti de ces ressources restreintes. Comme MicroPython s’exécute sur des contrôleurs reposant sur diverses architectures, les méthodes présentées sont génériques : dans certains cas, il sera nécessaire d’obtenir des informations détaillées dans la documentation propre à la plateforme.
Mémoire flash¶
Sur les OpenMV Cam, le moyen le plus simple de pallier la capacité limitée consiste à insérer une carte micro SD. Dans certains cas, cela n’est pas envisageable, soit parce que l’appareil ne dispose pas d’emplacement pour carte SD, soit pour des raisons de coût ou de consommation électrique ; il faut alors recourir à la mémoire flash interne. Le micrologiciel, y compris le sous-système MicroPython, est stocké dans la mémoire flash embarquée. La capacité restante est disponible à l’usage. Pour des raisons liées à l’architecture physique de la mémoire flash, une partie de cette capacité peut être inaccessible en tant que système de fichiers. Dans de tels cas, cet espace peut être exploité en intégrant des modules utilisateur dans une compilation du micrologiciel, qui est ensuite flashée sur l’appareil.
Il existe deux façons d’y parvenir : les modules figés et le bytecode figé. Les modules figés stockent le code source Python avec le micrologiciel. Le bytecode figé utilise le compilateur croisé pour convertir le source en bytecode, qui est ensuite stocké avec le micrologiciel. Dans les deux cas, le module est accessible au moyen d’une instruction import :
import mymodule
La procédure de production des modules figés et du bytecode dépend de la plateforme ; les instructions de compilation du micrologiciel se trouvent dans les fichiers README de la partie pertinente de l’arborescence des sources.
De manière générale, les étapes sont les suivantes :
Cloner le dépôt MicroPython.
Se procurer la chaîne d’outils (propre à la plateforme) pour compiler le micrologiciel.
Compiler le compilateur croisé.
Placer les modules à figer dans un répertoire spécifié (selon que le module doit être figé sous forme de source ou de bytecode).
Compiler le micrologiciel. Une commande spécifique peut être requise pour compiler du code figé de l’un ou l’autre type - voir la documentation de la plateforme.
Flasher le micrologiciel sur l’appareil.
RAM¶
When reducing RAM usage there are two phases to consider: compilation and execution. In addition to memory consumption, there is also an issue known as heap fragmentation. In general terms it is best to minimise the repeated creation and destruction of objects. The reason for this is covered in the section covering the heap.
Phase de compilation¶
Lorsqu’un module est importé, MicroPython compile le code en bytecode, qui est ensuite exécuté par la machine virtuelle MicroPython (VM). Le bytecode est stocké en RAM. Le compilateur lui-même nécessite de la RAM, mais celle-ci redevient disponible une fois la compilation terminée.
Si un certain nombre de modules ont déjà été importés, il peut arriver que la RAM soit insuffisante pour exécuter le compilateur. Dans ce cas, l’instruction import produira une exception de mémoire.
Si un module instancie des objets globaux lors de son import, il consommera de la RAM au moment de l’import, qui ne sera plus disponible pour le compilateur lors des imports ultérieurs. En général, il est préférable d’éviter le code qui s’exécute à l’import ; une meilleure approche consiste à disposer d’un code d’initialisation exécuté par l’application une fois que tous les modules ont été importés. Cela maximise la RAM disponible pour le compilateur.
Si la RAM reste insuffisante pour compiler tous les modules, une solution consiste à précompiler les modules. MicroPython dispose d’un compilateur croisé capable de compiler les modules Python en bytecode (voir le README dans le répertoire mpy-cross). Le fichier de bytecode résultant porte l’extension .mpy ; il peut être copié sur le système de fichiers et importé de la manière habituelle. Sinon, certains ou tous les modules peuvent être implémentés sous forme de bytecode figé : sur la plupart des plateformes, cela économise encore plus de RAM, car le bytecode est exécuté directement depuis la mémoire flash au lieu d’être stocké en RAM.
Phase d’exécution¶
Il existe un certain nombre de techniques de codage pour réduire l’utilisation de la RAM.
Constantes
MicroPython fournit un mot-clé const qui peut être utilisé comme suit :
from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS
Dans les deux cas où la constante est affectée à une variable, le compilateur évitera de coder une recherche du nom de la constante en substituant sa valeur littérale. Cela économise du bytecode et donc de la RAM. Cependant, la valeur ROWS occupera au moins deux mots machine, un pour la clé et un pour la valeur dans le dictionnaire des variables globales. Sa présence dans le dictionnaire est nécessaire car un autre module pourrait l’importer ou l’utiliser. Cette RAM peut être économisée en préfixant le nom d’un caractère de soulignement, comme dans _COLS : ce symbole n’est pas visible en dehors du module et n’occupera donc pas de RAM.
L’argument de const() peut être tout ce qui, au moment de la compilation, est évalué à une constante, par exemple 0x100, 1 << 8 ou (True, "string", b"bytes") (voir la section ci-dessous pour plus de détails). Il peut même inclure d’autres symboles const déjà définis, par exemple 1 << BIT.
Structures de données constantes
Lorsqu’il y a un volume substantiel de données constantes et que la plateforme prend en charge l’exécution depuis la mémoire flash, la RAM peut être économisée comme suit. Les données doivent être placées dans des modules Python et figées sous forme de bytecode. Les données doivent être définies sous forme d’objets bytes. Le compilateur « sait » que les objets bytes sont immuables et garantit qu’ils restent en mémoire flash plutôt que d’être copiés en RAM. Le module struct peut faciliter la conversion entre les types bytes et d’autres types intégrés de Python.
Lorsqu’on considère les implications du bytecode figé, il faut noter qu’en Python les chaînes, les flottants, les bytes, les entiers, les nombres complexes et les tuples sont immuables. En conséquence, ceux-ci seront figés en mémoire flash (pour les tuples, uniquement si tous leurs éléments sont immuables). Ainsi, dans la ligne
mystring = "The quick brown fox"
la chaîne réelle « The quick brown fox » résidera en mémoire flash. À l’exécution, une référence à la chaîne est affectée à la variable mystring. La référence occupe un seul mot machine. En principe, un entier long pourrait être utilisé pour stocker des données constantes :
bar = 0xDEADBEEF0000DEADBEEF
Comme dans l’exemple de la chaîne, à l’exécution une référence à l’entier arbitrairement grand est affectée à la variable bar. Cette référence occupe un seul mot machine.
Les tuples d’objets constants sont eux-mêmes constants. De tels tuples constants sont optimisés par le compilateur de sorte qu’ils n’ont pas besoin d’être créés à l’exécution chaque fois qu’ils sont utilisés. Par exemple :
foo = (1, 2, 3, 4, 5, 6, 100000, ("string", b"bytes", False, True))
Ce tuple entier existera en tant qu’objet unique (potentiellement en mémoire flash si le code est figé) et sera référencé chaque fois que nécessaire.
Création inutile d’objets
Il existe un certain nombre de situations où des objets peuvent être créés et détruits par inadvertance. Cela peut réduire l’utilisabilité de la RAM par fragmentation. Les sections suivantes traitent de cas de ce genre.
Concaténation de chaînes
Considérons les fragments de code suivants qui visent à produire des chaînes constantes :
var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""
Chacun produit le même résultat, mais le premier crée inutilement deux objets chaîne à l’exécution et alloue davantage de RAM pour la concaténation avant de produire le troisième. Les autres effectuent la concaténation au moment de la compilation, ce qui est plus efficace et réduit la fragmentation.
Lorsque des chaînes doivent être créées dynamiquement avant d’être transmises à un flux tel qu’un fichier, on économisera de la RAM en procédant de manière fragmentée. Plutôt que de créer un grand objet chaîne, créez une sous-chaîne et transmettez-la au flux avant de traiter la suivante.
La meilleure façon de créer des chaînes dynamiques est au moyen de la méthode format() des chaînes :
var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)
Tampons
Lors de l’accès à des périphériques tels que des instances d’interfaces UART, I2C et SPI, l’utilisation de tampons préalloués évite la création d’objets inutiles. Considérons ces deux boucles :
while True:
var = spi.read(100)
# process data
buf = bytearray(100)
while True:
spi.readinto(buf)
# process data in buf
La première crée un tampon à chaque passage, tandis que la seconde réutilise un tampon préalloué ; cela est à la fois plus rapide et plus efficace en termes de fragmentation de la mémoire.
Les bytes sont plus petits que les ints
Sur la plupart des plateformes, un entier consomme quatre octets. Considérons les trois appels à la fonction foo() :
def foo(bar):
for x in bar:
print(x)
foo([1, 2, 0xff])
foo((1, 2, 0xff))
foo(b'\1\2\xff')
Dans le premier appel, une list d’entiers est créée en RAM chaque fois que le code est exécuté. Le deuxième appel crée un objet tuple constant (un tuple ne contenant que des objets constants) lors de la phase de compilation, il n’est donc créé qu’une seule fois et est plus efficace que la list. Le troisième appel crée efficacement un objet bytes consommant la quantité minimale de RAM. Si le module était figé sous forme de bytecode, les objets tuple et bytes résideraient tous deux en mémoire flash.
Chaînes versus Bytes
Python3 a introduit la prise en charge de l’Unicode. Cela a introduit une distinction entre une chaîne et un tableau de bytes. MicroPython garantit que les chaînes Unicode n’occupent aucun espace supplémentaire tant que tous les caractères de la chaîne sont ASCII (c’est-à-dire ont une valeur < 128). Si des valeurs sur toute la plage de 8 bits sont requises, les objets bytes et bytearray peuvent être utilisés pour garantir qu’aucun espace supplémentaire ne sera nécessaire. Notez que la plupart des méthodes de chaîne (par exemple str.strip()) s’appliquent également aux instances bytes, de sorte que le processus d’élimination de l’Unicode peut se faire sans peine.
s = 'the quick brown fox' # A string instance
b = b'the quick brown fox' # A bytes instance
Lorsqu’il est nécessaire de convertir entre chaînes et bytes, les méthodes str.encode() et bytes.decode() peuvent être utilisées. Notez que les chaînes et les bytes sont tous deux immuables. Toute opération qui prend en entrée un tel objet et en produit un autre implique au moins une allocation de RAM pour produire le résultat. Dans la deuxième ligne ci-dessous, un nouvel objet bytes est alloué. Cela se produirait également si foo était une chaîne.
foo = b' empty whitespace'
foo = foo.lstrip()
Exécution du compilateur à l’exécution
Les fonctions Python eval et exec invoquent le compilateur à l’exécution, ce qui nécessite des quantités importantes de RAM. Notez que la bibliothèque pickle de micropython-lib utilise exec. Il peut être plus économe en RAM d’utiliser la bibliothèque json pour la sérialisation des objets.
Stockage des chaînes en mémoire flash
Les chaînes Python sont immuables et ont donc le potentiel d’être stockées en mémoire morte. Le compilateur peut placer en mémoire flash les chaînes définies dans le code Python. Comme pour les modules figés, il est nécessaire d’avoir une copie de l’arborescence des sources sur le PC ainsi que la chaîne d’outils pour compiler le micrologiciel. La procédure fonctionnera même si les modules n’ont pas été entièrement débogués, du moment qu’ils peuvent être importés et exécutés.
Après avoir importé les modules, exécutez :
micropython.qstr_info(1)
Copiez et collez ensuite toutes les lignes Q(xxx) dans un éditeur de texte. Vérifiez et supprimez les lignes manifestement invalides. Ouvrez le fichier qstrdefsport.h qui se trouve dans ports/stm32 (ou le répertoire équivalent pour l’architecture utilisée). Copiez et collez les lignes corrigées à la fin du fichier. Enregistrez le fichier, recompilez et flashez le micrologiciel. Le résultat peut être vérifié en important les modules et en émettant à nouveau :
micropython.qstr_info(1)
Les lignes Q(xxx) devraient avoir disparu.
Le tas¶
Lorsqu’un programme en cours d’exécution instancie un objet, la RAM nécessaire est allouée à partir d’un pool de taille fixe appelé le tas. Lorsque l’objet sort de la portée (autrement dit devient inaccessible au code), l’objet devenu superflu est appelé « déchet ». Un processus appelé « ramasse-miettes » (GC) récupère cette mémoire, la restituant au tas libre. Ce processus s’exécute automatiquement, mais il peut être invoqué directement en émettant gc.collect().
Le développement à ce sujet est quelque peu complexe. Pour une « solution rapide », émettez périodiquement ce qui suit :
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Pour plus d’informations, voir ci-dessous ainsi que la documentation du module intégré gc.
Pour des détails du point de vue des internes/développeurs de MicroPython, voir également Gestion de la mémoire.
Fragmentation¶
Supposons qu’un programme crée un objet foo, puis un objet bar. Par la suite, foo sort de la portée mais bar subsiste. La RAM utilisée par foo sera récupérée par le GC. Cependant, si bar a été alloué à une adresse plus élevée, la RAM récupérée de foo ne servira que pour des objets pas plus grands que foo. Dans un programme complexe ou de longue durée, le tas peut devenir fragmenté : bien qu’il y ait une quantité substantielle de RAM disponible, il n’y a pas suffisamment d’espace contigu pour allouer un objet particulier, et le programme échoue avec une erreur de mémoire.
Les techniques décrites ci-dessus visent à minimiser cela. Lorsque de grands tampons permanents ou d’autres objets sont nécessaires, il est préférable de les instancier tôt dans le déroulement de l’exécution du programme, avant que la fragmentation ne puisse se produire. D’autres améliorations peuvent être apportées en surveillant l’état du tas et en contrôlant le GC ; celles-ci sont décrites ci-dessous.
Rapport¶
Un certain nombre de fonctions de bibliothèque sont disponibles pour rendre compte de l’allocation de mémoire et pour contrôler le GC. Elles se trouvent dans les modules gc et micropython. L’exemple suivant peut être collé dans le REPL (Ctrl-E pour entrer en mode collage, Ctrl-D pour l’exécuter).
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)
Méthodes employées ci-dessus :
gc.collect()Force un ramasse-miettes. Voir la note de bas de page.micropython.mem_info()Affiche un résumé de l’utilisation de la RAM.gc.mem_free()Renvoie la taille du tas libre en octets.gc.mem_alloc()Renvoie le nombre d’octets actuellement alloués.micropython.mem_info(1)Affiche un tableau de l’utilisation du tas (détaillé ci-dessous).
Les chiffres produits dépendent de la plateforme, mais on peut voir que la déclaration de la fonction utilise une petite quantité de RAM sous la forme du bytecode émis par le compilateur (la RAM utilisée par le compilateur a été récupérée). L’exécution de la fonction utilise plus de 10 Kio, mais au retour a est un déchet car il est hors de portée et ne peut pas être référencé. Le dernier gc.collect() récupère cette mémoire.
La sortie finale produite par micropython.mem_info(1) variera dans les détails mais peut être interprétée comme suit :
Symbole |
Signification |
|---|---|
. |
bloc libre |
h |
bloc de tête |
= |
bloc de queue |
m |
bloc de tête marqué |
T |
tuple |
L |
liste |
D |
dict |
F |
flottant |
B |
bytecode |
M |
module |
S |
chaîne ou bytes |
A |
bytearray |
Chaque lettre représente un seul bloc de mémoire, un bloc faisant 16 octets. Ainsi, chaque ligne du vidage du tas représente 0x400 octets, soit 1 Kio de RAM.
Contrôle du ramasse-miettes¶
Un GC peut être demandé à tout moment en émettant gc.collect(). Il est avantageux de le faire à intervalles réguliers, d’abord pour prévenir la fragmentation et ensuite pour les performances. Un GC peut prendre plusieurs millisecondes mais est plus rapide lorsqu’il y a peu de travail à faire (environ 1 ms sur une OpenMV Cam). Un appel explicite peut minimiser ce délai tout en garantissant qu’il se produit à des moments du programme où cela est acceptable.
Le GC automatique est déclenché dans les circonstances suivantes. Lorsqu’une tentative d’allocation échoue, un GC est effectué et l’allocation est de nouveau tentée. Ce n’est que si cela échoue qu’une exception est levée. Deuxièmement, un GC automatique sera déclenché si la quantité de RAM libre tombe sous un seuil. Ce seuil peut être adapté à mesure que l’exécution progresse :
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Cela déclenchera un GC lorsque plus de 25 % du tas actuellement libre sera occupé.
En général, les modules devraient instancier les objets de données à l’exécution à l’aide de constructeurs ou d’autres fonctions d’initialisation. La raison en est que si cela se produit à l’initialisation, le compilateur peut manquer de RAM lorsque les modules suivants sont importés. Si des modules instancient effectivement des données à l’import, alors un gc.collect() émis après l’import atténuera le problème.
Opérations sur les chaînes¶
MicroPython gère les chaînes de manière efficace, et comprendre cela peut aider à concevoir des applications destinées à s’exécuter sur des microcontrôleurs. Lorsqu’un module est compilé, les chaînes qui apparaissent plusieurs fois ne sont stockées qu’une seule fois, un processus connu sous le nom d’internement de chaînes. Dans MicroPython, une chaîne internée est appelée un qstr. Dans un module importé normalement, cette unique instance se trouvera en RAM, mais comme décrit ci-dessus, dans les modules figés sous forme de bytecode, elle se trouvera en mémoire flash.
Les comparaisons de chaînes sont également effectuées efficacement en utilisant le hachage plutôt que caractère par caractère. La pénalité liée à l’utilisation de chaînes plutôt que d’entiers peut donc être faible, tant en termes de performances que d’utilisation de la RAM - un fait qui peut surprendre les programmeurs C.
Post-scriptum¶
MicroPython transmet, renvoie et (par défaut) copie les objets par référence. Une référence occupe un seul mot machine, ces processus sont donc efficaces en termes d’utilisation de la RAM et de vitesse.
Lorsque des variables dont la taille n’est ni un octet ni un mot machine sont requises, il existe des bibliothèques standard qui peuvent aider à les stocker efficacement et à effectuer des conversions. Voir les modules array, struct et uctypes.
Note de bas de page : valeur de retour de gc.collect()¶
Sur les plateformes Unix et Windows, la méthode gc.collect() renvoie un entier qui indique le nombre de régions de mémoire distinctes qui ont été récupérées lors du ramassage (plus précisément, le nombre de têtes qui ont été transformées en blocs libres). Pour des raisons d’efficacité, les portages sur métal nu ne renvoient pas cette valeur.