MicroPython:Основы/Язык MicroPython и его реализация/MicroPython на микроконтроллерах
MicroPython на микроконтроллерах[1]
MicroPython создан так, чтобы его можно было запускать на микроконтроллерах. У микроконтроллеров есть аппаратные ограничения, которые могут быть незнакомы программистам, привыкшим работать с обычными компьютерами. В частности, у них ограничено количество RAM-памяти и энергонезависимой «дисковой» (flash) памяти. В этом руководстве мы расскажем, как выжать из этих скудных ресурсов самый максимум. Микроконтроллеры, на которых работает MicroPython, могут иметь разные архитектуры, поэтому мы здесь расскажем об универсальных вещах: в некоторых случаях вам надо будет ознакомиться с документацией своей платформы, чтобы получить там более прицельную и подробную информацию.
Flash-память
Если у вас Pyboard, то самый простой способ решить проблему ограниченной памяти – это вставить карту Micro SD. В некоторых случаях это непрактично – либо потому что у устройства нет слота для SD-карты, либо из-за ее дороговизны, либо из-за того, что это повысит требования к энергопотреблению. Следовательно, в таких случаях придется воспользоваться встроенной в чип flash-памятью. На встроенной flash-памяти хранится прошивка, включая подсистему MicroPython, а все оставшееся место flash-памяти вы можете использовать в своих целях. По причинам, связанным с физической архитектурой flash-памяти, часть этого места может быть недоступна для файловой системы. Но этим местом все же можно воспользоваться, если встроить модули пользователя в прошивку, а потом записать ее на устройство.
Это можно сделать двумя способами: с помощью замороженных модулей или замороженного байт-кода. В замороженных модулях хранится Python-код, который сохраняется вместе с прошивкой. Для преобразования исходного кода в замороженный байт-код, который затем сохраняется вместе с прошивкой, используется кросс-компилятор. В обоих случаях доступ к модулю можно получить с помощью оператора import:
import название_модуля
Процедура создания замороженных модулей и байт-кода зависит от используемой вами платформы; инструкции по сборке прошивки можно найти в README-файлах в соответствующей части дерева исходного кода. В общем и целом, вам нужно будет выполнить следующие шаги:
- Клонируем репозиторий MicroPython.
- Скачиваем тулчейн для сборки прошивки (какой именно – зависит от платформы)
- Собираем кросс-компилятор.
- Размещаем модули, которые нужно заморозить, в нужной директории (в какой именно – зависит от того, будет ли модуль заморожен как исходный код или как байт-код).
- Собираем прошивку. Для сборки замороженного кода (обоих типов) может потребоваться специальная команда – более подробно читайте в документации к своей платформе.
- Записываем прошивку на устройство.
RAM-память
Если вы хотите снизить нагрузку на RAM-память, обратите внимание на фазы компиляции и выполнения кода. Помимо потребления памяти, есть еще и проблема под названием «фрагментация кучи». Если не вдаваться в подробности, старайтесь минимизировать многократное создание и уничтожение объектов. Причина объясняется ниже в разделе «Куча».
Фаза компиляции
При импортировании модуля MicroPython компилирует код в байт-код, который затем выполняется виртуальной машиной MicroPython. Байт-код хранится в RAM-памяти. RAM-память нужна и самому компилятору, но после завершения компиляции ее можно использовать и для других целей.
Если вы импортировали много модулей, может возникнуть ситуация, когда вам будет не хватать RAM-памяти для запуска компилятора. В этом случае оператор import выдаст исключение о нехватке памяти.
Если в момент импорта модуль инстанцинирует глобальные объекты, это потребует RAM-памяти, которая, соответственно, будет недоступна компилятору для последующих импортов. В целом лучше избегать использования кода, который запускается при импорте; наилучший подход – это иметь такой инициализирующий код, который будет запускаться приложением уже после импорта всех модулей. Это максимизирует RAM-память, доступную компилятору.
Если RAM-памяти по-прежнему не хватает для компиляции всех модулей, можно воспользоваться предкомпиляцией модулей. В MicroPython есть кросс-компилятор, умеющий компилировать Python-модули в байт-код (см. README в директории «mpy-cross»). У получившегося файла байт-кода будет расширение «*.mpy»; его можно будет скопировать в файловую систему и импортировать обычным образом. Или еще один способ – в виде замороженного байт-кода в прошивку можно встроить некоторые или даже все модули: на большинстве платформ это позволяет сэкономить даже больше RAM-памяти, чем байт-код, запущенный напрямую с flash-памяти, а не из 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»). В нем даже можно задать уже заданные ранее константы (например, «1 << BIT»).
Структуры константных данных
RAM-память можно сэкономить и в ситуации, когда ваша платформа поддерживает запуск из flash-памяти и вам нужно воспользоваться большим количеством константных данных. Данные должны располагаться в Python-модулях и быть замороженными в виде байт-кода. Кроме того, эти данные должны быть заданы как объекты bytes. Компилятор «знает», что объекты bytes неизменяемы и делает так, чтобы они оставались во flash-памяти и не были скопированы в RAM-память. Для преобразования между bytes и другими встроенными типами Python-данных можно воспользоваться модулем ustruct.
Собираясь применить замороженный байт-код, помните, что в Python строки, числа с плавающей точкой, байты, целые числа и сложные числа неизменяемы. Соответственно, они будут заморожены в flash-память. Таким образом, в строчке...
mystring = "быстрая бурая лиса"
...сама строчка «быстрая бурая лиса» будет находиться в flash-памяти. В фазе выполнения переменной mystring будет присвоен указатель на эту строку. Указатель занимает всего одно машинное слово. По сути, для хранения константных данных можно использовать и длинное целое число:
bar = 0xDEADBEEF0000DEADBEEF
Как и в примере со строкой, в фазе выполнения это большое целое число будет присвоено переменной bar. Указатель на него будет занимать всего одно машинное слово.
Здесь можно предположить, что RAM-память можно сэкономить, если сохранить константные данные в виде кортежа целых чисел. Но в нынешней версии компилятора это никакого оптимизационного эффекта не даст (код работает, но RAM-память сэкономлена не будет).
foo = (1, 2, 3, 4, 5, 6, 100000)
В фазе выполнения кортеж будет помещен в RAM-память. Но, по словам разработчиков, возможно, в будущем это будет оптимизировано.
Ненужное создание объектов
Бывают ситуации, когда объекты создаются и уничтожаются непреднамеренно. Это может снизить доступность RAM-памяти вследствие ее фрагментации. Ниже рассказывается о разных примерах таких ситуаций.
Объединение строк
Давайте взглянем на блок кода ниже, где разными способами создаются константные строки:
var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""
Каждый из них дает один и тот же результат, но в первом случае в фазе выполнения без особой нужды создается два строковых объекта, поэтому в период до создания третьей (итоговой) строки этой операции будет выделено больше RAM-памяти. В других вариантах объединение строк выполняется в фазе компиляции, что эффективнее и снижает фрагментацию.
Если у вас в коде выполняется динамическое создание строк, чтобы потом «скормить» их потоковому объекту вроде файла, то в этом случае RAM-память можно сэкономить, если делать это фрагментами. То есть вместо создания большого строкового объекта лучше создать подстроку, «скармливать» ее потоковому объекту, а потом заниматься следующей подстрокой.
Динамические строки лучше всего создавать при помощи строкового метода format():
var = "Температура {:5.2f} Давление {:06d}\n".format(temp, press)
Буферы
При получении доступа к устройствам с интерфейсами UART, I2C и SPI создания ненужных объектов можно избежать при помощи буферов с предварительно выделенной памятью. Взгляните на два цикла ниже:
while True:
var = spi.read(100)
# обрабатываем данные
buf = bytearray(100)
while True:
spi.readinto(buf)
# обрабатываем данные в буфере
В первом цикле буфер создается при каждом проходе цикла, а во втором многократно используется один и тот же буфер с предварительно выделенной памятью – это и быстрее, и эффективнее с точки зрения фрагментации памяти.
Байты меньше целых чисел
На большинстве платформ целому числу требуется четыре байта. Взгляните на код ниже, где два раза (но по-разному) вызывается функция foo():
def foo(bar):
for x in bar:
print(x)
foo((1, 2, 0xff))
foo(b'\1\2\xff')
При первом вызове foo() кортеж целых чисел создается в RAM-памяти. Второй вызов более эффективен – в нем создается объект bytes, которому нужно меньшее количество RAM-памяти. Если модуль был заморожен как байт-код, объект bytes будет помещен во flash-память.
Строки против объекта «bytes»
С релизом версии Python 3.0 в этом языке появилась поддержка Unicode, а вместе с ней – различие между строкой и массивом байтов. В MicroPython гарантируется, что пока символы в строке имеют кодировку ASCII (т.е. их значения не выше «126»), Unicode-строки дополнительного места занимать не будут. Если вашему приложению требуются значения в 8-битном диапазоне, можно воспользоваться объектами bytes и bytearray, и это не потребует дополнительной памяти. Помните, что многие строковые методы – например, str.strip() – также применимы и на экземплярах bytes, благодаря чему процесс устранения Unicode может быть безболезненным.
s = 'быстрая бурая лиса' # экземпляр строки
b = b'быстрая бурая лиса' # экземпляр объекта «bytes»
Если вам необходимо преобразовать строку в bytes и наоборот, можно воспользоваться методами str.encode() и bytes.encode(). Помните, что и строки, и bytes неизменяемы. Любая операция, где берется такой объект и потом конвертируется в другой, подразумевает, что для получения результата нужно выполнить как минимум одно выделение RAM-памяти. Во второй строчке ниже выделяется память под новый объект bytes. Это также произошло бы, если бы объект foo был строкой.
foo = b' пустые пробелы'
foo = foo.lstrip()
Запуск компилятора в фазе выполнения
Python-функции eval и exec запускают компилятор в фазе выполнения, что требует значительного количества RAM-памяти. Помните, что exec есть в библиотеке pickle из «micropython-lib». Возможно, с точки зрения использования RAM-памяти для сериализации объектов эффективнее было бы использовать библиотеку «ujson».
Хранение строк во flash-памяти
Python-строки неизменяемы, благодаря чему их можно хранить в памяти только для чтения. Компилятор может помещать строки, заданные в Python-коде, во flash-память. Как и в случае с замороженными модулями, у вас должен быть тулчейн для сборки прошивки и копия дерева исходного кода на ПК. Эта процедура будет работать, даже если модули не были полностью отлажены – достаточно, чтобы их можно было импортировать и запустить.
Импортировав модули, запустите их:
micropython.qstr_info(1)
Скопируйте и вставьте все Q(xxx) строки в текстовый редактор. Проверьте их и удалите те, что точно некорректны. Откройте файл «qstrdefsport.h», который можно найти в «ports/stm32» (или в соответствующей директории для используемой вами архитектуры). Скопируйте и вставьте поправленные строки в конец этого файла. Сохраните файл, а затем пересоберите и запишите прошивку. Чтобы проверить результат, импортируйте модули и снова напишите:
micropython.qstr_info(1)
Строки Q(xxx) должны исчезнуть.
Куча
Когда запущенная программа инстанцинирует объект, происходит выделение памяти в фиксированном хранилище памяти под названием «куча». Когда объект выходит за пределы области видимости (другими словами, становится недоступен для кода), то становится избыточным – такой объект называют «мусором». Этот мусор вычищается с помощью процесса, называемого (удивительно) «сборкой мусора», что позволяет освободить в куче место, которое этот мусор когда-то занимал. Этот процесс запускается автоматически, но его можно вызвать и напрямую при помощи gc.collect().
Это довольно сложная тема, но на скорую руку эту проблему можно решить, периодически вызывая в программе следующее:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Фрагментация
Допустим, программа создает объект foo, а затем объект bar. Далее foo выходит за пределы области видимости, а bar остается. В результате сборщик мусора освободит RAM-память, используемую foo. Но если адрес памяти, выделенной под bar, был выше, чем адрес памяти для foo, то память, очищенная от foo, впоследствии можно использовать только для объектов, чей размер не больше размера foo. Если у вас сложная и долгая программа, куча может фрагментироваться – в итоге у вас будет много свободной RAM-памяти, но не будет смежных участков достаточного размера, чтобы поместить в них необходимый объект, в результате чего программа выдаст ошибку памяти.
Техники, описанные выше, позволяют минимизировать эту проблему. Если в вашей программе используются долговременные буферы и другие объекты, их лучше инстанцинировать в начале программы – до появления возможной фрагментации. Среди других методов – мониторить состояние кучи и управлять сборкой мусора (об обоих методах читайте ниже).
Информирование о выделении памяти
В MicroPython есть несколько методов, чья задача – информировать о выделении памяти и управлять сборкой мусора. Их можно найти в модулях gc и micropyhton. Попробуйте запустить в REPL фрагмент кода ниже (нажмите Ctrl + E , чтобы войти в режим вставки, и Ctrl + D , чтобы запустить код):
import gc
import micropython
gc.collect()
micropython.mem_info()
print('-----------------------------')
print('Свободно: {} выделено: {}'.format(gc.mem_free(), gc.mem_alloc()))
def func():
a = bytearray(10000)
gc.collect()
print('После определения функции: {} выделено: {}'.format(gc.mem_free(), gc.mem_alloc()))
func()
print('Свободно после запуска функции: {} выделено: {}'.format(gc.mem_free(), gc.mem_alloc()))
gc.collect()
print('Свободно после сборки мусора: {} выделено: {}'.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 | Модуль |
Каждая буква означает один блок памяти размером в 16 байтов. Таким образом, каждая строчка кучи – это 0x400 байт или 1 Кб RAM-памяти.
Управление сборкой мусора
Сборку мусора можно запустить в любое время при помощи gc.collect(). Советуем регулярно запускать эту функцию – во-первых, для профилактики фрагментации, а во-вторых, для улучшения производительности. Сборка мусора может занять несколько миллисекунд, но она выполняется быстрее, если очищать нужно мало (около 1 мс на Pyboard). Явный вызов может снизить эту задержку и в то же время – обеспечить, что gc.collect() будет вызываться в тех местах программы, где она не будет сильно загружена.
Автоматическая сборка мусора запускается в следующих ситуациях:
- Если попытка выделения памяти провалилась. После этого запускается сборка мусора, а после нее – новая попытка выделения памяти. И если она тоже заканчивается неудачей, программа выдает исключение.
- Если количество свободной RAM-памяти снижается до критического порога. Этот порог можно адаптировать во время выполнения программы:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
В результате сборка мусора будет запускаться после заполнения кучи более чем на 25%.
В общем и целом, необходимо, чтобы модули инстанцинировали объекты данных (при помощи конструкторов и других инициализирующих функций) в фазе выполнения. Потому что, если это произойдет во время инициализации, компилятору может не хватить RAM-памяти для запуска следующих модулей. Но если в вашей программе модули инстанцинируют данные при импорте, то эту проблему можно минимизировать, если запустить gc.collect() после импорта.
Строковые операции
В MicroPython также оптимизирована обработка строк, и понимание того, как работает эта оптимизация, может хорошо подсобить в создании микроконтроллерных приложений. При компиляции модуля строки, появляющиеся несколько раз, сохраняются лишь единожды – этот процесс называется «интернированием строк». В MicroPython интернированная строка называется qstr. В модуле, импортируемом обычным образом, этот единственный экземпляр строки будет помещен в RAM-память, но, как уже говорилось выше, при использовании модулей, которые заморожены в виде байт-кода, она будет помещены во flash-память.
Сравнение строк тоже оптимизировано – оно выполняется через хэш, а не посимвольно. Следовательно, «штраф» за использование строк вместо целых чисел может быть небольшим – и с точки зрения производительности, и с точки зрения использования RAM-памяти, что, возможно, будет непривычно для тех, кто привык программировать на Си.
Постскриптум
Объекты в MicroPython передаются, возвращаются и (по умолчанию) копируются при помощи указателей. Указатель занимает одно машинное слово, благодаря чему эти процессы оптимизированы и в плане использования RAM-памяти, и в плане скорости работы программы.
Если вы используете переменные, чей размер не соответствует ни байту, ни машинному слову, есть стандартные библиотеки, которые помогут вам и эффективно хранить такие переменные, и выполнять с ними различные преобразования. Более подробно смотрите в статьях о модулях array, ustruct и uctypes.
Примечание: значение, возвращаемое gc.collect()
На платформах Unix и Windows метод gc.collect() возвращает целое число, обозначающее количество уникальных участков памяти, которые были очищены сборкой мусора (если быть точнее, количество головных блоков, превращенных в свободные блоки). В целях оптимизации порты для «голого железа» это значение не возвращают.