… или как выстрелить себе в ногу на 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к. Это маловероятно, потому что кода всё-таки больше, чем спрайтов, а значит скорее закончится память вообще.
Бонус
Этим летом мы написали игру в стиле Сокобана. Некоторые уровни получились довольно сложными. Попробуйте, к примеру, пройти вот этот:
Ссылки
Комментарии (14)
untilx
30.08.2018 14:55Рекомендую почитать, как такие ограничения обходились на 6502 и, в частности, на NES.
Dovgaluk Автор
30.08.2018 16:06В NES в каждом картридже кастомная аппаратура, а тут железо фиксированное. Но способ похожий — у AVR есть регистр, который используется для выбора нужного куска памяти программ.
Revertis
30.08.2018 14:56В компьютерах с процессором Z80 при добавлении памяти организовывали их в страницы в верхних 16кб, и переключали их записывая в определенный порт число страницы.
Serge78rus
30.08.2018 16:09+1Так делалось не только для Z80, но и для x86, но это все процессоры с внешней памятью и разработчику системы не представляло сложности воткнуть схему коммутации страниц между процессором и памятью. В статье же речь идет об однокристаллке со встроенной памятью, где вмешаться в адресную шину между ЦПУ и памятью не представляется возможным.
Zenitchik
30.08.2018 15:52+1Попробуйте, к примеру, пройти вот этот:
Он не проходим. В классическом сокобане ящики нельзя тянуть на себя, а здесь два из трёх ящиков иначе от стены не оторвать.Dovgaluk Автор
30.08.2018 16:01Он точно проходим. И один человек даже сделал это без написания программы для перебора :)
Кстати, ящики жёлтые.Andy_Big
30.08.2018 17:34Не вижу решения :) Ну если только ящики можно двигать поверх уже установленных на места.
Zenitchik
30.08.2018 20:21А, вот в чём прикол! Я подумал, что наоборот — синие ящики, а жёлтые — места для них.
ru_vlad
30.08.2018 17:24Проходится, весь секрет что через места для ящиков (синие клетки) можно проходить.
Первый ящик вниз потом в сторону и так далее.
elve
Ваша статья мне напомнила про вот это — habr.com/post/163627 В нем тоже были развлечения с обращием к памяти =)
Dovgaluk Автор
Спасибо. Прекрасная история об отладке.