MicroPython .mpy 文件

MicroPython 定义了 .mpy 文件的概念,它是一种保存预编译代码的二进制容器文件格式,并且可以像普通的 .py 模块一样被导入。文件 foo.mpy 可以通过 import foo 导入,只要导入机制能以通常的方式找到 foo.mpy 即可。通常,会按顺序搜索 sys.path 中列出的每个目录。在搜索某个特定目录时,会首先查找 foo.py,如果未找到则查找 foo.mpy,如果两者都未找到则继续在下一个目录中搜索。因此,foo.py 的优先级高于 foo.mpy

这些 .mpy 文件可以包含 bytecode,它通常是通过 mpy-cross 程序从 Python 源文件(.py 文件)生成的。对于某些架构,.mpy 文件还可以包含原生机器码,它可以通过多种方式生成,最常见的是从 C 源代码生成。

mpy-cross 编译器

mpy-cross 是一个交叉编译器,它将 .py 源文件转换为可在摄像头上导入的 .mpy 二进制容器。它是 MicroPython 源代码树(与构建摄像头固件所用的源代码树相同)的一部分,并且也作为 pip 包发布,以便在不进行完整固件检出的情况下用于主机端:

$ pip install --user mpy-cross

或者通过 pipx 安装:

$ pipx install mpy-cross

安装完成后,对单个源文件调用它:

$ mpy-cross foo.py

这会在当前目录中生成 foo.mpy,可以将其与其他模块一起复制到摄像头的文件系统中,或者用于生成 ROMFS 镜像。

最常用的命令行选项:

  • -o <path> —— 生成的 .mpy 的输出路径(默认为输入文件名并替换扩展名;-o - 写入到标准输出)。

  • -O<n> —— 优化级别 03。默认的 0 会保留断言和完整的源代码位置;3 会去除断言和文档字符串,并重写 if __debug__ 代码块。该级别控制的是运行时所暴露的同一个 micropython.opt_level 接口。

  • -march=<arch> —— 用于 @native@viper 装饰的函数的目标原生架构。当源代码使用这些装饰器时为必需项。该值必须与摄像头的 MCU 类别相匹配:可从 mpy-cross --help 打印的列表中选取,或在运行时通过 sys.implementation._mpy 从摄像头上读取。

  • -s <path> —— 嵌入到 .mpy 调试信息中的源路径字符串。当磁盘上的路径与该文件在回溯中应显示的导入路径不同时很有用。

  • -X emit=bytecode|native|viper —— 为整个模块选择默认的发射器(这是 @native / @viper 装饰器的一种逐函数替代方案)。

  • --version —— 打印该二进制文件发射的 .mpy 格式版本。该版本号必须与摄像头运行时所支持的版本相匹配(参见下方的发布对照表),否则导入将引发 ValueError('incompatible .mpy file')

运行 mpy-cross --help 可查看完整的标志列表。

该 pip 包还暴露了一个小型 Python 模块 API,以便构建脚本可以在进程内驱动编译器,而不必手动派生子进程:

import mpy_cross

mpy_cross.compile('foo.py', dest='build/foo.mpy', opt=3,
                  march=mpy_cross.NATIVE_ARCH_ARMV7EMSP)

mpy_cross.compilempy_cross.runmpy_cross.mpy_version 是三个入口点;当出现错误时,mpy_cross.CrossCompileError 会携带编译器的 stderr。架构常量(NATIVE_ARCH_ARMV7EMSPNATIVE_ARCH_ARMV7EMDP 等)与 -march 标志所接受的字符串相对应。

.mpy 文件的版本控制与兼容性

给定的 .mpy 文件可能与给定的 MicroPython 系统兼容,也可能不兼容。兼容性基于以下几点:

  • .mpy 文件的版本:文件的版本必须与加载它的系统所支持的版本相匹配。

  • .mpy 文件的子版本:如果 .mpy 文件包含原生机器码,则文件的子版本必须与加载它的系统所支持的版本相匹配。否则,如果 .mpy 文件中没有原生机器码,则在加载时忽略子版本。

  • 小整数位数:.mpy 文件要求 small integer 具有最少的位数,而加载它的系统必须至少支持这么多的位数。

  • 原生架构:如果 .mpy 文件包含原生机器码,则它会指定该机器码的架构,而加载它的系统必须支持执行该架构的代码。

