Externe C-modules voor MicroPython

Bij het ontwikkelen van modules voor gebruik met MicroPython kun je tegen beperkingen van de Python-omgeving aanlopen, vaak doordat bepaalde hardwarebronnen niet toegankelijk zijn of vanwege de snelheidsbeperkingen van Python.

Als je beperkingen niet kunnen worden opgelost met de suggesties in Maximaliseren van de snelheid van MicroPython, is het schrijven van een deel of de gehele module in C (en/of C++ als dit voor jouw port is geïmplementeerd) een haalbare optie.

Als je module is ontworpen om toegang te krijgen tot of te werken met breed beschikbare hardware of libraries, overweeg dan om deze binnen de MicroPython-broncode te implementeren naast vergelijkbare modules en deze in te dienen als pull request. Als je echter obscure of propriëtaire systemen aanstuurt, kan het zinvoller zijn om deze buiten de hoofdrepository van MicroPython te houden.

Dit hoofdstuk beschrijft hoe je dergelijke externe modules in het uitvoerbare bestand of de firmware-image van MicroPython compileert. Zowel Make- als CMake-buildtools worden ondersteund, en bij het schrijven van een externe module is het verstandig om voor beide tools de buildbestanden toe te voegen, zodat de module op alle ports kan worden gebruikt. Maar bij het compileren van een specifieke port hoef je slechts één buildmethode te gebruiken, ofwel Make ofwel CMake.

Een alternatieve aanpak is het gebruik van Native machinecode in .mpy-bestanden, waarmee je aangepaste C-code kunt schrijven die in een .mpy-bestand wordt geplaatst en die dynamisch in een draaiend MicroPython-systeem kan worden geïmporteerd zonder dat de hoofdfirmware opnieuw hoeft te worden gecompileerd.

Structuur van een externe C-module

Een MicroPython-C-module van de gebruiker is een directory met de volgende bestanden:

  • *.c / *.cpp / *.h broncodebestanden voor je module.

    Deze bevatten doorgaans de laaggelaagde functionaliteit die wordt geïmplementeerd en de MicroPython-bindingfuncties om de functies en module(s) beschikbaar te maken.

    Momenteel is de beste referentie voor het schrijven van deze functies/modules om vergelijkbare modules binnen de MicroPython-broncode te vinden en deze als voorbeeld te gebruiken.

  • micropython.mk bevat het Makefile-fragment voor deze module.

    $(USERMOD_DIR) is in micropython.mk beschikbaar als het pad naar je moduledirectory. Aangezien deze voor elke C-module opnieuw wordt gedefinieerd, moet deze in je micropython.mk worden uitgebreid naar een lokale make-variabele, bijvoorbeeld EXAMPLE_MOD_DIR := $(USERMOD_DIR)

    Je micropython.mk moet de broncodebestanden van je modules toevoegen aan de variabelen SRC_USERMOD_C of SRC_USERMOD_LIB_C. De eerste wordt verwerkt op MP_QSTR_- en MP_REGISTER_MODULE-definities, de laatste niet (bijvoorbeeld helpers en librarycode die niet MicroPython-specifiek is). Deze paden moeten je uitgebreide kopie van $(USERMOD_DIR) bevatten, bijvoorbeeld:

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

    Gebruik op dezelfde manier SRC_USERMOD_CXX en SRC_USERMOD_LIB_CXX voor C++-broncodebestanden. Als je assemblybestanden wilt opnemen, gebruik dan SRC_USERMOD_LIB_ASM.

    Als je aangepaste compileropties hebt (zoals -I om directories toe te voegen waarin naar headerbestanden wordt gezocht), moeten deze worden toegevoegd aan CFLAGS_USERMOD voor C-code en aan CXXFLAGS_USERMOD voor C++-code.

  • micropython.cmake bevat de CMake-configuratie voor deze module.

    In micropython.cmake kun je ${CMAKE_CURRENT_LIST_DIR} gebruiken als het pad naar de huidige module.

    Je micropython.cmake moet een INTERFACE-library definiëren en je broncodebestanden, compileerdefinities en include-directories hieraan koppelen. De library moet vervolgens worden gelinkt aan de usermod-target.

    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)
    

    Zie hieronder voor een volledig gebruiksvoorbeeld.

