Código máquina nativo en archivos .mpy

Esta sección describe cómo compilar y trabajar con archivos .mpy que contienen código máquina nativo de un lenguaje distinto de Python. Esto le permite escribir código en un lenguaje como C, compilarlo y enlazarlo en un archivo .mpy, y luego importar este archivo como un módulo normal de Python. Esto puede usarse para implementar funcionalidades que son críticas para el rendimiento, o para incluir una biblioteca existente escrita en otro lenguaje.

Una de las principales ventajas de usar archivos .mpy nativos es que el código máquina nativo puede ser importado dinámicamente por un script, sin necesidad de recompilar el firmware principal de MicroPython. Esto contrasta con Módulos C externos de MicroPython, que también permite definir módulos personalizados en C pero que deben compilarse dentro de la imagen del firmware principal.

El enfoque aquí está en usar C para compilar módulos nativos, pero en principio cualquier lenguaje que pueda compilarse a código máquina autónomo puede colocarse en un archivo .mpy.

Un módulo .mpy nativo se compila usando la herramienta mpy_ld.py, que se encuentra en el directorio tools/ del proyecto. Esta herramienta toma un conjunto de archivos objeto (archivos .o) y los enlaza entre sí para crear un archivo .mpy nativo. Requiere CPython 3 y la biblioteca pyelftools v0.25 o superior.

Características compatibles y limitaciones

Un archivo .mpy puede contener bytecode de MicroPython o código máquina nativo, o ambos. Si contiene código máquina nativo, entonces el archivo .mpy tiene una arquitectura específica asociada. Las arquitecturas actualmente compatibles son (estas son las opciones válidas para la variable ARCH, véase más abajo):

  • x86 (32 bits)

  • x64 (x86 de 64 bits)

  • armv6m (ARM Thumb, p. ej. Cortex-M0)

  • armv7m (ARM Thumb 2, p. ej. Cortex-M3)

  • armv7emsp (ARM Thumb 2, coma flotante de precisión simple, p. ej. Cortex-M4F, Cortex-M7)

  • armv7emdp (ARM Thumb 2, coma flotante de doble precisión, p. ej. Cortex-M7)

  • xtensa (sin ventanas, p. ej. ESP8266)

  • xtensawin (con ventanas de tamaño 8, p. ej. ESP32, ESP32S3)

  • rv32imc (RISC-V de 32 bits con instrucciones comprimidas, p. ej. ESP32C3, ESP32C6)

  • rv64imc (RISC-V de 64 bits con instrucciones comprimidas)

Si la plataforma elegida admite flags de arquitectura explícitos y desea que el archivo .mpy de salida lleve el valor de esos flags, debe pasarlos a la variable de flags ARCH_FLAGS al compilar el archivo .mpy.

Al compilar y enlazar el archivo .mpy nativo se debe elegir la arquitectura, y el archivo correspondiente solo puede importarse en esa arquitectura (y, si hay flags de arquitectura presentes, solo si coinciden con las capacidades del destino). Para más detalles sobre los archivos .mpy, véase Archivos .mpy de MicroPython.

El código nativo debe compilarse como código independiente de la posición (PIC) y usar una tabla de desplazamientos global (GOT), aunque los detalles de esto varían de una arquitectura a otra. Al importar archivos .mpy con código nativo, el mecanismo de importación es capaz de realizar cierta reubicación básica del código nativo. Esto incluye la reubicación de las secciones text, rodata y BSS.

Las características compatibles del enlazador y el cargador dinámico son:

  • código ejecutable (text)

  • datos de solo lectura (rodata), incluyendo cadenas y datos constantes (arrays, structs, etc.)

  • datos a cero (BSS)

  • punteros en text hacia text, rodata y BSS

  • punteros en rodata hacia text, rodata y BSS

Las limitaciones conocidas son:

  • las secciones de datos no son compatibles; solución alternativa: use datos BSS e inicialice los valores de los datos explícitamente

  • las variables BSS estáticas no son compatibles; solución alternativa: use variables BSS globales

  • las variables de almacenamiento local del hilo (thread-local storage) no son compatibles en rv32imc; solución alternativa: use variables BSS globales o reserve algo de espacio en el heap para almacenarlas

Por lo tanto, si su código C tiene datos modificables, asegúrese de que los datos estén definidos globalmente, sin inicializador, y de que solo se escriban dentro de funciones.

