Módulos C externos do MicroPython

Ao desenvolver módulos para uso com o MicroPython, você pode se deparar com limitações do ambiente Python, frequentemente devido à impossibilidade de acessar determinados recursos de hardware ou às limitações de velocidade do Python.

Se suas limitações não puderem ser resolvidas com as sugestões em Maximizando a velocidade do MicroPython, escrever parte ou todo o seu módulo em C (e/ou C++ se implementado para a sua porta) é uma opção viável.

Se o seu módulo for projetado para acessar ou trabalhar com hardware ou bibliotecas comumente disponíveis, considere implementá-lo dentro da árvore de código do MicroPython, ao lado de módulos semelhantes, e enviá-lo como um pull request. No entanto, se você estiver visando sistemas obscuros ou proprietários, pode fazer mais sentido manter esse código externo ao repositório principal do MicroPython.

Este capítulo descreve como compilar tais módulos externos no executável ou na imagem de firmware do MicroPython. Tanto a ferramenta de build Make quanto a CMake são suportadas e, ao escrever um módulo externo, é uma boa ideia adicionar os arquivos de build para ambas as ferramentas, para que o módulo possa ser usado em todas as portas. Mas, ao compilar uma porta específica, você só precisará usar um método de build, seja Make ou CMake.

Uma abordagem alternativa é usar Código de máquina nativo em arquivos .mpy, que permite escrever código C personalizado colocado em um arquivo .mpy, o qual pode ser importado dinamicamente em um sistema MicroPython em execução sem a necessidade de recompilar o firmware principal.

Estrutura de um módulo C externo

Um módulo C de usuário do MicroPython é um diretório com os seguintes arquivos:

  • Arquivos de código-fonte *.c / *.cpp / *.h do seu módulo.

    Eles normalmente incluirão a funcionalidade de baixo nível sendo implementada e as funções de binding do MicroPython que expõem as funções e o(s) módulo(s).

    Atualmente, a melhor referência para escrever essas funções/módulos é encontrar módulos semelhantes dentro da árvore do MicroPython e usá-los como exemplos.

  • micropython.mk contém o fragmento de Makefile para este módulo.

    $(USERMOD_DIR) está disponível em micropython.mk como o caminho para o diretório do seu módulo. Como ele é redefinido para cada módulo C, deve ser expandido em seu micropython.mk para uma variável local do make, por exemplo EXAMPLE_MOD_DIR := $(USERMOD_DIR)

    Seu micropython.mk deve adicionar os arquivos de código-fonte dos seus módulos às variáveis SRC_USERMOD_C ou SRC_USERMOD_LIB_C. A primeira será processada em busca de definições MP_QSTR_ e MP_REGISTER_MODULE, a segunda não (por exemplo, helpers e código de biblioteca que não é específico do MicroPython). Esses caminhos devem incluir a sua cópia expandida de $(USERMOD_DIR), por exemplo:

    SRC_USERMOD_C += $(EXAMPLE_MOD_DIR)/modexample.c
    SRC_USERMOD_LIB_C += $(EXAMPLE_MOD_DIR)/utils/algorithm.c
    

    De forma semelhante, use SRC_USERMOD_CXX e SRC_USERMOD_LIB_CXX para arquivos de código-fonte C++. Se quiser incluir arquivos de assembly, use SRC_USERMOD_LIB_ASM.

    Se você tiver opções de compilador personalizadas (como -I para adicionar diretórios de busca de arquivos de cabeçalho), elas devem ser adicionadas a CFLAGS_USERMOD para código C e a CXXFLAGS_USERMOD para código C++.

  • micropython.cmake contém a configuração CMake para este módulo.

    Em micropython.cmake, você pode usar ${CMAKE_CURRENT_LIST_DIR} como o caminho para o módulo atual.

    Seu micropython.cmake deve definir uma biblioteca INTERFACE e associar a ela seus arquivos de código-fonte, definições de compilação e diretórios de inclusão. A biblioteca deve então ser vinculada ao alvo usermod.

    add_library(usermod_cexample INTERFACE)
    
    target_sources(usermod_cexample INTERFACE
        ${CMAKE_CURRENT_LIST_DIR}/examplemodule.c
    )
    
    target_include_directories(usermod_cexample INTERFACE
        ${CMAKE_CURRENT_LIST_DIR}
    )
    
    target_link_libraries(usermod INTERFACE usermod_cexample)
    

    Veja abaixo um exemplo de uso completo.

Exemplo básico