Basisvoorbeeld

De module cexample biedt voorbeelden voor een functie en een class. De functie cexample.add_ints(a, b) telt twee integer-argumenten bij elkaar op en retourneert het resultaat. Het type cexample.Timer() maakt timers die kunnen worden gebruikt om de verstreken tijd te meten sinds het object is geïnstantieerd.

De module is te vinden in de MicroPython-broncode in de examples-directory en heeft een broncodebestand en een Makefile-fragment met inhoud zoals hierboven beschreven:

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

Raadpleeg de opmerkingen in deze bestanden voor aanvullende uitleg. Naast de module cexample is er ook cppexample, die op dezelfde manier werkt maar één manier laat zien om C- en C++-code in MicroPython te mengen.

De cmodule in MicroPython compileren

Om zo’n module te bouwen, compileer je MicroPython (zie aan de slag) en pas je 2 aanpassingen toe:

  1. Stel de buildtime-flag USER_C_MODULES in om naar de modules te wijzen die je wilt opnemen. Voor ports die Make gebruiken moet deze variabele een directory zijn die automatisch op modules wordt doorzocht. Voor ports die CMake gebruiken moet deze variabele een bestand zijn dat de te bouwen modules opneemt. Zie hieronder voor details.

  2. Schakel de modules in door de bijbehorende C-preprocessormacro op 1 te zetten. Dit is alleen nodig als de modules die je bouwt niet automatisch zijn ingeschakeld.

Om de voorbeeldmodules te bouwen die bij MicroPython worden geleverd, stel je USER_C_MODULES in op de directory examples/usercmodule voor Make, of op examples/usercmodule/micropython.cmake voor CMake.

Hier is bijvoorbeeld hoe je de unix-port bouwt met de voorbeeldmodules:

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

Mogelijk moet je eenmalig make clean uitvoeren aan het begin wanneer je nieuwe gebruikersmodules in de build opneemt. De build-uitvoer toont de gevonden modules:

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

Voor een CMake-gebaseerde port zoals rp2 ziet dit er iets anders uit (merk op dat CMake feitelijk wordt aangeroepen door make):

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

Ook hier moet je mogelijk eerst make clean uitvoeren zodat CMake de gebruikersmodules oppikt. De CMake-build-uitvoer toont de modules op naam:

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

De inhoud van de top-level micropython.cmake kan worden gebruikt om te bepalen welke modules zijn ingeschakeld.

Voor je eigen projecten is het handiger om aangepaste code buiten de hoofdbroncode van MicroPython te houden, dus een typische projectdirectorystructuur ziet er als volgt uit:

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

Stel bij het bouwen met Make USER_C_MODULES in op de directory my_project/modules. Bijvoorbeeld, het bouwen van de stm32-port:

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

Bij het bouwen met CMake moet de top-level micropython.cmake – die zich direct in de directory my_project/modules bevindt – alle modules die je beschikbaar wilt hebben include-en:

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

Bouw vervolgens met:

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

Je kunt ook absolute paden opgeven voor USER_C_MODULES.

Alle modules die door de variabele USER_C_MODULES worden opgegeven (ofwel gevonden in deze directory bij gebruik van Make, ofwel toegevoegd via include bij gebruik van CMake) worden gecompileerd, maar alleen de modules die zijn ingeschakeld zijn beschikbaar voor importeren. Gebruikersmodules zijn meestal standaard ingeschakeld (dit wordt bepaald door de ontwikkelaar van de module), in welk geval er niets meer hoeft te gebeuren dan USER_C_MODULES instellen zoals hierboven beschreven.

Als een module niet standaard is ingeschakeld, moet de bijbehorende C-preprocessormacro worden ingeschakeld. Deze macronaam kun je vinden door te zoeken naar de regel MP_REGISTER_MODULE in de broncode van de module (deze staat meestal aan het einde van het hoofdbroncodebestand). Deze macro moet worden omgeven door een #if X / #endif-paar, en de configuratieoptie X moet met CFLAGS_EXTRA op 1 worden gezet om de module beschikbaar te maken. Als er geen #if X / #endif-paar is, dan is de module standaard ingeschakeld.

