Natywny kod maszynowy w plikach .mpy

Ta sekcja opisuje, jak budować pliki .mpy zawierające natywny kod maszynowy z języka innego niż Python oraz jak z nimi pracować. Pozwala to napisać kod w języku takim jak C, skompilować go i zlinkować do pliku .mpy, a następnie zaimportować ten plik jak zwykły moduł Pythona. Można to wykorzystać do zaimplementowania funkcjonalności o krytycznym znaczeniu dla wydajności lub do dołączenia istniejącej biblioteki napisanej w innym języku.

Jedną z głównych zalet używania natywnych plików .mpy jest to, że natywny kod maszynowy może być importowany przez skrypt dynamicznie, bez konieczności ponownego budowania głównego oprogramowania układowego MicroPython. Stoi to w kontraście do Zewnętrzne moduły C dla MicroPython, które również pozwalają definiować własne moduły w C, ale muszą one być skompilowane do głównego obrazu oprogramowania układowego.

Skupiamy się tutaj na używaniu C do budowania natywnych modułów, ale w zasadzie każdy język, który można skompilować do samodzielnego kodu maszynowego, może zostać umieszczony w pliku .mpy.

Natywny moduł .mpy buduje się za pomocą narzędzia mpy_ld.py, które znajduje się w katalogu tools/ projektu. Narzędzie to pobiera zestaw plików obiektowych (pliki .o) i linkuje je razem, tworząc natywny plik .mpy. Wymaga CPython 3 oraz biblioteki pyelftools w wersji v0.25 lub nowszej.

Obsługiwane funkcje i ograniczenia

Plik .mpy może zawierać kod bajtowy MicroPython i/lub natywny kod maszynowy. Jeśli zawiera natywny kod maszynowy, to plik .mpy ma powiązaną z nim konkretną architekturę. Aktualnie obsługiwane architektury to (są to prawidłowe opcje dla zmiennej ARCH, patrz poniżej):

  • x86 (32-bitowa)

  • x64 (64-bitowa x86)

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

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

  • armv7emsp (ARM Thumb 2, liczby zmiennoprzecinkowe pojedynczej precyzji, np. Cortex-M4F, Cortex-M7)

  • armv7emdp (ARM Thumb 2, liczby zmiennoprzecinkowe podwójnej precyzji, np. Cortex-M7)

  • xtensa (bez okien, np. ESP8266)

  • xtensawin (z oknami o rozmiarze 8, np. ESP32, ESP32S3)

  • rv32imc (RISC-V 32-bitowy z instrukcjami skompresowanymi, np. ESP32C3, ESP32C6)

  • rv64imc (RISC-V 64-bitowy z instrukcjami skompresowanymi)

Jeśli wybrana platforma obsługuje jawne flagi architektury i chcesz, aby wyjściowy plik .mpy przenosił wartość tych flag, musisz przekazać je do zmiennej flag ARCH_FLAGS podczas budowania pliku .mpy.

Podczas kompilowania i linkowania natywnego pliku .mpy należy wybrać architekturę, a odpowiadający jej plik może być importowany tylko na tej architekturze (a jeśli obecne są flagi architektury, tylko gdy pasują one do możliwości celu). Więcej szczegółów na temat plików .mpy znajduje się w Pliki .mpy w MicroPython.

Natywny kod musi być skompilowany jako kod niezależny od położenia (PIC) i używać globalnej tablicy przesunięć (GOT), choć szczegóły tego różnią się w zależności od architektury. Podczas importowania plików .mpy z natywnym kodem mechanizm importu jest w stanie wykonać podstawową relokację natywnego kodu. Obejmuje to relokację sekcji text, rodata i BSS.

Obsługiwane funkcje linkera i dynamicznego ładowarki to:

  • kod wykonywalny (text)

  • dane tylko do odczytu (rodata), w tym łańcuchy znaków i dane stałe (tablice, struktury itp.)

  • wyzerowane dane (BSS)

  • wskaźniki w text do text, rodata i BSS

  • wskaźniki w rodata do text, rodata i BSS

