Archivos .mpy de MicroPython

MicroPython define el concepto de archivo .mpy, que es un formato de archivo contenedor binario que almacena código precompilado y que puede importarse como un módulo .py normal. El archivo foo.mpy puede importarse mediante import foo, siempre que la maquinaria de importación pueda encontrar foo.mpy de la forma habitual. Normalmente, se busca en orden en cada directorio listado en sys.path. Al buscar en un directorio concreto, primero se busca foo.py y, si no se encuentra, entonces se busca foo.mpy; la búsqueda continúa en el siguiente directorio si no se encuentra ninguno. Por tanto, foo.py tendrá prioridad sobre foo.mpy.

Estos archivos .mpy pueden contener bytecode, que normalmente se genera a partir de archivos fuente de Python (archivos .py) mediante el programa mpy-cross. Para algunas arquitecturas, un archivo .mpy también puede contener código máquina nativo, que puede generarse de varias maneras, en particular a partir de código fuente C.

El compilador mpy-cross

mpy-cross es el compilador cruzado que convierte un archivo fuente .py en un contenedor binario .mpy listo para importarse en la cámara. Forma parte del árbol de fuentes de MicroPython (el mismo que se usa para compilar el firmware de la cámara) y también se publica como paquete pip para su uso en el host sin una descarga completa del firmware:

$ pip install --user mpy-cross

O mediante pipx:

$ pipx install mpy-cross

Una vez instalado, invócalo sobre un único archivo fuente:

$ mpy-cross foo.py

Esto produce foo.mpy en el directorio actual, listo para copiarse en el sistema de archivos de la cámara junto a otros módulos o para incorporarse a una imagen ROMFS.

Las opciones de línea de comandos más útiles:

  • -o <path> – ruta de salida del .mpy generado (por defecto, el nombre del archivo de entrada con la extensión reemplazada; -o - escribe en stdout).

  • -O<n> – nivel de optimización de 0 a 3. El valor por defecto 0 conserva las aserciones y la información completa de ubicación en el código fuente; 3 elimina las aserciones y los docstrings y reescribe los bloques if __debug__. El nivel controla la misma superficie micropython.opt_level que expone el runtime.

  • -march=<arch> – arquitectura nativa de destino para las funciones decoradas con @native y @viper. Es obligatoria cuando el código fuente usa esos decoradores. El valor debe coincidir con la clase de MCU de la cámara: elígelo de la lista que imprime mpy-cross --help o léelo de la cámara en tiempo de ejecución con sys.implementation._mpy.

  • -s <path> – cadena de ruta fuente incrustada en la información de depuración del .mpy. Útil cuando la ruta en disco difiere de la ruta de importación bajo la que el archivo debería mostrarse en los rastreos de pila.

  • -X emit=bytecode|native|viper – elige el emisor por defecto para todo el módulo (una alternativa por función a los decoradores @native / @viper).

  • --version – imprime la versión del formato .mpy que emite este binario. Ese número debe coincidir con la versión que admite el runtime de la cámara (consulta la tabla de versiones más abajo) o la importación lanzará ValueError('incompatible .mpy file').

Ejecuta mpy-cross --help para ver la lista completa de opciones.

El paquete pip también expone una pequeña API de módulo de Python para que los scripts de compilación puedan controlar el compilador en el mismo proceso en lugar de bifurcar manualmente un subproceso:

import mpy_cross

mpy_cross.compile('foo.py', dest='build/foo.mpy', opt=3,
                  march=mpy_cross.NATIVE_ARCH_ARMV7EMSP)

mpy_cross.compile, mpy_cross.run y mpy_cross.mpy_version son los tres puntos de entrada; mpy_cross.CrossCompileError transporta el stderr del compilador cuando algo va mal. Las constantes de arquitectura (NATIVE_ARCH_ARMV7EMSP, NATIVE_ARCH_ARMV7EMDP, etc.) coinciden con las cadenas que acepta la opción -march.

Versionado y compatibilidad de los archivos .mpy

Un archivo .mpy dado puede ser compatible o no con un sistema MicroPython concreto. La compatibilidad se basa en lo siguiente:

  • Versión del archivo .mpy: la versión del archivo debe coincidir con la versión que admite el sistema que lo carga.

  • Subversión del archivo .mpy: si el archivo .mpy contiene código máquina nativo, la subversión del archivo debe coincidir con la versión que admite el sistema que lo carga. En caso contrario, si no hay código máquina nativo en el archivo .mpy, la subversión se ignora durante la carga.

  • Bits de los enteros pequeños: el archivo .mpy requerirá un número mínimo de bits en un small integer y el sistema que lo carga debe admitir al menos esa cantidad de bits.

  • Arquitectura nativa: si el archivo .mpy contiene código máquina nativo, especificará la arquitectura de ese código máquina y el sistema que lo carga debe admitir la ejecución del código de esa arquitectura.

