Отладка C на ZX Spectrum

в 7:30, , рубрики: C, debugger, gdb, gdbserver, Z80, zx spectrum, отладка, программирование микроконтроллеров, Процессоры, Сетевые технологии
клон ZX Spectrum harlequin 128k c ethernet адаптером Spectranet
клон ZX Spectrum harlequin 128k c ethernet адаптером Spectranet

Если ваш Спектрум пылится на полке, эта статья подскажет, как дать ему вторую жизнь, а вам — новое хобби.

Кому 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 файл, в котором опишет все символы, включая отладочные:

Функция haha() находится по адресу 0x82AC
Функция haha() находится по адресу 0x82AC

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

Используем z88dk-ticks

У z88dk есть свой небольшой отладчик, немного похожий на gdb, который и использует информацию выше (на текущий момент, только C_LINE + символы) для того, чтобы ставить точки останова. К сожалению, этот отладчик сильно связан со своим небольшим эмулятором, который все что умеет, это подсчитывать количество тактов (отсюда и название).

Что умеет ticks
Что умеет ticks

Пару слов о Fuse

Fuse — один из самых популярных эмуляторов для ZX Spectrum. Он поддерживает большое количество аппаратуры, умеет эмулировать Spectranet, и даже есть свой ассемблерный отладчик.

Отладчик Fuse
Отладчик Fuse

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

Связываем 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);
пропатченный ticks показывает стек вызова
пропатченный ticks показывает стек вызова

Конечно, все усложняется, стоит функции вызвать реализовать нестандартное соглашение.

В заключение

Работы по развитию отладчика для z80 активно ведутся, но многие темы еще предстоит раскрыть.

Имплементация форка Fuse fuse-for-macosx со встроенным gdbserver доступна по ссылке, а форк z88dk с обновленным отладчиком доступен на этом репозитории. Так как все это находится в стадии разработки, пощупать можно, только если скомпилировать самостоятельно.

IX регистр и библиотечные функции

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

Железный отладчик?

Вполне возможно реализовать отладку "живой" машины через картридж Spectranet, реализовав в нем поддержку gdbserver, ведь он поддерживает одну настраиваемую точку останова.

Локальные переменные?

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

Автор:
desertkun

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js