O módulo cexample fornece exemplos de uma função e de uma classe. A função cexample.add_ints(a, b) soma dois argumentos inteiros e retorna o resultado. O tipo cexample.Timer() cria timers que podem ser usados para medir o tempo decorrido desde que o objeto foi instanciado.

O módulo pode ser encontrado na árvore de código do MicroPython no diretório examples e possui um arquivo de código-fonte e um fragmento de Makefile com o conteúdo descrito acima:

micropython/
└──examples/
   └──usercmodule/
      └──cexample/
         ├── examplemodule.c
         ├── micropython.mk
         └── micropython.cmake

Consulte os comentários nesses arquivos para explicações adicionais. Ao lado do módulo cexample há também o cppexample, que funciona da mesma maneira, mas mostra uma forma de misturar código C e C++ no MicroPython.

Compilando o cmodule no MicroPython

Para compilar tal módulo, compile o MicroPython (veja getting started), aplicando 2 modificações:

  1. Defina a flag de tempo de build USER_C_MODULES para apontar para os módulos que você quer incluir. Para portas que usam Make, esta variável deve ser um diretório que é pesquisado automaticamente em busca de módulos. Para portas que usam CMake, esta variável deve ser um arquivo que inclui os módulos a serem compilados. Veja os detalhes abaixo.

  2. Habilite os módulos definindo a macro de pré-processador C correspondente como 1. Isso só é necessário se os módulos que você está compilando não forem habilitados automaticamente.

Para compilar os módulos de exemplo que vêm com o MicroPython, defina USER_C_MODULES como o diretório examples/usercmodule para Make, ou como examples/usercmodule/micropython.cmake para CMake.

Por exemplo, veja como compilar a porta unix com os módulos de exemplo:

cd micropython/ports/unix
make USER_C_MODULES=../../examples/usercmodule

Pode ser necessário executar make clean uma vez no início ao incluir novos módulos de usuário no build. A saída do build mostrará os módulos encontrados:

...
Including User C Module from ../../examples/usercmodule/cexample
Including User C Module from ../../examples/usercmodule/cppexample
...

Para uma porta baseada em CMake, como a rp2, isso será um pouco diferente (observe que o CMake é, na verdade, invocado pelo make):

cd micropython/ports/rp2
make USER_C_MODULES=../../examples/usercmodule/micropython.cmake

Novamente, pode ser necessário executar make clean primeiro para que o CMake detecte os módulos de usuário. A saída do build do CMake lista os módulos por nome:

...
Including User C Module(s) from ../../examples/usercmodule/micropython.cmake
Found User C Module(s): usermod_cexample, usermod_cppexample
...

O conteúdo do micropython.cmake de nível superior pode ser usado para controlar quais módulos são habilitados.

Para os seus próprios projetos, é mais conveniente manter o código personalizado fora da árvore de código principal do MicroPython, de modo que uma estrutura típica de diretório de projeto terá a seguinte aparência:

my_project/
├── modules/
│   ├── example1/
│   │   ├── example1.c
│   │   ├── micropython.mk
│   │   └── micropython.cmake
│   ├── example2/
│   │   ├── example2.c
│   │   ├── micropython.mk
│   │   └── micropython.cmake
│   └── micropython.cmake
└── micropython/
    ├──ports/
   ... ├──stm32/
      ...

Ao compilar com Make, defina USER_C_MODULES como o diretório my_project/modules. Por exemplo, compilando a porta stm32:

cd my_project/micropython/ports/stm32
make USER_C_MODULES=../../../modules

Ao compilar com CMake, o micropython.cmake de nível superior – encontrado diretamente no diretório my_project/modules – deve usar include em todos os módulos que você quer ter disponíveis:

include(${CMAKE_CURRENT_LIST_DIR}/example1/micropython.cmake)
include(${CMAKE_CURRENT_LIST_DIR}/example2/micropython.cmake)

Em seguida, compile com:

cd my_project/micropython/ports/rp2
make USER_C_MODULES=../../../modules/micropython.cmake

Você também pode especificar caminhos absolutos para USER_C_MODULES.

Todos os módulos especificados pela variável USER_C_MODULES (sejam eles encontrados neste diretório ao usar Make, ou adicionados via include ao usar CMake) serão compilados, mas apenas aqueles que estiverem habilitados estarão disponíveis para importação. Módulos de usuário normalmente são habilitados por padrão (isso é decidido pelo desenvolvedor do módulo), caso em que não há nada mais a fazer além de definir USER_C_MODULES conforme descrito acima.

