MicroPython на микроконтроллерах

MicroPython разработан с расчётом на возможность работы на микроконтроллерах. У них есть аппаратные ограничения, которые могут быть незнакомы программистам, привыкшим к обычным компьютерам. В частности, объём оперативной памяти (RAM) и энергонезависимого «дискового» хранилища (флеш-память) ограничен. В этом руководстве предлагаются способы максимально эффективно использовать ограниченные ресурсы. Поскольку MicroPython работает на контроллерах, основанных на различных архитектурах, представленные методы носят общий характер: в некоторых случаях потребуется получить подробную информацию из документации для конкретной платформы.

Флеш-память

На OpenMV Cam простой способ справиться с ограниченной ёмкостью — установить карту micro SD. В некоторых случаях это нецелесообразно: либо потому, что устройство не имеет слота для SD-карты, либо по причинам стоимости или энергопотребления; следовательно, приходится использовать встроенную флеш-память. Прошивка, включая подсистему MicroPython, хранится во встроенной флеш-памяти. Оставшаяся ёмкость доступна для использования. По причинам, связанным с физической архитектурой флеш-памяти, часть этой ёмкости может быть недоступна в виде файловой системы. В таких случаях это пространство может быть задействовано путём включения пользовательских модулей в сборку прошивки, которая затем прошивается на устройство.

Есть два способа добиться этого: замороженные модули и замороженный байт-код. Замороженные модули хранят исходный код Python вместе с прошивкой. Замороженный байт-код использует кросс-компилятор для преобразования исходного кода в байт-код, который затем хранится вместе с прошивкой. В любом случае к модулю можно обратиться с помощью оператора import:

import mymodule

Процедура создания замороженных модулей и байт-кода зависит от платформы; инструкции по сборке прошивки можно найти в файлах README в соответствующей части дерева исходного кода.

В общих чертах шаги следующие:

  • Клонируйте репозиторий MicroPython.

  • Получите (специфичный для платформы) набор инструментов для сборки прошивки.

  • Соберите кросс-компилятор.

  • Поместите модули, которые нужно заморозить, в указанный каталог (в зависимости от того, должен ли модуль быть заморожен как исходный код или как байт-код).

  • Соберите прошивку. Для сборки замороженного кода любого типа может потребоваться особая команда — см. документацию по платформе.

  • Прошейте прошивку на устройство.

RAM

При снижении использования RAM следует учитывать две фазы: компиляцию и выполнение. Помимо потребления памяти, существует также проблема, известная как фрагментация кучи. В общих чертах лучше всего минимизировать многократное создание и уничтожение объектов. Причина этого рассматривается в разделе, посвящённом heap.

Фаза компиляции

Когда модуль импортируется, MicroPython компилирует код в байт-код, который затем выполняется виртуальной машиной MicroPython (VM). Байт-код хранится в RAM. Самому компилятору требуется RAM, но она становится доступной для использования после завершения компиляции.

Если несколько модулей уже импортированы, может возникнуть ситуация, когда для запуска компилятора недостаточно RAM. В этом случае оператор import вызовет исключение нехватки памяти.

Если модуль создаёт глобальные объекты при импорте, он будет потреблять RAM в момент импорта, и эта память станет недоступной для использования компилятором при последующих импортах. В общем случае лучше избегать кода, который выполняется при импорте; более удачный подход — иметь код инициализации, который выполняется приложением после того, как все модули были импортированы. Это максимизирует RAM, доступную компилятору.

Если RAM по-прежнему недостаточно для компиляции всех модулей, одним из решений является предварительная компиляция модулей. MicroPython имеет кросс-компилятор, способный компилировать модули Python в байт-код (см. README в каталоге mpy-cross). Полученный файл байт-кода имеет расширение .mpy; его можно скопировать в файловую систему и импортировать обычным образом. В качестве альтернативы некоторые или все модули могут быть реализованы как замороженный байт-код: на большинстве платформ это экономит ещё больше RAM, поскольку байт-код выполняется непосредственно из флеш-памяти, а не хранится в RAM.

