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

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

フラッシュメモリ

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

これを実現する2つの手段があります。凍結モジュールと凍結バイトコードです。凍結モジュールは Python ソースコードをファームウェアと一緒に保存します。凍結バイトコードはクロスコンパイラを使用してソースをバイトコードに変換し、変換後のバイトコードをファームウェアに保存します。どちらの場合でも、モジュールには import 文でアクセスできます:

import mymodule

凍結したモジュールとバイトコードを生成するための手順はプラットフォームに依存します。ファームウェアを構築するための手順は関連するソースツリー中の README ファイルにあります。

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

  • MicroPython の リポジトリ をクローンします。
  • ファームウェアをビルドするための(プラットフォーム固有の)ツールチェーンを入手します。
  • クロスコンパイラをビルドします。
  • 凍結するモジュールを指定したディレクトリに配置します(モジュールをソースとして固定するかバイトコードとして固定するかによって異なります)。
  • ファームウェアをビルドします。どちらかのタイプの凍結コードをビルドするには、特定のコマンドが必要な場合があります - プラットフォームのドキュメントを参照してください。
  • ファームウェアをデバイスにフラッシュします。

RAM

RAM の使用量を減らすときには、コンパイルと実行という2つの段階を考慮する必要があります。メモリ消費量に加えて、ヒープフラグメンテーションとして知られている問題もあります。一般的には、オブジェクトの作成と消去の繰り返しを最小限に抑えることが最善です。その理由については ヒープ ついての章で説明します。

コンパイルフェーズ

モジュールがインポートされると、MicroPython はコードをバイトコードにコンパイルし、それが MicroPython 仮想マシン(VM)によって実行されます。バイトコードは RAM に保存されます。コンパイラ自体も RAM を必要としますが、コンパイルが完了した後は解放されます。

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

モジュールのインポート時にグローバルオブジェクトをインスタンス化する場合、インポート時に RAM を消費します。この消費した 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 値については少なくとも2つの機械語が必要となります。それは、グローバル辞書のキーと値です。別のモジュールがそれをインポートまたは使う可能性があるため、辞書内に格納しておくことは必要となります。この RAM は _COLS のように名前の前にアンダースコアを付けることで節約できます。このシンボルはモジュールの外側には表示されないので、RAMを占有することはありません。

const() の引数には、0x1001 << 8 など、コンパイル時に整数に評価できるものであれば何でも指定できます。 1 << BIT など、定義済の const シンボルを含めることもできます。

定数データ構造

かなりの量の定数データが​​あり、プラットフォームがフラッシュからの実行をサポートしている場合、RAM は次のように節約できます。データは Python モジュールに配置し、バイトコードとして凍結されるべきです。データは bytes オブジェクトとして定義してください。コンパイラは、 bytes オブジェクトが不変であることを「認識」しているので、 bytes オブジェクトを RAM にコピーするのではなく、フラッシュメモリに残したままにします。 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))

このタプル全体が1つのオブジェクトとして存在し(コードを凍結している場合はフラッシュにある可能性もあり)、必要なときに毎回参照されます。

不要なオブジェクト作成

無意識のうちにオブジェクトが作成され消去される可能性のある状況は数多くあります。これによりフラグメンテーションが発生して、RAM 可用性は低下することがありえます。以降の章では、この事例について説明します。

文字列の結合

定数文字列を生成することを目的とした次のコードフラグメントを検討してください:

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

それぞれが同じ結果を生成しますが、1番目のものは実行時に2つの文字列オブジェクトを不必要に作成し、結果を生成する前に連結用に RAM を追加で割り当てます。他のものはコンパイル時に連結を実行します。これはより効率的で、フラグメンテーションを減らします。

ファイルのようなストリームに送る前に文字列を動的に作成しなければならない場合、これを少しずつ行うようにすれば RAM を節約できます。大きな文字列オブジェクトを作成するのではなく、部分文字列を作成して送りってから、次の部分文字列を扱うようにします。

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

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

バッファ

UART, I2C, SPI インタフェースなどのデバイスにアクセスするときに、事前に割り当てたバッファを使うようにすると、不要なオブジェクトが作成されなくなります。次の2つのループについて考えてみましょう:

while True:
    var = spi.read(100)
    # データを処理

buf = bytearray(100)
while True:
    spi.readinto(buf)
    # buf でデータを処理

1つ目のループでは各パスで毎回バッファを作成するのに対して、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')

