Código máquina nativo em ficheiros .mpy

Esta secção descreve como compilar e trabalhar com ficheiros .mpy que contêm código máquina nativo de uma linguagem diferente do Python. Isto permite escrever código numa linguagem como C, compilá-lo e ligá-lo num ficheiro .mpy, e depois importar esse ficheiro como um módulo Python normal. Pode ser utilizado para implementar funcionalidades críticas em termos de desempenho, ou para incluir uma biblioteca existente escrita noutra linguagem.

Uma das principais vantagens de utilizar ficheiros .mpy nativos é que o código máquina nativo pode ser importado por um script de forma dinâmica, sem necessidade de recompilar o firmware principal do MicroPython. Isto contrasta com Módulos C externos para MicroPython, que também permite definir módulos personalizados em C, mas estes têm de ser compilados na imagem principal do firmware.

O foco aqui é na utilização de C para construir módulos nativos, mas em princípio qualquer linguagem que possa ser compilada para código máquina autónomo pode ser colocada num ficheiro .mpy.

Um módulo .mpy nativo é construído com a ferramenta mpy_ld.py, que se encontra no directório tools/ do projecto. Esta ferramenta recebe um conjunto de ficheiros objecto (.o) e liga-os para criar um ficheiro .mpy nativo. Requer CPython 3 e a biblioteca pyelftools v0.25 ou superior.

Funcionalidades suportadas e limitações

Um ficheiro .mpy pode conter bytecode MicroPython e/ou código máquina nativo. Se contiver código máquina nativo, o ficheiro .mpy tem uma arquitectura específica associada. As arquitecturas actualmente suportadas são (estas são as opções válidas para a variável ARCH, ver abaixo):

  • x86 (32 bits)

  • x64 (x86 de 64 bits)

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

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

  • armv7emsp (ARM Thumb 2, vírgula flutuante de precisão simples, p. ex. Cortex-M4F, Cortex-M7)

  • armv7emdp (ARM Thumb 2, vírgula flutuante de precisão dupla, p. ex. Cortex-M7)

  • xtensa (sem janelas, p. ex. ESP8266)

  • xtensawin (com janelas de tamanho 8, p. ex. ESP32, ESP32S3)

  • rv32imc (RISC-V 32 bits com instruções comprimidas, p. ex. ESP32C3, ESP32C6)

  • rv64imc (RISC-V 64 bits com instruções comprimidas)

Se a plataforma escolhida suportar flags de arquitectura explícitas e pretender que o ficheiro .mpy de saída as inclua, deve passá-las para a variável ARCH_FLAGS ao compilar o ficheiro .mpy.

Ao compilar e ligar o ficheiro .mpy nativo, a arquitectura tem de ser escolhida e o ficheiro correspondente só pode ser importado nessa arquitectura (e se estiverem presentes flags de arquitectura, apenas se estas coincidirem com as capacidades do alvo). Para mais detalhes sobre ficheiros .mpy, consulte Ficheiros .mpy do MicroPython.

O código nativo tem de ser compilado como código independente de posição (PIC) e utilizar uma tabela de deslocamentos global (GOT), embora os detalhes variem de arquitectura para arquitectura. Ao importar ficheiros .mpy com código nativo, o mecanismo de importação consegue efectuar alguma recolocação básica do código nativo. Isto inclui a recolocação das secções de texto, rodata e BSS.

As funcionalidades suportadas pelo ligador e pelo carregador dinâmico são:

  • código executável (text)

  • dados apenas de leitura (rodata), incluindo cadeias de caracteres e dados constantes (arrays, structs, etc.)

  • dados zerados (BSS)

  • ponteiros em text para text, rodata e BSS

  • ponteiros em rodata para text, rodata e BSS

As limitações conhecidas são:

  • as secções de dados não são suportadas; alternativa: usar dados BSS e inicializar os valores de dados explicitamente

  • variáveis BSS estáticas não são suportadas; alternativa: usar variáveis BSS globais

  • variáveis de armazenamento local por thread não são suportadas em rv32imc; alternativa: usar variáveis BSS globais ou alocar espaço no heap para as armazenar

Assim, se o seu código C tiver dados graváveis, certifique-se de que os dados estão definidos globalmente, sem inicializador, e apenas escritos dentro de funções.

O módulo nativo não é automaticamente ligado contra as bibliotecas estáticas padrão como libm.a e libgcc.a, o que pode resultar em erros de undefined symbol. Pode ligar as bibliotecas de runtime definindo LINK_RUNTIME = 1 no seu Makefile. Bibliotecas estáticas personalizadas também podem ser ligadas adicionando MPY_LD_FLAGS += -l path/to/library.a. Note que estas são ligadas ao módulo nativo e não serão partilhadas com outros módulos ou com o sistema.

Limitação do ligador: o módulo nativo não é ligado contra a tabela de símbolos do firmware completo do MicroPython. Em vez disso, é ligado contra uma tabela explícita de símbolos exportados encontrada em mp_fun_table (em py/nativeglue.h), que é fixada no momento da compilação do firmware. Assim, não é possível simplesmente chamar alguma função arbitrária de HAL/OS/RTOS/sistema, por exemplo, a menos que esta resida num endereço fixo. Nesse caso, o caminho de um linkerscript contendo uma série de nomes de símbolos e os seus endereços fixos pode ser passado para mpy_ld.py através do argumento de linha de comandos --externs. Dessa forma, os símbolos presentes no linkerscript terão precedência sobre os fornecidos pelos ficheiros objecto, mas neste momento a implementação dos ficheiros objecto continuará a residir no ficheiro MPY final. O parser do linkerscript tem capacidades limitadas e é actualmente utilizado apenas para analisar a lista de símbolos ROM do porto ESP8266 (ver ports/esp8266/boards/eagle.rom.addr.v6.ld).

