На Хабре уже немало писали про обучающий микрокомпьютер BBC micro:bit, который в 2016 раздали всем британским школьникам, и сейчас он продаётся по $15. С прошлого года появились в продаже и micro:bit v2, в которых ОЗУ расширена с 16 КБ до 128 КБ. Неизменным остался форм-фактор: две кнопки для ввода, матрица 5х5 светодиодов для вывода, всё что сверх этого – подключайте через 25-контактный edge connector. Очевидно, что создатели задумывали micro:bit не как самостоятельное устройство, а как «мозг» для более сложного проекта со внешними датчиками, индикаторами, релюшками, сервоприводами и т.п. – этакий «детский Arduino».

Энтузиасты из Шэньчжэня, взявшие себе название KittenBot, решили заполнить пустующую нишу «обучающий микрокомпьютер, который как micro:bit, но со всем необходимым для нескучных проектов — уже внутри». Их плата MeowBit, выпущенная в 2018, стоит $40; сохраняет edge connector, совместимый с micro:bit; и добавляет четыре кнопки-«джойстик», полноцветный TFT-экран 160х128, динамик, и силиконовую оболочку с отсеком для аккумулятора – всё, что нужно для создания мини-«геймбоя» размером с кредитную карточку. У MeowBit 96 КБ ОЗУ – до выхода micro:bit v2 это было ещё одним его существенным превосходством – и 2 МБ флеш-памяти, по сравнению с 256 КБ у micro:bit v1 и 512 КБ у micro:bit v2. Игры для MeowBit можно писать на MakeCode Arcade (диалект Scratch от Microsoft), Kittenblock (собственный диалект Scratch от KittenBot), или на MicroPython. На сайте KittenBot есть туториалы п�� использованию этих трёх языков, но весьма бедные, и увы, только на китайском.



MicroPython создавался для тех, кто привык программировать микроконтроллеры на Си, но хотел быиспользовать при этом синтаксис Python. Процесс разработки организован соответствующим образом: после загрузки MicroPython выполняет файл main.py из флеш-памяти, затем запускает REPL (интерактивную оболочку) на последовательном интерфейсе. Единственный способ запустить новый скрипт – перезагрузка с новым main.py. Единственный способ удалить из памяти переменные и импорты, оставшиеся от отработавшего скрипта – перезагрузка. Единственный способ прервать выполнение скрипта, вошедшего в бесконечный цикл – перезагрузка. Вдобавок, стандартный вывод MicroPython направляется на последовательный интерфейс, а не на экран; чтобы напечатать что-либо на экране, вместо стандартного print надо использовать FrameBuffer: fb.text(s, x, y); pyb.SCREEN().show(fb) – с явным заданием координат, без автоматического разбиения на строки и без автоматической прокрутки экрана. Для разработки на Си всё это естественно, но программисты на Python привыкли к намного большему комфорту.

Осознавая это, в 2017 нью-йоркский стартап Adafruit начал разработку собственного форка MicroPython, получившего название CircuitPython. Эта реализация выполняет main.py каждый раз, когда файл с таким названием копируется через USB, и затем «обнуляет» среду, так что переменные из main.py не мешают ни REPL, ни следующему запускаемому скрипту. Чтобы остановить выполнение скрипта, достаточно удалить main.py через USB. В MicroPython нужно проявлять осторожность, чтобы во флеш-память не писали одновременно скрипт и компьютер через USB, иначе файловая система могла повредиться. В CircuitPython же перезапись main.py во время его выполнения – основной сценарий использования, так что ради предосторожности нужно выбрать один из двух вариантов – доступ ко флешу из Python только на чтение и через USB на чтение и запись, либо из Python на чтение и запись и через USB только на чтение, без возможности перезаписи либо удаления main.py. И ещё одна приятная фича – что до перехода в графический режим стандартный вывод дублируется и на последовательный интерфейс, и на экран. С другой стороны, MicroPython позволяет писать на Python обработчики прерываний (с рядом ограничений – например, в них нельзя создавать/удалять объекты на куче и нельзя бросать исключения), а CircuitPython – не позволяет, и не собирается добавлять такую
возможность.

