.mpy 檔案中的原生機器碼

本節說明如何建置與使用包含原生機器碼(以 Python 以外的語言撰寫)的 .mpy 檔案。這讓您可以使用如 C 之類的語言撰寫程式碼,將其編譯並連結成 .mpy 檔案,然後像一般 Python 模組一樣 import 此檔案。這可用於實作對效能要求嚴苛的功能,或用於納入以其他語言撰寫的現有函式庫。

使用原生 .mpy 檔案的主要優點之一,是原生機器碼可由指令碼動態 import,而無需重新建置主要的 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,單精度浮點,例如 Cortex-M4F、Cortex-M7)

  • armv7emdp(ARM Thumb 2,雙精度浮點,例如 Cortex-M7)

  • xtensa(非視窗化,例如 ESP8266)

  • xtensawin(視窗大小為 8 的視窗化,例如 ESP32、ESP32S3)

  • rv32imc(具壓縮指令的 32 位元 RISC-V,例如 ESP32C3、ESP32C6)

  • rv64imc(具壓縮指令的 64 位元 RISC-V)

如果所選平台支援明確的架構旗標,且您希望讓輸出的 .mpy 檔案帶有這些旗標的值,則在建置 .mpy 檔案時必須將它們傳遞給 ARCH_FLAGS 旗標變數。

在編譯與連結原生 .mpy 檔案時必須選擇架構,且對應的檔案只能在該架構上 import(若存在架構旗標,則僅在它們與目標的能力相符時才能 import)。關於 .mpy 檔案的更多細節,請參閱 MicroPython .mpy 檔案

原生程式碼必須編譯為位置無關程式碼(PIC)並使用全域偏移表(GOT),不過其細節因架構而異。在 import 含有原生程式碼的 .mpy 檔案時,import 機制能夠對原生程式碼進行一些基本的重定位。這包括重定位 text、rodata 與 BSS 區段。

連結器與動態載入器支援的功能為:

  • 可執行程式碼(text)

  • 唯讀資料(rodata),包括字串與常數資料(陣列、結構等)

  • 歸零資料(BSS)

  • text 中指向 text、rodata 與 BSS 的指標

  • rodata 中指向 text、rodata 與 BSS 的指標

已知的限制為:

  • 不支援 data 區段;變通方法:使用 BSS 資料並明確地初始化資料值

  • 不支援靜態 BSS 變數;變通方法:使用全域 BSS 變數

  • 在 rv32imc 上不支援執行緒區域儲存(thread-local storage)變數;變通方法:使用全域 BSS 變數,或在堆積上配置一些空間來儲存它們

因此,如果您的 C 程式碼有可寫入的資料,請確保該資料是全域定義的、不含初始化值,且只在函式內被寫入。

原生模組不會自動連結至如 libm.alibgcc.a 等標準靜態函式庫,這可能導致 undefined symbol 錯誤。您可以透過在 Makefile 中設定 LINK_RUNTIME = 1 來連結執行階段函式庫。也可以透過加入 MPY_LD_FLAGS += -l path/to/library.a 來連結自訂的靜態函式庫。請注意,這些函式庫是被連結進原生模組中的,不會與其他模組或系統共用。

連結器限制:原生模組並非連結至完整 MicroPython 韌體的符號表。相反地,它是連結至一個明確的匯出符號表,該表位於 mp_fun_table(在 py/nativeglue.h 中),並在韌體建置時固定。因此,除非某個任意的 HAL/OS/RTOS/系統函式位於固定位址,否則無法直接呼叫它。在這種情況下,可以透過 --externs 命令列引數,將一個包含一系列符號名稱及其固定位址的連結器指令稿路徑傳遞給 mpy_ld.py。這樣一來,連結器指令稿中出現的符號將優先於目的檔所提供的符號,但目前目的檔的實作仍會保留在最終的 MPY 檔案中。連結器指令稿剖析器的能力有限,目前僅用於剖析 ESP8266 移植版的 ROM 符號清單(請參閱 ports/esp8266/boards/eagle.rom.addr.v6.ld)。

可以在表的尾端加入新的符號並重新建置韌體。這些符號也需要被加入到 tools/mpy_ld.pyfun_table 字典中的相同位置。這讓 mpy_ld.py 能夠擷取到新符號,並在 import mpy 時為它們提供重定位。最後,如果該符號是函式,則應在 py/dynruntime.h 中加入一個巨集或 stub,以便於呼叫該函式。

定義原生模組

原生 .mpy 模組是由一組用於建置 .mpy 的檔案所定義。檔案系統的版面配置由兩個主要部分組成,即原始碼檔案與 Makefile:

  • 在最簡單的情況下,只需要一個 C 原始碼檔案,其中包含所有將被編譯進 .mpy 模組的程式碼。此 C 原始碼必須引入 py/dynruntime.h 檔案以存取 MicroPython 動態 API,且至少必須定義一個名為 mpy_init 的函式。此函式將成為模組的進入點,會在 import 模組時被呼叫。

    如有需要,模組可以拆分成多個 C 原始碼檔案。模組的某些部分也可以用 Python 實作。所有原始碼檔案都應列在 Makefile 中,方法是將它們加入到 SRC 變數(請見下文)。這包括 C 原始碼檔案以及任何將被納入最終 .mpy 檔案的 Python 檔案。

  • Makefile 包含模組的建置設定,並列出用於建置 .mpy 模組的原始碼檔案。它應將 MPY_DIR 定義為 MicroPython 儲存庫的位置(以便找到標頭檔、相關的 Makefile 片段以及 mpy_ld.py 工具)、將 MOD 定義為模組名稱、將 SRC 定義為原始碼檔案清單,並可選擇透過 ARCH 指定機器架構,連同透過 ARCH_FLAGS 指定的可選機器架構旗標,然後 include 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 系統的檔案系統上存取,並能在 import 路徑中被找到。現在便可在 Python 中像存取任何其他模組一樣存取此模組,例如:

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

建置模組時使用 Picolibc

使用 Picolibc 作為您的 C 標準函式庫不僅受到支援,事實上它還是 rv32imc 與 rv64imc 平台的預設選擇。然而,有幾件事值得一提,以確保您日後在建置程式碼時不會遇到問題。

某些預先建置的 Picolibc 版本(例如,Ubuntu Linux 以 picolibc-arm-none-eabipicolibc-riscv64-unknown-elfpicolibc-xtensa-lx106-elf 套件形式提供的那些)假設執行階段可使用執行緒區域儲存(TLS),但很可惜 MicroPython 模組在某些架構上(即 rv32imcrv64imc)並不支援這一點。這表示 Picolibc 提供的某些功能將預設使用 TLS,在編譯或連結期間回傳錯誤。

關於這可能如何影響您的範例,examples/natmod/btree 範例模組包含一個變通方法以確保 errno 能正常運作(在 Makefile 中尋找 __PICOLIBC_ERRNO_FUNCTION 並從那裡開始追蹤)。

更多範例

請參閱 examples/natmod/ 以取得更多範例,這些範例展示了原生 .mpy 模組許多可用的功能。此類功能包括:

  • 使用多個 C 原始碼檔案

  • 在 C 程式碼旁納入 Python 程式碼

  • rodata 與 BSS 資料

  • 記憶體配置

  • 浮點數的使用

  • 例外處理

  • 納入外部 C 函式庫