Некоторое время назад я поделился первыми впечатлениями от знакомства с Ардуино-совместимой платой ELBEAR ACE-UNO на базе отечественного микроконтроллера MIK32 «Амур». Материал нашёл хороший отклик среди читателей, и это подогрело моё желание развить тему. Правда, подогрев слегка перешёл в фазу медленного бурления, и достиг точки закипания только сейчас. Но лучше поздно, чем никогда.

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

Готовлю нападение

Несмотря на не особо впечатляющие результаты, в первый подход я получил ценный опыт, на основе которого получше подготовился, и сейчас я вам покажу, как готовлю нападение на выявленные проблемы.

Тщательное планирование перед нападением (брифинг из Super Battletank)
Тщательное планирование перед нападением (брифинг из Super Battletank)

По результатам предыдущего подхода задача кажется весьма амбициозной: ведь я не смог получить хоть сколько-нибудь плавную анимацию даже на микроскопическом монохромном OLED-дисплее с разрешением 128 на 64 точки и объёмом буфера кадра всего один килобайт. Да и с качественным звуком тоже не задалось, несмотря на наличие двухканального 12-битного ЦАП. Что-то явно пошло не так.

Однако, первые тесты дали понимание слабых мест, с которыми предстоит разобраться. А комментарии к статье, давшие полезные подсказки и наводки, плюс чтение различных публикаций на тему, позволили наметить пути решения возникших сложностей.

Основных проблем наметилось ровно три штуки:

  • Очень низкая скорость передачи данных в экран. Нужно убедиться, что аппаратный SPI действительно работает. По скорости работы прошлых тестов было очень похоже, что что-то идёт не так, и фактически работает через программный SPI, по крайней мере на OLED-дисплее.

  • Очень большой оверхед (сопутствующая нагрузка на процессор) при использовании прерываний по таймеру даже на частотах порядка десяти килогерц. Это одновременно и проблема местной реализации обработчика прерываний, и самого подхода с выборкой сэмплов звука по таймеру: лучше бы задействовать DMA.

  • Очень медленная загрузка скетча в плату: до восьми минут на один мегабайт. Нужно выяснить, почему она такая, и можно ли её как-то поднять до адекватных значений.

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

План-минимум на сегодня — показать монохромную анимацию на маленьком OLED-дисплее, а план-максимум — цветную на большом TFT, с цифровым звуком, возможно, даже в стерео. Как говорится, хотеть не вредно, вредно не хотеть.

Обновления

Так как с моего предыдущего подхода к плате ACE-UNO прошло уже немало времени — девять месяцев, надо бы обновиться. Ведь разработчики платы поддерживают свой проект. Ведь поддерживают же?

Сразу при возвращении к проектам в среде Arduino у меня возникла проблема: перестала работать ссылка на пакет поддержки плат (Board Support Package, BSP), на котором я делал свои первые тесты. В документации она не поменялась, пишет ошибку:

Ошибка загрузки BSP в IDE 1.8.19
Ошибка загрузки BSP в IDE 1.8.19

При этом вне IDE она открывалась, и в JSON-файле видно, что версия пакета поменялась с 0.5.1 на 0.5.3. Я нагуглил изменения и захотел обновиться: в новой версии были учтены некоторые моменты, которые иначе пришлось бы решать вручную. К тому же, обновился загрузчик, что давало надежду на решение проблемы очень низкой скорости загрузки скетчей.

Довольно быстро я догадался, почему IDE не открывает ссылку. Если предыдущий пакет работал в Arduino IDE 1.8.x, которым до сих пор пользуются многие энтузиасты, новый работает исключительно в IDE 2.x.x. Довольно сомнительное решение, которое сразу закрыло возможность ��родолжать работу на компьютере с Windows 7. А в прошлый-то раз прокатило. Ушла эпоха!

Установленный BSP 0.5.3 в Arduino IDE 2.3.8
Установленный BSP 0.5.3 в Arduino IDE 2.3.8

Пришлось закрывать тему ретроградства и открывать ноутбук с Windows 10. Поставил на него IDE 2.3.8, поставил пакет поддержки платы. Теперь всё прошло без проблем. Нужно отдать должное: начало работы с платой не требует плясок с бубном: поставил пакет в IDE, подключил плату, залил Blink, светодиод мигает. Всё как у взрослых.

