在微控制器上运行 MicroPython

MicroPython 的设计目标是能够在微控制器上运行。这些设备存在硬件限制,对于更熟悉传统计算机的程序员来说,这些限制可能比较陌生。尤其是 RAM 以及非易失性"磁盘"(闪存)存储的容量都很有限。本教程提供了若干方法,帮助你充分利用有限的资源。由于 MicroPython 运行在基于多种架构的控制器上,因此这里介绍的方法是通用的:在某些情况下,需要从特定平台的文档中获取详细信息。

闪存

在 OpenMV Cam 上,应对容量有限这一问题的简单方法是插入一张 micro SD 卡。但在某些情况下这并不可行,要么是因为设备没有 SD 卡插槽,要么是出于成本或功耗的考虑;因此必须使用片上闪存。包括 MicroPython 子系统在内的固件都存储在板载闪存中。剩余容量可供使用。由于闪存的物理架构原因,这部分容量中的一部分可能无法作为文件系统访问。在这种情况下,可以通过将用户模块编译进固件,再将固件烧录到设备中,来利用这部分空间。

实现这一点有两种方式:冻结模块(frozen modules)和冻结字节码(frozen bytecode)。冻结模块将 Python 源代码与固件一起存储。冻结字节码则使用交叉编译器将源代码转换为字节码,然后与固件一起存储。无论哪种方式,模块都可以通过 import 语句来访问:

import mymodule

生成冻结模块和字节码的步骤因平台而异;构建固件的说明可以在源代码树相关部分的 README 文件中找到。

总体而言,步骤如下:

  • 克隆 MicroPython 代码库

  • 获取用于构建固件的(特定平台的)工具链。

  • 构建交叉编译器。

  • 将要冻结的模块放入指定目录(取决于该模块是要作为源代码冻结还是作为字节码冻结)。

  • 构建固件。构建任一类型的冻结代码可能都需要特定的命令——请参阅平台文档。

  • 将固件烧录到设备中。

RAM

在减少 RAM 占用时,需要考虑两个阶段:编译和执行。除了内存消耗之外,还存在一个称为堆碎片化(heap fragmentation)的问题。一般来说,最好尽量减少对象的反复创建与销毁。其中的原因将在介绍 heap 的章节中说明。

编译阶段

当一个模块被导入时,MicroPython 会将代码编译为字节码,然后由 MicroPython 虚拟机(VM)执行。字节码存储在 RAM 中。编译器本身需要 RAM,但在编译完成后,这部分 RAM 即可被重新使用。

如果已经导入了若干模块,就可能出现 RAM 不足以运行编译器的情况。此时 import 语句会产生内存异常。

如果一个模块在导入时实例化了全局对象,它就会在导入时消耗 RAM,这部分 RAM 在后续导入时便无法供编译器使用。一般来说,最好避免在导入时运行的代码;更好的做法是编写初始化代码,在所有模块导入完成后由应用程序运行。这样可以最大化编译器可用的 RAM。

如果 RAM 仍不足以编译所有模块,一种解决方案是预编译模块。MicroPython 提供了一个交叉编译器,能够将 Python 模块编译为字节码(参见 mpy-cross 目录中的 README)。生成的字节码文件带有 .mpy 扩展名;它可以复制到文件系统中,并以通常的方式导入。或者,可以将部分或全部模块实现为冻结字节码:在大多数平台上,这样可以节省更多 RAM,因为字节码直接从闪存运行,而不是存储在 RAM 中。

执行阶段

有许多编码技巧可以用于减少 RAM 占用。

常量

MicroPython 提供了一个 const 关键字,可以按如下方式使用:

from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS

在将常量赋给变量的两种情形中,编译器都会通过用其字面值替换常量名来避免对常量名进行查找。这样可以节省字节码,从而节省 RAM。然而,ROWS 值至少会占用两个机器字,分别用于全局字典中的键和值。之所以必须存在于字典中,是因为另一个模块可能会导入或使用它。可以通过在名称前加下划线(如 _COLS)来节省这部分 RAM:该符号在模块外部不可见,因此不会占用 RAM。

传给 const() 的参数可以是任何在编译时求值为常量的表达式,例如 0x1001 << 8(True, "string", b"bytes")(详见下文)。它甚至可以包含已经定义过的其他 const 符号,例如 1 << BIT

常量数据结构

当存在大量常量数据且平台支持从闪存执行时,可以按如下方式节省 RAM。这些数据应放在 Python 模块中并冻结为字节码。数据必须定义为 bytes 对象。编译器"知道" bytes 对象是不可变的,因此会确保这些对象保留在闪存中,而不会被复制到 RAM。struct 模块可以帮助在 bytes 类型与其他 Python 内置类型之间进行转换。

在考虑冻结字节码的含义时,请注意在 Python 中字符串、浮点数、字节、整数、复数和元组都是不可变的。因此这些都会被冻结到闪存中(对于元组,仅当其所有元素都不可变时)。这样,在下面这一行中

mystring = "The quick brown fox"

