Нативний машинний код у файлах .mpy

У цьому розділі описано, як збирати та працювати з файлами .mpy, що містять нативний машинний код, написаний мовою, відмінною від Python. Це дозволяє писати код, наприклад, мовою C, компілювати та компонувати його у файл .mpy, а потім імпортувати цей файл як звичайний модуль Python. Такий підхід може бути корисним для реалізації функціональності, критичної до продуктивності, або для підключення існуючих бібліотек, написаних іншою мовою.

Однією з головних переваг використання нативних файлів .mpy є те, що нативний машинний код може імпортуватися скриптом динамічно, без необхідності перезбирати основну мікропрограму MicroPython. Це відрізняє такий підхід від Зовнішні модулі MicroPython на мові C, що також дозволяє визначати власні модулі на C, але ці модулі мають бути скомпільовані в основний образ мікропрограми.

Тут основна увага приділяється використанню C для побудови нативних модулів, однак у принципі будь-яка мова, яку можна скомпілювати у самостійний машинний код, може бути вміщена у файл .mpy.

Нативний модуль .mpy будується за допомогою інструменту mpy_ld.py, який знаходиться в каталозі tools/ проекту. Цей інструмент приймає набір об’єктних файлів (.o файли) і компонує їх разом для створення нативного файлу .mpy. Для роботи потрібні CPython 3 та бібліотека pyelftools v0.25 або вище.

Підтримувані можливості та обмеження

Файл .mpy може містити байт-код MicroPython та/або нативний машинний код. Якщо він містить нативний машинний код, то файл .mpy пов’язаний із певною архітектурою. Поточні підтримувані архітектури (це допустимі значення для змінної ARCH, див. нижче):

  • x86 (32 біти)

  • x64 (64-бітний x86)

  • armv6m (ARM Thumb, наприклад Cortex-M0)

  • armv7m (ARM Thumb 2, наприклад Cortex-M3)

  • armv7emsp (ARM Thumb 2, float одинарної точності, наприклад Cortex-M4F, Cortex-M7)

  • armv7emdp (ARM Thumb 2, float подвійної точності, наприклад Cortex-M7)

  • xtensa (без віконного режиму, наприклад ESP8266)

  • xtensawin (з віконним режимом, розмір вікна 8, наприклад ESP32, ESP32S3)

  • rv32imc (RISC-V 32 біти зі стисненими інструкціями, наприклад ESP32C3, ESP32C6)

  • rv64imc (RISC-V 64 біти зі стисненими інструкціями)

Якщо обрана платформа підтримує явні прапорці архітектури та ви хочете, щоб вихідний файл .mpy містив значення цих прапорців, їх необхідно передати до змінної ARCH_FLAGS під час збирання файлу .mpy.

При компіляції та компонуванні нативного файлу .mpy необхідно обрати архітектуру — отриманий файл можна імпортувати лише на цій архітектурі (а якщо присутні прапорці архітектури, то лише якщо вони відповідають можливостям цільового пристрою). Докладніші відомості про файли .mpy дивіться в MicroPython .mpy файли.

Нативний код має бути скомпільований як позиційно-незалежний код (PIC) із використанням таблиці глобальних зміщень (GOT), хоча деталі цього відрізняються залежно від архітектури. Під час імпортування файлів .mpy із нативним кодом механізм імпорту може виконувати базове переміщення нативного коду, включаючи переміщення секцій text, rodata та BSS.

Підтримувані можливості компонувальника та динамічного завантажувача:

  • виконуваний код (text)

  • дані лише для читання (rodata), включаючи рядки та константні дані (масиви, структури тощо)

  • ініціалізовані нулями дані (BSS)

  • вказівники в text, що ведуть до text, rodata та BSS

  • вказівники в rodata, що ведуть до text, rodata та BSS

Відомі обмеження:

  • секції даних не підтримуються; вирішення: використовуйте дані BSS та ініціалізуйте їх значення явно

  • статичні змінні BSS не підтримуються; вирішення: використовуйте глобальні змінні BSS

  • змінні локального сховища потоків не підтримуються для rv32imc; вирішення: використовуйте глобальні змінні BSS або виділіть місце у купі для їх зберігання

Отже, якщо у вашому коді на C є записувані дані, переконайтеся, що вони визначені глобально, без ініціалізатора, і записуються лише у функціях.

Нативний модуль не компонується автоматично зі стандартними статичними бібліотеками, такими як libm.a та libgcc.a, що може призвести до помилок undefined symbol. Ви можете підключити бібліотеки середовища виконання, встановивши LINK_RUNTIME = 1 у вашому Makefile. Власні статичні бібліотеки також можна підключати, додавши MPY_LD_FLAGS += -l path/to/library.a. Зверніть увагу, що вони компонуються в нативний модуль і не будуть спільними з іншими модулями або системою.

Обмеження компонувальника: нативний модуль не компонується з таблицею символів повної мікропрограми MicroPython. Натомість він компонується з явною таблицею експортованих символів, що міститься в mp_fun_tablepy/nativeglue.h), яка фіксується під час збирання мікропрограми. Таким чином, неможливо просто викликати деяку довільну функцію HAL/OS/RTOS/системи, якщо вона не знаходиться за фіксованою адресою. У такому випадку шлях до скрипта компонувальника, що містить серію імен символів та їх фіксованих адрес, може бути переданий до mpy_ld.py через аргумент командного рядка --externs. У такий спосіб символи, що містяться у скрипті компонувальника, матимуть пріоритет над тим, що надається з об’єктних файлів, однак наразі реалізація з об’єктних файлів все одно залишиться у фінальному MPY-файлі. Парсер скриптів компонувальника має обмежені можливості та наразі використовується лише для розбору списку символів ROM порту ESP8266 (div. ports/esp8266/boards/eagle.rom.addr.v6.ld).