Увы, в остальном всё осталось без изменений. Я снова собрал стандартный тест Adafruit GFX для OLED-дисплея на контроллере SH1106. 170-килобайтный скетч загружался мучительно долго, и как и прежде — целых полторы минуты, и работает старый код всё так же медленно. То есть автоматически никакие проблемы не решаются, решать их предстоит врукопашную.

Упаковка, в которой поставляется ELJTAG
Упаковка, в которой поставляется ELJTAG

Я всё же лелеял надежду, что новый загрузчик будет побыстрее, и решил его прошить в плату. Для этого производитель предлагает использовать ELJTAG — собственный JTAG-программатор. Из прочитанных материалов я узнал, что это не обязательно, подойдёт любой на чипе FT2232H. Но у меня под рукой был только старенький Segger J-Link, и меня не сильно вдохновляла перспектива экспериментировать с ним и самодельными кабелями, рискуя окирпичить дорогостоящую плату ACE-UNO. К слову, цены ��а платы за прошедшее время особо не изменились: 3 тысячи за ACE-NANO и 6 тысяч за ACE-UNO 8MB с ELJTAG в комплекте.

Сам ELJTAG и чип, на котором он собран
Сам ELJTAG и чип, на котором он собран

На Озоне отдельный ELJTAG стоил 2781 рубль, а в некоторых других самостоятельных Ардуино-ориентированных магазинах и вовсе 4000-5500. Импортозамещение, что поделать. Покряхтев, я совершил ещё одно обновление: отслюнявил в поддержку отечественного производителя необходимую сумму и заказал родной программатор. Благо, он может мне пригодиться и для других RISC-V контроллеров. Как говорится, у богатых свои причуды. Причуды уже есть, осталось только разбогатеть. Ну а пока программатор ехал, я начал проводить новые тесты.

Экран и SPI

Для начала я решил побороть проблему медленной работы OLED-дисплея. На этот раз я не стал гадать, и применил внешние средства контроля. Для начала измерил тактовую частоту SPI на пине SCK дисплея с помощью простенького цифрового осциллографа FNIRSI 5012H с частотой сэмплирования 100 МГц — он терпимо показывает цифровые сигналы в единицы мегагерц, этого хватает для моих упражнений со старой 8-битной техникой.

Низкая частота SPI на осциллографе
Низкая частота SPI на осциллографе

Осциллограф увидел тактовые импульсы, они оказались достаточно прямоугольные, что указывает на их низкую частоту. Автоматическое измерение показывает около 31 килогерца, и значение такого же порядка видно на сетке 20 микросекунд: импульс немного меньше, чем одна клетка. И это катастрофически мало для шины SPI: с такой скоростью передаётся порядка 4000 байт в секунду, а с объёмом буфера кадра в 1024 байта получается порядка 4 кадров в секунду. Скорость анимации примерно того же порядка я наблюдаю в тесте и визуально.

Вывод: аппаратный SPI в моём старом коде не работает, действует программная реализация через bitbang. Для моей же затеи абсолютно необходим аппаратный SPI, тогда скорость передачи данных будет на порядок больше. Ведь контроллер этого OLED-экранчика может работать с частотами около 10 МГц и обеспечивать скорость обновления более ста кадров в секунду.

Высокая частота SPI на осциллографе
Высокая частота SPI на осциллографе

Проблема решилась удивительно просто. Просто настроил ещё раз, как обычно делаю на Ардуинах, и аппаратный SPI заработал. Почему не работал ранее — я не знаю. Возможно, я делал что-то не так. А возможно, что-то было исправлено в пакете поддержки, так как на Хабре можно найти более старую статью, в которой говорится, что аппаратный SPI не работал, если задействовать десятый пин. Я использовал его и ранее, и сейчас. В первый подход не работало, теперь работает.

Так или иначе, правильная инициализация библиотеки Adafruit SH110X для работы с аппаратным SPI выглядит так:

Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &SPI, OLED_DC, OLED_RST, OLED_CS, 10000000);

Чтобы убедиться в успехе, я снова проконтролировал тактовую частоту осциллографом, и она всегда высокая. Мой осциллограф не может нормально увидеть 10 МГц, видна только синусоида, и она присутствует и в начальных сценах, и при падающих звёздочках. Правда, почему-то идёт она редкими пакетами, а не сплошным потоком. Видимо, тест обновляет сцену по фрагментам, а не шлёт весь буфер экрана целиком.