Znane ograniczenia to:

  • sekcje data nie są obsługiwane; obejście: użyj danych BSS i zainicjalizuj wartości danych jawnie

  • statyczne zmienne BSS nie są obsługiwane; obejście: użyj globalnych zmiennych BSS

  • zmienne pamięci lokalnej dla wątku (thread-local storage) nie są obsługiwane na rv32imc; obejście: użyj globalnych zmiennych BSS lub przydziel nieco miejsca na stercie, aby je przechowywać

Tak więc, jeśli Twój kod C ma dane zapisywalne, upewnij się, że dane są zdefiniowane globalnie, bez inicjalizatora, i są zapisywane tylko wewnątrz funkcji.

Natywny moduł nie jest automatycznie linkowany ze standardowymi bibliotekami statycznymi, takimi jak libm.a i libgcc.a, co może prowadzić do błędów undefined symbol. Biblioteki uruchomieniowe możesz zlinkować, ustawiając LINK_RUNTIME = 1 w swoim pliku Makefile. Własne biblioteki statyczne można również zlinkować, dodając MPY_LD_FLAGS += -l path/to/library.a. Należy pamiętać, że są one linkowane do natywnego modułu i nie będą współdzielone z innymi modułami ani systemem.

Ograniczenie linkera: natywny moduł nie jest linkowany z tablicą symboli pełnego oprogramowania układowego MicroPython. Zamiast tego jest linkowany z jawną tablicą wyeksportowanych symboli znajdującą się w mp_fun_table (w py/nativeglue.h), która jest ustalona w czasie budowania oprogramowania układowego. Nie jest więc możliwe po prostu wywołanie dowolnej funkcji HAL/OS/RTOS/systemowej, na przykład, chyba że znajduje się ona pod stałym adresem. W takim przypadku ścieżkę do skryptu linkera zawierającego serię nazw symboli i ich stałych adresów można przekazać do mpy_ld.py za pomocą argumentu wiersza poleceń --externs. W ten sposób symbole pojawiające się w skrypcie linkera będą miały pierwszeństwo przed tym, co dostarczają pliki obiektowe, ale obecnie implementacja z plików obiektowych nadal będzie znajdować się w końcowym pliku MPY. Parser skryptu linkera ma ograniczone możliwości i obecnie jest używany wyłącznie do parsowania listy symboli ROM portu ESP8266 (patrz ports/esp8266/boards/eagle.rom.addr.v6.ld).

Nowe symbole można dodawać na końcu tablicy i ponownie budować oprogramowanie układowe. Symbole należy również dodać do słownika fun_table w tools/mpy_ld.py w tym samym miejscu. Pozwala to mpy_ld.py na wychwycenie nowych symboli i dostarczenie dla nich relokacji podczas importu mpy. Wreszcie, jeśli symbol jest funkcją, należy dodać makro lub stub do py/dynruntime.h, aby ułatwić wywoływanie tej funkcji.

Definiowanie natywnego modułu

Natywny moduł .mpy jest definiowany przez zestaw plików używanych do zbudowania .mpy. Układ systemu plików składa się z dwóch głównych części: plików źródłowych i pliku Makefile:

  • W najprostszym przypadku wymagany jest tylko jeden plik źródłowy C, który zawiera cały kod, jaki zostanie skompilowany do modułu .mpy. Ten kod źródłowy C musi dołączać plik py/dynruntime.h, aby uzyskać dostęp do dynamicznego API MicroPython, oraz musi zdefiniować przynajmniej funkcję o nazwie mpy_init. Ta funkcja będzie punktem wejścia modułu, wywoływanym podczas importu modułu.

    W razie potrzeby moduł można podzielić na wiele plików źródłowych C. Części modułu można również zaimplementować w Pythonie. Wszystkie pliki źródłowe powinny być wymienione w pliku Makefile poprzez dodanie ich do zmiennej SRC (patrz poniżej). Obejmuje to zarówno pliki źródłowe C, jak i wszelkie pliki Pythona, które zostaną dołączone do wynikowego pliku .mpy.

  • Plik Makefile zawiera konfigurację budowania modułu oraz wymienia pliki źródłowe używane do zbudowania modułu .mpy. Powinien definiować MPY_DIR jako lokalizację repozytorium MicroPython (aby znaleźć pliki nagłówkowe, odpowiedni fragment Makefile oraz narzędzie mpy_ld.py), MOD jako nazwę modułu, SRC jako listę plików źródłowych, opcjonalnie określać architekturę maszyny za pomocą ARCH, wraz z opcjonalnymi flagami architektury maszyny określonymi za pomocą ARCH_FLAGS, a następnie dołączać py/dynruntime.mk.