Si un sistema MicroPython admite la importación de archivos .mpy, el campo sys.implementation._mpy existirá y devolverá un entero que codifica la versión (los 8 bits inferiores), las características y la arquitectura nativa.

Intentar importar un archivo .mpy que falle una de las primeras cuatro pruebas lanzará ValueError('incompatible .mpy file'). Intentar importar un archivo .mpy que falle la prueba de arquitectura nativa (si contiene código máquina nativo) lanzará ValueError('incompatible .mpy arch').

Si la importación de un archivo .mpy falla, prueba lo siguiente:

  • Determina la versión .mpy y las opciones que admite tu sistema MicroPython ejecutando:

    import sys
    sys_mpy = sys.implementation._mpy
    arch = [None, 'x86', 'x64',
        'armv6', 'armv6m', 'armv7m', 'armv7em', 'armv7emsp', 'armv7emdp',
        'xtensa', 'xtensawin', 'rv32imc', 'rv64imc'][(sys_mpy >> 10) & 0x0F]
    print('mpy version:', sys_mpy & 0xff)
    print('mpy sub-version:', sys_mpy >> 8 & 3)
    print('mpy flags:', end='')
    if arch:
        print(' -march=' + arch, end='')
    if (sys_mpy >> 16) != 0:
        print(' -march-flags=' + (sys_mpy >> 16), end='')
    print()
    
  • Comprueba la validez del archivo .mpy inspeccionando los dos primeros bytes del archivo. El primer byte debe ser una “M” mayúscula y el segundo byte será el número de versión, que debe coincidir con la versión del sistema obtenida arriba. Si no coincide, vuelve a compilar el archivo .mpy.

  • Comprueba si la versión .mpy del sistema coincide con la versión emitida por el mpy-cross que se usó para compilar el archivo .mpy, que se obtiene con mpy-cross --version. Si no coincide, vuelve a compilar mpy-cross desde el repositorio Git extraído en la etiqueta (o hash) que informa mpy-cross --version.

  • Asegúrate de estar usando las opciones correctas de mpy-cross, que se obtienen con el código anterior o inspeccionando la variable de Makefile MPY_CROSS_FLAGS del port que estás usando.

  • Si el tercer byte del archivo .mpy tiene activado el bit n.º 6, comprueba si el vuint de bits de indicadores específicos de la arquitectura codificado es compatible con el destino en el que estás importando el archivo.

La siguiente tabla muestra la correspondencia entre la versión de MicroPython y la versión .mpy.

Versión de MicroPython

versión .mpy

v1.23.0 y superiores

6.3

v1.22.x

6.2

v1.20 - v1.21.0

6.1

v1.19.x

6

v1.12 - v1.18

5

v1.11

4

v1.9.3 - v1.10

3

v1.9 - v1.9.2

2

v1.5.1 - v1.8.7

0

Para mayor completitud, la siguiente tabla muestra el commit de Git del repositorio principal de MicroPython en el que se cambió la versión .mpy.

cambio de versión .mpy

commit de Git

6.2 a 6.3

bdbc869f9ea200c0d28b2bc7bfb60acd9d884e1b

6.1 a 6.2

6967ff3c581a66f73e9f3d78975f47528db39980

6 a 6.1

d94141e1473aebae0d3c63aeaa8397651ad6fa01

5 a 6

f2040bfc7ee033e48acef9f289790f3b4e6b74e5

4 a 5

5716c5cf65e9b2cb46c2906f40302401bdd27517

3 a 4

9a5f92ea72754c01cc03e5efcdfe94021120531e

2 a 3

ff93fd4f50321c6190e1659b19e64fef3045a484

1 a 2

dd11af209d226b7d18d5148b239662e30ed60bad

0 a 1

6a11048af1d01c78bdacddadd1b72dc7ba7c6478

versión inicial 0

d8c834c95d506db979ec871417de90b7951edc30

Codificación binaria de los archivos .mpy