Однако, не всё так хорошо. Да, аппаратный SPI работает, и первые тесты в стандартной библиотеке Adafruit_GFX рисуются очень быстро, как и должны. Но финальная анимация со звёздочками снова еле-еле шевелится. На всякий случай я поменял номера управляющих пинов, чтобы не трогать десятый пин, но ничего не поменялось. Из этого я сделал вывод, что дальше тормозит уже не SPI, а код рисования графики.

Скорость работы кода

У MIK32 «Амур» и, соответственно, плат на нём есть два больших подводных камня, прямо-таки скалы, связанных со скоростью работы. Несмотря на 32-битное ядро RISC-V, работающее на 32 мегагерцах, фактическая производительность легко может налететь на эти рифы и оказаться хуже, чем у завалящей 8-битной классической Ардуины на AVR ATmega.

К1948ВК018 MIK32 Amur собственной персоной
К1948ВК018 MIK32 Amur собственной персоной

Как и большинство современных микроконтроллеров, «Амур» хранит код во внешней Flash-памяти. К контроллеру она подключена последовательной шиной, которая работает сильно медленнее, чем ядро контроллера. По мере надобности код запрашивается из внешней памяти и тем или иным способом кэшируется в специальном ОЗУ, где и выполняется. Это позволяет коду внутри циклов, где обычно и происходит основная вычислительная нагрузка, выполняться довольно быстро. Ну а если код длинный и ветвистый, тогда он медленно и печально подгружается из Flash-памяти по мере выполнения.

Разумеется, на практике всё несколько сложнее и разнообразнее, но общая идея остаётся прежней: быстрое ядро, быстрое небольшое ОЗУ, медленная последовательная Flash-память. Этой схемы придерживаются и ESP32 на всех ядрах, включая RISC-V, и STM32, и SAMD, и некоторые другие современные микроконтроллеры, и это не мешает им обеспечивать довольно хорошую производительность. При этом с точки зрения кода всё работает совершенно прозрачно, и средний Ардуинщик вполне может даже не углубляться в такие дебри, и не догадываться о наличии подобного механизма.

Блок-схема устройства кэша из документации. Ядро и ОЗУ находятся на шине AHB, Flash-память на шине QSPI
Блок-схема устройства кэша из документации. Ядро и ОЗУ находятся на шине AHB, Flash-память на шине QSPI

К сожалению, именно на «Амуре» углубляться в дебри придётся. Flash-память, физически расположенная в отдельной микросхеме (на моей плате Winbond W25Q256), подключена к контроллеру шиной Quad SPI, работающей на частоте 16 мегагерц. Это значительно медленнее скорости работы ядра (шина-то п��следовательная). К счастью, предусмотрена кэш-память. К сожалению, её объём составляет всего 1024 байта, и работает она блоками — 8 страниц по 128 байт. Если код и его данные какого-то цикла не поместятся в кэш, будет значительная просадка производительности.

Есть способ обойти проблему: можно размещать часть кода в ОЗУ либо в EEPROM. Они расположены непосредственно на кристалле микроконтроллера, подключены к быстрой параллельной шине, и код оттуда работает существенно быстрее. Для этого в линкере предусмотрены специальные атрибуты. Заодно можно включить и максимальную оптимизацию:

void __attribute__((noinline, section(".ram_text"), optimize("O3"))) test_code_in_ram()
{
   …
}

Однако, объём ОЗУ на «Амуре» составляет всего 16 килобайт, а EEPROM вообще 8 килобайт, и много кода туда не поместится. К тому же, часть кода уже находится там. Как минимум, в ОЗУ располагается часть кода обработки прерываний, потому что на них необходимо реагировать максимально быстро.

Я сделал тест выполнения одинакового фрагмента кода из ОЗУ, куда он был помещён атрибутами для линкера, и из Flash. Чтобы код гарантировано не влез в кэш, я по совету ИИ сгенерировал макросами 2000 опкодов NOP (8 килобайт), а чтобы компилятор при оптимизации не убрал бесполезный цикл, объявил переменную цикла как volatile:

#define NOP  __asm__ volatile ("nop")
#define NOP10   NOP; NOP; NOP; NOP; NOP; NOP; NOP; NOP; NOP; NOP;
#define NOP100  NOP10; NOP10; NOP10; NOP10; NOP10; NOP10; NOP10; NOP10; NOP10; NOP10;
#define NOP1000 NOP100; NOP100; NOP100; NOP100; NOP100; NOP100; NOP100; NOP100; NOP100; NOP100;
#define NOP2000 NOP1000; NOP1000;
 
