Módulos C externos para MicroPython

Ao desenvolver módulos para utilização com MicroPython, pode deparar-se com limitações do ambiente Python, frequentemente devido à impossibilidade de aceder a determinados recursos de hardware ou a limitações de velocidade do Python.

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

Se o seu módulo foi concebido para aceder ou trabalhar com hardware ou bibliotecas comummente disponíveis, considere implementá-lo dentro da árvore de código-fonte do MicroPython, junto de módulos semelhantes, e submetê-lo como um pull request. Se, no entanto, o seu alvo for sistemas obscuros ou proprietários, pode fazer mais sentido mantê-lo externo ao repositório principal do MicroPython.

Este capítulo descreve como compilar esses módulos externos no executável ou imagem de firmware do MicroPython. As ferramentas de construção Make e CMake são suportadas; ao escrever um módulo externo, é boa ideia adicionar os ficheiros de construção para ambas as ferramentas, para que o módulo possa ser utilizado em todos os ports. Contudo, ao compilar um port específico, apenas precisará de utilizar um dos métodos de construção: Make ou CMake.

Uma abordagem alternativa é utilizar Código máquina nativo em ficheiros .mpy, que permite escrever código C personalizado colocado num ficheiro .mpy, que pode ser importado dinamicamente para um sistema MicroPython em execução sem necessidade de recompilar o firmware principal.

Estrutura de um módulo C externo

Um módulo C de utilizador para MicroPython é um diretório com os seguintes ficheiros:

  • Ficheiros de código-fonte *.c / *.cpp / *.h para o seu módulo.

    Estes incluirão tipicamente a funcionalidade de baixo nível a implementar e as funções de ligação ao MicroPython para expor as funções e o(s) módulo(s).

    Atualmente, a melhor referência para escrever estas funções/módulos é encontrar módulos semelhantes na árvore do MicroPython e utilizá-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 é redefinido para cada módulo C, deve ser expandido no seu micropython.mk para uma variável make local, por exemplo EXAMPLE_MOD_DIR := $(USERMOD_DIR)

    O seu micropython.mk deve adicionar os ficheiros de código-fonte dos seus módulos às variáveis SRC_USERMOD_C ou SRC_USERMOD_LIB_C. A primeira será processada para definições de MP_QSTR_ e MP_REGISTER_MODULE; a segunda não será (por exemplo, auxiliares e código de biblioteca que não é específico do MicroPython). Estes 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 análoga, utilize SRC_USERMOD_CXX e SRC_USERMOD_LIB_CXX para ficheiros de código-fonte C++. Se pretender incluir ficheiros de assembly, utilize SRC_USERMOD_LIB_ASM.

    Se tiver opções de compilador personalizadas (como -I para adicionar diretórios a pesquisar por ficheiros de cabeçalho), estas 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, pode utilizar ${CMAKE_CURRENT_LIST_DIR} como caminho para o módulo atual.

    O seu micropython.cmake deve definir uma biblioteca INTERFACE e associar os seus ficheiros de código-fonte, definições de compilação e diretórios de inclusão a ela. A biblioteca deve então ser ligada 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)
    

    Consulte abaixo um exemplo de utilização 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 devolve o resultado. O tipo cexample.Timer() cria temporizadores que podem ser usados para medir o tempo decorrido desde a instanciação do objeto.

O módulo pode ser encontrado na árvore de código-fonte do MicroPython no diretório de exemplos e tem um ficheiro 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 nestes ficheiros para explicações adicionais. Junto ao módulo cexample existe também o cppexample, que funciona da mesma forma, mas mostra uma maneira de misturar código C e C++ no MicroPython.

Compilar o cmodule no MicroPython

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

  1. Defina a opção de construção USER_C_MODULES para apontar para os módulos que pretende incluir. Para ports que utilizam Make, esta variável deve ser um diretório que é pesquisado automaticamente por módulos. Para ports que utilizam CMake, esta variável deve ser um ficheiro que inclui os módulos a construir. Consulte abaixo para mais detalhes.

  2. Ative os módulos definindo a macro C de pré-processador correspondente para 1. Isto só é necessário se os módulos que está a construir não forem ativados automaticamente.

Para construir os módulos de exemplo que acompanham o MicroPython, defina USER_C_MODULES para o diretório examples/usercmodule para Make, ou para examples/usercmodule/micropython.cmake para CMake.

Por exemplo, eis como construir o port 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 utilizador na construção. O resultado da construção mostrará os módulos encontrados:

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

Para um port baseado em CMake, como o rp2, isto terá um aspeto ligeiramente diferente (note que o CMake é invocado por make):

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

