… или как выстрелить себе в ногу на Arduino




В летней компьютерной школе мы используем для обучения разработке игр собственноручно сделанный старый компьютер.

Сейчас в нём установлена плата Arduino Mega с процессором ATmega2560, в котором целых 256 килобайт флеш-памяти. Предполагалось, что этого хватит очень надолго, ведь игры получаются простые (экран-то всего лишь 64x64 пикселя). В реальности мы столкнулись с некоторыми проблемами уже по достижении прошивкой размера примерно 128 килобайт.

В памяти программ, несмотря на её название, кроме исполняемого кода игр хранятся всякие неизменные данные типа спрайтов и таблиц уровней. Этих данных не так уж и много.

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


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

Указатели на код


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

В такой ситуации gcc вставляет в нижних 64кб «трамплин» — инструкцию jmp, которая переходит на нужную функцию. Тогда в качестве адреса функции, который надо хранить в переменной, будет выступать адрес этого трамплина — ведь он же помещается в два байта. А при вызове будет происходить переход куда надо.

Указатели на данные


Но мы-то храним в памяти программ не только исполняемый код. А значит трамплины здесь не помогут — мы разыменовываем указатели, а не переходим на них.

В библиотеке AVR даже есть функции/макросы типа pgm_read_byte_far(addr), чтобы разыменовать полный указатель (им передаются четырёхбайтовые значения). Но gcc не умеет добывать эти указатели средствами языка Си.

К счастью, есть макрос pgm_get_far_address(var) для получения полного адреса переменной. Это делается с помощью встроенного ассемблера (тот случай, когда ассемблер умнее компилятора).

Осталось переписать весь код, который использует данные в ПЗУ. То есть музыкальный проигрыватель, отрисовку спрайтов, вывод текста,… Не очень приятное занятие. Да ещё и код станет более тормозным, а для вывода графики это очень критично. Поэтому,

Распределяем данные по ПЗУ


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

Для этого будем эксплуатировать особенности линкерного скрипта. Одна из последних секций, которые линкер размещает в ПЗУ, называется .fini7. Сохраним все массивы с музыкой в этой секции:

#define MUSICMEM __attribute__((section(".fini7")))
const uint8_t tetris2[] MUSICMEM = { ... };

Теперь avr-nm говорит нам, что всё в порядке — данные со спрайтами и уровнями оказались в нижней части ПЗУ, а музыка в верхней.

00002f9c t _ZL10level_menu
00002e0f t _ZL10rope_lines
000006de t _ZL10ShipSprite
00023a09 t tetris2
00024714 T the_last_v8

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

00006992 <_Z12tetris2_addrv>:
    6992:	61 ef       	ldi	r22, 0xF1	; 241
    6994:	7a e3       	ldi	r23, 0x3A	; 58
    6996:	82 e0       	ldi	r24, 0x02	; 2
    6998:	99 27       	eor	r25, r25
    699a:	08 95       	ret

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

Бонус


Этим летом мы написали игру в стиле Сокобана. Некоторые уровни получились довольно сложными. Попробуйте, к примеру, пройти вот этот:



Ссылки


  1. Страница проекта на github
  2. Arduino и светодиодный дисплей
  3. Arduino и философский музыкальный камень
  4. Немного прошлогодних игр

Комментарии (14)


  1. elve
    30.08.2018 10:43

    Ваша статья мне напомнила про вот это — habr.com/post/163627 В нем тоже были развлечения с обращием к памяти =)


    1. Dovgaluk Автор
      30.08.2018 10:46

      Спасибо. Прекрасная история об отладке.


  1. untilx
    30.08.2018 14:55

    Рекомендую почитать, как такие ограничения обходились на 6502 и, в частности, на NES.


    1. Dovgaluk Автор
      30.08.2018 16:06

      В NES в каждом картридже кастомная аппаратура, а тут железо фиксированное. Но способ похожий — у AVR есть регистр, который используется для выбора нужного куска памяти программ.


  1. Revertis
    30.08.2018 14:56

    В компьютерах с процессором Z80 при добавлении памяти организовывали их в страницы в верхних 16кб, и переключали их записывая в определенный порт число страницы.


    1. Serge78rus
      30.08.2018 16:09
      +1

      Так делалось не только для Z80, но и для x86, но это все процессоры с внешней памятью и разработчику системы не представляло сложности воткнуть схему коммутации страниц между процессором и памятью. В статье же речь идет об однокристаллке со встроенной памятью, где вмешаться в адресную шину между ЦПУ и памятью не представляется возможным.


  1. Zenitchik
    30.08.2018 15:52
    +1

    Попробуйте, к примеру, пройти вот этот:

    Он не проходим. В классическом сокобане ящики нельзя тянуть на себя, а здесь два из трёх ящиков иначе от стены не оторвать.


    1. Dovgaluk Автор
      30.08.2018 16:01

      Он точно проходим. И один человек даже сделал это без написания программы для перебора :)
      Кстати, ящики жёлтые.


      1. Andy_Big
        30.08.2018 17:34

        Не вижу решения :) Ну если только ящики можно двигать поверх уже установленных на места.


      1. Zenitchik
        30.08.2018 20:21

        А, вот в чём прикол! Я подумал, что наоборот — синие ящики, а жёлтые — места для них.


    1. ru_vlad
      30.08.2018 17:24

      Проходится, весь секрет что через места для ящиков (синие клетки) можно проходить.
      Первый ящик вниз потом в сторону и так далее.


  1. impwx
    30.08.2018 15:53
    +1

    Попробуйте, к примеру, пройти вот этот:
    А собранная версия где-то есть?


    1. Dovgaluk Автор
      30.08.2018 16:07

      Если речь про поиграть, то достаточно легко собирается эмулятор на базе qt. Он живёт в game/qt_emulator.


  1. tormozedison
    30.08.2018 23:30
    +1

    Классная у вас платформа для экспериментов.