Podem ser adicionados novos símbolos ao fim da tabela e o firmware recompilado. Os símbolos também têm de ser adicionados ao dicionário fun_table de tools/mpy_ld.py na mesma localização. Isto permite que mpy_ld.py possa identificar os novos símbolos e fornecer recolocações para eles quando o mpy é importado. Por fim, se o símbolo for uma função, deve ser adicionada uma macro ou stub a py/dynruntime.h para facilitar a chamada da função.

Definição de um módulo nativo

Um módulo .mpy nativo é definido por um conjunto de ficheiros utilizados para o construir. A estrutura do sistema de ficheiros consiste em duas partes principais, os ficheiros fonte e o Makefile:

  • No caso mais simples, apenas é necessário um único ficheiro fonte em C, que contém todo o código que será compilado no módulo .mpy. Este código fonte em C tem de incluir o ficheiro py/dynruntime.h para aceder à API dinâmica do MicroPython, e tem de definir pelo menos uma função chamada mpy_init. Esta função será o ponto de entrada do módulo, chamada quando o módulo for importado.

    O módulo pode ser dividido em múltiplos ficheiros fonte em C, se desejado. Partes do módulo também podem ser implementadas em Python. Todos os ficheiros fonte devem ser listados no Makefile, adicionando-os à variável SRC (ver abaixo). Isto inclui tanto os ficheiros fonte em C como quaisquer ficheiros Python que serão incluídos no ficheiro .mpy resultante.

  • O Makefile contém a configuração de compilação do módulo e lista os ficheiros fonte utilizados para construir o módulo .mpy. Deve definir MPY_DIR como a localização do repositório MicroPython (para encontrar ficheiros de cabeçalho, o fragmento de Makefile relevante e a ferramenta mpy_ld.py), MOD como o nome do módulo, SRC como a lista de ficheiros fonte, opcionalmente especificar a arquitectura da máquina através de ARCH, juntamente com flags opcionais de arquitectura da máquina especificadas via ARCH_FLAGS, e depois incluir py/dynruntime.mk.

Exemplo mínimo

Esta secção fornece um exemplo completamente funcional de um módulo simples chamado factorial. Este módulo disponibiliza uma única função factorial.factorial(x) que calcula o factorial da entrada e devolve o resultado.

Estrutura de directórios:

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

O ficheiro factorial.c contém:

// 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
}

O ficheiro Makefile contém:

# 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

Compilação do módulo

As ferramentas pré-requisito necessárias para construir um ficheiro .mpy nativo são:

  • O repositório MicroPython (pelo menos os directórios py/ e tools/).

  • CPython 3 e a biblioteca pyelftools (p. ex. pip install 'pyelftools>=0.25').

  • GNU make.

  • Um compilador C para a arquitectura alvo (se for utilizado código fonte em C).

  • Opcionalmente mpy-cross, compilado a partir do repositório MicroPython (se for utilizado código fonte .py).

Certifique-se de seleccionar o ARCH correcto para o alvo em que vai executar. Depois compile com:

$ make

Sem modificar o Makefile, pode especificar a arquitectura alvo via:

$ make ARCH=armv7m

O mesmo se aplica às flags opcionais de arquitectura via:

$ make ARCH=rv32imc ARCH_FLAGS=zba

Utilização do módulo em MicroPython

Depois de o módulo ser construído, deverá existir um ficheiro chamado factorial.mpy. Copie-o de modo a que seja acessível no sistema de ficheiros do seu sistema MicroPython e possa ser encontrado no caminho de importação. O módulo pode agora ser acedido em Python tal como qualquer outro módulo, por exemplo:

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

Utilizar Picolibc ao compilar módulos

Utilizar Picolibc como biblioteca padrão C não é apenas suportado, mas é de facto o padrão para as plataformas rv32imc e rv64imc. No entanto, há algumas questões que vale a pena mencionar para garantir que não encontra problemas ao compilar código.

Algumas versões pré-compiladas do Picolibc (por exemplo, as fornecidas pelo Ubuntu Linux como os pacotes picolibc-arm-none-eabi, picolibc-riscv64-unknown-elf e picolibc-xtensa-lx106-elf) pressupõem que o armazenamento local por thread (TLS) está disponível em tempo de execução, mas infelizmente os módulos MicroPython não suportam isso em algumas arquitecturas (nomeadamente rv32imc e rv64imc). Isto significa que algumas funcionalidades fornecidas pelo Picolibc utilizarão por defeito TLS, devolvendo um erro durante a compilação ou durante a ligação.

Para um exemplo de como isto pode afectá-lo, o módulo de exemplo examples/natmod/btree contém uma solução alternativa para garantir que errno funciona (procure __PICOLIBC_ERRNO_FUNCTION no Makefile e siga o rasto a partir daí).

Exemplos adicionais

Consulte examples/natmod/ para obter exemplos adicionais que mostram muitas das funcionalidades disponíveis dos módulos .mpy nativos. Tais funcionalidades incluem:

  • utilização de múltiplos ficheiros fonte em C

  • inclusão de código Python juntamente com código C

  • dados rodata e BSS

  • alocação de memória

  • utilização de vírgula flutuante

  • tratamento de excepções

  • inclusão de bibliotecas C externas