Codice macchina nativo nei file .mpy

Questa sezione descrive come compilare e lavorare con i file .mpy che contengono codice macchina nativo proveniente da un linguaggio diverso da Python. Questo consente di scrivere codice in un linguaggio come il C, compilarlo e collegarlo in un file .mpy, per poi importare tale file come un normale modulo Python. Può essere usato per implementare funzionalità critiche per le prestazioni, oppure per includere una libreria esistente scritta in un altro linguaggio.

Uno dei principali vantaggi dell’uso dei file .mpy nativi è che il codice macchina nativo può essere importato dinamicamente da uno script, senza la necessità di ricompilare il firmware principale di MicroPython. Questo è in contrasto con i Moduli C esterni di MicroPython, che consentono anch’essi di definire moduli personalizzati in C, ma che devono essere compilati nell’immagine del firmware principale.

Qui ci si concentra sull’uso del C per costruire moduli nativi, ma in linea di principio qualsiasi linguaggio che possa essere compilato in codice macchina autonomo può essere inserito in un file .mpy.

Un modulo .mpy nativo viene costruito usando lo strumento mpy_ld.py, che si trova nella directory tools/ del progetto. Questo strumento prende un insieme di file oggetto (file .o) e li collega insieme per creare un file .mpy nativo. Richiede CPython 3 e la libreria pyelftools v0.25 o superiore.

Funzionalità supportate e limitazioni

Un file .mpy può contenere bytecode di MicroPython e/o codice macchina nativo. Se contiene codice macchina nativo, allora il file .mpy ha una specifica architettura associata. Le architetture attualmente supportate sono (queste sono le opzioni valide per la variabile ARCH, vedi sotto):

  • x86 (32 bit)

  • x64 (x86 a 64 bit)

  • armv6m (ARM Thumb, ad es. Cortex-M0)

  • armv7m (ARM Thumb 2, ad es. Cortex-M3)

  • armv7emsp (ARM Thumb 2, virgola mobile a precisione singola, ad es. Cortex-M4F, Cortex-M7)

  • armv7emdp (ARM Thumb 2, virgola mobile a precisione doppia, ad es. Cortex-M7)

  • xtensa (non-windowed, ad es. ESP8266)

  • xtensawin (windowed con dimensione finestra 8, ad es. ESP32, ESP32S3)

  • rv32imc (RISC-V a 32 bit con istruzioni compresse, ad es. ESP32C3, ESP32C6)

  • rv64imc (RISC-V a 64 bit con istruzioni compresse)

Se la piattaforma scelta supporta flag di architettura espliciti e si desidera che il file .mpy di output contenga il valore di tali flag, è necessario passarli alla variabile di flag ARCH_FLAGS durante la compilazione del file .mpy.

Quando si compila e si collega il file .mpy nativo, occorre scegliere l’architettura e il file corrispondente potrà essere importato solo su quell’architettura (e, se sono presenti flag di architettura, solo se essi corrispondono alle capacità del target). Per maggiori dettagli sui file .mpy vedi File .mpy di MicroPython.

Il codice nativo deve essere compilato come codice indipendente dalla posizione (PIC) e utilizzare una global offset table (GOT), sebbene i dettagli di ciò varino da architettura ad architettura. Quando si importano file .mpy con codice nativo, il meccanismo di importazione è in grado di effettuare alcune rilocazioni di base del codice nativo. Questo include la rilocazione delle sezioni text, rodata e BSS.

Le funzionalità supportate dal linker e dal loader dinamico sono:

  • codice eseguibile (text)

  • dati di sola lettura (rodata), incluse stringhe e dati costanti (array, struct, ecc.)

  • dati azzerati (BSS)

  • puntatori in text verso text, rodata e BSS

  • puntatori in rodata verso text, rodata e BSS

Le limitazioni note sono:

  • le sezioni di dati (data) non sono supportate; soluzione alternativa: usare dati BSS e inizializzare i valori dei dati esplicitamente

  • le variabili BSS statiche non sono supportate; soluzione alternativa: usare variabili BSS globali

  • le variabili di thread-local storage non sono supportate su rv32imc; soluzione alternativa: usare variabili BSS globali oppure allocare dello spazio sull’heap per memorizzarle

Quindi, se il tuo codice C ha dati scrivibili, assicurati che i dati siano definiti globalmente, senza un inizializzatore, e che vengano scritti solo all’interno di funzioni.

Il modulo nativo non viene collegato automaticamente con le librerie statiche standard come libm.a e libgcc.a, il che può portare a errori di tipo undefined symbol. Puoi collegare le librerie di runtime impostando LINK_RUNTIME = 1 nel tuo Makefile. È possibile collegare anche librerie statiche personalizzate aggiungendo MPY_LD_FLAGS += -l path/to/library.a. Nota che queste vengono collegate nel modulo nativo e non saranno condivise con altri moduli o con il sistema.

Limitazione del linker: il modulo nativo non viene collegato con la tabella dei simboli dell’intero firmware di MicroPython. Viene invece collegato con una tabella esplicita di simboli esportati che si trova in mp_fun_table (in py/nativeglue.h), fissata al momento della compilazione del firmware. Non è quindi possibile chiamare semplicemente una qualsiasi funzione HAL/OS/RTOS/di sistema, ad esempio, a meno che essa non risieda a un indirizzo fisso. In tal caso, il percorso di un linkerscript contenente una serie di nomi di simboli e i loro indirizzi fissi può essere passato a mpy_ld.py tramite l’argomento da riga di comando --externs. In questo modo i simboli che appaiono nel linkerscript avranno la precedenza su quanto fornito dai file oggetto, ma al momento l’implementazione dei file oggetto risiederà comunque nel file MPY finale. Il parser del linkerscript ha capacità limitate ed è attualmente usato solo per l’analisi dell’elenco dei simboli ROM del port ESP8266 (vedi ports/esp8266/boards/eagle.rom.addr.v6.ld).

