Código de máquina nativo em arquivos .mpy

Esta seção descreve como compilar e trabalhar com arquivos .mpy que contêm código de máquina nativo de uma linguagem diferente do Python. Isso permite escrever código em uma linguagem como C, compilá-lo e vinculá-lo em um arquivo .mpy, e então importar esse arquivo como um módulo Python normal. Isso pode ser usado para implementar funcionalidades cuja performance seja crítica, ou para incluir uma biblioteca existente escrita em outra linguagem.

Uma das principais vantagens de usar arquivos .mpy nativos é que o código de máquina nativo pode ser importado dinamicamente por um script, sem a necessidade de recompilar o firmware principal do MicroPython. Isso contrasta com os Módulos C externos do MicroPython, que também permitem definir módulos personalizados em C, mas que precisam ser compilados na imagem principal do firmware.

O foco aqui está em usar C para compilar módulos nativos, mas em princípio qualquer linguagem que possa ser compilada em código de máquina autônomo pode ser colocada em um arquivo .mpy.

Um módulo .mpy nativo é compilado usando a ferramenta mpy_ld.py, que se encontra no diretório tools/ do projeto. Essa ferramenta recebe um conjunto de arquivos-objeto (arquivos .o) e os vincula para criar um arquivo .mpy nativo. Ela requer o CPython 3 e a biblioteca pyelftools v0.25 ou superior.

Recursos suportados e limitações

Um arquivo .mpy pode conter bytecode do MicroPython e/ou código de máquina nativo. Se contiver código de máquina nativo, o arquivo .mpy terá uma arquitetura específica associada a ele. As arquiteturas atualmente suportadas são (estas são as opções válidas para a variável ARCH, veja abaixo):

  • x86 (32 bits)

  • x64 (x86 de 64 bits)

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

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

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

  • armv7emdp (ARM Thumb 2, ponto flutuante de precisão dupla, ex. Cortex-M7)

  • xtensa (não janelado, ex. ESP8266)

  • xtensawin (janelado com tamanho de janela 8, ex. ESP32, ESP32S3)

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

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

Se a plataforma escolhida oferece suporte a flags de arquitetura explícitas e você quiser que o arquivo .mpy de saída carregue o valor dessas flags, você deve passá-las para a variável de flags ARCH_FLAGS ao compilar o arquivo .mpy.

Ao compilar e vincular o arquivo .mpy nativo, a arquitetura deve ser escolhida, e o arquivo correspondente só pode ser importado naquela arquitetura (e, se houver flags de arquitetura presentes, somente se elas corresponderem às capacidades do alvo). Para mais detalhes sobre arquivos .mpy, veja Arquivos .mpy do MicroPython.

O código nativo deve ser compilado como código independente de posição (PIC) e usar uma tabela de deslocamento global (GOT), embora os detalhes disso variem de arquitetura para arquitetura. Ao importar arquivos .mpy com código nativo, o mecanismo de importação é capaz de realizar algumas relocações básicas do código nativo. Isso inclui a relocação das seções text, rodata e BSS.

Os recursos suportados pelo vinculador e pelo carregador dinâmico são:

  • código executável (text)

  • dados somente leitura (rodata), incluindo strings 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:

  • seções de dados não são suportadas; solução alternativa: use dados BSS e inicialize os valores dos dados explicitamente

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

  • variáveis de armazenamento local de thread não são suportadas em rv32imc; solução alternativa: use variáveis BSS globais ou aloque algum espaço no heap para armazená-las

Portanto, se o seu código C tiver dados graváveis, certifique-se de que os dados sejam definidos globalmente, sem um inicializador, e que sejam gravados somente dentro de funções.

O módulo nativo não é vinculado automaticamente às bibliotecas estáticas padrão como libm.a e libgcc.a, o que pode levar a erros de undefined symbol. Você pode vincular as bibliotecas de tempo de execução definindo LINK_RUNTIME = 1 no seu Makefile. Bibliotecas estáticas personalizadas também podem ser vinculadas adicionando MPY_LD_FLAGS += -l path/to/library.a. Observe que elas são vinculadas ao módulo nativo e não serão compartilhadas com outros módulos ou com o sistema.

Limitação do vinculador: o módulo nativo não é vinculado à tabela de símbolos do firmware completo do MicroPython. Em vez disso, ele é vinculado a 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. Não é, portanto, possível simplesmente chamar uma função arbitrária do HAL/OS/RTOS/sistema, por exemplo, a menos que ela resida em um endereço fixo. Nesse caso, o caminho de um linkerscript contendo uma série de nomes de símbolos e seus endereços fixos pode ser passado para o mpy_ld.py por meio do argumento de linha de comando --externs. Dessa forma, os símbolos que aparecem no linkerscript terão precedência sobre o que é fornecido pelos arquivos-objeto, mas, no momento, a implementação dos arquivos-objeto ainda residirá no arquivo MPY final. O analisador de linkerscript é limitado em suas capacidades e, atualmente, é usado apenas para analisar a lista de símbolos da ROM da porta ESP8266 (veja ports/esp8266/boards/eagle.rom.addr.v6.ld).