void test_code_in_flash()
{
  volatile uint32_t j = 0;

  for (volatile uint32_t i = 0; i < 100000; ++i)
  {
	NOP2000
  }
}

void __attribute__((noinline, section(".ram_text"), optimize("O3"))) test_code_in_ram()
{
  for (volatile uint32_t i = 0; i < 100000; ++i)
  {
	NOP2000
  }
}

void setup() {
  Serial.begin(115200);

  char buf[256];

  uint32_t t1 = millis();
  test_code_in_flash();
  sprintf(buf, "code in flash %u ms", millis() - t1);
  Serial.println(buf);

  uint32_t t2 = millis();
  test_code_in_ram();
  sprintf(buf, "code in ram %u ms", millis() - t2);
  Serial.println(buf);
}

void loop() {
}

Результат соответствует теории — крупнокалиберный код, размер которого превышает размер кэша, в ОЗУ работает почти в десять раз быстрее, чем из Flash-памяти:

23:42:53.981 -> code in flash 59369 ms
23:43:00.281 -> code in ram 6291 ms

Обработка прерываний

Другая большая проблема на «Амуре» — обработка прерываний. У контроллера есть всего один обработчик прерываний, который срабатывает при приходе любого прерывания. В нём нужно последовательно разобрать флаги всех возможных источников, чтобы понять, какое же прерывание сработало. Так как среда Arduino обеспечивает универсальность, она проверяет все возможные источники, и это работает очень медленно.

Единственное решение, с помощью которого можно повысить скорость обработки прерываний — заменить обработчик на свой собственный, в котором реагировать только на нужные источники и в нужном порядке. Такая возможность была предусмотрена изначально, по стандартной для платформы Arduino схеме:

extern "C" __attribute__((section(".ram_text"))) bool ISR(void)
{
   // прерывание от пина GPIO?
   if (EPIC_CHECK_GPIO_IRQ())
   {
      // проверка конкретного пина
      if (HAL_GPIO_LineInterruptState(GPIO_LINE_2))
      {
         // необходимые действия при прерывании от пина
      }
      // сброс прерываний от пинов
      HAL_GPIO_ClearInterrupts();
   }

   // сброс всех флагов прерываний
   HAL_EPIC_Clear(0xFFFFFFFF);
}

Однако, раньше вместе с пользовательским ISR выполнялся и весь стандартный код проверки источников прерываний, что тратило кучу времени зря — ведь в реальном проекте наверняка будет использоваться лишь несколько заранее известных источников прерываний. В частности, в моём пробном проекте эмулятора звукового чипа AY-3-8910, работающего на прерывании от 16-битного таймера, большая часть времени уходила на эти накладные расходы, поэтому тогда мне пришлось понизить частоту дискретизации аж до 6 килогерц.

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

extern "C" __attribute__((section(".ram_text"))) bool ISR(void)
{
   // прерывание от 16-битного таймера?
   if (EPIC_CHECK_TIMER16_1())
   {
      // проверка первого таймера
      if (TIM16_GET_ARRM_INT_STATUS(htimer16_1_))
      {
         // необходимые действия при переполнении таймера
      }
      // очистить флаги прерывания от таймера
      TIM16_CLEAR_INT_MASK(htimer16_1_, 0xFFFFFFFF);
   }

   return false;
}

Ключевое отличие в том, что теперь обработчик может возвращать булевый флаг. Если вернуть true, стандартный код проверки источников прерываний выполняться не будет.

Загрузка скетча

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

Микросхема последовательной Flash-памяти Winbond W25Q256
Микросхема последовательной Flash-памяти Winbond W25Q256

Есть простое обходное решение: подключить SD-карту и выполнять загрузку данных с неё. Но загрузка с SD сама по себе значительно медленнее, чем доступ к встроенной Flash-памяти, а со скоростью работы кода и без этого наблюдаются большие проблемы. Да и просто это неспортивно — использовать внешнюю память, когда на плате установлены безумные 32 мегабайта памяти, куда всё могло бы прекрасно поместиться. За них ведь уплочено! Значит, нужно бы разобраться.

