微控制器中的MicroPython

MicroPython设计为可在微控制器上运行。熟悉常规计算机的程序员可能不熟悉这些硬件限制。尤其是RAM和非易失性”磁盘”(闪存)存储量是有限的。本教程提供了充分利用有限资源的方法。由于MicroPython在基于各种体系结构的控制器上运行,因此所提供的方法是通用的:某些情况下,需要从平台特定的文档中获取详细信息。

闪存

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

有两种方法可以实现这一点:冻结模块和冻结字节码。冻结模块将Python源代码与固件一同存储。冻结字节码使用交叉编译器将源代码转换为随后与固件一同存储的字节码。这两种情况下都可使用导入语句访问该模块:

import mymodule

生成冻结模块和字节码的过程取决于平台;有关构建固件的说明可查阅源代码树相关部分中的README文件。

一般来说,步骤如下:

  • 克隆MicroPython repository

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

  • 构建交叉编译器。

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

  • 构建固件。需特定指令以构建任一类型的冻结代码-见平台文件。

  • 将固件闪存到设备。

RAM

在减少RAM使用时,需考虑两个阶段:编译和执行。除内存消耗外,还有一个称为堆碎片的问题。总的来说,最好是尽量减少对象的重复创建和损坏。其原因在与堆( heap)相关的部分中进行了介绍。

编译阶段

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

若已导入多个模块,则可能出现没有足够的RAM来运行编译器的情况。在这种情况下,导入语句将引发内存异常。

若模块在导入时实例化全局对象,则将在导入时占用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 值将占用至少两个机器字,两个字分别对应globals字典中的键值和值。必须出现在字典中,因为另一个模块可能会导入或使用它。这个RAM可通过在名称前加下划线前面(如 _COLS )来保存:这个符号在模块外不可见,所以不会占用RAM。

const() 的参数可为在编译时计算结果为常量的任何值,如 0x1001 << 8(True, "string", b"bytes") (有关详细信息,请参阅下面的一节)。甚至可包括其他已定义的常量符号,如 1 << BIT

常量数据结构

若存在大量常量数据,且平台支持从Flash执行,则RAM可能会如下保存。数据应该位于Python模块中并冻结为字节码。数据必须定义为 bytes 对象。编译器”知道” bytes 对象是不可变的,并确保对象保留在闪存中,而不是被复制到RAM中。struct 模块可协助 bytes 类型和其他Python内置类型间的转换。

在考虑冻结字节码的含义时,请注意:在Python中,字符串、浮点数、字节、整数和复数是不可变的。因此这些将被冻结进Flash中(对于元组,只有当它们的所有元素都是不可变的)。因此,在如下行中

mystring = "The quick brown fox"

实际的字符串”The quick brown fox”将停留在Flash中。运行时,字符串的引用被分配给*变量 * mystring 。该引用占用一个机器字。原则上可使用长整数来存储常量数据:

bar = 0xDEADBEEF0000DEADBEEF

正如字符串示例中所示,运行时,将对任意大整数的引用分配给变量 bar。该引用占据一个机器字节。

常量对象的元组本身也是常量。这样的常量元组是由编译器优化的,所以它们不需要在每次使用时都在运行时创建。例如:

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

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

无需创建对象

很多情况下,可能无意地创建和销毁了对象。这可能会因碎片化而降低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

第一种方法在每次传递时都会创建一个缓冲区,而第二种方法则重用一个预分配的缓冲区;这种方式在速度上更快,并且在内存碎片化方面更高效。

字节小于int整型

在大多数平台中,一个整数消耗四个字节。思考对函数 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 更高效。第三个调用有效地创建了一个消耗最少RAM的 bytes 对象。如果模块被冻结为字节码, tuplebytes 对象都将驻留在闪存中。

字符串vs字节

Python3引入了Unicode支持,也就引入了字符串和字节数组之间的区别。只要字符串中的所有字符都为ASCII(即值<126),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分配来产生结果。在下面第二行中,分配了一个新的字节对象。若 foo 为字符串,也会出现这种情况。

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

运行时的编译器执行

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

将字符串储存到Flash中

Python字符串是不可变的,因此可能存储在只读存储器中。编译器可将Python代码中定义的字符串置于Flash中。与冻结模块一样,必须在PC上有一个源代码树的副本,然后使用工具链来构建固件。即使模块尚未完全调试,只要可以导入并运行,该程序仍将正常工作。

导入模块后,执行:

micropython.qstr_info(1)

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

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 仍保留。 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)

以上使用的方法:

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

micropython.mem_info(1) 生成的最终输出将有所不同,但可能会如下解释:

符号

含义

.

空闲快

h

头块

=

尾块

m

标记的头部块

T

元组

L

list

D

字典

F

float

B

字节代码

M

模块

S

字符串或字节

A

字节数组

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

控制垃圾回收

可随时通过发出 gc.collect() 来请求GC。定期执行是有利的,首先是为了抢占碎片,其次也有利于提高性能。GC可能耗费数毫秒,在工作量较小时耗时更短(在Pyboard上只需大约1ms)。显式调用可最大限度减少延迟,同时确保其在程序中可接受的情况下出现。

以下情况下,自动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使用率和速度方面较为高效。

在必需变量的大小既非一个字节也非一个机器字的情况下,有一些标准库将有可帮助有效存储变量并进行转换。见 arrayustructuctypes 模块。

脚注:gc.collect()返回值

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