Minimalny przykład

Ta sekcja zawiera w pełni działający przykład prostego modułu o nazwie factorial. Moduł ten udostępnia pojedynczą funkcję factorial.factorial(x), która oblicza silnię z wartości wejściowej i zwraca wynik.

Układ katalogu:

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

Plik factorial.c zawiera:

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

Plik Makefile zawiera:

# 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

Kompilowanie modułu

Narzędzia będące warunkiem wstępnym, potrzebne do zbudowania natywnego pliku .mpy, to:

  • Repozytorium MicroPython (przynajmniej katalogi py/ i tools/).

  • CPython 3 oraz biblioteka pyelftools (np. pip install 'pyelftools>=0.25').

  • GNU make.

  • Kompilator C dla docelowej architektury (jeśli używany jest kod źródłowy C).

  • Opcjonalnie mpy-cross, zbudowany z repozytorium MicroPython (jeśli używany jest kod źródłowy .py).

Pamiętaj, aby wybrać prawidłową wartość ARCH dla celu, na którym zamierzasz uruchamiać kod. Następnie zbuduj za pomocą:

$ make

Bez modyfikowania pliku Makefile możesz określić docelową architekturę za pomocą:

$ make ARCH=armv7m

To samo dotyczy opcjonalnych flag architektury za pomocą:

$ make ARCH=rv32imc ARCH_FLAGS=zba

Użycie modułu w MicroPython

Po zbudowaniu modułu powinien powstać plik o nazwie factorial.mpy. Skopiuj go tak, aby był dostępny w systemie plików Twojego systemu MicroPython i mógł zostać znaleziony w ścieżce importu. Do modułu można teraz uzyskać dostęp w Pythonie tak jak do każdego innego modułu, na przykład:

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

Używanie Picolibc podczas budowania modułów

Używanie Picolibc jako biblioteki standardowej C jest nie tylko obsługiwane, ale w istocie jest domyślne dla platform rv32imc i rv64imc. Istnieje jednak kilka rzeczy wartych wspomnienia, aby upewnić się, że później, podczas budowania kodu, nie napotkasz problemów.

Niektóre prekompilowane wersje Picolibc (na przykład te dostarczane przez Ubuntu Linux jako pakiety picolibc-arm-none-eabi, picolibc-riscv64-unknown-elf i picolibc-xtensa-lx106-elf) zakładają, że pamięć lokalna dla wątku (TLS) jest dostępna w czasie wykonywania, ale niestety moduły MicroPython nie obsługują tego na niektórych architekturach (mianowicie rv32imc i rv64imc). Oznacza to, że niektóre funkcjonalności dostarczane przez Picolibc będą domyślnie używać TLS, zwracając błąd albo podczas kompilacji, albo podczas linkowania.

Aby zobaczyć przykład, jak może Cię to dotyczyć, przykładowy moduł examples/natmod/btree zawiera obejście zapewniające działanie errno (poszukaj __PICOLIBC_ERRNO_FUNCTION w pliku Makefile i podążaj dalej tym tropem).

Dalsze przykłady

Zobacz examples/natmod/, aby znaleźć dalsze przykłady pokazujące wiele dostępnych funkcji natywnych modułów .mpy. Do takich funkcji należą:

  • używanie wielu plików źródłowych C

  • dołączanie kodu Pythona obok kodu C

  • dane rodata i BSS

  • alokacja pamięci

  • użycie liczb zmiennoprzecinkowych

  • obsługa wyjątków

  • dołączanie zewnętrznych bibliotek C