微控制器上的 MicroPython

MicroPython 旨在能够在微控制器上运行。这些具有硬件限制,对于更熟悉传统计算机的程序员来说可能并不熟悉。特别是 RAM 和非易失性“磁盘”(闪存)存储的数量是有限的。本教程提供了充分利用有限资源的方法。由于 MicroPython 在基于各种架构的控制器上运行,因此所提供的方法是通用的:在某些情况下,需要从特定于平台的文档中获取详细信息。

闪存

在 Pyboard 上,解决容量有限的简单方法是安装微型 SD 卡。在某些情况下,这是不切实际的,要么是因为设备没有 SD 卡插槽,要么是出于成本或功耗的原因;因此必须使用片上闪存。包括 MicroPython 子系统在内的固件存储在板载闪存中。剩余容量可供使用。由于与闪存的物理架构相关的原因,该容量的一部分可能无法作为文件系统访问。在这种情况下,可以通过将用户模块合并到固件版本中来利用此空间,然后将固件版本刷入设备。

有两种方法可以实现这一点:冻结模块和冻结字节码。冻结模块将 Python 源代码与固件一起存储。冻结字节码使用交叉编译器将源转换为字节码,然后与固件一起存储。在任何一种情况下,都可以使用 import 语句访问模块:

import mymodule

生成冻结模块和字节码的过程是平台相关的;可以在源代码树相关部分的自述文件中找到构建固件的说明。

一般而言,步骤如下:

  • 克隆 MicroPython 存储库

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

  • 构建交叉编译器。

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

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

  • 将固件刷入设备。

内存

在减少 RAM 使用量时,需要考虑两个阶段:编译和执行。除了内存消耗之外,还有一个称为堆碎片的问题。一般而言,最好尽量减少对象的重复创建和销毁。原因在涉及的部分中有所介绍。

编译阶段

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

如果已经导入了多个模块,则可能会出现 RAM 不足以运行编译器的情况。在这种情况下,import 语句将产生内存异常。

如果模块在导入时实例化全局对象,它将在导入时消耗 RAM,然后编译器无法在后续导入中使用 RAM。一般来说,最好避免在导入时运行的代码;更好的方法是让应用程序在导入所有模块后运行初始化代码。这最大化了编译器可用的 RAM。

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

执行阶段

有许多编码技术可用于减少 RAM 使用。

常数

MicroPython 提供了一个 const可以如下使用的关键字:

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

在常量分配给变量的两种情况下,编译器将通过替换常量的字面值来避免对常量名称的查找进行编码。这节省了字节码,从而节省了 RAM。但是,该ROWS 值将至少占用两个机器字,全局字典中的键和值各一个。字典中的存在是必要的,因为另一个模块可能会导入或使用它。可以通过在名称前加上下划线来保存此 RAM,例如_COLS:此符号在模块外部不可见,因此不会占用 RAM。

的参数const()可以是任何在编译时计算为整数的东西,例如0x100或。它甚至可以包含其他已经定义的 const 符号,例如. 1 << 81 << BIT.

常量数据结构

在有大量恒定数据且平台支持从 Flash 执行的情况下,RAM 可以按如下方式保存。数据应位于 Python 模块中并冻结为字节码。数据必须定义为bytes 对象。编译器“知道”bytes对象是不可变的,并确保对象保留在闪存中而不是被复制到 RAM 中。该 ustruct 模块可以帮助在bytes 类型和其他 Python 内置类型之间进行转换。

在考虑冻结字节码的含义时,请注意在 Python 字符串中,浮点数、字节、整数和复数是不可变的。因此,这些将被冻结成闪光。因此,在该行

mystring = "The quick brown fox"

实际的字符串“The quick brown fox”将驻留在闪存中。在运行时,对字符串的引用被分配给变量 mystring。引用占用单个机器字。原则上,长整数可用于存储常量数据:

bar = 0xDEADBEEF0000DEADBEEF

在字符串示例中,在运行时,对任意大整数的引用被分配给变量bar。该引用占用单个机器字。

可以期望整数元组可以用于以最少的 RAM 存储常量数据的目的。对于当前的编译器,这是无效的(代码有效,但未保存 RAM)。

foo = (1, 2, 3, 4, 5, 6, 100000)

在运行时,元组将位于 RAM 中。这可能取决于未来的改进。

不必要的对象创建

在许多情况下,对象可能会在不知不觉中被创建和销毁。这会通过碎片化降低 RAM 的可用性。以下部分讨论了这种情况。

字符串连接

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

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

每个产生相同的结果,但是第一个在运行时不必要地创建两个字符串对象,在产生第三个之前分配更多的 RAM 用于连接。其他人在编译时执行连接,效率更高,减少碎片。

