マイクロコントローラ上の MicroPython

MicroPython はマイクロコントローラ上で動作できるように設計されています。マイクロコントローラには、従来のコンピュータに慣れたプログラマには馴染みのないハードウェア上の制約があります。特に、RAM と不揮発性の「ディスク」(フラッシュメモリ)ストレージの容量が限られています。このチュートリアルでは、限られたリソースを最大限に活用する方法を紹介します。MicroPython はさまざまなアーキテクチャに基づくコントローラ上で動作するため、ここで示す手法は汎用的なものです。場合によっては、プラットフォーム固有のドキュメントから詳細な情報を入手する必要があります。

フラッシュメモリ

OpenMV Cam では、限られた容量に対処する簡単な方法は micro SD カードを取り付けることです。デバイスに SD カードスロットがない場合や、コストや消費電力の理由で、これが現実的でない場合もあります。そのような場合にはオンチップのフラッシュを使用しなければなりません。MicroPython サブシステムを含むファームウェアはオンボードのフラッシュに保存されています。残りの容量を利用できます。フラッシュメモリの物理的なアーキテクチャに関連する理由で、この容量の一部はファイルシステムとしてアクセスできない場合があります。そのような場合には、ユーザモジュールをファームウェアビルドに組み込み、それをデバイスにフラッシュすることで、この空間を利用できます。

これを実現するには 2 つの方法があります。フローズンモジュールとフローズンバイトコードです。フローズンモジュールは Python のソースをファームウェアとともに保存します。フローズンバイトコードはクロスコンパイラを使用してソースをバイトコードに変換し、それをファームウェアとともに保存します。いずれの場合も、モジュールは import 文でアクセスできます。

import mymodule

フローズンモジュールやバイトコードを生成する手順はプラットフォームによって異なります。ファームウェアをビルドするための手順は、ソースツリーの関連部分にある README ファイルに記載されています。

一般的な手順は次のとおりです。

  • MicroPython の リポジトリ をクローンします。

  • ファームウェアをビルドするための(プラットフォーム固有の)ツールチェーンを入手します。

  • クロスコンパイラをビルドします。

  • フリーズするモジュールを指定のディレクトリに配置します(モジュールをソースとしてフリーズするか、バイトコードとしてフリーズするかによって異なります)。

  • ファームウェアをビルドします。どちらのタイプのフローズンコードをビルドするにも特定のコマンドが必要になる場合があります。プラットフォームのドキュメントを参照してください。

  • ファームウェアをデバイスにフラッシュします。

RAM

RAM の使用量を削減する際には、コンパイルと実行という 2 つの段階を考慮する必要があります。メモリ消費に加えて、ヒープの断片化として知られる問題もあります。一般論として、オブジェクトの生成と破棄を繰り返すことは最小限に抑えるのが最善です。その理由は heap を扱うセクションで説明します。

コンパイル段階

モジュールがインポートされると、MicroPython はコードをバイトコードにコンパイルし、そのバイトコードを MicroPython 仮想マシン(VM)が実行します。バイトコードは RAM に保存されます。コンパイラ自体も RAM を必要としますが、これはコンパイルが完了すると利用可能になります。

すでにいくつかのモジュールがインポートされている場合、コンパイラを実行するのに十分な RAM がない状況が生じることがあります。この場合、import 文はメモリ例外を発生させます。

モジュールがインポート時にグローバルオブジェクトをインスタンス化すると、インポート時に RAM を消費し、それ以降のインポートでコンパイラが使用できなくなります。一般的には、インポート時に実行されるコードは避けるのが最善です。より良いアプローチは、すべてのモジュールがインポートされた後にアプリケーションによって実行される初期化コードを用意することです。これによりコンパイラが利用できる RAM が最大化されます。

それでもすべてのモジュールをコンパイルするのに RAM が不足する場合、1 つの解決策はモジュールを事前にコンパイルすることです。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 の値はグローバル辞書のキーと値にそれぞれ 1 つずつ、少なくとも 2 つのマシンワードを占有します。別のモジュールがそれをインポートまたは使用する可能性があるため、辞書に存在することが必要です。_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 に代入されます。参照は 1 つのマシンワードを占有します。原理的には、長整数を使用して定数データを保存することもできます。

bar = 0xDEADBEEF0000DEADBEEF

文字列の例と同様に、実行時にはその任意に大きい整数への参照が変数 bar に代入されます。その参照は 1 つのマシンワードを占有します。

