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

В этом разделе описывается, как собирать и работать с файлами .mpy, содержащими нативный машинный код, написанный на языке, отличном от Python. Это позволяет писать код на таком языке, как C, компилировать и компоновать его в файл .mpy, а затем импортировать этот файл как обычный модуль Python. Это можно использовать для реализации функциональности, критичной к производительности, или для включения существующей библиотеки, написанной на другом языке.

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

Здесь основное внимание уделяется использованию C для сборки нативных модулей, но в принципе любой язык, который можно скомпилировать в автономный машинный код, может быть помещён в файл .mpy.

Нативный модуль .mpy собирается с помощью инструмента mpy_ld.py, который находится в каталоге tools/ проекта. Этот инструмент берёт набор объектных файлов (файлов .o) и компонует их вместе для создания нативного файла .mpy. Для него требуется CPython 3 и библиотека pyelftools версии 0.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, число с плавающей точкой одинарной точности, например Cortex-M4F, Cortex-M7)

  • armv7emdp (ARM Thumb 2, число с плавающей точкой двойной точности, например Cortex-M7)

  • xtensa (без окон, например ESP8266)

  • xtensawin (с окнами размером 8, например ESP32, ESP32S3)

  • rv32imc (RISC-V 32 бита со сжатыми инструкциями, например ESP32C3, ESP32C6)

  • rv64imc (RISC-V 64 бита со сжатыми инструкциями)

Если выбранная платформа поддерживает явные флаги архитектуры и вы хотите, чтобы выходной файл .mpy нёс значение этих флагов, необходимо передать их в переменную флагов ARCH_FLAGS при сборке файла .mpy.

При компиляции и компоновке нативного файла .mpy необходимо выбрать архитектуру, и соответствующий файл можно импортировать только на этой архитектуре (а если присутствуют флаги архитектуры, то только если они соответствуют возможностям целевого устройства). Подробнее о файлах .mpy см. Файлы .mpy в MicroPython.

Нативный код должен компилироваться как позиционно-независимый код (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 (см. ports/esp8266/boards/eagle.rom.addr.v6.ld).

Новые символы можно добавлять в конец таблицы и пересобирать прошивку. Символы также необходимо добавить в словарь fun_table файла tools/mpy_ld.py в том же месте. Это позволяет mpy_ld.py подхватывать новые символы и предоставлять для них перемещения при импорте mpy. Наконец, если символ является функцией, в py/dynruntime.h следует добавить макрос или заглушку, чтобы упростить вызов функции.

Определение нативного модуля

Нативный модуль .mpy определяется набором файлов, используемых для сборки .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