MicroPython 外部 C 模块

在为 MicroPython 开发模块时,你可能会发现自己受到 Python 环境的限制,这通常是由于无法访问某些硬件资源或受到 Python 速度的限制。

如果你的限制无法通过 最大化 MicroPython 运行速度 中的建议解决,那么用 C(和/或 如果你的移植版本实现了 C++)编写部分或全部模块是一个可行的选择。

如果你的模块旨在访问或配合常见硬件或库使用,请考虑在 MicroPython 源代码树中与类似模块一起实现,并以拉取请求(pull request)的形式提交。但如果你针对的是冷门或专有系统,那么将其保留在主 MicroPython 仓库之外可能更合理。

本章介绍如何将这类外部模块编译进 MicroPython 可执行文件或固件镜像中。Make 和 CMake 两种构建工具都受支持,在编写外部模块时,最好同时为这两种工具添加构建文件,以便该模块可以在所有移植版本上使用。但在编译某个特定移植版本时,你只需使用其中一种构建方式,Make 或 CMake 均可。

另一种方法是使用 .mpy 文件中的原生机器码,它允许编写自定义 C 代码并放入 .mpy 文件中,该文件可以动态导入到正在运行的 MicroPython 系统中,而无需重新编译主固件。

外部 C 模块的结构

一个 MicroPython 用户 C 模块是一个包含以下文件的目录:

  • *.c / *.cpp / *.h 模块的源代码文件。

    这些文件通常包含正在实现的底层功能,以及用于暴露这些函数和模块的 MicroPython 绑定函数。

    目前,编写这些函数/模块的最佳参考方式是在 MicroPython 源码树中找到类似的模块,并以它们作为示例。

  • micropython.mk 包含本模块的 Makefile 片段。

    $(USERMOD_DIR)micropython.mk 中可用,表示你的模块目录路径。由于它会为每个 C 模块重新定义,因此应在你的 micropython.mk 中将其展开为一个局部 make 变量,例如 EXAMPLE_MOD_DIR := $(USERMOD_DIR)

    你的 micropython.mk 必须将模块的源文件添加到 SRC_USERMOD_CSRC_USERMOD_LIB_C 变量中。前者会被处理以查找 MP_QSTR_MP_REGISTER_MODULE 定义,后者则不会(例如非 MicroPython 专用的辅助代码和库代码)。这些路径应包含你展开后的 $(USERMOD_DIR) 副本,例如:

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

    类似地,对于 C++ 源文件,使用 SRC_USERMOD_CXXSRC_USERMOD_LIB_CXX。如果要包含汇编文件,请使用 SRC_USERMOD_LIB_ASM

    如果你有自定义的编译器选项(例如用 -I 添加搜索头文件的目录),对于 C 代码应将它们添加到 CFLAGS_USERMOD,对于 C++ 代码则添加到 CXXFLAGS_USERMOD

  • micropython.cmake 包含本模块的 CMake 配置。

    micropython.cmake 中,你可以使用 ${CMAKE_CURRENT_LIST_DIR} 作为当前模块的路径。

    你的 micropython.cmake 应定义一个 INTERFACE 库,并将你的源文件、编译定义和包含目录与其关联。然后应将该库链接到 usermod 目标。

    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)
    

    完整使用示例见下文。

基本示例

cexample 模块提供了一个函数和一个类的示例。cexample.add_ints(a, b) 函数将两个整数参数相加并返回结果。cexample.Timer() 类型创建计时器,可用于测量自对象实例化以来经过的时间。

该模块位于 MicroPython 源码树的 examples 目录中,包含一个源文件和一个 Makefile 片段,其内容如上所述:

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

请参阅这些文件中的注释以获得更多说明。在 cexample 模块旁边还有一个 cppexample,它的工作方式相同,但展示了在 MicroPython 中混合使用 C 和 C++ 代码的一种方式。

将 cmodule 编译进 MicroPython

要构建这样一个模块,请编译 MicroPython(参见 入门指南),并进行 2 处修改:

  1. 设置构建时标志 USER_C_MODULES,使其指向你想要包含的模块。对于使用 Make 的移植版本,该变量应是一个目录,系统会自动在其中搜索模块。对于使用 CMake 的移植版本,该变量应是一个包含待构建模块的文件。详见下文。

  2. 通过将相应的 C 预处理器宏设置为 1 来启用模块。仅当你正在构建的模块未被自动启用时才需要这样做。

要构建 MicroPython 自带的示例模块,对于 Make,请将 USER_C_MODULES 设置为 examples/usercmodule 目录;对于 CMake,则设置为 examples/usercmodule/micropython.cmake

例如,下面演示了如何为 unix 移植版本构建带示例模块的版本:

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

在构建中包含新的用户模块时,你可能需要在开始时运行一次 make clean。构建输出会显示找到的模块:

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

对于基于 CMake 的移植版本(例如 rp2),情况会略有不同(注意 CMake 实际上是由 make 调用的):

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

