MicroPython на мікроконтролерах¶
MicroPython розроблений для роботи на мікроконтролерах. Вони мають апаратні обмеження, з якими можуть бути незнайомі програмісти, які звикли до традиційних комп’ютерів. Зокрема, обсяг оперативної пам’яті та енергонезалежного «дискового» (флеш-пам’яті) сховища обмежений. Цей посібник пропонує способи максимально ефективного використання обмежених ресурсів. Оскільки MicroPython працює на контролерах різних архітектур, наведені методи є загальними: у деяких випадках може знадобитися отримати детальну інформацію з документації до конкретної платформи.
Флеш-пам’ять¶
На OpenMV Cams найпростіший спосіб вирішити проблему обмеженої ємності — встановити картку micro SD. У деяких випадках це неможливо: або пристрій не має слота для SD-карти, або через міркування вартості чи енергоспоживання; тому слід використовувати вбудовану флеш-пам’ять. Мікропрограма, включно з підсистемою MicroPython, зберігається у вбудованій флеш-пам’яті. Залишкова ємність доступна для використання. З причин, пов’язаних із фізичною архітектурою флеш-пам’яті, частина цієї ємності може бути недоступна як файлова система. У таких випадках цей простір можна використати, включивши призначені для користувача модулі до збірки мікропрограми, яка потім прошивається на пристрій.
Є два способи досягти цього: заморожені модулі та заморожений байткод. Заморожені модулі зберігають вихідний код Python разом із мікропрограмою. Заморожений байткод використовує крос-компілятор для перетворення вихідного коду в байткод, який потім зберігається разом із мікропрограмою. В обох випадках доступ до модуля можна отримати за допомогою оператора import:
import mymodule
Процедура створення заморожених модулів і байткоду залежить від платформи; інструкції зі збирання мікропрограми можна знайти у файлах README у відповідній частині дерева вихідних кодів.
Загалом кроки такі:
Клонуйте репозиторій MicroPython.
Отримайте набір інструментів (залежний від платформи) для збирання мікропрограми.
Зберіть крос-компілятор.
Помістіть модулі, які потрібно заморозити, у вказаний каталог (залежно від того, чи потрібно заморожувати модуль як вихідний код або як байткод).
Зберіть мікропрограму. Для збирання замороженого коду будь-якого типу може знадобитися спеціальна команда — дивіться документацію до платформи.
Прошийте мікропрограму на пристрій.
Оперативна пам’ять¶
При зменшенні використання оперативної пам’яті слід розглянути дві фази: компіляцію та виконання. Окрім споживання пам’яті, існує також проблема фрагментації купи. Загалом найкраще мінімізувати багаторазове створення і знищення об’єктів. Причина цього розглядається в розділі про heap.
Фаза компіляції¶
Під час імпорту модуля MicroPython компілює код у байткод, який потім виконується віртуальною машиною MicroPython (VM). Байткод зберігається в оперативній пам’яті. Сам компілятор теж потребує оперативної пам’яті, але вона стає доступною після завершення компіляції.
Якщо вже імпортовано кілька модулів, може виникнути ситуація, коли оперативної пам’яті недостатньо для запуску компілятора. У цьому випадку оператор import викличе виняток пам’яті.
Якщо модуль при імпорті створює глобальні об’єкти, він споживатиме оперативну пам’ять у момент імпорту, яка після цього буде недоступна компілятору для подальших імпортів. Загалом найкраще уникати коду, що виконується під час імпорту; кращим підходом є наявність коду ініціалізації, який запускається програмою після імпорту всіх модулів. Це максимізує оперативну пам’ять, доступну компілятору.
Якщо оперативної пам’яті все ще недостатньо для компіляції всіх модулів, одним із рішень є попередня компіляція модулів. MicroPython має крос-компілятор, здатний компілювати модулі Python у байткод (дивіться README у каталозі mpy-cross). Результуючий файл байткоду має розширення .mpy; його можна скопіювати в файлову систему та імпортувати звичайним способом. Крім того, деякі або всі модулі можна реалізувати як заморожений байткод: на більшості платформ це економить ще більше оперативної пам’яті, оскільки байткод виконується безпосередньо з флеш-пам’яті, а не зберігається в оперативній пам’яті.
Фаза виконання¶
Існує ряд методів програмування для зменшення використання оперативної пам’яті.
Константи
MicroPython надає ключове слово const, яке можна використовувати таким чином:
from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS
В обох випадках, коли константа присвоюється змінній, компілятор уникне кодування пошуку за іменем константи, підставляючи її літеральне значення. Це економить байткод і, отже, оперативну пам’ять. Однак значення ROWS займатиме щонайменше два машинних слова — по одному для ключа та значення у словнику глобальних змінних. Наявність у словнику необхідна, оскільки інший модуль може імпортувати або використовувати його. Цю оперативну пам’ять можна зекономити, додавши до імені префікс підкресленням, як у _COLS: цей символ не видимий за межами модуля, тому не займатиме оперативну пам’ять.
Аргумент const() може бути будь-чим, що під час компіляції обчислюється як константа, наприклад 0x100, 1 << 8 або (True, "string", b"bytes") (дивіться розділ нижче для отримання детальної інформації). Він навіть може включати інші символи const, що вже визначені, наприклад 1 << BIT.
Константні структури даних
Якщо є значний обсяг константних даних і платформа підтримує виконання з флеш-пам’яті, оперативну пам’ять можна зекономити таким чином. Дані слід розміщувати в модулях Python і заморожувати як байткод. Дані повинні бути визначені як об’єкти bytes. Компілятор «знає», що об’єкти bytes є незмінними, і гарантує, що об’єкти залишаться у флеш-пам’яті, а не будуть скопійовані в оперативну пам’ять. Модуль 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))
Весь цей кортеж існуватиме як єдиний об’єкт (потенційно у флеш-пам’яті, якщо код заморожений) і буде використовуватися за посиланням кожного разу, коли це потрібно.
Непотрібне створення об’єктів
Існує ряд ситуацій, коли об’єкти можуть ненавмисно створюватися і знищуватися. Це може знижувати корисність оперативної пам’яті через фрагментацію. У наступних розділах розглядаються такі випадки.
Конкатенація рядків
Розглянемо такі фрагменти коду, метою яких є отримання константних рядків:
var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""
Кожен дає однаковий результат, однак перший непотрібно створює два рядкові об’єкти під час виконання, виділяє більше оперативної пам’яті для конкатенації перед отриманням третього. Інші виконують конкатенацію під час компіляції, що є ефективнішим і зменшує фрагментацію.
Якщо рядки потрібно динамічно формувати перед передачею в потік, наприклад у файл, це дозволить заощадити оперативну пам’ять, якщо робити це поступово. Замість створення великого рядкового об’єкта, створіть підрядок і передайте його в потік перед обробкою наступного.
Найкращий спосіб створювати динамічні рядки — за допомогою методу 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')
In the first call a list of integers is created in RAM each time the code is
executed. The second call creates a constant tuple object (a tuple containing
only constant objects) as part of the compilation phase, so it is only created
once and is more efficient than the list. The third call efficiently
creates a bytes object consuming the minimum amount of RAM. If the module
were frozen as bytecode, both the tuple and bytes object would reside in flash.
Рядки та байти
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(). Зверніть увагу, що і рядки, і байти є незмінними. Будь-яка операція, що приймає такий об’єкт на вхід і виробляє інший, передбачає щонайменше одне виділення оперативної пам’яті для отримання результату. У другому рядку нижче виділяється новий об’єкт bytes. Те саме відбудеться, якщо foo є рядком.
foo = b' empty whitespace'
foo = foo.lstrip()
Виконання компілятора під час виконання
Функції Python eval та exec викликають компілятор під час виконання, що вимагає значних обсягів оперативної пам’яті. Зверніть увагу, що бібліотека pickle з micropython-lib використовує exec. Може бути ефективнішим з точки зору оперативної пам’яті використання бібліотеки json для серіалізації об’єктів.
Збереження рядків у флеш-пам’яті
Рядки Python є незмінними, тому мають потенціал для зберігання в пам’яті лише для читання. Компілятор може розміщувати у флеш-пам’яті рядки, визначені в коді Python. Як і у випадку заморожених модулів, необхідно мати копію дерева вихідних кодів на ПК та набір інструментів для збирання мікропрограми. Ця процедура працюватиме навіть якщо модулі не повністю налагоджені, за умови, що їх можна імпортувати та запустити.
Після імпорту модулів виконайте:
micropython.qstr_info(1)
Потім скопіюйте та вставте всі рядки Q(xxx) у текстовий редактор. Перевірте та видаліть рядки, що явно є недійсними. Відкрийте файл qstrdefsport.h, який знаходиться у ports/stm32 (або відповідному каталозі для використовуваної архітектури). Скопіюйте та вставте виправлені рядки в кінець файлу. Збережіть файл, перезберіть і прошийте мікропрограму. Результат можна перевірити, імпортувавши модулі та знову виконавши:
micropython.qstr_info(1)
Рядки Q(xxx) мають зникнути.
Купа¶
Коли запущена програма створює об’єкт, необхідна оперативна пам’ять виділяється з пулу фіксованого розміру, відомого як купа. Коли об’єкт виходить за межі видимості (тобто стає недоступним для коду), зайвий об’єкт вважається «сміттям». Процес, відомий як «збирання сміття» (GC), повертає цю пам’ять, повертаючи її до вільної купи. Цей процес виконується автоматично, однак його можна викликати безпосередньо за допомогою gc.collect().
Обговорення цього питання дещо складне. Для «швидкого виправлення» виконуйте таке periodically:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Для отримання додаткової інформації дивіться нижче та документацію до вбудованого модуля gc.
Для отримання деталей з точки зору внутрішнього устрою/розробника MicroPython дивіться також Керування пам’яттю.
Фрагментація¶
Припустимо, програма створює об’єкт foo, потім об’єкт bar. Згодом foo виходить за межі видимості, але bar залишається. Оперативна пам’ять, яку використовував foo, буде повернута GC. Однак якщо bar був виділений за більш старшою адресою, повернена пам’ять від foo буде корисна лише для об’єктів, не більших за foo. У складній або тривалій програмі купа може стати фрагментованою: незважаючи на значну кількість доступної оперативної пам’яті, може не вистачати суцільного простору для розміщення певного об’єкта, і програма завершується з помилкою пам’яті.
Методи, викладені вище, спрямовані на мінімізацію цього. Якщо потрібні великі постійні буфери або інші об’єкти, найкраще створювати їх на початку виконання програми до того, як може виникнути фрагментація. Подальші поліпшення можна досягти шляхом моніторингу стану купи та керування 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()Виводить короткий підсумок використання оперативної пам’яті.gc.mem_free()Повертає розмір вільної купи в байтах.gc.mem_alloc()Повертає кількість байтів, що наразі виділені.micropython.mem_info(1)Виводить таблицю використання купи (докладно описано нижче).
Числа, що виводяться, залежать від платформи, але видно, що оголошення функції використовує невелику кількість оперативної пам’яті у вигляді байткоду, виданого компілятором (оперативна пам’ять, що використовувалася компілятором, була повернута). Виконання функції використовує понад 10КіБ, але після повернення a є сміттям, оскільки виходить за межі видимості і не може бути використана. Фінальний gc.collect() повертає цю пам’ять.
Кінцевий вивід, що виробляється micropython.mem_info(1), буде відрізнятися в деталях, але може бути інтерпретований таким чином:
Символ |
Значення |
|---|---|
. |
вільний блок |
h |
головний блок |
= |
хвостовий блок |
m |
позначений головний блок |
T |
кортеж |
L |
список |
D |
словник |
F |
число з плаваючою точкою |
B |
байткод |
M |
модуль |
S |
рядок або байти |
A |
масив байтів |
Кожна літера представляє один блок пам’яті розміром 16 байтів. Отже, кожен рядок дампу купи представляє 0x400 байтів або 1КіБ оперативної пам’яті.
Керування збиранням сміття¶
GC може бути ініційований у будь-який час за допомогою gc.collect(). Робити це з певними інтервалами є вигідним — по-перше, щоб запобігти фрагментації, по-друге, для покращення продуктивності. GC може займати кілька мілісекунд, але виконується швидше, коли роботи мало (близько 1 мс на OpenMV Cam). Явний виклик може мінімізувати затримку, гарантуючи, що він відбувається у тих точках програми, де це прийнятно.
Автоматичний GC запускається за таких обставин. Коли спроба виділення пам’яті зазнає невдачі, виконується GC і виділення повторюється. Виняток виникає лише у разі повторної невдачі. По-друге, автоматичний GC буде запущений, якщо кількість вільної оперативної пам’яті опуститься нижче порогу. Цей поріг можна змінювати в міру виконання програми:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
Це спровокує GC, коли більше 25% поточної вільної купи буде зайнято.
Загалом модулі повинні створювати об’єкти даних під час виконання за допомогою конструкторів або інших функцій ініціалізації. Причина полягає в тому, що якщо це відбувається під час ініціалізації, компілятор може не мати достатньо оперативної пам’яті при імпорті наступних модулів. Якщо модулі все ж таки створюють дані при імпорті, то gc.collect(), викликаний після імпорту, допоможе вирішити цю проблему.
Рядкові операції¶
MicroPython обробляє рядки ефективним чином, і розуміння цього може допомогти у проектуванні програм для мікроконтролерів. Під час компіляції модуля рядки, що зустрічаються кілька разів, зберігаються лише один раз — цей процес відомий як інтернування рядків. У MicroPython інтернований рядок відомий як qstr. У модулі, що імпортується звичайним чином, цей єдиний екземпляр буде розташований в оперативній пам’яті, але, як описано вище, у модулях, заморожених як байткод, він буде розташований у флеш-пам’яті.
Порівняння рядків також виконується ефективно за допомогою хешування, а не посимвольно. Тому втрати від використання рядків замість цілих чисел можуть бути невеликими як з точки зору продуктивності, так і використання оперативної пам’яті — факт, який може здивувати програмістів на C.
Постскриптум¶
MicroPython передає, повертає та (за замовчуванням) копіює об’єкти за посиланням. Посилання займає одне машинне слово, тому ці процеси є ефективними з точки зору використання оперативної пам’яті та швидкості.
Якщо потрібні змінні, розмір яких не є ні байтом, ні машинним словом, існують стандартні бібліотеки, що допомагають ефективно їх зберігати та виконувати перетворення. Дивіться модулі array, struct та uctypes.
Виноска: повернене значення gc.collect()¶
На платформах Unix і Windows метод gc.collect() повертає ціле число, що позначає кількість окремих регіонів пам’яті, які були повернені під час збирання (точніше, кількість головних блоків, що були перетворені у вільні). З міркувань ефективності порти на голому залізі не повертають це значення.