De module examples/usercmodule/cexample is bijvoorbeeld standaard ingeschakeld en heeft daarom de volgende regel in zijn broncode:

MP_REGISTER_MODULE(MP_QSTR_cexample, example_user_cmodule);

Om deze module daarentegen standaard uitgeschakeld maar selecteerbaar via een preprocessor-configuratieoptie te maken, zou het zijn:

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

In dit geval wordt de module ingeschakeld door CFLAGS_EXTRA=-DMODULE_CEXAMPLE_ENABLED=1 toe te voegen aan het make-commando, of door mpconfigport.h of mpconfigboard.h te bewerken om toe te voegen

#define MODULE_CEXAMPLE_ENABLED (1)

Merk op dat de exacte methode afhangt van de port, aangezien deze verschillende structuren hebben. Als dit niet correct wordt gedaan, zal het wel compileren maar zal het importeren de module niet kunnen vinden.

Modulegebruik in MicroPython

Eenmaal ingebouwd in je kopie van MicroPython, is de module nu in Python toegankelijk net als elke andere ingebouwde module, bijvoorbeeld

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

Dynamische geheugenallocatie in C

MicroPython gebruikt zijn eigen “Python heap” voor Geheugenbeheer, die niet hetzelfde is als de “C heap” die wordt gebruikt door C-libraryfuncties zoals malloc(), free(), enz. Niet elke MicroPython-port wordt überhaupt met een “C heap” geleverd.

Tier 1- en 2-ports hebben uiteenlopende ondersteuning voor dynamische C-geheugenallocatie via een “C heap”:

  • De unix-, windows-, esp32- en webassembly-ports ondersteunen dynamische C-geheugenallocatie.

  • De rp2-port zal er niet in slagen om tijdens runtime geheugen te alloceren, tenzij de firmware is gebouwd met MICROPY_C_HEAP_SIZE=n om n bytes geheugen te reserveren voor een C heap. Dit geheugen is niet beschikbaar voor gebruik door Python-code.

  • Builds van de alif-, mimxrt-, nrf-, renesas-ra-, samd- en stm32-port die dynamische C-allocatie bevatten zullen tijdens het linken mislukken met fouten zoals undefined reference to `malloc'. MicroPython heeft op deze ports geen ingebouwde ondersteuning voor dynamische C-allocatie. Elke oplossing vereist het handmatig toevoegen van een C-heap-implementatie aan de aangepaste build.

  • De zephyr-port ondersteunt momenteel het bouwen met gebruikersmodules niet.

Python heap als C heap

Het kan praktisch zijn voor C-code om in plaats daarvan dynamische allocatiefuncties van de “Python heap” aan te roepen, zoals m_malloc(), m_malloc0() en m_free().

Zie MicroPython-geheugen vanuit C-code voor meer informatie over deze aanpak.

C++-modules

De meeste Tier 1- en 2-MicroPython-ports (en sommige Tier 3) ondersteunen het bouwen van C++-gebruikersmodules, met behulp van de hierboven beschreven C++-specifieke omgevingsvariabelen.

Het succesvol integreren van C++ en MicroPython brengt enkele aanvullende overwegingen met zich mee:

Dynamische geheugenallocatie in C++

C++-programma’s (evenals features van de C++ Standard Library) gebruiken doorgaans dynamische geheugenallocatie. De standaard C++-geheugenallocator (d.w.z. de operators new en delete) is doorgaans geïmplementeerd als een laag boven op Dynamische geheugenallocatie in C.

Voor MicroPython-ports die geen ondersteuning voor dynamische C-geheugenallocatie bevatten, kan dynamische C++-geheugenallocatie op een van twee manieren worden ondersteund:

  • Implementeer dynamische C-geheugenallocatie in je aangepaste build.

  • Implementeer een aangepaste C++-allocator in je aangepaste build.

Overwegingen bij het linken

Omdat MicroPython een op C gebaseerd project is, moeten alle symbolen die naar of vanuit MicroPython linken in C++-code worden gekwalificeerd met extern "C".

Het wordt sterk aanbevolen om het patroon te volgen dat wordt gedemonstreerd in examples/usercmodule/cppexample, waar de Python-module is geïmplementeerd in een minimale C-bestandswrapper rondom de C++-code.