Фаза выполнения

Существует ряд приёмов написания кода для снижения использования RAM.

Константы

MicroPython предоставляет ключевое слово const, которое можно использовать следующим образом:

from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS

В обоих случаях, когда константа присваивается переменной, компилятор избегает генерации поиска по имени константы, подставляя её литеральное значение. Это экономит байт-код и, следовательно, RAM. Однако значение ROWS займёт как минимум два машинных слова — по одному для ключа и значения в глобальном словаре. Присутствие в словаре необходимо, поскольку другой модуль может импортировать или использовать его. Эту RAM можно сэкономить, добавив перед именем символ подчёркивания, как в _COLS: этот символ не виден за пределами модуля и поэтому не будет занимать RAM.

Аргументом const() может быть всё, что во время компиляции вычисляется в константу, например 0x100, 1 << 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. Ссылка занимает одно машинное слово. В принципе, для хранения константных данных можно было бы использовать длинное целое число:

bar = 0xDEADBEEF0000DEADBEEF

Как и в примере со строкой, во время выполнения ссылка на произвольно большое целое число присваивается переменной bar. Эта ссылка занимает одно машинное слово.

Кортежи константных объектов сами являются константами. Такие константные кортежи оптимизируются компилятором, так что их не нужно создавать во время выполнения при каждом использовании. Например:

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

Весь этот кортеж будет существовать как единый объект (потенциально во флеш-памяти, если код заморожен) и будет ссылаться на него каждый раз, когда он необходим.

Ненужное создание объектов

Существует ряд ситуаций, когда объекты могут непреднамеренно создаваться и уничтожаться. Это может снизить пригодность 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

Первый создаёт буфер на каждом проходе, тогда как второй повторно использует предварительно выделенный буфер; это и быстрее, и эффективнее с точки зрения фрагментации памяти.

Байты меньше целых чисел

На большинстве платформ целое число занимает четыре байта. Рассмотрим три вызова функции foo():

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

В первом вызове list целых чисел создаётся в RAM каждый раз при выполнении кода. Второй вызов создаёт константный объект tuple (tuple, содержащий только константные объекты) на этапе компиляции, поэтому он создаётся только один раз и более эффективен, чем list. Третий вызов эффективно создаёт объект bytes, потребляя минимальный объём RAM. Если бы модуль был заморожен как байт-код, и объект tuple, и объект bytes находились бы во флеш-памяти.

Строки против байтов

Python3 ввёл поддержку Unicode. Это привнесло различие между строкой и массивом байтов. MicroPython обеспечивает, чтобы строки Unicode не занимали дополнительного места, пока все символы в строке являются ASCII (то есть имеют значение < 128). Если требуются значения во всём 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(). Обратите внимание, что и строки, и байты неизменяемы. Любая операция, которая принимает на вход такой объект и производит другой, подразумевает как минимум одно выделение RAM для получения результата. Во второй строке ниже выделяется новый объект bytes. Это также произошло бы, если бы foo был строкой.

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

Выполнение компилятора во время выполнения

Функции Python eval и exec вызывают компилятор во время выполнения, что требует значительного объёма RAM. Обратите внимание, что библиотека pickle из micropython-lib использует exec. Возможно, более эффективным по RAM будет использование библиотеки json для сериализации объектов.

Хранение строк во флеш-памяти

Строки Python неизменяемы, поэтому потенциально могут храниться в памяти только для чтения. Компилятор может размещать во флеш-памяти строки, определённые в коде Python. Как и в случае с замороженными модулями, необходимо иметь копию дерева исходного кода на ПК и набор инструментов для сборки прошивки. Процедура будет работать, даже если модули не были полностью отлажены, при условии, что их можно импортировать и запустить.

После импорта модулей выполните:

micropython.qstr_info(1)