1つ目の呼び出しでは、コードが実行されるたびに整数の list が RAM に作成されます。2つ目の呼び出しでは、コンパイル時に定数オブジェクト tuple (定数オブジェクトのみを含む tuple)を生成するので、一度だけ生成され、 list よりも効率的になります。3つ目の呼び出しでは、最小量の RAM を消費する bytes オブジェクトを効率的に作成します。このモジュールをバイトコードに凍結した場合、 tuplebytes オブジェクトの両方がフラッシュ内に置かれます。

文字列とバイト列

Python3 は Unicode サポートを導入しました。これは文字列とバイトの配列の間の区別をもたらしました。MicroPython は文字列内のすべての文字が ASCII である(つまり < 126 の値を持つ)限り、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() メソッドを使えます。文字列とバイト列の両方が不変であることに注意してください。そのようなオブジェクトを入力として受け取り、別のオブジェクトを生成する演算は、結果を生成するために少なくとも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 がヒープと呼ばれる固定サイズのプールから割り当てられます。オブジェクトがスコープ外になると(つまり、コードにアクセスできなくなると)、この不要オブジェクトは「ガベージ」というものになります。「ガベージコレクション」(Garbage Collection: GC)と呼ばれるプロセスがそのメモリを回収し、空きヒープに戻します。このプロセスは自動的に実行されますが、 gc.collect() によって直接呼び出すこともできます。

これについての説明は多少複雑です。「迅速な修正」のために、定期的に以下を発行してください:

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

フラグメンテーション

プログラムがオブジェクト foo を作成し、次にオブジェクト bar を作成したとします。その後 foo がスコープ外になり、 bar が残ります。 foo によって使用された RAM は、GCによって回収されます。ただし bar が、より高いアドレスに割り当てられている場合、 foo から再利用される RAM は、それより大きくないオブジェクトにのみ使用されます。複雑なプログラムや長時間実行されているプログラムでは、ヒープが断片化する可能性があります。使用可能な 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 タプル
L リスト
D 辞書
F 浮動小数点
B バイトコード
M モジュール

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

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

GC は gc.collect() の実行によっていつでも要求できます。第一にフラグメンテーション解消のために、第二にパフォーマンスのために、間隔を置いてこれを実行することが有利です。GCには数ミリ秒かかることがありますが、実行する作業がほとんどない場合は速くなります(pyboardでは約1ms)。明示的な呼び出しは、プログラム内の適切な時点で確実に発生するようにしながら、GC による遅延を最小限に抑えることができます。

自動 GC は次の状況で引き起こされます。割り当ての試みが失敗すると、GC が実行され、割り当てが再試行されます。これが失敗した場合にのみ、例外が発生します。次に、空き RAM の量がしきい値を下回ると、自動 GC がトリガーされます。実行が進むにつれて、このしきい値を調整することができます。

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

これは、現在の空きヒープの25%以上が占有されるようになると、GCを引き起こします。

一般的にモジュールはコンストラクタや他の初期化関数を使って実行時にデータオブジェクトをインスタンス化するべきです。このようにする理由は、モジュールのインポート時にこれが発生すると、後続のモジュールのインポート時にコンパイラのRAMが不足する可能性があるためです。モジュールがインポート時にデータをインスタンス化する場合は、インポート後に gc.collect() を実行すると、問題が改善します。

文字列操作

MicroPython は文字列を効率的に処理します。これを理解することは、マイクロコントローラ上で動作するようにアプリケーションを設計するのに役立ちます。モジュールがコンパイルされると、複数回出現する文字列は1回だけ格納されます。これを文字列の隔離化といいます。MicroPython では隔離化された文字列を qstr と呼びます。通常インポートされたモジュールでは、その単一のインスタンスは RAM に配置されますが、前述のように、バイトコードとして凍結されたモジュールではフラッシュに配置されます。

文字列比較も、文字ごとではなくハッシュを使用して効率的に実行されます。したがって、整数ではなく文字列を使用することによるペナルティは、パフォーマンスと RAM の使用量の両方の点で小さい場合があります。これは、C プログラマにとっては驚くべきことです。

あとがき

MicroPython は、参照によってオブジェクトの受け渡しと(デフォルトで)コピーを行います。参照は単一のマシンワードを占有するため、これらのプロセスは RAM の使用と速度の点で効率的です。

サイズがバイトでも1機械語でもない変数が必要な場合は、これらを効率的に格納して変換を実行するのを助けることができる標準ライブラリがあります。array, struct, uctypes モジュールを参照してください。

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

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