Разница в подходе проявляется и в API. MicroPython стремится к краткости, CircuitPython – к абстракции:
MicroPython CircuitPython
from pyb import Pin
pin = Pin(«BTNA», pull=Pin.PULL_UP)
print(pin.value())
import board, digitalio
pin = digitalio.DigitalInOut(board.BTNA)
pin.pull = digitalio.Pull.UP
print(pin.value)
from pyb import Pin, Timer, delay
# нужные номера таймера и канала
# берутся из даташита
tim = Timer(4, freq=440)
ch = tim.channel(3, Timer.PWM,
                 pin=Pin('BUZZ'),
                 pulse_width=15909)
                 # 50% от 14 MHz / 440
delay(100)
ch.pulse_width_percent(0)
import board, pwmio, time
pwm = pwmio.PWMOut(board.BUZZ,
                   frequency=440,
                   duty_cycle=2**15)
                   # 50% от  2**16
time.sleep(0.1)
pwm.duty_cycle = 0
import pyb, framebuf
fbuf = bytearray(160*128*2)
fb = framebuf.FrameBuffer(fbuf, 160, 128,
                          framebuf.RGB565)
tft = pyb.SCREEN()
fb.pixel(123, 45, 0x07E0) # blue
tft.show(fb)
import board, displayio
fb = displayio.Bitmap(160, 128, 2)
palette = displayio.Palette(2)
palette[1] = 0xFF0000 # blue
splash = displayio.Group()
splash.append(displayio.TileGrid(fb,
              pixel_shader=palette))
tft = board.DISPLAY
tft.show(splash)
fb[123, 45] = 1
tft.refresh()
Во втором примере разница в подходе наиболее наглядна: MicroPython предоставляет очень простую и прозрачную модель «создал массив значений пикселей, отправил его целиком на экран», навязывающую программисту довольно неудобный формат RGB565, потому что именно с таким форматом работает экран MeowBit (ST7735). Недостаток этой модели в том, что буфер 160х128х2 занимает 40 КБ – почти всю память, остающуюся свободной после загрузки MicroPython. Держать два таких буфера и отображать их поочерёдно – нет никакой возможности.

С другой стороны, CircuitPython навязывает программисту многоуровневую абстракцию: Bitmap произвольной цветовой глубины, позволяющий сэкономить память, когда одновременно используемых цветов не так много; Palette, превращающая значения Bitmap в конкретные значения цвета, задаваемые в стандартном 24-битном формате, и автоматически конвертируемые в тот формат, с которым работает экран; затем TileGrid, позволяющая сдвигать и масштабировать несколько спрайтов как одно целое; и наконец Group, позволяющая переключаться между «стопками спрайтов». Для простых задач, типа отрисовки графика функции, все эти дополнительные абстракции со��ершенно лишние; но для разработки игр, скорее всего, программисту на MicroPython пришлось бы самостоятельно разрабатывать нечто аналогичное этой иерархии абстракций.

Самое удивительное в CircuitPython – то, что он занимает не больше памяти, чем MicroPython:
свежезагруженной в MeowBit программе остаётся для работы 55 КБ и 53 КБ соответственно. Одна из причин – то, что большинство стандартных модулей CircuitPython ожидаются во флеш-памяти отдельными файлами, и не загружаются, пока не востребованы. (Таков, например, модуль adafruit_framebuf, предоставляющий интерфейс стандартного framebuf из MicroPython.) Полный набор стандартных внешних модулей занимает больше 2 МБ и даже не помещается целиком во флеш-память MeowBit.