如果某个 MicroPython 系统支持导入 .mpy 文件,则 sys.implementation._mpy 字段将存在,并返回一个整数,该整数编码了版本(低 8 位)、特性以及原生架构。

尝试导入未通过前四项测试之一的 .mpy 文件将引发 ValueError('incompatible .mpy file')。尝试导入未通过原生架构测试(如果它包含原生机器码)的 .mpy 文件将引发 ValueError('incompatible .mpy arch')

如果导入 .mpy 文件失败,请尝试以下操作:

  • 通过执行以下代码确定你的 MicroPython 系统所支持的 .mpy 版本和标志:

    import sys
    sys_mpy = sys.implementation._mpy
    arch = [None, 'x86', 'x64',
        'armv6', 'armv6m', 'armv7m', 'armv7em', 'armv7emsp', 'armv7emdp',
        'xtensa', 'xtensawin', 'rv32imc', 'rv64imc'][(sys_mpy >> 10) & 0x0F]
    print('mpy version:', sys_mpy & 0xff)
    print('mpy sub-version:', sys_mpy >> 8 & 3)
    print('mpy flags:', end='')
    if arch:
        print(' -march=' + arch, end='')
    if (sys_mpy >> 16) != 0:
        print(' -march-flags=' + (sys_mpy >> 16), end='')
    print()
    
  • 通过检查文件的前两个字节来检验 .mpy 文件的有效性。第一个字节应为大写的 'M',第二个字节为版本号,它应与上述的系统版本相匹配。如果不匹配,则重新构建 .mpy 文件。

  • 检查系统的 .mpy 版本是否与用于构建该 .mpy 文件的 mpy-cross 所发射的版本相匹配,后者可通过 mpy-cross --version 查得。如果不匹配,则从在 mpy-cross --version 所报告的标签(或哈希)处检出的 Git 仓库重新编译 mpy-cross

  • 确保你使用了正确的 mpy-cross 标志,这些标志可通过上面的代码查得,或通过检查你所用端口的 MPY_CROSS_FLAGS Makefile 变量查得。

  • 如果 .mpy 文件的第三个字节设置了第 6 位,则检查所编码的架构特定标志位 vuint 是否与你导入该文件的目标兼容。

下表显示了 MicroPython 发布版本与 .mpy 版本之间的对应关系。

MicroPython 发布版本

.mpy 版本

v1.23.0 及以上

6.3

v1.22.x

6.2

v1.20 - v1.21.0

6.1

v1.19.x

6

v1.12 - v1.18

5

v1.11

4

v1.9.3 - v1.10

3

v1.9 - v1.9.2

2

v1.5.1 - v1.8.7

0

为完整起见,下表显示了 .mpy 版本发生变化时主 MicroPython 仓库的 Git 提交。

.mpy 版本变更

Git 提交

6.2 到 6.3

bdbc869f9ea200c0d28b2bc7bfb60acd9d884e1b

6.1 到 6.2

6967ff3c581a66f73e9f3d78975f47528db39980

6 到 6.1

d94141e1473aebae0d3c63aeaa8397651ad6fa01

5 到 6

f2040bfc7ee033e48acef9f289790f3b4e6b74e5

4 到 5

5716c5cf65e9b2cb46c2906f40302401bdd27517

3 到 4

9a5f92ea72754c01cc03e5efcdfe94021120531e

2 到 3

ff93fd4f50321c6190e1659b19e64fef3045a484

1 到 2

dd11af209d226b7d18d5148b239662e30ed60bad

0 到 1

6a11048af1d01c78bdacddadd1b72dc7ba7c6478

初始版本 0

d8c834c95d506db979ec871417de90b7951edc30

