微控制器上的 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 不足以執行編譯器的情況。在這種情況下,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 值至少會佔用兩個機器字(machine word),分別用於全域字典中的鍵與值。之所以必須存在於字典中,是因為其他模組可能會匯入或使用它。若在名稱前加上底線,如 _COLS,便可節省這部分 RAM:此符號在模組外部不可見,因此不會佔用 RAM。

const() 的引數可以是任何在編譯期會求值為常數的內容,例如 0x1001 << 8(True, "string", b"bytes")(詳情請見下方章節)。它甚至可以包含其他已定義的 const 符號,例如 1 << BIT

常數資料結構

當存在大量常數資料且平台支援從快閃記憶體執行時,可依下列方式節省 RAM。資料應放置於 Python 模組中並凍結為位元組碼。資料必須定義為 bytes 物件。編譯器「知道」bytes 物件是不可變的,並會確保這些物件保留在快閃記憶體中而非複製到 RAM。struct 模組可協助在 bytes 型別與其他 Python 內建型別之間進行轉換。

在考量凍結位元組碼的影響時,請注意在 Python 中字串、浮點數、bytes、整數、複數與 tuple 都是不可變的。因此這些都會被凍結至快閃記憶體(對 tuple 而言,僅當其所有元素皆為不可變時才會如此)。因此,在下面這一行中

mystring = "The quick brown fox"

實際的字串 "The quick brown fox" 將存放在快閃記憶體中。在執行期,對該字串的參照會被指派給變數 mystring。該參照佔用單一機器字。原則上,可以用一個長整數來儲存常數資料:

bar = 0xDEADBEEF0000DEADBEEF

如同字串的例子,在執行期,對這個任意大整數的參照會被指派給變數 bar。該參照佔用單一機器字。

由常數物件構成的 tuple 本身即為常數。這類常數 tuple 會由編譯器最佳化,因此不需要在每次使用時於執行期重新建立。例如:

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

整個 tuple 將以單一物件的形式存在(若程式碼經過凍結,則可能位於快閃記憶體中),並在每次需要時被參照。

不必要的物件建立

在許多情況下,物件可能會在不知不覺中被建立與銷毀。這會因碎片化而降低 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

第一個在每次迭代時都建立一個緩衝區,而第二個則重複使用一個預先配置的緩衝區;後者不僅更快,在記憶體碎片化方面也更有效率。

bytes 比 ints 更小

在大多數平台上,一個整數會耗用四個位元組。請參考對函式 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 物件都會存放在快閃記憶體中。

字串與 bytes 的比較

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

當需要在字串與 bytes 之間轉換時,可使用 str.encode()bytes.decode() 方法。請注意字串與 bytes 都是不可變的。任何以這類物件作為輸入並產生另一個物件的運算,至少都意味著為了產生結果而進行一次 RAM 配置。在下方第二行中,會配置一個新的 bytes 物件。如果 foo 是字串,也會發生同樣的情況。

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

執行期編譯器執行

Python 的 evalexec 函式會在執行期叫用編譯器,這需要大量的 RAM。請注意,來自 micropython-libpickle 函式庫使用了 exec。使用 json 函式庫進行物件序列化可能在 RAM 使用上更有效率。

將字串儲存於快閃記憶體

Python 字串是不可變的,因此有潛力儲存在唯讀記憶體中。編譯器可將 Python 程式碼中定義的字串放置於快閃記憶體。如同凍結模組一樣,必須在 PC 上有一份原始碼樹的副本以及建置韌體的工具鏈。即使模組尚未完全除錯完成,只要它們能夠被匯入並執行,此程序仍可運作。

在匯入模組之後,執行:

micropython.qstr_info(1)

然後將所有 Q(xxx) 行複製貼上到文字編輯器中。檢查並移除明顯無效的行。開啟可在 ports/stm32(或所使用架構的對應目錄)中找到的 qstrdefsport.h 檔案。將修正後的行複製貼上到檔案結尾處。儲存檔案、重新建置並燒錄韌體。可透過匯入模組並再次執行下列指令來檢查結果:

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

tuple

L

list

D

dict

F

float

B

位元組碼

M

模組

S

字串或 bytes

A

bytearray

每個字母代表一個記憶體區塊,一個區塊為 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 中,但如上所述,在凍結為位元組碼的模組中,它會位於快閃記憶體中。

字串比較同樣會以高效率的方式進行,使用雜湊(hashing)而非逐字元比較。因此,使用字串而非整數所付出的代價,無論在效能還是 RAM 使用方面可能都很小——這對 C 程式設計師而言或許會是個意外。

後記

MicroPython 以參照(reference)的方式傳遞、傳回並(在預設情況下)複製物件。一個參照佔用單一機器字,因此這些程序在 RAM 使用與速度方面都很有效率。

當需要的變數其大小既非一個位元組也非一個機器字時,有標準函式庫可協助有效率地儲存這些變數並執行轉換。請參閱 arraystructuctypes 模組。

註腳:gc.collect() 的傳回值

在 Unix 與 Windows 平台上,gc.collect() 方法會傳回一個整數,代表在這次回收中被回收的相異記憶體區域的數量(更精確地說,是被轉換為空閒的起始區塊數量)。基於效率考量,裸機(bare metal)連接埠並不會傳回此值。