MicroPython是为在微控制器上运行而设计的,受微控制器硬件资源的限制,传统计算机程序员对这部分可能不太熟悉,特别是RAM和非易失性存储器“磁盘”(闪存)容量的限制,本文提供了一些方法,可以充分利用这些有限的资源。由于MicroPython运行于各种架构,因此提供的方法是通用的:在某些情况下可能需要上从特定平台的文档中获取详细信息。
闪存
在Pyboard上,解决容量问题最简单的方法就是安装一个Micro SD卡,但在某些情况可能会不太现实,比如没有SD卡插槽、成本或功耗过高等原因,因此必须使用芯片上的闪存。固件和MicroPython子系统都存储在板载闪存,剩余的容量可供程序使用,由于闪存物理体系结构的原因,这部分剩余容量可能无法作为文件系统访问,在这种情况下,可以将用户模块和固件合并构建来利用这部分空间,构建后把固件刷到设备上。
有两种方法可以实现合并构建:冻结模块和冻结字节码。冻结模块把Python源代码与固件存在一起,冻结字节码使用交叉编译器将源代码转换为字节码,然后与固件一起存储。这两种情况都可以使用import引入模块。
import mymodule
生成的冻结模块和字节码与平台相关,构建固件的说明可以在源代码树相关部分的 README文件中找到。
生成的步骤如下:
- 克隆MicroPython仓库
- 获取固件构建(平台专用)工具链
- 交叉编译
- 将需要冻结的模块放到指定的目录(取决于模块是比冻结为源代码还是字节码)。
- 构建固件,可能需要一个特定的命令来构建两种类型的冻结代码。
- 将固件刷入设备。
内存(RAM)
在减少内存使用量时,需要考虑两个阶段:编译和执行。除了内存消耗外,还有一个被称为堆碎片的问题。一般来说,最好尽量减少重复创建和销毁对象。其原因将在有关堆的章节中阐述。
编译阶段
模块被导入后,MircoPython将源码编译为字节码,然后由MicroPython虚拟机(VM)执行。字节码存储在内存中,所以编译器自身占用部分内存,当编译完成后这部分内存就会被释放。
如果导入大量的模块,可能会出现内存不足导致无法编译的情况,此时导入语句将抛出内存异常。
模块在导入时实例化全局对象会占用内存,这可能会影响到编译器的后续操作。比较好的做法是在所有模块导入完成后再执行代码,这样能让编译器最大化使用内存。
如果编译所有模块时仍然内存不足,方案之一是可以通过预编译模块来应对。MicroPython有对Python模块交叉编译的能力(请参考mpy-cross目录下的README文件内容),编译后的字节码文件以.mpy
为扩展名,可以将其复制到文件系统并以常规方式导入。另外,也可以把部分或全部模块作为冻结字节码来实现,因为字节码可以直接运行,所以在大多数平台会更节省内存。
执行阶段
下面是一些在执行阶段减少内存使用的技术:
常量
MicroPython提供了const
关键字,可以这样用:
from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS
针对这两种把常量赋值给变量的方式,编译器会通过替换常量的字面值来避免对常量名称进行编码查询,这可以节省字节码从而节省内存。不过,ROWS
的值会至少占用两个机器字,在全局字典中键和值各占一个。这在字典中是必须得因为其他模块可能会引入并使用它。在名称前面加下划线可以节省内存,如:_COLS,该标识符在模块外不可见,因此不会占用内存。
const()
的参数可以任意值或表达式,在编译时会被计算为常量,如:0x100
、1<<8
或(True, "string", b"bytes")
,它甚至可以包含其他已定义的常量标识符,如:1 << BIT
。
常量数据结构
如果常量数据很多并且平台支持从闪存执行,可以考虑下面的方式节省内存:把数据放在Python模块可以找到的地方并且已经冻结为字节码,数据必须被定义为bytes
对象,编译器“知道”字节对象是不变的,并确保对象保留在闪存,而不是复制到内存,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))
整个元组将作为单个对象存在(如果代码被冻结,则可能存在闪存中),并在每次需要时被引用。
不必要的对象创建
在很多情况下,对象可能会在不知不觉中被创建和销毁。这会导致内存碎片化,从而降低内存的可用性。
字符串拼接
考虑下面生成字符串常量的代码片段:
var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""
每种方法都能产生相同的结果,但第一种方法在运行时不必要的创建了两个字符串对象,在产生第三个对象之前需要分配更多的内存用于连接。而其它方法在编译时执行连接,效率更高,减少了碎片。
在向流(如文件)中写入字符串之前,需要动态创建字符串,为节省内存,不要使用一个大的字符串对象,可以将大的字符串拆分成若干小字符串,逐个进行处理。
创建动态字符串的最佳方法是使用字符串的format()
方法:
var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)
缓冲区
在访问如UART、I2C和SPI等设备接口时,使用预分配缓冲区可避免创建不必要的对象。请看下面两个循环:
while True:
var = spi.read(100)
# 处理数据
buf = bytearray(100)
while True:
spi.readinto(buf)
# 利用buf处理数据
前者在每次传输时创建一个缓冲区,而后者则重复使用一个预先分配的缓冲区;就内存碎片而言,这种方法更快、更有效。
字节比整数小
在大多数平台上,一个整数占用四个字节。请看对函数foo()
的三次调用:
def foo(bar):
for x in bar:
print(x)
foo([1, 2, 0xff])
foo((1, 2, 0xff))
foo(b'\1\2\xff')
在第一次调用中,每次执行代码时都会在内存中创建一个整数列表。第二次调用会在编译阶段创建一个常量元组对象(只包含常量对象的元组),因此只创建一次,比列表更高效。第三次调用只创建一个字节对象,占用的内存最小。如果将模块冻结为字节码,元组对象和字节对象都将位于闪存中。
字符串与字节
Python3引入了Unicode支持。这引入了字符串和字节数组之间的区别。只要字符串中的所有字符都是ASCII字符(即值 < 126),MicroPython就能确保Unicode字符串不占用额外空间。如果需要完整的8位范围内的值,可以使用字节和字节数组对象来确保不需要额外空间。请注意,大多数字符串方法(如 str.strip())也适用于字节实例,因此消除 Unicode 的过程非常简单。
s = 'the quick brown fox' # 字符串实例
b = b'the quick brown fox' # 字节实例
如果需要在字符串和字节之间进行转换,可以使用str.encode()
和bytes.decode()
方法。请注意,字符串和字节都是不可变的。任何将此类对象作为输入并产生另一个对象的操作都意味着至少一次的内存分配。下面第二行分配了一个新的字节对象,如果foo
是字符串,也会出现这种情况。
foo = b' empty whitespace'
foo = foo.lstrip()
运行时编译器执行
Python的eval
和exec
函数在运行时调用编译器,这需要大量内存。请注意,micropython-lib
中的pickle
库使用了exec
。使用json
库进行对象序列化可能更节省内存。
把字符串放在闪存中
Python字符串是不变的,因此可以存储在只读存储器中。编译器可以将Python代码中定义的字符串放入闪存中。与冻结模块一样,必须在PC上有一份源代码副本,并使用工具链来构建固件。即使模块尚未完全调试,只要能导入并运行,该程序也能正常工作。
导入模块后,执行:
micropython.qstr_info(1)
然后将所有Q(xxx)的行复制并粘贴到文本编辑器,检查并删除明显无效的行,打开文件qstrdefsport.h
,该文件位于 ports/stm32(或所用体系结构的相应目录)中,将更正后的行复制并粘贴到文件末尾,保存文件,重新构建并刷入固件。可以通过导入模块并再次运行下面的命令来检查结果:
micropython.qstr_info(1)
此时,Q(xxx) 的行应该消失。
堆(Heap)
当运行中的程序实例化一个对象时,会从一个固定大小的池子(即堆)中分配需要的内存。当对象超出范围(换句话说,代码无法访问)时,多余的对象被称为 “垃圾”。一个被称为 “垃圾回收”(GC)的过程会回收内存,并将其返回到空闲的堆中。这个过程会自动运行,但也可以通过gc.collect()直接调用。
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
碎片化
假设一个程序创建了一个对象foo
,然后又创建了一个对象bar
。随后,foo
退出作用域,但bar
仍然存在。foo
使用的内存将被GC回收。但是,如果bar
被分配到一个更高的地址,那么从foo
回收的内存就只能用于不比foo
大的对象。在复杂或长时间运行的程序中,堆可能会变得支离破碎:尽管有大量可用内存,但没有足够的连续空间来分配特定对象,程序就会因内存错误而失败。
上述技术旨在尽量减少这种情况。如果需要大型永久缓冲区或其他对象,最好在程序执行过程的早期就将其实例化,以免出现碎片。通过监控堆的状态和控制GC,还可以做出进一步的改进;下文对此进行概述。
报告
有许多库函数可用于报告内存分配情况和控制GC。这些函数可在gc
和micropython
模块中找到。可以在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() 打印内存使用情况摘要。
- gc.mem_free() 以字节为单位返回空闲堆大小。
- gc.mem_alloc() 返回当前分配的字节数。
- micropython.mem_info(1) 打印堆使用情况表(详见下文)。
生成的数字取决于平台,但可以看出,声明函数时编译器以字节码的形式使用了少量内存(编译器使用的内存已被回收)。运行函数使用了超过10KiB的内存,但返回的 a
是垃圾,因为它超出了作用域,无法被引用。最后的gc.collect()
会回收这些内存。
由micropython.mem_info(1)
生成的最终输出结果在细节上会有所不同,但可以解释如下:
标识符 | 含义 |
---|---|
. | 自由块 |
h | 头块 |
= | 尾块 |
m | 标记的头块 |
T | 元组 |
L | 列表 |
D | 字典 |
F | 浮点数 |
B | 字节码 |
M | 模块 |
S | 字符串或字节 |
A | 字节数组 |
每个字母代表一个内存块,一个内存块为16字节。因此,堆转储的每一行代表 0x400字节或1KB的内存。
控制垃圾回收
可以随时发出gc.collect()
要求进行GC。每隔一段时间进行一次 GC 是有好处的,首先可以避免碎片,其次可以提高性能。GC 可能需要几毫秒,但在工作很少的情况下会更快(在 Pyboard 上约为 1 毫秒)。显式调用可以最大限度地减少延迟,同时确保在程序中可以接受的时间点进行。
自动 GC 在以下情况下启动:当尝试分配失败时,将执行 GC 并重新尝试分配。只有当尝试失败时,才会引发异常。其次,如果可用内存数量低于阈值,将触发自动 GC。该阈值可随着执行进程进行调整:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
当空闲堆被占用超过 25% 时,就会触发 GC。
一般来说,模块应在运行时使用构造函数或其他初始化函数实例化数据对象。因为如果在初始化时实例化数据对象,编译器在导入后续模块时可能会缺乏内存。如果模块确实在导入时实例化了数据,那么在导入后发布的 gc.collect() 将会改善这一问题。
字符串操作
MicroPython 可以高效地处理字符串,了解这一点有助于设计在微控制器上运行的应用程序。在编译模块时,多次出现的字符串只存储一次,这一过程称为字符串内插。在 MicroPython 中,被置换的字符串被称为qstr
。在导入的模块中,单个实例通常位于 RAM 中,但如上所述,在冻结为字节码的模块中,它将位于闪存中。
使用散列而不是逐个字符进行字符串比较也很有效。因此,使用字符串而非整数的代价在性能和内存使用方面都很小,这可能会让C语言程序员感到惊讶。
后记
MicroPython 通过引用传递、返回和复制对象(默认情况下)。一个引用占用一个机器字,因此这些进程在内存使用和速度方面都很高效。
如果需要使用大小既不是字节也不是机器字的变量,标准库可以帮助高效地存储这些变量并进行转换。请参见数组、结构和结构类型模块。
脚注:gc.collect() 返回值
在 Unix 和 Windows 平台上,gc.collect()
方法会返回一个整数,表示在集合中被回收的不同内存区域的数量(更准确地说,是变成空闲的头数)。出于提高效率的考虑,裸机端口不返回该值。