Se um módulo não for habilitado por padrão, então a macro de pré-processador C correspondente deve ser habilitada. O nome dessa macro pode ser encontrado pesquisando pela linha MP_REGISTER_MODULE no código-fonte do módulo (ela geralmente aparece no final do arquivo de código-fonte principal). Essa macro deve estar envolvida por um par #if X / #endif, e a opção de configuração X deve ser definida como 1 usando CFLAGS_EXTRA para tornar o módulo disponível. Se não houver um par #if X / #endif, então o módulo é habilitado por padrão.

Por exemplo, o módulo examples/usercmodule/cexample é habilitado por padrão, portanto tem a seguinte linha em seu código-fonte:

MP_REGISTER_MODULE(MP_QSTR_cexample, example_user_cmodule);

Como alternativa, para tornar este módulo desabilitado por padrão, mas selecionável através de uma opção de configuração de pré-processador, ela seria:

#if MODULE_CEXAMPLE_ENABLED
MP_REGISTER_MODULE(MP_QSTR_cexample, example_user_cmodule);
#endif

Neste caso, o módulo é habilitado adicionando CFLAGS_EXTRA=-DMODULE_CEXAMPLE_ENABLED=1 ao comando make, ou editando mpconfigport.h ou mpconfigboard.h para adicionar

#define MODULE_CEXAMPLE_ENABLED (1)

Observe que o método exato depende da porta, pois elas têm estruturas diferentes. Se não for feito corretamente, a compilação ocorrerá, mas a importação não conseguirá encontrar o módulo.

Uso do módulo no MicroPython

Uma vez compilado em sua cópia do MicroPython, o módulo agora pode ser acessado em Python como qualquer outro módulo embutido, por exemplo

import cexample
print(cexample.add_ints(1, 3))
# should display 4
from cexample import Timer
from time import sleep_ms

watch = Timer()
sleep_ms(1000)
print(watch.time())
# should display approximately 1000

Alocação dinâmica de memória em C

O MicroPython usa seu próprio “heap do Python” para Gerenciamento de Memória, que não é o mesmo que o “heap do C” usado pelas funções da biblioteca C malloc(), free(), etc. Nem todas as portas do MicroPython vêm com um “heap do C”.

As portas Tier 1 e 2 têm suporte variado para alocação dinâmica de memória em C via um “heap do C”:

  • As portas unix, windows, esp32 e webassembly suportam alocação dinâmica de memória em C.

  • A porta rp2 falhará ao alocar qualquer memória em tempo de execução, a menos que o firmware seja compilado com MICROPY_C_HEAP_SIZE=n para reservar n bytes de memória para um heap do C. Essa memória não estará disponível para uso pelo código Python.

  • Builds das portas alif, mimxrt, nrf, renesas-ra, samd e stm32 que incluem alocação dinâmica em C falharão no momento da vinculação com erros como undefined reference to `malloc'. O MicroPython não tem suporte embutido para alocação dinâmica em C nessas portas. Qualquer solução exige adicionar manualmente uma implementação de heap do C ao build personalizado.

  • A porta zephyr atualmente não suporta a compilação com módulos de usuário.

Heap do Python como heap do C

Pode ser prático para o código C chamar funções de alocação dinâmica do “heap do Python”, como m_malloc(), m_malloc0() e m_free().

Veja Memória do MicroPython a partir de código C para mais informações sobre essa abordagem.

Módulos C++

A maioria das portas Tier 1 e 2 do MicroPython (e algumas Tier 3) suporta a compilação de módulos de usuário em C++, usando as variáveis de ambiente específicas de C++ descritas acima.

Integrar C++ e MicroPython com sucesso envolve algumas considerações adicionais:

Alocação dinâmica de memória em C++

Programas C++ (assim como recursos da Biblioteca Padrão do C++) normalmente usam alocação dinâmica de memória. O alocador de memória padrão do C++ (ou seja, os operadores new e delete) é normalmente implementado como uma camada sobre o Alocação dinâmica de memória em C.

Para portas do MicroPython que não incluem suporte a alocação dinâmica de memória em C, a alocação dinâmica de memória em C++ pode ser suportada de uma de duas maneiras:

  • Implemente a alocação dinâmica de memória em C no seu build personalizado.

  • Implemente um alocador C++ personalizado no seu build personalizado.

Considerações sobre vinculação

Como o MicroPython é um projeto baseado em C, quaisquer símbolos que se vinculem ao ou a partir do MicroPython precisam ser qualificados como extern "C" no código C++.

É fortemente recomendado seguir o padrão demonstrado em examples/usercmodule/cppexample, onde o módulo Python é implementado em um wrapper mínimo de arquivo C em torno do código C++.