Novamente, pode ser necessário executar make clean primeiro para o CMake detetar os módulos de utilizador. O resultado da construção 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 ficheiro micropython.cmake de nível superior pode ser usado para controlar quais os módulos ativados.

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

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

Ao construir com Make, defina USER_C_MODULES para o diretório my_project/modules. Por exemplo, ao construir o port stm32:

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

Ao construir com CMake, o ficheiro micropython.cmake de nível superior – encontrado diretamente no diretório my_project/modules – deve incluir com include todos os módulos que pretende ter disponíveis:

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

Em seguida, construa com:

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

Também pode especificar caminhos absolutos para USER_C_MODULES.

Todos os módulos especificados pela variável USER_C_MODULES (encontrados neste diretório ao utilizar Make, ou adicionados via include ao utilizar CMake) serão compilados, mas apenas os que estiverem ativados estarão disponíveis para importação. Os módulos de utilizador são geralmente ativados por padrão (isto é decidido pelo programador do módulo); nesse caso, não há mais nada a fazer além de definir USER_C_MODULES conforme descrito acima.

Se um módulo não estiver ativado por padrão, a macro C de pré-processador correspondente deve ser ativada. O nome desta macro pode ser encontrado pesquisando a linha MP_REGISTER_MODULE no código-fonte do módulo (aparece geralmente no final do ficheiro de código-fonte principal). Esta macro deve estar rodeada por um par #if X / #endif, e a opção de configuração X deve ser definida como 1 utilizando CFLAGS_EXTRA para tornar o módulo disponível. Se não existir o par #if X / #endif, o módulo está ativado por padrão.

Por exemplo, o módulo examples/usercmodule/cexample está ativado por padrão, pelo que tem a seguinte linha no seu código-fonte:

MP_REGISTER_MODULE(MP_QSTR_cexample, example_user_cmodule);

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

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

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

#define MODULE_CEXAMPLE_ENABLED (1)

Note que o método exato depende do port, uma vez que estes têm estruturas diferentes. Se não for feito corretamente, compilará, mas a importação falhará ao encontrar o módulo.

Utilização do módulo no MicroPython

Uma vez incorporado na sua cópia do MicroPython, o módulo pode agora ser acedido em Python tal como qualquer outro módulo integrado, 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 utiliza o seu próprio «Python heap» para Gestão de Memória, que não é o mesmo que o «C heap» utilizado pelas funções da biblioteca C malloc(), free(), etc. Nem todos os ports do MicroPython incluem um «C heap».

Os ports de Nível 1 e 2 têm suporte variável para alocação dinâmica de memória em C através de um «C heap»:

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

  • O port rp2 falhará ao alocar qualquer memória em tempo de execução a menos que o firmware seja construído com MICROPY_C_HEAP_SIZE=n para reservar n bytes de memória para um C heap. Esta memória não estará disponível para uso por código Python.

  • As construções dos ports alif, mimxrt, nrf, renesas-ra, samd e stm32 que incluem alocação C dinâmica falharão em tempo de ligação com erros como undefined reference to `malloc'. O MicroPython não tem suporte integrado para alocação C dinâmica nestes ports. Qualquer solução requer adicionar manualmente uma implementação de C heap à construção personalizada.

  • O port zephyr atualmente não suporta construção com módulos de utilizador.

Python heap como C heap

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

Consulte Memória MicroPython a partir de código C para mais informações sobre esta abordagem.

Módulos C++

A maioria dos ports MicroPython de Nível 1 e 2 (e alguns de Nível 3) suportam a construção de módulos de utilizador C++, utilizando as variáveis de ambiente específicas de C++ descritas acima.

A integração bem-sucedida de C++ e MicroPython envolve algumas considerações adicionais:

Alocação Dinâmica de Memória em C++

Os programas C++ (bem como as funcionalidades da Biblioteca Padrão C++) utilizam tipicamente alocação dinâmica de memória. O alocador de memória padrão C++ (ou seja, os operadores new e delete) é tipicamente implementado como uma camada sobre o Alocação Dinâmica de Memória em C.

Para ports do MicroPython que não incluem suporte para 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 formas:

  • Implementar alocação dinâmica de memória em C na sua construção personalizada.

  • Implementar um alocador C++ personalizado na sua construção personalizada.

Considerações sobre Ligação

Como o MicroPython é um projeto baseado em C, todos os símbolos que estabelecem ligação com ou a partir do MicroPython precisam de ser qualificados como extern "C" em código C++.

É fortemente recomendado seguir o padrão demonstrado em examples/usercmodule/cppexample, onde o módulo Python é implementado num ficheiro C mínimo que envolve o código C++.