Как я выяснил ранее, низкая скорость загрузки не является какой-то моей местной проблемой. Она воспроизводится на всех моих компьютерах с разными ОС. К счастью, проблема медленной загрузки пользователям известна, и напрягает не только меня. Есть два варианта: попробовать обновить загрузчик и попробовать загружать скетч непосредственно через JTAG. А У меня теперь как раз совершенно случайно есть ELJTAG. За него ведь уплочено!

Разъёмы JTAG на плате ACE-UNO и на адаптере ELJTAG
Разъёмы JTAG на плате ACE-UNO и на адаптере ELJTAG

Но для начала нужно его правильно подключить. Разъём на самом JTAG-адаптере имеет пластиковый ключ, провод в него вставить можно только одним способом. А вот на плате нет ни ключа, ни каких-либо надписей. И в Интернете найти хотя бы фотку, как правильно подключать, с наскоку не удалось. Вставляй как хочешь. Но если получится неправильно, есть вероятность, что плате настанут кранты, потому что через этот провод идёт и питание платы.

Соединять на самом деле нужно пины по схеме первый-к-первому: тот, что с квадратной площадкой к такому же. Убедиться в правильности подключения можно, прозвонив между собой экраны USB-разъёмов на плате и адаптере: при правильном подключении звонилка запищит. Только после этого можно вставлять ELJTAG в USB.

Правильно подключённый ELJTAG
Правильно подключённый ELJTAG

Далее я быстренько настроил адаптер через Zadig согласно официальному мануалу, и, радуясь, что хоть и заплатил немало денег, теперь-то точно смогу стремительно заливать свои мегабайты, попробовал с ходу загрузить всё тот же тестовый 170-килобайтный скетч непосредственно через JTAG, так как в теории это должно быть быстрее. Делается это просто: в IDE в инструментах выбираем программатор «mik32 uploader», потом в меню скетча «загрузка через программатор» (Ctrl+Shift+U).

Изменения в скорости загрузки оказались впечатляющими: 185 секунд, три минуты. Средняя скорость загрузки через JTAG оказалась около 900 байт в секунду. Я также попробовал прошить через JTAG обновлённый загрузчик от производителя, и снова загрузить скетч через USB-провод. С ним всё те же полторы минуты, как и раньше.

Время загрузки через ELJTAG
Время загрузки через ELJTAG

Далее начались пл��ски с бубном в условиях частичной неопределённости. Я не очень знаком ни с RISC-V, ни с JTAG и процедурами загрузки подобных контроллеров, понимаю только общие идеи. Конкретной информации на эту тему, касающейся именно «Амура», почти нет, и судя по небольшим её обрывкам, то, что есть сейчас — это уже значительно лучше, чем было раньше (раньше, видимо, слали скетчи голубиной почтой).

Настройка частоты JTAG в файле mikron-link.cfg
Настройка частоты JTAG в файле mikron-link.cfg

По советам от ИИ попробовал поменять в конфигурационном скрипте mik32-link.cfg скорость для JTAG — с 500 кГц на 2000 и на 3200. openocd принимает эти параметры, что видно в детальном логе загрузки, но по фактической скорости никаких изменений не наблюдается. Я поигрался и другими параметрами в скриптах, не особо понимая, что делаю, и ничего не дало никакого эффекта. Или работает как прежде, или перестаёт работать загрузка.

Настройка скорости соединения в boards.txt
Настройка скорости соединения в boards.txt

Я также попытался изменить настройки загрузки через USB. Если поменять upload.speed в boards.txt для моей платы с дефолтного значения 230400 на любое другое, загрузка перестаёт работать. Дефолтное значение, к слову, указывается в битах в секунду и подразумевает скорость передачи данных порядка 28 килобайт в секунду, чего не наблюдается даже отдалённо.

Всё вышесказанное намекает, что дело не в самих интерфейсах, а в медленных реализациях загрузчиков. JTAG тоже реализует загрузку в SPIFI память посредством некоторого протокола и исполняемого внутри микроконтроллера кода, который сначала загружается в ОЗУ или EEPROM. И похоже, там алгоритм записи в память по какой-то причине работает ещё хуже. Вероятно, потому что он не задействует ОЗУ для выполнения загрузчика целиком и работает фрагментами кода через progbuf.

Фрагмент кода из bootloader.c с инициализацией UART
Фрагмент кода из bootloader.c с инициализацией UART