El módulo nativo no se enlaza automáticamente con las bibliotecas estáticas estándar como libm.a y libgcc.a, lo que puede provocar errores de undefined symbol. Puede enlazar las bibliotecas en tiempo de ejecución estableciendo LINK_RUNTIME = 1 en su Makefile. También se pueden enlazar bibliotecas estáticas personalizadas añadiendo MPY_LD_FLAGS += -l path/to/library.a. Tenga en cuenta que estas se enlazan dentro del módulo nativo y no se compartirán con otros módulos ni con el sistema.

Limitación del enlazador: el módulo nativo no se enlaza con la tabla de símbolos del firmware completo de MicroPython. En cambio, se enlaza con una tabla explícita de símbolos exportados que se encuentra en mp_fun_table (en py/nativeglue.h), que se fija en el momento de compilar el firmware. Por lo tanto, no es posible simplemente llamar a una función arbitraria de HAL/SO/RTOS/sistema, por ejemplo, a menos que esta resida en una dirección fija. En ese caso, la ruta de un linkerscript que contenga una serie de nombres de símbolos y sus direcciones fijas puede pasarse a mpy_ld.py mediante el argumento de línea de comandos --externs. De esa manera, los símbolos que aparezcan en el linkerscript tendrán prioridad sobre lo que proporcionen los archivos objeto, pero por el momento la implementación de los archivos objeto seguirá residiendo en el archivo MPY final. El analizador del linkerscript es limitado en sus capacidades, y actualmente se usa solo para analizar la lista de símbolos de ROM del port ESP8266 (véase ports/esp8266/boards/eagle.rom.addr.v6.ld).

Se pueden añadir nuevos símbolos al final de la tabla y recompilar el firmware. Los símbolos también deben añadirse al diccionario fun_table de tools/mpy_ld.py en la misma ubicación. Esto permite que mpy_ld.py pueda detectar los nuevos símbolos y proporcionar reubicaciones para ellos cuando se importe el mpy. Por último, si el símbolo es una función, debe añadirse una macro o un stub a py/dynruntime.h para facilitar la llamada a la función.

Definición de un módulo nativo

Un módulo .mpy nativo se define mediante un conjunto de archivos que se usan para compilar el .mpy. La disposición del sistema de archivos consta de dos partes principales, los archivos fuente y el Makefile:

  • En el caso más simple solo se requiere un único archivo fuente en C, que contiene todo el código que se compilará en el módulo .mpy. Este código fuente en C debe incluir el archivo py/dynruntime.h para acceder a la API dinámica de MicroPython, y debe definir al menos una función llamada mpy_init. Esta función será el punto de entrada del módulo, que se llama cuando se importa el módulo.

    El módulo puede dividirse en varios archivos fuente en C si se desea. Algunas partes del módulo también pueden implementarse en Python. Todos los archivos fuente deben listarse en el Makefile, añadiéndolos a la variable SRC (véase más abajo). Esto incluye tanto los archivos fuente en C como cualquier archivo Python que se incluirá en el archivo .mpy resultante.

  • El Makefile contiene la configuración de compilación del módulo y lista los archivos fuente usados para compilar el módulo .mpy. Debe definir MPY_DIR como la ubicación del repositorio de MicroPython (para encontrar los archivos de cabecera, el fragmento de Makefile correspondiente y la herramienta mpy_ld.py), MOD como el nombre del módulo, SRC como la lista de archivos fuente, especificar opcionalmente la arquitectura de la máquina mediante ARCH, junto con los flags opcionales de arquitectura de la máquina especificados mediante ARCH_FLAGS, y luego incluir py/dynruntime.mk.

Ejemplo mínimo

Esta sección proporciona un ejemplo completamente funcional de un módulo simple llamado factorial. Este módulo proporciona una única función factorial.factorial(x) que calcula el factorial de la entrada y devuelve el resultado.

Disposición del directorio:

factorial/
├── factorial.c
└── Makefile

El archivo factorial.c contiene:

// Include the header file to get access to the MicroPython API
#include "py/dynruntime.h"

// Helper function to compute factorial
static mp_int_t factorial_helper(mp_int_t x) {
    if (x == 0) {
        return 1;
    }
    return x * factorial_helper(x - 1);
}