同样,你可能需要先运行 make clean 以便 CMake 识别用户模块。CMake 构建输出会按名称列出这些模块:

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

顶层 micropython.cmake 的内容可用于控制启用哪些模块。

对于你自己的项目,将自定义代码保留在主 MicroPython 源码树之外会更方便,因此典型的项目目录结构如下所示:

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

使用 Make 构建时,将 USER_C_MODULES 设置为 my_project/modules 目录。例如,构建 stm32 移植版本:

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

使用 CMake 构建时,顶层 micropython.cmake(直接位于 my_project/modules 目录中)应 include 你想要提供的所有模块:

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

然后使用以下命令构建:

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

你也可以为 USER_C_MODULES 指定绝对路径。

USER_C_MODULES 变量指定的所有模块(使用 Make 时在该目录中找到的,或使用 CMake 时通过 include 添加的)都会被编译,但只有那些已启用的模块才可供导入。用户模块通常默认启用(由模块开发者决定),在这种情况下,除了如上所述设置 USER_C_MODULES 之外无需再做任何事情。

如果某个模块默认未启用,则必须启用相应的 C 预处理器宏。可以通过在模块源代码中搜索 MP_REGISTER_MODULE 这一行来找到该宏名(它通常出现在主源文件的末尾)。该宏应被一对 #if X / #endif 包围,并且必须使用 CFLAGS_EXTRA 将配置选项 X 设置为 1 才能使该模块可用。如果没有 #if X / #endif 这一对,则该模块默认启用。

例如,examples/usercmodule/cexample 模块默认启用,因此其源代码中有如下一行:

MP_REGISTER_MODULE(MP_QSTR_cexample, example_user_cmodule);

或者,要使该模块默认禁用但可通过预处理器配置选项进行选择,则应为:

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

在这种情况下,通过在 make 命令中添加 CFLAGS_EXTRA=-DMODULE_CEXAMPLE_ENABLED=1,或编辑 mpconfigport.hmpconfigboard.h 来添加以下内容,即可启用该模块

#define MODULE_CEXAMPLE_ENABLED (1)

请注意,确切的方法取决于移植版本,因为它们具有不同的结构。如果操作不正确,它仍能编译通过,但导入时将找不到该模块。

在 MicroPython 中使用模块

一旦编译进你的 MicroPython 副本中,该模块现在就可以像任何其他内置模块一样在 Python 中访问,例如

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

C 动态内存分配

MicroPython 为 内存管理 使用其自己的“Python 堆”,它与 C 库函数 malloc()free() 等所使用的“C 堆”不同。并非每个 MicroPython 移植版本都自带“C 堆”。

第 1 级和第 2 级移植版本对通过“C 堆”进行 C 动态内存分配的支持程度各不相同:

  • unix、windows、esp32 和 webassembly 移植版本支持 C 动态内存分配。

  • rp2 移植版本在运行时将无法分配任何内存,除非使用 MICROPY_C_HEAP_SIZE=n 构建固件,为 C 堆预留 n 个字节的内存。这块内存将无法供 Python 代码使用。

  • alif, mimxrt, nrf, renesas-ra, samd, and stm32 port builds that include dynamic C allocation will fail at link-time with errors such as undefined reference to `malloc'. MicroPython has no built-in support for dynamic C allocation on these ports. Any solution requires manually adding a C heap implementation to the custom build.

  • zephyr 移植版本目前不支持使用用户模块进行构建。

将 Python 堆用作 C 堆

对 C 代码来说,改为调用诸如 m_malloc()m_malloc0()m_free() 之类的“Python 堆”动态分配函数可能更为实用。

有关此方法的更多信息,请参阅 从 C 代码使用 MicroPython 内存

C++ 模块

大多数第 1 级和第 2 级 MicroPython 移植版本(以及部分第 3 级)支持构建 C++ 用户模块,使用上文所述的 C++ 专用环境变量。

成功集成 C++ 和 MicroPython 需要考虑一些额外的因素:

C++ 动态内存分配

C++ 程序(以及 C++ 标准库特性)通常使用动态内存分配。C++ 默认内存分配器(即 newdelete 运算符)通常实现为 C 动态内存分配 之上的一层。

对于不包含 C 动态内存分配支持的 MicroPython 移植版本,可以通过以下两种方式之一支持 C++ 动态内存分配:

  • 在你的自定义构建中实现 C 动态内存分配。

  • 在你的自定义构建中实现自定义 C++ 分配器。

链接相关注意事项

由于 MicroPython 是基于 C 的项目,任何链接到 MicroPython 或从 MicroPython 链接出去的符号在 C++ 代码中都需要用 extern "C" 限定。

强烈建议遵循 examples/usercmodule/cppexample 中演示的模式,即 Python 模块在一个极简的 C 文件中实现,该文件对 C++ 代码进行了封装。