Из любопытства я заглянул в код официального загрузчика, elbear_fw_bootloader (bootloader.c). UART в нём действительно настроен на 230400 бод. Но загрузчик принимает данные и пишет их во Flash-память 256-байтными пакетами, проверяя их на чтение после записи. При этом пакеты идут в текстовом HEX-формате и парсятся на стороне контроллера. Вероятно, это работает недостаточно быстро. Вряд ли узкое место в самой используемой Flash-памяти. Даже в самом худшем случае она должна обеспечивать скорость записи от 20 килобайт в секунду.

Существует альтернативный загрузчик ex_loader_2 от fabmicro. Он обещает более быструю загрузку, блоками по 4 килобайта. Но я пока не смог опробовать его на практике: он не связан с инфраструктурой Arduino, его надо собирать из исходников, а также собирать коммуникационную программу. Даже если я провернул бы данный фокус, рекомендовать этот путь Ардуинщикам было бы сложно.

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

Хреновое яблоко

На данном этапе у меня уже есть все необходимые для рецепта успеха ингредиенты, с помощью которых можно реализовать вариант проекта попроще: монохромное видео на OLED с какой-то примитивной озвучкой.

Изначально я планировал сделать такую версию в довесок к лучшему финальному результату, просто для полноты и интересности материала. Но теперь она стала основным блюдом. И чтобы загрузить её в плату за обозримое время, хотя бы за время жизни Вселенной, а лучше за пару часов, пришлось пересмотреть некоторые моменты первоначального плана.

Конверсия с понижением разрешения в VirtualDub
Конверсия с понижением разрешения в VirtualDub

Для начала я подготовил саму анимацию к последующей конверсии. При помощи VirtualDub разобрал исходное видео на кадры с уменьшением до 128 на 64, и сохранил их в 24-битные BMP-файлы. Но результат был в оттенках серого, а для OLED-дисплея мне нужны однобитные изображения.

Чтобы не сильно мудрить, прогнал файлы с помощью пакетной обработки в IrfanView, получив готовые двухцветные изображения. В опциях включил дизеринг по Флойд-Стейнбергу, чтобы смотрелось побогаче, хотя решение это довольно сомнительное.

Итоговый монохромный кадр 128 на 64 пикселя с дизерингом
Итоговый монохромный кадр 128 на 64 пикселя с дизерингом

Потом точно таким же образом преобразовал все файлы в 16-цветные BMP. Это потребовалось для того, чтобы я мог задействовать в качестве основы для конвертера анимации свой старый код, который делал ещё для проекта hway. Переконвертировать файлы лишний раз было быстрее, чем добавлять поддержку 2-цветных изображений в мою библиотечку на Питоне.

Изначально я планировал хранить кадры без какого-либо сжатия, прямо в формате буфера кадра OLED-дисплея. Один кадр имеет размер 1024 байта, всего в анимации, проигрываемой со скоростью 30 кадров в секунду, 6572 кадра, то есть около 7 мегабайт данных. Такой объём прекрасно помещается во Flash-память, а сами кадры достаточно небольшие, что позволяет пересылать их в дисплей целиком.

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

Так как исходная анимация силуэтная и содержит много залитых одним цветом областей, я решил использовать простейший вариант: конвертер отслеживает изменения между соседними кадрами в блоках размером 8x4, и в данные кадра добавляются только изменившиеся блоки. Размер блока был подобран экспериментально, чтобы получить наименьший размер выходных данных. Эта простая схема обеспечила сжатие примерно в 4.5 раза.

Для работы с дисплеем я решил отказаться от библиотеки Adafruit SH110X, потому что она очень жирная: тащит за собой шрифт и заставку, и на этапе первоначальных тестов всё это добро каждый раз грузилось в плату мучительно долго. Вместо неё я поддержал отечественного производителя и взял GyverOLED от Алекса Гайвера. Но библиотека используется только для инициализации дисплея, а пересылку в буфер я делаю вручную, чтобы избежать лишних преобразований и копирований. Всё просто:

void oled_send_frame(const unsigned char* data)
{
   oled.sendCommand(0x00);
   oled.sendCommand(0x10);
   oled.sendCommand(0x40);

   for (int i = 0; i < 64 / 8; ++i)
   {
      oled.sendCommand(0xB0 + i + 0);  // set page address
      oled.sendCommand(2 & 0xf);   	// set lower column address
      oled.sendCommand(0x10);      	// set higher column address

      oled.beginData();
      for (int j = 0; j < 128; ++j) SPI.transfer(data[i * 128 + j]);
      oled.endTransm();
   }
}

