Если ваш Спектрум пылится на полке, эта статья подскажет, как дать ему вторую жизнь, а вам — новое хобби.
Кому Spectrum может быть интересен в 2021?
В первую очередь, опытным разработчикам, которым хотелось бы встретить вызов: всего лишь ~40кб памяти, включая код программы. Реализовать хорошее приложение крайне затруднительно, так как вы столкнетесь не только с нехваткой памяти, медленным процессором, но и отсутствием многих привычных вещей: например, printf это слишком дорого. В какой-то момент вы превысите лимит и вам придется поднапрячься, чтобы раздобыть драгоценные байты. Многие вещи вы будете просто вынуждены реализовать ассемблером, что есть интересный опыт.
С сетевым адаптером Spectranet применение у Спектрума кардинально расширяется — это могут быть не только игры, но и мессенджеры, терминалы и даже браузер. В комплекте к Spectranet идут библиотеки на ассемблере, бейсике, и Си, о расширении возможностей которого и пойдет речь в этой статье.
Чем можно компилировать?
В отладке кода для ZX Spectrum вас ждет сюрприз — компилятора под него всего два: binutils-z80 и z88dk, первый содержит в себе gdb и полный toolchain, но создан для "generic" z80, и что такое Spectrum он не знает (без библиотек), а второй — имеет обширную библиотеку, но в нем отсутсвует возможность отладки. В этой статье я опишу процесс создания отладчика для z88dk (на примере компилятора sccz80).
Как происходит компиляция в z88dk?
Этот раздел справедлив и для других компиляторов, но т.к. это будет важно позднее, я решил описать процесс детальней.
Сначала, транслятор (z88dk-ucpp) превращает #define'ы в результат вычисления и встраивает #include'ы внутрь файла компиляции. Перед встроенными файлами включений транслятор вставляет специальную инструкцию для компилятора, чтобы тот мог восстановить точку встраивания:
#line 1 "header.h"
enum the_enum {
VALUE_0 = 0,
VALUE_1 = 1,
};
#line 2 "other.c"
int haha(int a, int b) {
...
}
Затем, транслированный файл передается компилятору, который превращает Си в ассемблер.
; Function haha flags 0x00000200 __smallc
; int haha(int a, int b)
C_LINE 4,"other.c::haha::0::0"
._haha
push ix // эти три инструкции
ld ix,0 // будут раскрыты в конце
add ix,sp // статьи
C_LINE 4,"other.c::haha::0::0"
C_LINE 5,"other.c::haha::1::1"
C_LINE 5,"other.c::haha::1::1"
ld hl,6 ;const
add hl,sp
push hl
call l_gint ;
...
Компилятор "подхватывает" #line и генерирует C_LINE для ассемблера. Также sccz80 генерирует символы __CDBINFO__XXX с отладочной информацией о сущностях, которые он скомпилировал:
PUBLIC __CDBINFO__S_3aG_24VALUE_5f_30_24_30_5f_30_24_30_28_7b_30_7d_29_2cE_2c_30_2c_30
defc __CDBINFO__S_3aG_24VALUE_5f_30_24_30_5f_30_24_30_28_7b_30_7d_29_2cE_2c_30_2c_30 = 1
PUBLIC __CDBINFO__S_3aG_24VALUE_5f_31_24_30_5f_30_24_30_28_7b_30_7d_29_2cE_2c_30_2c_30
defc __CDBINFO__S_3aG_24VALUE_5f_31_24_30_5f_30_24_30_28_7b_30_7d_29_2cE_2c_30_2c_30 = 1
...
Вся информация содержится в именах символов, urlencode кодировкой c заменой "%" на "_". Само значение у символов игнорируется.
Затем происходит сборка (assemble) — процесс превращения asm кода в машинный. На выходе получается один объектный файл для одного исходного. Такой машинный код необходимо "переместить" (relocate), т.к. инструкции вроде CALL X не могут знать, где находится настоящий X на этом этапе. Инструкции C_LINE и __CDBINFO__ также попадают в объектный файл, как обычные символы.
В заключении, все объектные файлы сливаются воедино, это называется линковкой (компоновкой). В z88dk и сборкой и линковкой занимается z80asm по совместительству. Во время линковки, всем меткам и символам присваиваются абсолютные адреса, а на выходе получается машинный код, готовый к исполнению. Если при линковке передать флаг -m, то линковщик также сгенерирует специальный .map файл, в котором опишет все символы, включая отладочные:
Таким образом, в файле .map содержится достаточное количество отладочной информации. Вся эта информация собирается в этот файл не просто так — ведутся работы по созданию отладчика, но самого отладчика пока нет, кроме ticks, который полезен только для регрессионных тестов. Этой статьей я надеюсь, в том числе, ускорить процесс.
Используем z88dk-ticks
У z88dk есть свой небольшой отладчик, немного похожий на gdb, который и использует информацию выше (на текущий момент, только C_LINE + символы) для того, чтобы ставить точки останова. К сожалению, этот отладчик сильно связан со своим небольшим эмулятором, который все что умеет, это подсчитывать количество тактов (отсюда и название).
Пару слов о Fuse
Fuse — один из самых популярных эмуляторов для ZX Spectrum. Он поддерживает большое количество аппаратуры, умеет эмулировать Spectranet, и даже есть свой ассемблерный отладчик.
К сожалению, таким отладчиком трудно пользоваться — он ничего не знает о приложении и приходится то и дело руками выяснять адреса, чтобы поставить точку останова.
Связываем Fuse и ticks вместе
Так как gdb — очень популярный отладчик, протокол у него хорошо описан. У имплементации этого протокола, вместо написания своего, также есть преимущество — возможность отладки другими отладчиками, в т.ч. и через z80-elf-gdb. Преимущество работает в обе стороны: другие эмуляторы могут также создать имплементацию этого протокола, и стать отлаживаемыми.
Например, пакет для получение блока памяти пишется просто "mXXXX,XXXX", а код для его обработки выглядит также просто:
case 'm':
{
struct action_mem_args_t mem;
assert(sscanf(payload, "%zx,%zx", &mem.maddr, &mem.mlen) == 2);
...
gdbserver_execute_on_main_thread(action_get_mem, &mem, tmpbuf);
write_packet(tmpbuf);
break;
}
Т.к. сеть и эмулятор находятся на разных потоках, нужен механизм в стиле "post runnable", чтобы это синхронизировать.
Содержимое ticks я разделил на две части — сам отладчик и эмулятор.
Через специальное API получилось две имплементации общения отладчика с эмулятором — напрямую со встроенным эмулятором ticks, и удаленно как gdb клиент, при подключении к gdbserver.
Далее, в клиенте отладчика необходимо заменить старый функционал на парочку "виртуальных" (в C-стиле) функций:
static int cmd_next(int argc, char **argv) {
bk.next();
return 1; /* We should exit the loop */
}
static int cmd_continue(int argc, char **argv) {
bk.resume();
debugger_active = 0;
return 1;
}
А также, не забыть имплементацию gdb протокола для клиента:
void debugger_next() {
char buf[100];
int len = disassemble2(bk.pc(), buf, sizeof(buf), 0);
char req[64];
sprintf(req, "i%d", len);
write_packet(req);
write_flush(connection_socket);
debugger_active = 0;
}
void debugger_resume() {
debugger_active = 0;
write_packet("D");
write_flush(connection_socket);
}
static backend_t gdb_backend = {
...
.break_ = &debugger_break,
.resume = &debugger_resume,
.next = &debugger_next,
.step = &debugger_step,
.add_breakpoint = &add_breakpoint,
...
};
Строим стек вызова
Чтобы получить стек обратного вызова функции, нужно разобраться в том, как стек работает. Компилятор понимает несколько соглашений по вызову, но для простоты остановимся на "стандартном" для sccz80.
Перед тем, как вызвать функцию, аргументы функции добавляются в стек, слева направо, затем следует адрес возврата. Согласно "стандартному" соглашению, вызывающий функцию обязан почистить стек от аргументов, после вызова. Результат вызова функции возвращается через регистр(ы) HL/DE. В виде псевдокода это выглядит так:
; int main()
...
PUSH A
PUSH B
; адрес возврата добавляется в стек неявно, через CALL
CALL _haha
POP BC
POP BC
Регистр z80 IX практически не используется библиотеками, поэтому, если скомпилировать с флагом -debug, то sccz80 добавит в начале функции специальный код:
; int haha(int a, int b)
._haha
push ix
ld ix,0
add ix,sp
...
Этот небольшой трюк (называется Frame Pointer) сохраняет в IX значение регистра SP, ответственного за стек, перед выполнением функции. Таким образом, в любой точке функции мы можем знать "уровень стека" на момент начала вызова. Зачем это нужно? Дело в том, что функция может решить разместить в стеке локальные переменные, и/или начать вызывать другую функцию, аргументы к которой нужно также разместить в стек.
Первой же инструкцией, в стек добавляется старый IX, отвечающий за Frame Pointer той функции, которая вызвала эту. После этих трех инструкций стек может выглядеть примерно так:
; int haha(int a, int b)
; IX = текущий Frame Pointer = 0x8006
0x800A Локальная переменная ZZ
0x8008 Локальная переменная XX
0x8006 Указатель на Frame Pointer _main = 0x7FFE
0x8004 Адрес возврата из _haha
0x8002 B
0x8000 A
; int main()
0x7FFE Frame Pointer _main
0x7FFC Адрес возврата из _main
Таким образом, чтобы построить стек вызова, нужно всего лишь последовать по стеку, через регистр IX или напрямую, в зависимости от того, испортили мы уже стек (6 байт смещения), или еще нет:
uint16_t at = registers.pc;
uint16_t stack = registers.sp;
uint16_t ix = registers.ix;
uint16_t offset;
do {
// найдем символ функции по текущему адресу
symbol* sym = symbol_find(at, SYM_ADDRESS, &offset);
// печатаем функцию
debug_print_source_location(sym, ...)
if (offset <= 6) {
// sp еще не испорчен, адрес возврата прямо на стеке
uint16_t caller = wrap_reg(bk.get_memory(stack + 1), bk.get_memory(stack));
at = caller;
// скипаем адрес возврата
stack += 2;
} else {
// ix указывает на стек на начале функции
stack = ix;
// восстановим ix вызывавшей функции, чтобы повторить шаг
ix = wrap_reg(bk.get_memory(ix + 1), bk.get_memory(ix));
// скипаем ix
stack += 2;
// по востановленному стеку понимаем вызывавшую функцию
uint16_t caller = wrap_reg(bk.get_memory(stack + 1), bk.get_memory(stack));
at = caller;
}
if (strcmp(sym->name, "_main") == 0) {
// на main можно закончить
break;
}
} while (1);
Конечно, все усложняется, стоит функции реализовать нестандартное соглашение.
В заключение
Работы по развитию отладчика для z80 активно ведутся, но многие темы еще предстоит раскрыть.
Имплементация форка Fuse fuse со встроенным gdbserver доступна по ссылке, а форк z88dk с обновленным отладчиком доступен на этом репозитории. Так как все это находится в стадии разработки, пощупать можно, только если скомпилировать самостоятельно.
UPD: Пока статья проходила модерацию, изменения, описанные выше, были слиты с главной веткой z88dk, в виде нового инструмента z88dk-gdb
. Кроме того, оказалось, другой популярный эмулятор mame также поддерживает gdbserver (./mame spectrum -debug -debugger gdbstub -debugger_port 1337
), так что пощупать все это можно будет уже со следующего релиза z88dk (или собрав главную ветку), не дожидаясь обновления Fuse.
IX регистр и библиотечные функции
Некоторые функции стандартной библиотеки z88dk для sccz80 меняют регистр IX. Чтобы решить эту проблему, нужно выпустить две версии таких функций, включая отладочные, которые сохранят IX через стек.
Железный отладчик?
Вполне возможно реализовать отладку "живой" машины через картридж Spectranet, реализовав в нем поддержку gdbserver, ведь он поддерживает одну настраиваемую точку останова.
Локальные переменные?
Чтобы получить доступ к локальным переменным, необходимо посчитать смещения относительно адреса возврата, ведь они идут сразу перед ним, а также адреса смещений доступны в переменных __CDBINFO__. К сожалению, на момент написания статьи работа с этим еще не завершена.
Комментарии (40)
VelocidadAbsurda
29.08.2021 14:57+2IAR ещё. Тот хоть и старый, но в своё время разрабатывался для «серьёзных дел».
Alexey2005
29.08.2021 15:53Главная беда ZX Spectrum заключается в его совершенно кошмарной палитре. Такое ощущение, что её специально разрабатывали с целью исключить малейшую возможность создания хоть сколь-нибудь привлекательно выглядящей игрушки/демки.
Даже CGA-палитра не настолько кошмарна.desertkun Автор
29.08.2021 16:02+1И соглашусь, и не соглашусь одновременно. В детстве я был впечатлен игрушками того времени, а сейчас — любопытно обходить его ограничения. Например, посмотрите "The Lyra II Megademo", или "slightly magic" или другие диззи-подобные игры. Или, например, Зеркало (гуглить Zerkalo zx spectrum)
Vadimatorikda
29.08.2021 16:22+4А мне именно палитра нравится. Выглядит замечательно. Хотя мне и герои меча и магии 4 нравятся больше остальных частей вместе с российскими микроконтроллерами. Возможно это связано.
ash_lm
29.08.2021 16:49+2Скорее дело было не в палитре, а клэшинге, когда экран был поделён на знакоместа и одно знакоместо (8 на 8 пикселов) могло, стандартно, иметь одновременно только два цвета и всё (говорим тут о цветном режиме, а не монохроме). А это сильно ограничивало красочность игр. Потом, конечно, появилась такая штука как мультиколор, но использовалось это в демках, да и то, по причине разных таймингов, на разных моделях спектрума работало это не одинаково.
mistergrim
29.08.2021 19:47Клэшинг тут ни при чём, спектрумовская палитра совершенно не подходит для создания сколько-нибудь реалистичных картинок вообще.
Ср. Commodore 64 и Amstrad CPC:
tvoybro
29.08.2021 19:48Мультиколор, кстати, почти такое же древнее явление как и сам спектрум, в играх встречалось тоже, навскидку - заставка Action Force II. И более современные, напр. Old Tower, где вся игра - один большой мультиколор.
tvoybro
29.08.2021 16:55https://youtu.be/yHXx3orN35Y
Демка на CGA, например.
Вот уж где была самая отвратительная палитра, так это на БК 0010.
da-nie
29.08.2021 16:56+2Её никто не разрабатывал. И строго говоря, палитры специальной там нет. Там просто три бита — R,G,B. Вот их комбинация и даёт 8 цветов. Плюс яркостный бит. Вот и всё.
GospodinKolhoznik
29.08.2021 20:12Есть эмуляторы в которых можно переназначить палитру. И по моему были электронные примочки, которые меняли палитру. Я поменял в эмуляторе, чтобы было похоже на палитру c64. И игры реально стали выглядеть гораздо лучше.
Жалко скриншоты не сохранились. Например exolon вообще офигенно смотрелся с коммадоровской палитрой.
GospodinKolhoznik
29.08.2021 20:23+1А клэшинг это была расплата за дёшево и сердито. Зато существенная экономия памяти на графике, больше помещается в 48 килобайт ОЗУ, процессор гораздо быстрее отрисовывает графику, и наверно самое важное - игра быстрее загружается с кассеты! Ради такого можно и потерпеть клэшинг.
mpa4b
30.08.2021 19:45Формально ничего не мешало сделать на спектруме 2ой режим — когда атрибут был не на каждое знакоместо (8х8), а на каждый байт (8х1). Требования к пропускной способности памяти это бы не изменило (ULA в спектруме и так фетчит каждый атрибут 8 раз подряд для каждого из байтов знакоместа), а картинки бы стали лучше выглядеть. Такое делали для многочисленных клонов: https://speccy.info/%D0%90%D0%BF%D0%BF%D0%B0%D1%80%D0%B0%D1%82%D0%BD%D1%8B%D0%B9_%D0%BC%D1%83%D0%BB%D1%8C%D1%82%D0%B8%D0%BA%D0%BE%D0%BB%D0%BE%D1%80
raydac
30.08.2021 21:47новые режимы заставляли бы переделывать так или иначе код и на такое мало кто подпишется, а вот добавление нескольких процессоров как в zx-poly решало вопрос с обратной совместимостью
mpa4b
02.09.2021 20:48Речь о том, что это можно было бы сделать СРАЗУ, в 82ом году или когда там УЛу делали.
Z80A
08.09.2021 21:58+2Первый спектрум вышел с 16 Кб оперативки. Если бы сделали как Вы предлагаете, то на экран ушло бы 12288 байт из 16384, для программ оставалось бы всего 4096 байт, вычитая системную область. Это похоронило бы большинство игр.
BurguyMlt
04.09.2021 00:07Его практически реализовали?
Еще кроме zx-poly был zx next и не реализованый Wild Speccy Robus a
VioletGiraffe
30.08.2021 01:34ИМХО отличная яркая палитра для игр. Commodore 64 — вот где ужасная палитра из бледно-серо-буро-малиновых цветов.
GospodinKolhoznik
30.08.2021 13:48+2У c64 приятная палитра. Цвета сочетаются друг с другом. Чувствуется, что палитру художник составлял. За основу палитры взят коричневый цвет, как на картинах Леонардо и Рембрандта. Выбор разумный, в такой палитре легко нарисовать портрет человека, землю, поля, скалы, каменистые планеты, стволы деревьев, деревянные и каменные строения, деревянную же мебель, осеннюю листву.
Очень неплохо для всего лишь 16 цветов.
Dioxin
30.08.2021 07:22+1Все в точности наоборот - палитра отличная, у многих компов с гораздо большими возможностями цвета куда как хуже(привет эплу), а в спек втиснули аж всю радугу.
То что нельзя каждую точку раскрасить - ну посчитайте сколько на это уйдет памяти и проца, для прикладного ПО не останется ресурсов.
В первую очередь, опытным разработчикам, которым хотелось бы встретить вызов: всего лишь ~40кб памяти, включая код программы.
Разработчики не могут написать нетормозящий браузер, и это при охулиардах ОЗУ, куче ядер и ССД.
Alexey2005
30.08.2021 11:08Браузер — это не столько просмотрщик документов, сколько виртуальная машина. Поэтому попросить «напишите нетормозящий браузер» — это всё равно что попросить разработать процессор, под который ни один программист не смог бы написать тормозящий код.
Как ни оптимизируй, разработчики веб-страничек всё одно уронят производительность в ноль.
А вот почему до сих пор так и не смогли написать нетормозящий просмотрщик PDF, для меня действительно загадка.
FForth
29.08.2021 16:28+2Можно ещё попробовать для ZX-Spectrun кросс программирования.
M4 FORTH (ZX Spectrum, Z80)Простой компилятор FORTH, созданный с помощью макросов M4.
Создает читаемый и аннотированный код в ассемблере Z80. Пузырьковая oптимизация (peephole) не используется, но для некоторых часто связанных слов создается новое слово с оптимизированным кодом. Например, для dup <число> <условие> else. Небольшая библиотека среды выполнения для печати чисел и текста предназначена для компьютера ZX Spectrum. Несмотря на свою примитивность, M4 FORTH производит более короткий код и в 2-4 раза более быстрый, чем zd88k, вероятно, лучший компилятор для Z80.
В примерах проекта, приведены пока два демо примера — игра змейка и реализация «игры» имитации «Жизнь».
Автор проекта, также использовал разные тесты для оценки времени их выполнения и сравнения с другими реализациями Форт работающими на разнообразном железе.
P.S. По запросу словосочетания Forth Z80 на Github находятся ещё разныe Форт-системы, в том числе и под разработку для использования с ZX-Spectrum и кросс компиляции.
Sabubu
29.08.2021 21:58+1Вообще, выполнение кода в эмуляторе может дать много возможностей, которые невозможно или сложно получить на реальном железе. Это позволит перевести разработку с ручного ковыряния в байтах на более высокий уровень. Вот что приходит мне в голову:
- профайлинг кода с точностью до такта
- сбор метрик и телеметрии. Например, мы можем в игре собирать статистику, сколько спрайтов отображается на экране, сколько времени это отняло. И естественно, отлавливать случаи, когда пропускается кадр из-за нехватки времени. Более того, мы можем написать скрипт, который будет проходить по уровню и замерять производительность каждого экрана в игре. Затем строить тепловую карту. Это поможет увидеть "тяжелые" места в игре и может быть, что-то поменяв, мы сможем от них избавиться.
- возможность выполнять юнит-тестирование кода
- возможность расстановки ассертов вроде assert HL >= 0x2000. Такие ассерты могут найти ошибку.
- возможность реализовать защиту памяти при косвенной адресации (вроде LD (HL), A) и косвенных переходах. Например, мы можем указать, что эта команда LD (HL), A пишет только в буфер экрана и если программа из-за ошибки попытается произвести запись в другую ячейку, это будет обнаружено.
А что касается ручной отладки, то я gdb не люблю. Я им пользовался несколько раз, но команды каждый раз забываю и гуглю. Неудобно. Мне как-то привычнее "отладка через printf".
desertkun Автор
29.08.2021 22:08Спасибо за статью-комментарий. Натолкнули меня сделать такую пожжержку в fuse, чтобы gdb показал стек на падении. Но, своей задаче — свой инструмент, программа, которую я сейчас отлаживаю, очень завязана на UI и я могу делать printf только или через сеть, или через экран, что не идеально.
Sabubu
30.08.2021 01:01Вообще, gdb позволяет при срабатывании брейкпойнта вывести значение переменной и продолжить выполнение автоматически. Это довольно удобная опция.
Но я себе это представляю немного по-другому: мы просто в исходном коде ставим аннотацию Log(HL) и эмулятор, когда доходит до этого места, выводит в консоль значение HL. При этом это именно аннотация, то есть она не создает кодов команд в объектном файле, и не тратит циклы процессора. Аналогично должна работать аннотация assert HL > 0x2000 — она отсутствует в бинарном файле и присутствует только в метаданных к файлу. Аннотации используются только эмулятором и игнорируются на реальном железе.
Аналогично с помощью аннотаций можно собирать метрики и размечать код для профайлинга.
screwer
30.08.2021 17:51Раз уж все равно кросс-разработка, то положение стека может отслеживать эмулятор, пользуясь для этого отладочной информацией. Не надо тормозить и раздувать программу для этого. Второй плюс - эквивалентность бинарного кода исключит часть ошибок вида "в дебаге работает, в релизе не работает", связанных, например, с выравниванием.
desertkun Автор
30.08.2021 18:03Это исключит возможность реализовать хардварный дебаг без эмулятора, что так же в планах. Ну или создаст необходимость работы над двумя версиями отладчика. Также, gdb, на самом деле, мало что знает про эмулятор и то что он может отслеживать, ему неизвестно, или нужно реализовывать на недокументированном протоколе. Ломать протокол — ломать другие отладчики.
raydac
30.08.2021 21:37еще когда то был компилятор micro-c, я его лет 20+ назад под Z80 адаптировал (в спековских фидошных эхах он распространялся как PC110) и игру в качестве примера написал
ash_lm
Справедливости ради — у speccy был/есть ещё компилятор "HiSoft C".
desertkun Автор
Спасибо, видимо, пропустил. Быстрый гугл показал, что это компилятор для спектрума, на спектруме. Не совсем то, что нужно.
Newbilius
А я из названия подумал, что весь процесс (компиляция и отладка) будут от и до проходить на спекки, без юзания современного компа и софта. На мобильном хабре тегов и описания то нет, только заголовок… :-(
GospodinKolhoznik
HiSoft C самый известный, но я точно помню, что помимо него были ещё и другие.
https://worldofspectrum.org/archive/software/utilities/c-compiler-kamasoft
https://worldofspectrum.org/archive/software/utilities/softek-super-c-compiler-softek
desertkun Автор
Проблема не только в том, что нужно скомпилировать, также нужно поставить хорошую стандартную библиотеку. У z88dk довольно скудный(е) компилятор(ы), но никто не может переплюнуть его массивную библиотеку. Также, что для меня лично критично, "SDK" для Spectranet идёт только к z88dk.
GospodinKolhoznik
Подключать библиотеки? Это как то слишком новомодно, не олдскульно.
mpa4b
А ещё есть IAR z80 компилятор, код генерит не хуже чем sdcc.