Novos símbolos podem ser adicionados ao final da tabela e o firmware recompilado. Os símbolos também precisam ser adicionados ao dicionário fun_table do tools/mpy_ld.py, no mesmo local. Isso permite que o mpy_ld.py consiga capturar os novos símbolos e fornecer relocações para eles quando o mpy é importado. Por fim, se o símbolo for uma função, uma macro ou stub deve ser adicionada a py/dynruntime.h para facilitar a chamada da função.

Definindo um módulo nativo

Um módulo .mpy nativo é definido por um conjunto de arquivos que são usados para compilar o .mpy. O layout do sistema de arquivos consiste em duas partes principais, os arquivos-fonte e o Makefile:

  • No caso mais simples, é necessário apenas um único arquivo-fonte C, que contém todo o código que será compilado no módulo .mpy. Esse código-fonte C deve incluir o arquivo py/dynruntime.h para acessar a API dinâmica do MicroPython e deve, no mínimo, definir uma função chamada mpy_init. Essa função será o ponto de entrada do módulo, chamada quando o módulo é importado.

    O módulo pode ser dividido em vários arquivos-fonte C, se desejado. Partes do módulo também podem ser implementadas em Python. Todos os arquivos-fonte devem ser listados no Makefile, adicionando-os à variável SRC (veja abaixo). Isso inclui tanto os arquivos-fonte C quanto quaisquer arquivos Python que serão incluídos no arquivo .mpy resultante.

  • O Makefile contém a configuração de compilação do módulo e lista os arquivos-fonte usados para compilar o módulo .mpy. Ele deve definir MPY_DIR como a localização do repositório do MicroPython (para encontrar os arquivos 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 arquivos-fonte, opcionalmente especificar a arquitetura de máquina via ARCH, juntamente com flags opcionais de arquitetura de máquina especificadas via ARCH_FLAGS, e então incluir py/dynruntime.mk.

Exemplo mínimo

Esta seção fornece um exemplo totalmente funcional de um módulo simples chamado factorial. Esse módulo fornece uma única função factorial.factorial(x) que calcula o fatorial da entrada e retorna o resultado.

Layout do diretório:

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

O arquivo 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 arquivo 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

Compilando o módulo

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

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

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

  • GNU make.

  • Um compilador C para a arquitetura alvo (se for usado código-fonte C).

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

Certifique-se de selecionar o ARCH correto para o alvo no qual você vai executar. Depois compile com:

$ make

Sem modificar o Makefile, você pode especificar a arquitetura alvo via:

$ make ARCH=armv7m

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

$ make ARCH=rv32imc ARCH_FLAGS=zba

Uso do módulo no MicroPython

Uma vez que o módulo seja compilado, deve haver um arquivo chamado factorial.mpy. Copie-o de modo que ele fique acessível no sistema de arquivos do seu sistema MicroPython e possa ser encontrado no caminho de importação. O módulo agora pode ser acessado em Python como qualquer outro módulo, por exemplo:

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

Usando Picolibc ao compilar módulos

Usar o Picolibc como sua biblioteca C padrão não só é suportado, como na verdade é o padrão para as plataformas rv32imc e rv64imc. No entanto, há algumas coisas que vale a pena mencionar para garantir que você não tenha problemas mais tarde ao compilar o código.

Algumas versões pré-compiladas do Picolibc (por exemplo, aquelas fornecidas pelo Ubuntu Linux como os pacotes picolibc-arm-none-eabi, picolibc-riscv64-unknown-elf e picolibc-xtensa-lx106-elf) presumem que o armazenamento local de thread (TLS) está disponível em tempo de execução, mas, infelizmente, os módulos do MicroPython não suportam isso em algumas arquiteturas (a saber, rv32imc e rv64imc). Isso significa que algumas funcionalidades fornecidas pelo Picolibc, por padrão, usarão TLS, retornando um erro tanto durante a compilação quanto durante a vinculação.

Para um exemplo de como isso pode afetar você, o módulo de exemplo examples/natmod/btree contém uma solução alternativa para garantir que o errno funcione (procure por __PICOLIBC_ERRNO_FUNCTION no Makefile e siga a trilha a partir daí).

Mais exemplos

Veja examples/natmod/ para mais exemplos que mostram muitos dos recursos disponíveis dos módulos .mpy nativos. Tais recursos incluem:

  • uso de vários arquivos-fonte C

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

  • dados rodata e BSS

  • alocação de memória

  • uso de ponto flutuante

  • tratamento de exceções

  • inclusão de bibliotecas C externas