Текущий кадр собирается из изменившихся блоков в буфере в ОЗУ в формате дисплея, и собранный кадр каждый пересылается в дисплей целиком. Это быстрее и проще, чем проводить серию блочных обновлений, слишком много накладных расходов.

Два OLED-дисплея: 1.3 дюйма и 2.42 дюйма
Два OLED-дисплея: 1.3 дюйма и 2.42 дюйма

Отлаживал код я на обычном небольшом OLED-дисплейчике с диагональю 1.3 дюйма и контроллером SSD1306/SH1106, а для финальной демонстрации решил перейти на крупнокалиберный 2.42-дюймовый дисплей на контроллере SSD1309. Вроде бы эти контроллеры совместимы, однако, так просто ничего не заработало, картинка была неправильной: вертикальная полоса слева и фрагмент изображения сверху. Пришлось поразбираться, в итоге поддержал оба типа дисплеев. У них отличается и формат передачи данных, и одна команда в инициализации.

Неправильное отображение на большом дисплее
Неправильное отображение на большом дисплее

Чтобы зрелище было не таким унылым, а код более разнообразным, я решил добавить музыку, но не оригинальный аудиопоток, а чиптюн-версию. Причём монофоническую, одноголосую с имитацией многоголосия, как для PC Speaker, и чисто однобитную, выводимую по старой доброй традиции, через пин GPIO. А чтобы довольно однообразный музыкальный трек звучал в таком виде более интересно, добавил разные битовые пакеты для разных тембров.

Музыкальный трек в Reaper и плагин PETCB2
Музыкальный трек в Reaper и плагин PETCB2

Для подготовки мелодии я задействовал свой VST-плагин PETCB2, предназначенный для создания музыки для Commodore PET с помощью современных цифровых студий. В Reaper собрал монофоническую аранжировку, экспортировал данные, потом преобразовал их программкой на C в более компактный и удобный для проигрывания формат, чтобы сэкономить ещё несколько килобайт и немного выиграть в скорости работы. Далее написал простенький проигрыватель, синтезирующий нужный звук.

В остальном код проекта очевидный, ничего особенного не требуется: строим текущий кадр по данным изменений блоков из массива данных анимации, и 30 раз в секунду отправляем результат на дисплей. На прерываниях таймера синтезируем звук, играем музыку и отслеживаем время. Код подготовки кадра, пересылки в дисплей и синтеза звука на всякий случай размещается в ОЗУ, потому что почему бы и нет.

Размер итогового скетча со сжатой анимацией полной длительности и музыкальным сопровождением составил 1450400 байт, то есть почти полтора мегабайта. Это всё ещё довольно много, и грузилось в плату аж 16 с половиной минут. Но это хотя бы в пределах возможностей человеческого терпения.

И вот вам, пожалуйста, действительно плохое яблоко на «Амуре» готово к употреблению:

Я также собирал проект с BSP 0.5.1, и с его менее эффективной схемой обработки прерываний максимальная частота дискретизации звука, при которой анимация ещё не подтормаживает, и работает на стабильных 30 кадрах в секунду, составила 32000 гц, что в общем-то неплохо. Ну а в сборке для BSP 0.5.3, которая и показана на видео, экспериментальным путём было подобрано значение в 55000 гц (в 1.7 раз больше).

Заключение

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

Нельзя не отметить, что опыт на данном этапе не самый приятный. На три изделия отечественного производителя я потратил уже 11 тысяч рублей, и едва ли получил удовлетворительный результат. Отдам должное: всё это хотя бы как-то работает из коробки: прошивается, загружается, запускается, и это уже большое достижение. Но цикл тестирования скетчей пока получается очень медленный, а воспользоваться всеми мегабайтами Flash-памяти на плате в таком виде просто невозможно.

Но плохое яблоко ещё может стать лучше! Да и другие интересные проекты на «Амуре» давно уже мной задуманы и близятся к реализации. Продолжение последует.

P.S. Пока материал ожидал публикации, «Микрон» выпустил в продажу отладочный набор «Игровая консоль», имитирующий формат некогда популярной самодельной портативной игровой приставки Arduboy, но на базе «Амура». А значит, будут ещё траты и ещё проекты.

P.P.S. Исходники пока что выложил в посте на моём Бусти.

© 2026 ООО «МТ ФИНАНС»