Затем скопируйте и вставьте все строки Q(xxx) в текстовый редактор. Проверьте и удалите строки, которые явно недопустимы. Откройте файл qstrdefsport.h, который находится в ports/stm32 (или в эквивалентном каталоге для используемой архитектуры). Скопируйте и вставьте исправленные строки в конец файла. Сохраните файл, пересоберите и прошейте прошивку. Результат можно проверить, импортировав модули и снова выполнив:

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 остаётся. RAM, использованная объектом foo, будет освобождена 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, использованная компилятором, была освобождена). Выполнение функции использует более 10 КиБ, но при возврате a является мусором, поскольку выходит из области видимости и на него нельзя сослаться. Финальный вызов gc.collect() восстанавливает эту память.

Финальный вывод, производимый micropython.mem_info(1), будет различаться в деталях, но может быть интерпретирован следующим образом:

Символ

Значение

.

свободный блок

h

головной блок

=

хвостовой блок

m

помеченный головной блок

T

кортеж

L

список

D

словарь

F

число с плавающей точкой

B

байт-код

M

модуль

S

строка или байты

A

bytearray

Каждая буква представляет один блок памяти, причём блок равен 16 байтам. Таким образом, каждая строка дампа кучи представляет 0x400 байт или 1 КиБ RAM.

Управление сборкой мусора

GC можно затребовать в любой момент, выполнив gc.collect(). Делать это через интервалы выгодно, во-первых, чтобы упредить фрагментацию, и во-вторых, для производительности. GC может занимать несколько миллисекунд, но выполняется быстрее, когда работы мало (около 1 мс на OpenMV Cam). Явный вызов может минимизировать эту задержку, обеспечивая при этом её возникновение в тех точках программы, где это приемлемо.

Автоматическая GC провоцируется при следующих обстоятельствах. Когда попытка выделения завершается неудачей, выполняется GC, и выделение повторяется. Только если это не удаётся, возбуждается исключение. Во-вторых, автоматическая GC будет запущена, если объём свободной RAM падает ниже порога. Этот порог можно адаптировать по мере выполнения:

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

Это спровоцирует GC, когда более 25% текущей свободной кучи окажется занятой.

В общем случае модули должны создавать объекты данных во время выполнения с помощью конструкторов или других функций инициализации. Причина в том, что если это происходит при инициализации, компилятор может остаться без RAM при импорте последующих модулей. Если модули всё же создают данные при импорте, то gc.collect(), выполненный после импорта, смягчит проблему.

Операции со строками

MicroPython обрабатывает строки эффективным образом, и понимание этого может помочь в проектировании приложений для работы на микроконтроллерах. Когда модуль компилируется, строки, встречающиеся несколько раз, сохраняются только один раз — процесс, известный как интернирование строк. В MicroPython интернированная строка называется qstr. В модуле, импортированном обычным образом, этот единственный экземпляр будет находиться в RAM, но, как описано выше, в модулях, замороженных как байт-код, он будет находиться во флеш-памяти.

Сравнения строк также выполняются эффективно с использованием хеширования, а не посимвольно. Поэтому штраф за использование строк вместо целых чисел может быть небольшим как с точки зрения производительности, так и с точки зрения использования RAM — факт, который может стать неожиданностью для программистов на C.

Постскриптум

MicroPython передаёт, возвращает и (по умолчанию) копирует объекты по ссылке. Ссылка занимает одно машинное слово, поэтому эти процессы эффективны с точки зрения использования RAM и скорости.

Там, где требуются переменные, размер которых не является ни байтом, ни машинным словом, существуют стандартные библиотеки, которые могут помочь в их эффективном хранении и в выполнении преобразований. См. модули array, struct и uctypes.

Сноска: возвращаемое значение gc.collect()

На платформах Unix и Windows метод gc.collect() возвращает целое число, обозначающее количество отдельных областей памяти, которые были освобождены при сборке (точнее, количество голов, которые были превращены в свободные). По соображениям эффективности порты для «голого железа» не возвращают это значение.