.mpy 文件的二进制编码

MicroPython .mpy 文件是一种二进制容器格式,其中的代码对象(字节码和原生机器码)以嵌套的层次结构存储于内部。最外层模块的代码先存储,然后其子项跟随其后。每个子项可以有进一步的子项,例如一个类含有方法的情况,或者一个函数定义了 lambda 或推导式的情况。为了在保持文件较小的同时仍能提供较大范围的可能取值,它在许多地方使用了可变编码无符号整数(vuint)的概念。与 UTF-8 编码类似,这种编码每个字节存储 7 位,如果后面还有一个或多个字节,则第 8 位(MSB)置位。无符号整数的各位以 LSB 在前的形式存储在 vuint 中。

.mpy 文件的顶层由三部分组成:

  • 头部。

  • 全局 qstr 表和常量表。

  • 模块外层作用域的原始代码(raw-code)。该外层作用域在导入 .mpy 文件时执行。

你可以使用 mpy-tool.py 检查 .mpy 文件的内容,例如(在主 MicroPython 仓库的根目录下运行):

$ ./tools/mpy-tool.py -xd myfile.mpy

头部

.mpy 头部如下:

大小

字段

字节

值 0x4d(ASCII 'M')

字节

.mpy 主版本号

字节

特性标志、原生架构、次版本号(在较旧版本中为特性标志)

字节

小整数的位数

第三个字节按如下方式拆分(MSB 在前):

含义

7

保留,必须为 0

6

头部之后跟随一个架构特定标志 vuint

5..2

原生架构编号

1..0

次版本号

架构特定标志

如果头部的特性标志字节的第 6 位被置位,则头部之后将跟随一个包含可选架构特定信息的 vuint。该整数的内容取决于该文件所针对的原生架构。

这目前用于存储 MPY 文件为正确运行所需的 RISC-V 处理器扩展(除 I、M、C 和 Zicsr 之外)。不同风格的 ArmV7 通过其原生架构编号来标识,但对 RV32 和 RV64 而言,重用该机制会使事情复杂化。

针对 RV32 或 RV64 且不需要任何特定处理器扩展的 MPY 文件无需提供标志整数(同时在头部设置相应的位)。RV32 和 RV64 MPY 文件缺少标志值用于表示不需要任何特定扩展,并且在最终的输出二进制文件中节省一个字节。

另请参阅 mpy-tool.pympy-cross 中的 -march-flags 命令行选项,以及 mpy_ld.py 中的 --arch-flags 命令行选项,它们用于在创建 MPY 文件时设置该值。

全局 qstr 表和常量表

.mpy 文件包含一个 qstr 表和一个常量对象表。它们对于该 .mpy 文件而言是全局的,所有嵌套的原始代码对象都会引用它们。qstr 表将内部 qstr 编号(在 .mpy 文件内部)映射到 .mpy 文件被导入到的运行时的已解析 qstr 编号。这将 .mpy 文件与其执行所在的系统的其余部分关联起来。常量对象表填充了对该 .mpy 文件所需的所有常量对象的引用。

大小

字段

vuint

qstr 的数量

vuint

常量对象的数量

...

qstr 数据

...

已编码的常量对象

原始代码元素

原始代码元素包含代码,可以是字节码或原生机器码。其内容为:

大小

字段

vuint

类型、大小以及是否存在子原始代码元素

...

代码(字节码或机器码)

vuint

子原始代码元素的数量(仅当非零时)

...

子原始代码元素

原始代码元素中的第一个 vuint 编码了存储在该元素中的代码类型(最低两位)、该原始代码是否有子项(最低第三位),以及随后代码的长度(为其分配的 RAM 数量)。

vuint 之后是代码本身。除非代码类型是带有重定位的 viper 代码,否则该代码是常量数据,无需修改。

如果该原始代码有任何子项(由第一个 vuint 中的某一位指示),则代码之后跟随一个用于计数子原始代码元素数量的 vuint。

最后,所有子原始代码元素以递归方式存储。