在将字符串馈送到流(例如文件)之前必须动态创建字符串的情况下,如果以零碎的方式完成,它将节省 RAM。与其创建一个大的字符串对象,不如创建一个子字符串并将其提供给流,然后再处理下一个。

创建动态字符串的最佳方法是使用 stringformat() 方法:

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(b'\1\2\xff')

在第一次调用中,在 RAM 中创建了一个整数元组。第二个有效地创建了一个bytes消耗最少 RAM的对象。如果模块被冻结为字节码,则 bytes对象将驻留在闪存中。

字符串与字节

Python3 引入了 Unicode 支持。这引入了字符串和字节数组之间的区别。只要字符串中的所有字符都是 ASCII(即值 < 126),MicroPython 就可以确保 Unicode 字符串不占用额外的空间。如果需要完整 8 位范围内的值,bytes 并且 bytearray可以使用对象来确保不需要额外的空间。请注意,大多数字符串方法(例如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 以产生结果。在下面的第二行中,分配了一个新的字节对象。如果foo 是字符串,也会发生这种情况。

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

运行时编译器执行

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

在闪存中存储字符串

Python 字符串是不可变的,因此有可能存储在只读内存中。编译器可以放置在 Python 代码中定义的 flash 字符串中。与冻结模块一样,有必要在 PC 和工具链上有一份源代码树的副本来构建固件。即使模块尚未完全调试,该过程也能工作,只要它们可以导入和运行。

导入模块后,执行:

micropython.qstr_info(1)

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

micropython.qstr_info(1)

Q(xxx) 行应该消失了。

当一个正在运行的程序实例化一个对象时,必要的 RAM 从一个固定大小的池中分配,称为堆。当对象超出范围(换句话说,代码无法访问)时,冗余对象被称为“垃圾”。称为“垃圾收集”(GC) 的进程回收该内存,将其返回到空闲堆。此过程自动运行,但可以通过发出gc.collect().

这方面的论述有些牵强。对于“快速修复”问题,请定期执行以下操作:

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

碎片化

假设一个程序创建一个对象 foo,然后创建一个对象bar。随后 foo 超出范围但 bar仍然存在。使用的 RAM foo 将被 GC 回收。但是,如果bar 分配到更高的地址,则从中回收的 RAM foo将仅用于不大于 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)

上面采用的方法:

生成的数字取决于平台,但可以看出,声明函数使用了编译器发出的字节码形式的少量 RAM(编译器使用的 RAM 已被回收)。运行该函数使用超过 10KiB,但返回时a是垃圾,因为它超出范围且无法引用。最后gc.collect()恢复那个记忆。

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

象征

意义

.

空闲块

h

头块

=

尾块

m

标记的头块

T

元组

L

列表

D

字典

F

漂浮

B

字节码

M

模块

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

垃圾收集控制

GC 可以随时通过发出gc.collect(). 每隔一段时间这样做是有好处的,首先是为了防止碎片化,其次是为了性能。GC 可能需要几毫秒,但在几乎没有工作要做时会更快(在 Pyboard 上大约 1 毫秒)。显式调用可以最大限度地减少延迟,同时确保它发生在程序中可接受的点上。

在以下情况下会引发自动 GC。当分配尝试失败时,将执行 GC 并重新尝试分配。只有当这失败时才会引发异常。其次,如果空闲 RAM 量低于阈值,则会触发自动 GC。这个阈值可以随着执行的进展而调整:

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

当超过 25% 的当前空闲堆被占用时,这将引发 GC。

通常,模块应该在运行时使用构造函数或其他初始化函数实例化数据对象。原因是,如果在初始化时发生这种情况,则在导入后续模块时,编译器可能会缺乏 RAM。如果模块在导入时实例化数据,然后在导入后gc.collect()发出将改善问题。

字符串操作

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

字符串比较也可以使用散列而不是逐个字符有效地执行。因此,在性能和 RAM 使用方面,使用字符串而不是整数的代价可能很小——这一事实可能会让 C 程序员感到惊讶。

后记

MicroPython 通过引用传递、返回和(默认情况下)复制对象。一个引用占用一个机器字,因此这些进程在 RAM 使用和速度方面是有效的。

在需要大小既不是字节也不是机器字的变量的地方,有标准库可以帮助有效地存储这些变量并执行转换。请参阅array, ustructuctypes 模块。

脚注:gc.collect() 返回值

在 Unix 和 Windows 平台上,该gc.collect()方法返回一个整数,表示在集合中回收的不同内存区域的数量(更准确地说,变成空闲的磁头数量)。出于效率原因,裸机端口不返回此值。