Nuovi simboli possono essere aggiunti alla fine della tabella e il firmware ricompilato. I simboli devono essere aggiunti anche al dizionario fun_table di tools/mpy_ld.py nella stessa posizione. Questo consente a mpy_ld.py di poter rilevare i nuovi simboli e fornire le rilocazioni per essi quando l’mpy viene importato. Infine, se il simbolo è una funzione, è opportuno aggiungere una macro o uno stub a py/dynruntime.h per facilitare la chiamata della funzione.

Definire un modulo nativo

Un modulo .mpy nativo è definito da un insieme di file che vengono usati per costruire l”.mpy. Il layout del filesystem è composto da due parti principali, i file sorgente e il Makefile:

  • Nel caso più semplice è richiesto un solo file sorgente C, che contiene tutto il codice che verrà compilato nel modulo .mpy. Questo codice sorgente C deve includere il file py/dynruntime.h per accedere all’API dinamica di MicroPython, e deve definire almeno una funzione chiamata mpy_init. Questa funzione sarà il punto di ingresso del modulo, chiamata quando il modulo viene importato.

    Il modulo può essere suddiviso in più file sorgente C, se lo si desidera. Parti del modulo possono anche essere implementate in Python. Tutti i file sorgente devono essere elencati nel Makefile, aggiungendoli alla variabile SRC (vedi sotto). Questo include sia i file sorgente C che eventuali file Python che verranno inclusi nel file .mpy risultante.

  • Il Makefile contiene la configurazione di compilazione per il modulo ed elenca i file sorgente usati per costruire il modulo .mpy. Deve definire MPY_DIR come posizione del repository di MicroPython (per trovare i file di intestazione, il relativo frammento di Makefile e lo strumento mpy_ld.py), MOD come nome del modulo, SRC come elenco dei file sorgente, opzionalmente specificare l’architettura macchina tramite ARCH, insieme a flag opzionali di architettura macchina specificati tramite ARCH_FLAGS, e quindi includere py/dynruntime.mk.

Esempio minimo

Questa sezione fornisce un esempio completo e funzionante di un semplice modulo chiamato factorial. Questo modulo fornisce una singola funzione factorial.factorial(x) che calcola il fattoriale dell’input e ne restituisce il risultato.

Layout della directory:

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

Il file 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
}

Il file 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

Compilare il modulo

Gli strumenti prerequisiti necessari per costruire un file .mpy nativo sono:

  • Il repository di MicroPython (almeno le directory py/ e tools/).

  • CPython 3 e la libreria pyelftools (ad es. pip install 'pyelftools>=0.25').

  • GNU make.

  • Un compilatore C per l’architettura target (se viene usato sorgente C).

  • Opzionalmente mpy-cross, compilato dal repository di MicroPython (se viene usato sorgente .py).

Assicurati di selezionare l”ARCH corretto per il target su cui andrai a eseguire il codice. Quindi compila con:

$ make

Senza modificare il Makefile puoi specificare l’architettura target tramite:

$ make ARCH=armv7m

Lo stesso vale per i flag opzionali di architettura tramite:

$ make ARCH=rv32imc ARCH_FLAGS=zba

Uso del modulo in MicroPython

Una volta costruito il modulo dovrebbe esserci un file chiamato factorial.mpy. Copialo in modo che sia accessibile sul filesystem del tuo sistema MicroPython e possa essere trovato nel percorso di importazione. Il modulo può ora essere acceduto in Python proprio come qualsiasi altro modulo, ad esempio:

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

Usare Picolibc durante la compilazione dei moduli

L’uso di Picolibc come libreria standard C non è solo supportato, ma è di fatto l’impostazione predefinita per le piattaforme rv32imc e rv64imc. Tuttavia, ci sono un paio di cose che vale la pena menzionare per assicurarsi di non incorrere in problemi più avanti durante la compilazione del codice.

Alcune versioni precompilate di Picolibc (ad esempio quelle fornite da Ubuntu Linux con i pacchetti picolibc-arm-none-eabi, picolibc-riscv64-unknown-elf e picolibc-xtensa-lx106-elf) presuppongono che il thread-local storage (TLS) sia disponibile a runtime, ma sfortunatamente i moduli di MicroPython non lo supportano su alcune architetture (in particolare rv32imc e rv64imc). Questo significa che alcune funzionalità fornite da Picolibc useranno per impostazione predefinita il TLS, restituendo un errore durante la compilazione oppure durante il linking.

Per un esempio di come ciò possa interessarti, il modulo di esempio examples/natmod/btree contiene una soluzione alternativa per assicurarsi che errno funzioni (cerca __PICOLIBC_ERRNO_FUNCTION nel Makefile e segui la traccia da lì).

Ulteriori esempi

Vedi examples/natmod/ per ulteriori esempi che mostrano molte delle funzionalità disponibili dei moduli .mpy nativi. Tali funzionalità includono:

  • uso di più file sorgente C

  • inclusione di codice Python insieme a codice C

  • dati rodata e BSS

  • allocazione di memoria

  • uso della virgola mobile

  • gestione delle eccezioni

  • inclusione di librerie C esterne