定数オブジェクトのタプルはそれ自体が定数です。そのような定数タプルはコンパイラによって最適化されるため、使用されるたびに実行時に生成する必要がありません。例えば次のようになります。

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

このタプル全体が単一のオブジェクトとして存在し(コードがフリーズされていれば、フラッシュ内に存在する可能性があります)、必要になるたびに参照されます。

不要なオブジェクトの生成

オブジェクトが意図せずに生成および破棄される状況がいくつかあります。これは断片化によって RAM の利用効率を低下させることがあります。以下のセクションではその例について説明します。

文字列の連結

定数文字列を生成することを目的とした次のコード断片を考えてみましょう。

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

いずれも同じ結果になりますが、最初のものは実行時に不要に 2 つの文字列オブジェクトを生成し、3 つ目を生成する前に連結のためにさらに RAM を割り当てます。他のものはコンパイル時に連結を行うため、より効率的で断片化が抑えられます。

ファイルなどのストリームに渡す前に文字列を動的に生成しなければならない場合、これを少しずつ行うと RAM を節約できます。大きな文字列オブジェクトを生成するのではなく、部分文字列を生成して、次の処理に移る前にそれをストリームに渡します。

動的な文字列を生成する最良の方法は、文字列の format() メソッドを使うことです。

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

バッファ

UART、I2C、SPI インターフェースのインスタンスなどのデバイスにアクセスする際、事前に割り当てたバッファを使用すると不要なオブジェクトの生成を回避できます。次の 2 つのループを考えてみましょう。

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

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

最初のものは各回でバッファを生成しますが、2 つ目は事前に割り当てたバッファを再利用します。これはメモリの断片化の観点で、より高速かつ効率的です。

バイトは整数より小さい

ほとんどのプラットフォームでは、整数は 4 バイトを消費します。関数 foo() への 3 つの呼び出しを考えてみましょう。

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

最初の呼び出しでは、コードが実行されるたびに整数の list が RAM 内に生成されます。2 つ目の呼び出しでは、コンパイル段階の一部として定数 tuple オブジェクト(定数オブジェクトのみを含む tuple)が生成されるため、一度だけ生成され、list より効率的です。3 つ目の呼び出しは、最小限の RAM を消費する bytes オブジェクトを効率的に生成します。モジュールがバイトコードとしてフリーズされていれば、tuplebytes の両方のオブジェクトがフラッシュ内に存在することになります。

文字列とバイト列の比較

Python3 は Unicode サポートを導入しました。これにより文字列とバイト配列の区別が生じました。MicroPython は、文字列内のすべての文字が ASCII である(つまり値が 128 未満である)限り、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() メソッドを使用できます。文字列とバイト列はどちらも不変であることに注意してください。そのようなオブジェクトを入力として受け取り、別のオブジェクトを生成する操作はすべて、結果を生成するために少なくとも 1 回の RAM 割り当てを伴います。以下の 2 行目では、新しい 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 がヒープと呼ばれる固定サイズのプールから割り当てられます。オブジェクトがスコープ外になる(言い換えれば、コードからアクセスできなくなる)と、その不要なオブジェクトは「ガベージ」と呼ばれます。「ガベージコレクション」(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 を制御するためのライブラリ関数がいくつか用意されています。これらは 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() 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

バイト配列

各文字はメモリの単一のブロックを表し、1 ブロックは 16 バイトです。したがってヒープダンプの各行は 0x400 バイト、つまり 1KiB の RAM を表します。

ガベージコレクションの制御

GC はいつでも gc.collect() を発行することで要求できます。これを一定間隔で行うのは、第一に断片化を未然に防ぐため、第二に性能のために有利です。GC には数ミリ秒かかることがありますが、処理する作業が少ない場合はより速くなります(OpenMV Cam では約 1ms)。明示的に呼び出すことで、その遅延を最小限に抑えつつ、プログラム内で許容できる時点で GC が発生するようにできます。

自動 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 はオブジェクトを参照によって渡し、返し、(デフォルトでは)コピーします。参照は 1 つのマシンワードを占有するため、これらのプロセスは RAM 使用量と速度の点で効率的です。

サイズがバイトでもマシンワードでもない変数が必要な場合、これらを効率的に保存したり変換を実行したりするのに役立つ標準ライブラリがあります。arraystructuctypes モジュールを参照してください。

脚注:gc.collect() の戻り値

Unix および Windows のプラットフォームでは、gc.collect() メソッドは、コレクションで回収された個別のメモリ領域の数(より正確には、free に変換された head の数)を示す整数を返します。効率上の理由から、ベアメタルポートはこの値を返しません。