实际的字符串 "The quick brown fox" 将位于闪存中。在运行时,对该字符串的引用被赋给变量 mystring。该引用占用一个机器字。原则上可以用一个长整数来存储常量数据:

bar = 0xDEADBEEF0000DEADBEEF

与字符串示例一样,在运行时,对这个任意大整数的引用被赋给变量 bar。该引用占用一个机器字。

由常量对象构成的元组本身也是常量。这类常量元组会被编译器优化,因此每次使用时无需在运行时重新创建。例如:

foo = (1, 2, 3, 4, 5, 6, 100000, ("string", b"bytes", False, True))

整个元组将作为单个对象存在(如果代码被冻结,可能位于闪存中),并在每次需要时被引用。

不必要的对象创建

在许多情况下,对象可能会被无意中创建和销毁。这会因碎片化而降低 RAM 的可用性。以下各节将讨论此类情况的实例。

字符串拼接

考虑以下旨在生成常量字符串的代码片段:

var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""

每种写法产生的结果相同,但第一种在运行时不必要地创建了两个字符串对象,并在生成第三个字符串之前为拼接分配了更多 RAM。其他写法在编译时完成拼接,效率更高,可减少碎片化。

当字符串必须先动态创建再送入文件等流时,如果以分段方式进行,则可节省 RAM。与其创建一个大字符串对象,不如先创建一个子字符串并将其送入流,然后再处理下一个。

创建动态字符串的最佳方式是借助字符串的 format() 方法:

var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)

缓冲区

在访问 UART、I2C 和 SPI 接口实例等设备时,使用预分配的缓冲区可以避免创建不必要的对象。考虑以下两个循环:

while True:
    var = spi.read(100)
    # process data

buf = bytearray(100)
while True:
    spi.readinto(buf)
    # process data in buf

第一个循环在每次遍历时都创建一个缓冲区,而第二个循环重用一个预分配的缓冲区;后者在速度上更快,在内存碎片化方面也更高效。

字节比整数更小

在大多数平台上,一个整数占用四个字节。考虑对函数 foo() 的三次调用:

def foo(bar):
    for x in bar:
        print(x)
foo([1, 2, 0xff])
foo((1, 2, 0xff))
foo(b'\1\2\xff')

在第一次调用中,每次执行代码时都会在 RAM 中创建一个整数 list。第二次调用在编译阶段创建一个常量 tuple 对象(一个仅包含常量对象的 tuple),因此它只创建一次,比 list 更高效。第三次调用高效地创建一个 bytes 对象,消耗的 RAM 最少。如果该模块被冻结为字节码,则 tuplebytes 对象都会位于闪存中。

字符串与字节

Python3 引入了 Unicode 支持。这在字符串和字节数组之间引入了区别。只要字符串中的所有字符都是 ASCII(即值 < 128),MicroPython 就能确保 Unicode 字符串不占用额外空间。如果需要完整的 8 位范围内的值,则可以使用 bytesbytearray 对象,以确保不需要额外空间。请注意,大多数字符串方法(例如 str.strip())同样适用于 bytes 实例,因此消除 Unicode 的过程可以很轻松。

s = 'the quick brown fox'   # A string instance
b = b'the quick brown fox'  # A bytes instance

在需要在字符串和字节之间进行转换时,可以使用 str.encode()bytes.decode() 方法。请注意,字符串和字节都是不可变的。任何以这样的对象作为输入并产生另一个对象的操作,都意味着至少要分配一次 RAM 来生成结果。在下面的第二行中,会分配一个新的 bytes 对象。如果 foo 是字符串,也会发生同样的情况。

foo = b'   empty whitespace'
foo = foo.lstrip()

运行时编译器执行

Python 函数 evalexec 会在运行时调用编译器,这需要大量 RAM。请注意,micropython-lib 中的 pickle 库使用了 exec。使用 json 库进行对象序列化在 RAM 上可能更高效。

将字符串存储在闪存中

Python 字符串是不可变的,因此有可能存储在只读存储器中。编译器可以将 Python 代码中定义的字符串放入闪存。与冻结模块一样,需要在 PC 上有一份源代码树的副本以及用于构建固件的工具链。即使模块尚未完全调试好,只要它们能够被导入并运行,这一过程也能正常进行。

导入模块后,执行:

micropython.qstr_info(1)

然后将所有 Q(xxx) 行复制并粘贴到文本编辑器中。检查并删除明显无效的行。打开文件 qstrdefsport.h,它位于 ports/stm32 中(或所用架构对应的等效目录中)。将更正后的行复制并粘贴到该文件末尾。保存文件,重新构建并烧录固件。可以通过导入模块并再次执行以下命令来检查结果:

micropython.qstr_info(1)

Q(xxx) 行应该已经消失了。

当一个正在运行的程序实例化一个对象时,所需的 RAM 会从一个称为堆(heap)的固定大小内存池中分配。当对象超出作用域(换句话说,代码无法再访问它)时,这个多余的对象就被称为"垃圾"。一个称为"垃圾回收"(GC)的过程会回收该内存,将其归还给空闲堆。此过程会自动运行,不过也可以通过执行 gc.collect() 直接调用它。