Один из моментов, вызванный разницей в подходах, хотелось бы разобрать подробнее: сложно представить игру без музыки и/или звуковых эффектов, но если на время проигрывания звуков игра будет приостанавливаться (как в примерах выше с delay и sleep), то играть будет очень неудобно. Как же реализовать фоновый звук в двух вариантах Python?

В MicroPython можно напрямую перенести напрашивающееся низкоуровневое решение – обрабатывать прерывание от таймера: функция play будет добавлять записи в список music, а обработчик handle_music будет обрабатывать их по одной. Ограничения MicroPython не позволяют укорачивать список music прямо в handle_music по мере обработки записей, так что придётся пользоваться более низкоуровневыми средствами: продвигать в обработчике указатель next, и удалять из списка обработанные записи лишь при следующем вызове play.

# `tim` и `ch` как в примере выше
music = []
countdown = 0
next = 0

# понимает подмножество синтаксиса QBasic PLAY:
# https://en.wikibooks.org/wiki/QBasic/Appendix#PLAY
def play(m):
    global music, next
    music = music[next:]
    next = 0

    octave = 1
    duration = 75
    n = 0
    while n < len(m):
        note = m[n]
        if note >= 'A' and note <= 'G':
            freq = [440, 494, 262, 294, 330, 349, 392][ord(note)-ord('A')]
            music.append((freq * 2 ** (octave-1), duration * 7 / 8))
            music.append((0, duration / 8))
        elif note == 'O':
            n += 1
            octave = int(m[n])
        elif note == 'L':
            n += 2
            l = int(m[n-1:n+1])
            duration = 1500 / l
        n += 1

def handle_music(t):
  global countdown, next

  if countdown:
    countdown -= 1
    if countdown:
      return
    ch.pulse_width_percent(0)

  if next < len(music):
    (freq, countdown) = music[next]
    next += 1
    if freq:
      tim.freq(freq)
      ch.pulse_width_percent(50)

bg_tim = Timer(1, freq=1000)
bg_tim.callback(handle_music)

CircuitPython же не позволяет писать обработчики прерываний, так что понадобится намного более высокоуровневая реализация. handle_music из повторно вызываемого обработчика превращается в генератор – это ещё и упрощает логику кода: включение динамика, задержка, и выключение динамика теперь идут в коде последовательно, так что можно обойтись без глобального countdown. Кроме того, генератор может сам удалять из music обработанные записи, так что упрощается и функция play.

# `pwm` как в примере выше

def sleep(duration):
  until = monotonic_ns() + duration
  while monotonic_ns() < until:
    yield

def handle_music():
  global music
  while True:
    if music:
      (freq, d) = music[0]
      music = music[1:]
      if freq:
        pwm.frequency = int(freq)
        pwm.duty_cycle = 2**15
      yield from sleep(d * 1.e6)
      pwm.duty_cycle = 0
    yield

Но теперь фоновый звук будет проигрываться не сам собой, а только при регулярном «дёрганьи» генератора. Это склоняет к тому, ��тобы и остальные игровые процессы реализовать
в виде генераторов; например, заставка игры, прокручивающаяся вверх-вниз до нажатия любой
кнопки, реализуется следующим образом:

play("L08CDEDCDL04ECC")

def scroll():
  while True:
    while splash.y > max_scroll:
      splash.y -= 1
      yield from sleep(3.e8)
    while splash.y < 0:
      splash.y += 1
      yield from sleep(3.e8)

def handle_input():
  while all(button.value for button in buttons):
    yield

for _ in zip(scroll(), handle_input(), handle_music()):
  pass

С одной стороны, реализация на MicroPython даёт заметно более качественный звук, потому что обработчик прерывания вызывается точно в заданное время, тогда как в CircuitPython на время перерисовки экрана (порядка 0.15 с) звук «подвисает». С другой стороны, код на CircuitPython легче писать, легче отлаживать и легче расширять, а реализация игровых процессов в виде сопрограмм-генераторов естественна и в отрыве от требований ко звуку.