MicroPython en microcontroladores¶
MicroPython está diseñado para poder ejecutarse en microcontroladores. Estos tienen limitaciones de hardware que pueden resultar poco familiares para programadores más habituados a los ordenadores convencionales. En particular, la cantidad de RAM y de almacenamiento «en disco» no volátil (memoria flash) es limitada. Este tutorial ofrece formas de aprovechar al máximo los recursos limitados. Dado que MicroPython se ejecuta en controladores basados en una variedad de arquitecturas, los métodos presentados son genéricos: en algunos casos será necesario obtener información detallada de la documentación específica de cada plataforma.
Memoria flash¶
En las OpenMV Cam, la forma sencilla de afrontar la capacidad limitada es instalar una tarjeta micro SD. En algunos casos esto resulta poco práctico, ya sea porque el dispositivo no tiene ranura para tarjeta SD o por motivos de coste o consumo de energía; por lo tanto, debe utilizarse la memoria flash integrada en el chip. El firmware, incluido el subsistema de MicroPython, se almacena en la memoria flash integrada. La capacidad restante está disponible para su uso. Por razones relacionadas con la arquitectura física de la memoria flash, parte de esta capacidad puede resultar inaccesible como sistema de archivos. En tales casos, este espacio puede aprovecharse incorporando módulos de usuario en una compilación del firmware que luego se graba en el dispositivo.
Hay dos formas de lograr esto: módulos congelados (frozen modules) y bytecode congelado (frozen bytecode). Los módulos congelados almacenan el código fuente de Python junto con el firmware. El bytecode congelado utiliza el compilador cruzado para convertir el código fuente en bytecode, que luego se almacena con el firmware. En cualquier caso, se puede acceder al módulo con una sentencia import:
import mymodule
El procedimiento para producir módulos congelados y bytecode depende de la plataforma; las instrucciones para compilar el firmware se encuentran en los archivos README en la parte correspondiente del árbol de código fuente.
En términos generales, los pasos son los siguientes:
Clonar el repositorio de MicroPython.
Obtener el conjunto de herramientas (específico de la plataforma) para compilar el firmware.
Compilar el compilador cruzado.
Colocar los módulos que se van a congelar en un directorio específico (dependiendo de si el módulo se va a congelar como código fuente o como bytecode).
Compilar el firmware. Puede que se requiera un comando específico para compilar código congelado de cualquiera de los dos tipos; consulte la documentación de la plataforma.
Grabar el firmware en el dispositivo.
RAM¶
Al reducir el uso de RAM hay dos fases a considerar: la compilación y la ejecución. Además del consumo de memoria, existe también un problema conocido como fragmentación del montón (heap). En términos generales, lo mejor es minimizar la creación y destrucción repetida de objetos. La razón de esto se trata en la sección sobre el heap.
Fase de compilación¶
Cuando se importa un módulo, MicroPython compila el código a bytecode, que luego es ejecutado por la máquina virtual de MicroPython (VM). El bytecode se almacena en RAM. El compilador en sí requiere RAM, pero esta queda disponible para su uso una vez que la compilación se ha completado.
Si ya se han importado varios módulos, puede surgir la situación en la que no haya suficiente RAM para ejecutar el compilador. En este caso, la sentencia import producirá una excepción de memoria.
Si un módulo crea instancias de objetos globales al importarse, consumirá RAM en el momento de la importación, que luego no estará disponible para que el compilador la use en importaciones posteriores. En general, es mejor evitar código que se ejecute al importar; un enfoque mejor es disponer de código de inicialización que la aplicación ejecute después de que todos los módulos se hayan importado. Esto maximiza la RAM disponible para el compilador.
Si la RAM sigue siendo insuficiente para compilar todos los módulos, una solución es precompilar los módulos. MicroPython tiene un compilador cruzado capaz de compilar módulos de Python a bytecode (consulte el README en el directorio mpy-cross). El archivo de bytecode resultante tiene la extensión .mpy; puede copiarse al sistema de archivos e importarse de la forma habitual. Alternativamente, algunos o todos los módulos pueden implementarse como bytecode congelado: en la mayoría de las plataformas esto ahorra aún más RAM, ya que el bytecode se ejecuta directamente desde la memoria flash en lugar de almacenarse en RAM.
Fase de ejecución¶
Existen varias técnicas de programación para reducir el uso de RAM.
Constantes
MicroPython proporciona una palabra clave const que puede usarse de la siguiente manera:
from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS
En ambos casos en los que la constante se asigna a una variable, el compilador evitará codificar una búsqueda del nombre de la constante sustituyéndolo por su valor literal. Esto ahorra bytecode y, por tanto, RAM. Sin embargo, el valor ROWS ocupará al menos dos palabras de máquina, una para la clave y otra para el valor en el diccionario de globales. Su presencia en el diccionario es necesaria porque otro módulo podría importarlo o utilizarlo. Esta RAM puede ahorrarse anteponiendo un guion bajo al nombre, como en _COLS: este símbolo no es visible fuera del módulo, por lo que no ocupará RAM.
El argumento de const() puede ser cualquier cosa que, en tiempo de compilación, se evalúe como una constante, p. ej. 0x100, 1 << 8 o (True, "string", b"bytes") (consulte la sección siguiente para más detalles). Incluso puede incluir otros símbolos const que ya hayan sido definidos, p. ej. 1 << BIT.
Estructuras de datos constantes
Cuando hay un volumen sustancial de datos constantes y la plataforma admite la ejecución desde la memoria flash, se puede ahorrar RAM de la siguiente manera. Los datos deben ubicarse en módulos de Python y congelarse como bytecode. Los datos deben definirse como objetos bytes. El compilador “sabe” que los objetos bytes son inmutables y se asegura de que los objetos permanezcan en la memoria flash en lugar de copiarse a la RAM. El módulo struct puede ayudar a convertir entre tipos bytes y otros tipos integrados de Python.
Al considerar las implicaciones del bytecode congelado, tenga en cuenta que en Python las cadenas, los números de coma flotante, los bytes, los enteros, los números complejos y las tuplas son inmutables. En consecuencia, estos se congelarán en la memoria flash (para las tuplas, solo si todos sus elementos son inmutables). Así, en la línea
mystring = "The quick brown fox"
la cadena real «The quick brown fox» residirá en la memoria flash. En tiempo de ejecución, se asigna una referencia a la cadena a la variable mystring. La referencia ocupa una sola palabra de máquina. En principio, podría usarse un entero largo para almacenar datos constantes:
bar = 0xDEADBEEF0000DEADBEEF
Al igual que en el ejemplo de la cadena, en tiempo de ejecución se asigna una referencia al entero arbitrariamente grande a la variable bar. Esa referencia ocupa una sola palabra de máquina.
Las tuplas de objetos constantes son a su vez constantes. El compilador optimiza estas tuplas constantes de modo que no necesitan crearse en tiempo de ejecución cada vez que se usan. Por ejemplo:
foo = (1, 2, 3, 4, 5, 6, 100000, ("string", b"bytes", False, True))
Toda esta tupla existirá como un único objeto (potencialmente en la memoria flash si el código está congelado) y se referencia cada vez que se necesita.
Creación innecesaria de objetos
Hay varias situaciones en las que pueden crearse y destruirse objetos de forma involuntaria. Esto puede reducir la utilidad de la RAM debido a la fragmentación. Las siguientes secciones tratan ejemplos de esto.
Concatenación de cadenas
Considere los siguientes fragmentos de código que buscan producir cadenas constantes:
var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""
Cada uno produce el mismo resultado; sin embargo, el primero crea innecesariamente dos objetos de cadena en tiempo de ejecución y asigna más RAM para la concatenación antes de producir el tercero. Los demás realizan la concatenación en tiempo de compilación, lo cual es más eficiente y reduce la fragmentación.
Cuando deben crearse cadenas dinámicamente antes de enviarlas a un flujo como un archivo, se ahorrará RAM si esto se hace de forma fragmentada. En lugar de crear un objeto de cadena grande, cree una subcadena y envíela al flujo antes de ocuparse de la siguiente.
La mejor manera de crear cadenas dinámicas es mediante el método format() de las cadenas:
var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)
Búferes
Al acceder a dispositivos como instancias de interfaces UART, I2C y SPI, el uso de búferes preasignados evita la creación de objetos innecesarios. Considere estos dos bucles:
while True:
var = spi.read(100)
# process data
buf = bytearray(100)
while True:
spi.readinto(buf)
# process data in buf
El primero crea un búfer en cada pasada, mientras que el segundo reutiliza un búfer preasignado; esto es más rápido y más eficiente en términos de fragmentación de memoria.
Los bytes son más pequeños que los ints
En la mayoría de las plataformas, un entero consume cuatro bytes. Considere las tres llamadas a la función foo():
def foo(bar):
for x in bar:
print(x)
foo([1, 2, 0xff])
foo((1, 2, 0xff))
foo(b'\1\2\xff')
En la primera llamada se crea en RAM una list de enteros cada vez que se ejecuta el código. La segunda llamada crea un objeto tuple constante (una tuple que contiene solo objetos constantes) como parte de la fase de compilación, por lo que solo se crea una vez y es más eficiente que la list. La tercera llamada crea de forma eficiente un objeto bytes que consume la mínima cantidad de RAM. Si el módulo se congelara como bytecode, tanto el objeto tuple como el objeto bytes residirían en la memoria flash.
Cadenas frente a bytes
Python3 introdujo la compatibilidad con Unicode. Esto introdujo una distinción entre una cadena y un arreglo de bytes. MicroPython garantiza que las cadenas Unicode no ocupen espacio adicional siempre que todos los caracteres de la cadena sean ASCII (es decir, tengan un valor < 128). Si se requieren valores en todo el rango de 8 bits, pueden usarse objetos bytes y bytearray para garantizar que no se requiera espacio adicional. Tenga en cuenta que la mayoría de los métodos de cadena (p. ej. str.strip()) también se aplican a instancias de bytes, por lo que el proceso de eliminar Unicode puede ser indoloro.
s = 'the quick brown fox' # A string instance
b = b'the quick brown fox' # A bytes instance
Cuando es necesario convertir entre cadenas y bytes, pueden usarse los métodos str.encode() y bytes.decode(). Tenga en cuenta que tanto las cadenas como los bytes son inmutables. Cualquier operación que tome como entrada un objeto de este tipo y produzca otro implica al menos una asignación de RAM para producir el resultado. En la segunda línea siguiente se asigna un nuevo objeto bytes. Esto también ocurriría si foo fuera una cadena.
foo = b' empty whitespace'
foo = foo.lstrip()
Ejecución del compilador en tiempo de ejecución
Las funciones de Python eval y exec invocan al compilador en tiempo de ejecución, lo que requiere cantidades significativas de RAM. Tenga en cuenta que la biblioteca pickle de micropython-lib emplea exec. Puede ser más eficiente en cuanto a RAM usar la biblioteca json para la serialización de objetos.
Almacenamiento de cadenas en la memoria flash
Las cadenas de Python son inmutables, por lo que tienen el potencial de almacenarse en memoria de solo lectura. El compilador puede colocar en la memoria flash las cadenas definidas en el código de Python. Al igual que con los módulos congelados, es necesario tener una copia del árbol de código fuente en el PC y el conjunto de herramientas para compilar el firmware. El procedimiento funcionará incluso si los módulos no se han depurado por completo, siempre que puedan importarse y ejecutarse.
Después de importar los módulos, ejecute:
micropython.qstr_info(1)
Luego copie y pegue todas las líneas Q(xxx) en un editor de texto. Compruebe y elimine las líneas que sean obviamente inválidas. Abra el archivo qstrdefsport.h, que se encontrará en ports/stm32 (o el directorio equivalente para la arquitectura en uso). Copie y pegue las líneas corregidas al final del archivo. Guarde el archivo, recompile y grabe el firmware. El resultado puede comprobarse importando los módulos y ejecutando de nuevo:
micropython.qstr_info(1)
Las líneas Q(xxx) deberían haber desaparecido.
El montón (heap)¶
Cuando un programa en ejecución crea una instancia de un objeto, la RAM necesaria se asigna desde un conjunto de tamaño fijo conocido como montón (heap). Cuando el objeto sale de su ámbito (en otras palabras, se vuelve inaccesible para el código), el objeto redundante se conoce como «basura» (garbage). Un proceso conocido como «recolección de basura» (GC) recupera esa memoria, devolviéndola al montón libre. Este proceso se ejecuta automáticamente; sin embargo, puede invocarse directamente ejecutando gc.collect().
La explicación sobre esto es algo complicada. Para una “solución rápida”, ejecute lo siguiente periódicamente:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Para más información, consulte más abajo y la documentación del módulo integrado gc.
Para detalles desde la perspectiva del desarrollador/internos de MicroPython, consulte también Gestión de memoria.
Fragmentación¶
Supongamos que un programa crea un objeto foo y luego un objeto bar. Posteriormente, foo sale de su ámbito pero bar permanece. La RAM utilizada por foo será recuperada por el GC. Sin embargo, si bar se asignó a una dirección más alta, la RAM recuperada de foo solo será útil para objetos no mayores que foo. En un programa complejo o de larga duración, el montón puede fragmentarse: a pesar de haber una cantidad sustancial de RAM disponible, no hay suficiente espacio contiguo para asignar un objeto en particular, y el programa falla con un error de memoria.
Las técnicas descritas anteriormente buscan minimizar esto. Cuando se requieren grandes búferes permanentes u otros objetos, lo mejor es crear sus instancias al principio del proceso de ejecución del programa, antes de que pueda producirse la fragmentación. Pueden lograrse más mejoras supervisando el estado del montón y controlando el GC; estas se describen a continuación.
Informes¶
Hay varias funciones de biblioteca disponibles para informar sobre la asignación de memoria y controlar el GC. Se encuentran en los módulos gc y micropython. El siguiente ejemplo puede pegarse en el REPL (Ctrl-E para entrar en el modo de pegado, Ctrl-D para ejecutarlo).
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étodos empleados anteriormente:
gc.collect()Fuerza una recolección de basura. Vea la nota al pie.micropython.mem_info()Imprime un resumen de la utilización de RAM.gc.mem_free()Devuelve el tamaño del montón libre en bytes.gc.mem_alloc()Devuelve el número de bytes asignados actualmente.micropython.mem_info(1)Imprime una tabla de la utilización del montón (detallada a continuación).
Los números producidos dependen de la plataforma, pero puede observarse que declarar la función utiliza una pequeña cantidad de RAM en forma del bytecode emitido por el compilador (la RAM utilizada por el compilador se ha recuperado). La ejecución de la función utiliza más de 10 KiB, pero al retornar a es basura porque está fuera de ámbito y no puede referenciarse. La llamada final a gc.collect() recupera esa memoria.
La salida final producida por micropython.mem_info(1) variará en los detalles, pero puede interpretarse de la siguiente manera:
Símbolo |
Significado |
|---|---|
. |
bloque libre |
h |
bloque de cabecera |
= |
bloque de cola |
m |
bloque de cabecera marcado |
T |
tupla |
L |
lista |
D |
diccionario |
F |
coma flotante |
B |
bytecode |
M |
módulo |
S |
cadena o bytes |
A |
bytearray |
Cada letra representa un único bloque de memoria, siendo un bloque de 16 bytes. Así, cada línea del volcado del montón representa 0x400 bytes o 1 KiB de RAM.
Control de la recolección de basura¶
Se puede solicitar un GC en cualquier momento ejecutando gc.collect(). Es ventajoso hacer esto a intervalos, en primer lugar para anticiparse a la fragmentación y en segundo lugar por rendimiento. Un GC puede tardar varios milisegundos, pero es más rápido cuando hay poco trabajo por hacer (alrededor de 1 ms en una OpenMV Cam). Una llamada explícita puede minimizar ese retardo, garantizando al mismo tiempo que ocurra en puntos del programa donde sea aceptable.
El GC automático se provoca en las siguientes circunstancias. Cuando falla un intento de asignación, se realiza un GC y se reintenta la asignación. Solo si esto falla se lanza una excepción. En segundo lugar, se activará un GC automático si la cantidad de RAM libre cae por debajo de un umbral. Este umbral puede adaptarse a medida que avanza la ejecución:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Esto provocará un GC cuando más del 25 % del montón actualmente libre quede ocupado.
En general, los módulos deberían crear instancias de objetos de datos en tiempo de ejecución mediante constructores u otras funciones de inicialización. La razón es que, si esto ocurre durante la inicialización, el compilador puede quedarse sin RAM cuando se importan los módulos posteriores. Si los módulos sí crean instancias de datos al importarse, ejecutar gc.collect() después de la importación aliviará el problema.
Operaciones con cadenas¶
MicroPython maneja las cadenas de manera eficiente, y comprender esto puede ayudar a diseñar aplicaciones para ejecutarse en microcontroladores. Cuando se compila un módulo, las cadenas que aparecen varias veces se almacenan una sola vez, un proceso conocido como interning de cadenas. En MicroPython, una cadena internada se conoce como un qstr. En un módulo importado normalmente, esa única instancia se ubicará en RAM, pero como se describió anteriormente, en los módulos congelados como bytecode se ubicará en la memoria flash.
Las comparaciones de cadenas también se realizan de manera eficiente mediante hashing en lugar de carácter por carácter. La penalización por usar cadenas en lugar de enteros puede, por tanto, ser pequeña tanto en términos de rendimiento como de uso de RAM, un hecho que puede sorprender a los programadores de C.
Posdata¶
MicroPython pasa, devuelve y (de forma predeterminada) copia los objetos por referencia. Una referencia ocupa una sola palabra de máquina, por lo que estos procesos son eficientes en cuanto a uso de RAM y velocidad.
Cuando se requieren variables cuyo tamaño no es ni un byte ni una palabra de máquina, existen bibliotecas estándar que pueden ayudar a almacenarlas de forma eficiente y a realizar conversiones. Consulte los módulos array, struct y uctypes.
Nota al pie: valor de retorno de gc.collect()¶
En las plataformas Unix y Windows, el método gc.collect() devuelve un entero que indica el número de regiones de memoria distintas que se recuperaron en la recolección (más precisamente, el número de cabeceras que se convirtieron en bloques libres). Por razones de eficiencia, los ports bare metal no devuelven este valor.