Нові символи можна додавати в кінець таблиці, а мікропрограму — перезбирати. Символи також необхідно додати до словника fun_table у tools/mpy_ld.py в тому ж місці. Це дозволить mpy_ld.py підхоплювати нові символи та надавати для них переміщення при імпорті mpy. Нарешті, якщо символ є функцією, слід додати макрос або заглушку в py/dynruntime.h, щоб спростити виклик функції.

Визначення нативного модуля

Нативний модуль .mpy визначається набором файлів, що використовуються для його збирання. Структура файлової системи складається з двох основних частин: вихідних файлів та Makefile:

  • У найпростішому випадку потрібен лише один вихідний файл на C, що містить увесь код, який буде скомпільований у модуль .mpy. Цей вихідний код на C повинен включати файл py/dynruntime.h для доступу до динамічного API MicroPython та містити щонайменше функцію mpy_init. Ця функція буде точкою входу модуля та викликатиметься при його імпорті.

    За бажанням модуль можна розділити на декілька вихідних файлів на C. Частини модуля також можуть бути реалізовані на Python. Усі вихідні файли мають бути перераховані в Makefile шляхом додавання їх до змінної SRC (див. нижче). Це стосується як вихідних файлів на C, так і будь-яких файлів Python, що будуть включені до результуючого файлу .mpy.

  • Makefile містить конфігурацію збирання для модуля та перелік вихідних файлів для збирання модуля .mpy. У ньому слід визначити MPY_DIR як шлях до репозиторію MicroPython (для пошуку заголовних файлів, відповідного фрагмента Makefile та інструменту mpy_ld.py), MOD як назву модуля, SRC як список вихідних файлів, за потреби вказати архітектуру машини через ARCH разом із необов’язковими прапорцями архітектури через ARCH_FLAGS, а потім підключити py/dynruntime.mk.

Мінімальний приклад

У цьому розділі наведено повністю робочий приклад простого модуля з назвою factorial. Цей модуль надає єдину функцію factorial.factorial(x), яка обчислює факторіал вхідного числа та повертає результат.

Структура каталогів:

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

Файл factorial.c містить:

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

Файл Makefile містить:

# 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

Компіляція модуля

Необхідні інструменти для збирання нативного файлу .mpy:

  • Репозиторій MicroPython (щонайменше каталоги py/ та tools/).

  • CPython 3 та бібліотека pyelftools (наприклад, pip install 'pyelftools>=0.25').

  • GNU make.

  • Компілятор C для цільової архітектури (якщо використовується вихідний код на C).

  • Необов’язково mpy-cross, зібраний із репозиторію MicroPython (якщо використовуються вихідні файли .py).

Переконайтеся у виборі правильного ARCH для цільового пристрою, на якому буде виконуватися код. Потім збирайте за допомогою:

$ make

Без зміни Makefile можна вказати цільову архітектуру через:

$ make ARCH=armv7m

Те саме стосується необов’язкових прапорців архітектури через:

$ make ARCH=rv32imc ARCH_FLAGS=zba

Використання модуля в MicroPython

Після збирання модуля має з’явитися файл factorial.mpy. Скопіюйте його так, щоб він був доступний у файловій системі вашої системи MicroPython та міг бути знайдений у шляху імпорту. Тепер до модуля можна звертатися з Python так само, як і до будь-якого іншого модуля, наприклад:

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

Використання Picolibc при збиранні модулів

Використання Picolibc як стандартної бібліотеки C не лише підтримується, але й фактично є стандартним для платформ rv32imc та rv64imc. Однак є кілька речей, на які варто звернути увагу, щоб уникнути проблем під час збирання коду.

Деякі попередньо зібрані версії Picolibc (наприклад, ті, що надаються Ubuntu Linux у вигляді пакетів picolibc-arm-none-eabi, picolibc-riscv64-unknown-elf та picolibc-xtensa-lx106-elf) передбачають наявність локального сховища потоків (TLS) під час виконання, однак, на жаль, модулі MicroPython не підтримують це на деяких архітектурах (а саме rv32imc та rv64imc). Це означає, що деякі функції, надані Picolibc, за замовчуванням використовуватимуть TLS, повертаючи помилку або під час компіляції, або під час компонування.

Як приклад того, як це може вас вплинути, модуль-приклад examples/natmod/btree містить обхідне рішення для коректної роботи errno (шукайте __PICOLIBC_ERRNO_FUNCTION у Makefile та далі за посиланнями).

Додаткові приклади

Додаткові приклади, що демонструють більшість доступних можливостей нативних модулів .mpy, знаходяться в examples/natmod/. Серед таких можливостей:

  • використання декількох вихідних файлів на C

  • включення коду на Python поряд із кодом на C

  • дані rodata та BSS

  • виділення пам’яті

  • використання чисел з плаваючою комою

  • обробка виключень

  • підключення зовнішніх бібліотек C