// This is the function which will be called from Python, as factorial(x)
static mp_obj_t factorial(mp_obj_t x_obj) {
    // Extract the integer from the MicroPython input object
    mp_int_t x = mp_obj_get_int(x_obj);
    // Calculate the factorial
    mp_int_t result = factorial_helper(x);
    // Convert the result to a MicroPython integer object and return it
    return mp_obj_new_int(result);
}
// Define a Python reference to the function above
static MP_DEFINE_CONST_FUN_OBJ_1(factorial_obj, factorial);

// This is the entry point and is called when the module is imported
mp_obj_t mpy_init(mp_obj_fun_bc_t *self, size_t n_args, size_t n_kw, mp_obj_t *args) {
    // This must be first, it sets up the globals dict and other things
    MP_DYNRUNTIME_INIT_ENTRY

    // Make the function available in the module's namespace
    mp_store_global(MP_QSTR_factorial, MP_OBJ_FROM_PTR(&factorial_obj));

    // This must be last, it restores the globals dict
    MP_DYNRUNTIME_INIT_EXIT
}

El archivo Makefile contiene:

# Location of top-level MicroPython directory
MPY_DIR = ../../..

# Name of module
MOD = factorial

# Source files (.c or .py)
SRC = factorial.c

# Architecture to build for (x86, x64, armv6m, armv7m, xtensa, xtensawin, rv32imc, rv64imc)
ARCH = x64

# Include to get the rules for compiling and linking the module
include $(MPY_DIR)/py/dynruntime.mk

Compilación del módulo

Las herramientas prerrequisito necesarias para compilar un archivo .mpy nativo son:

  • El repositorio de MicroPython (al menos los directorios py/ y tools/).

  • CPython 3, y la biblioteca pyelftools (p. ej. pip install 'pyelftools>=0.25').

  • GNU make.

  • Un compilador de C para la arquitectura de destino (si se usa código fuente en C).

  • Opcionalmente mpy-cross, compilado desde el repositorio de MicroPython (si se usa código fuente .py).

Asegúrese de seleccionar la ARCH correcta para el destino en el que va a ejecutar. Luego compile con:

$ make

Sin modificar el Makefile puede especificar la arquitectura de destino mediante:

$ make ARCH=armv7m

Lo mismo se aplica para los flags opcionales de arquitectura mediante:

$ make ARCH=rv32imc ARCH_FLAGS=zba

Uso del módulo en MicroPython

Una vez compilado el módulo debería haber un archivo llamado factorial.mpy. Cópielo de modo que sea accesible en el sistema de archivos de su sistema MicroPython y pueda encontrarse en la ruta de importación. Ahora se puede acceder al módulo en Python igual que cualquier otro módulo, por ejemplo:

import factorial
print(factorial.factorial(10))
# should display 3628800

Uso de Picolibc al compilar módulos

Usar Picolibc como su biblioteca estándar de C no solo es compatible, sino que de hecho es la opción predeterminada para las plataformas rv32imc y rv64imc. Sin embargo, hay un par de cosas que vale la pena mencionar para asegurarse de no tener problemas más adelante al compilar código.

Algunas versiones precompiladas de Picolibc (por ejemplo, las que proporciona Ubuntu Linux como los paquetes picolibc-arm-none-eabi, picolibc-riscv64-unknown-elf y picolibc-xtensa-lx106-elf) asumen que el almacenamiento local del hilo (TLS) está disponible en tiempo de ejecución, pero lamentablemente los módulos de MicroPython no admiten eso en algunas arquitecturas (concretamente rv32imc y rv64imc). Esto significa que algunas funcionalidades proporcionadas por Picolibc usarán TLS por defecto, devolviendo un error ya sea durante la compilación o durante el enlazado.

Para ver un ejemplo de cómo esto puede afectarle, el módulo de ejemplo examples/natmod/btree contiene una solución alternativa para asegurarse de que errno funcione (busque __PICOLIBC_ERRNO_FUNCTION en el Makefile y siga el rastro a partir de ahí).

Más ejemplos

Véase examples/natmod/ para más ejemplos que muestran muchas de las características disponibles de los módulos .mpy nativos. Tales características incluyen:

  • uso de múltiples archivos fuente en C

  • inclusión de código Python junto con código C

  • datos rodata y BSS

  • asignación de memoria

  • uso de coma flotante

  • manejo de excepciones

  • inclusión de bibliotecas externas en C