Los archivos .mpy de MicroPython son un formato contenedor binario con objetos de código (bytecode y código máquina nativo) almacenados internamente en una jerarquía anidada. El código del módulo exterior se almacena primero y, a continuación, le siguen sus hijos. Cada hijo puede tener más hijos, por ejemplo en el caso de una clase con métodos, o de una función que define una lambda o una comprensión. Para mantener los archivos pequeños sin dejar de ofrecer un amplio rango de valores posibles, utiliza el concepto de entero sin signo codificado de forma variable (vuint) en muchos lugares. De forma similar a la codificación UTF-8, esta codificación almacena 7 bits por byte con el 8.º bit (MSB) activado si le siguen uno o más bytes. Los bits del entero sin signo se almacenan en el vuint en forma LSB.

El nivel superior de un archivo .mpy consta de tres partes:

  • La cabecera.

  • Las tablas globales de qstr y de constantes.

  • El código en bruto (raw-code) del ámbito exterior del módulo. Este ámbito exterior se ejecuta cuando se importa el archivo .mpy.

Puedes inspeccionar el contenido de un archivo .mpy usando mpy-tool.py, por ejemplo (ejecutado desde la raíz del repositorio principal de MicroPython):

$ ./tools/mpy-tool.py -xd myfile.mpy

La cabecera

La cabecera del .mpy es:

tamaño

campo

byte

valor 0x4d (ASCII “M”)

byte

número de versión mayor del .mpy

byte

indicadores de características, arquitectura nativa, número de versión menor (eran indicadores de características en versiones anteriores)

byte

número de bits en un entero pequeño

El tercer byte se divide de la siguiente manera (MSB primero):

bit

significado

7

reservado, debe ser 0

6

tras la cabecera sigue un vuint de indicadores específicos de la arquitectura

5..2

número de arquitectura nativa

1..0

número de versión menor

Indicadores específicos de la arquitectura

Si el bit n.º 6 del byte de indicadores de características de la cabecera está activado, tras la cabecera seguirá un vuint que contiene información opcional específica de la arquitectura. El contenido de este entero depende de la arquitectura nativa a la que va destinado el archivo.

Actualmente esto se usa para almacenar qué extensiones del procesador RISC-V necesita el archivo MPY para funcionar correctamente, además de I, M, C y Zicsr. Las distintas variantes de ArmV7 se identifican por su número de arquitectura nativa, pero reutilizar ese mecanismo complicaría las cosas para RV32 y RV64.

Los archivos MPY destinados a RV32 o RV64 que no necesitan ninguna extensión concreta del procesador no necesitan proporcionar un entero de indicadores (junto con la activación del bit apropiado en la cabecera). La ausencia de un valor de indicadores para los archivos MPY de RV32 y RV64 se usa para indicar que no se necesita ninguna extensión específica, y ahorra un byte en el binario de salida final.

Consulta también la opción de línea de comandos -march-flags tanto en mpy-tool.py como en mpy-cross, y la opción de línea de comandos --arch-flags en mpy_ld.py para establecer este valor al crear archivos MPY.

Las tablas globales de qstr y de constantes

Un archivo .mpy contiene una única tabla de qstr y una única tabla de objetos constantes. Estas son globales al archivo .mpy y todos los objetos de código en bruto anidados las referencian. La tabla de qstr asigna el número de qstr interno (interno al archivo .mpy) al número de qstr resuelto del runtime en el que se importa el archivo .mpy. Esto vincula el archivo .mpy con el resto del sistema dentro del cual se ejecuta. La tabla de objetos constantes se rellena con referencias a todos los objetos constantes que necesita el archivo .mpy.

tamaño

campo

vuint

número de qstr

vuint

número de objetos constantes

datos de qstr

objetos constantes codificados

Elementos de código en bruto

Un elemento de código en bruto contiene código, ya sea bytecode o código máquina nativo. Su contenido es:

tamaño

campo

vuint

tipo, tamaño y si hay subelementos de código en bruto

código (bytecode o código máquina)

vuint

número de subelementos de código en bruto (solo si es distinto de cero)

subelementos de código en bruto

El primer vuint de un elemento de código en bruto codifica el tipo de código almacenado en este elemento (los dos bits menos significativos), si este código en bruto tiene hijos (el tercer bit menos significativo) y la longitud del código que sigue (la cantidad de RAM que se le debe asignar).

Tras el vuint viene el propio código. A menos que el tipo de código sea código viper con reubicaciones, este código es dato constante y no necesita modificarse.

Si este código en bruto tiene hijos (según indica un bit del primer vuint), tras el código viene un vuint que cuenta el número de subelementos de código en bruto.

Por último, se almacenan, de forma recursiva, los subelementos de código en bruto que haya.