关于这一点的讨论略显复杂。若想"快速解决",可定期执行以下命令:

gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())

更多信息请参阅下文以及内置模块 gc 的文档。

如需从 MicroPython 内部实现/开发者视角了解详细信息,另请参阅 内存管理

碎片化

假设一个程序先创建对象 foo,然后创建对象 bar。随后 foo 超出作用域,但 bar 仍然存在。foo 使用的 RAM 将被 GC 回收。然而,如果 bar 被分配到了更高的地址,那么从 foo 回收的 RAM 只能用于不大于 foo 的对象。在复杂或长时间运行的程序中,堆可能会变得碎片化:尽管有大量可用 RAM,却没有足够的连续空间来分配某个特定对象,于是程序因内存错误而失败。

上述技巧旨在尽量减少这种情况。当需要大型的永久缓冲区或其他对象时,最好在程序执行的早期、碎片化发生之前就实例化它们。还可以通过监控堆的状态以及控制 GC 来进一步改善;这些将在下文中介绍。

报告

有许多库函数可用于报告内存分配情况以及控制 GC。它们位于 gcmicropython 模块中。下面的示例可以粘贴到 REPL 中(按 Ctrl-E 进入粘贴模式,按 Ctrl-D 运行)。

import gc
import micropython
gc.collect()
micropython.mem_info()
print('-----------------------------')
print('Initial free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
def func():
    a = bytearray(10000)
gc.collect()
print('Func definition: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
func()
print('Func run free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
gc.collect()
print('Garbage collect free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
print('-----------------------------')
micropython.mem_info(1)

上面使用的方法:

  • gc.collect() 强制进行一次垃圾回收。参见脚注。

  • micropython.mem_info() 打印 RAM 使用情况的摘要。

  • gc.mem_free() 返回空闲堆的大小(以字节为单位)。

  • gc.mem_alloc() 返回当前已分配的字节数。

  • micropython.mem_info(1) 打印堆使用情况的表格(详见下文)。

产生的数字取决于平台,但可以看出,声明该函数会以编译器生成的字节码的形式使用少量 RAM(编译器使用的 RAM 已被回收)。运行该函数会使用超过 10KiB,但返回后 a 就成了垃圾,因为它已超出作用域且无法被引用。最后的 gc.collect() 回收了这部分内存。

micropython.mem_info(1) 产生的最终输出在细节上会有所不同,但可以按如下方式解读:

符号

含义

.

空闲块

h

头块

=

尾块

m

已标记头块

T

元组

L

列表

D

字典

F

浮点数

B

字节码

M

模块

S

字符串或字节

A

字节数组

每个字母代表一个内存块,一个块为 16 字节。因此堆转储的每一行代表 0x400 字节,即 1KiB 的 RAM。

垃圾回收的控制

可以随时通过执行 gc.collect() 来要求进行一次 GC。每隔一段时间这样做是有好处的,一方面可以预防碎片化,另一方面有利于性能。一次 GC 可能耗时数毫秒,但当需要处理的工作很少时会更快(在 OpenMV Cam 上约为 1ms)。显式调用可以将这种延迟降到最低,同时确保它发生在程序中可接受的时间点上。

在以下情况下会触发自动 GC。当一次分配尝试失败时,会执行一次 GC 并重新尝试分配。只有当这次尝试也失败时才会抛出异常。其次,如果空闲 RAM 量降到某个阈值以下,也会触发一次自动 GC。这个阈值可以随着执行的推进而调整:

gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())

这将在当前空闲堆中有超过 25% 被占用时触发一次 GC。

一般来说,模块应在运行时使用构造函数或其他初始化函数来实例化数据对象。原因在于,如果这发生在初始化时,那么在后续模块导入时编译器可能会缺乏 RAM。如果模块确实在导入时实例化数据,那么在导入后执行 gc.collect() 可以缓解这个问题。

字符串操作

MicroPython 以高效的方式处理字符串,理解这一点有助于设计在微控制器上运行的应用程序。当一个模块被编译时,多次出现的字符串只存储一次,这一过程称为字符串驻留(string interning)。在 MicroPython 中,驻留的字符串称为 qstr。在正常导入的模块中,这个唯一实例会位于 RAM 中,但如上所述,在冻结为字节码的模块中,它会位于闪存中。

字符串比较也通过哈希而非逐字符的方式高效执行。因此,使用字符串而非整数所付出的代价,无论在性能还是 RAM 占用方面都可能很小——这一事实对于 C 程序员来说也许会出乎意料。

结语

MicroPython 按引用传递、返回并(默认)复制对象。一个引用占用一个机器字,因此这些过程在 RAM 占用和速度上都很高效。

当需要的变量大小既不是一个字节也不是一个机器字时,有一些标准库可以帮助高效地存储这些变量并进行转换。请参阅 arraystructuctypes 模块。

脚注:gc.collect() 的返回值

在 Unix 和 Windows 平台上,gc.collect() 方法返回一个整数,表示本次回收中被回收的不同内存区域的数量(更确切地说,是被转换为空闲块的头块数量)。出于效率原因,裸机移植版本不返回这个值。