Совмещаем Ассемблер и Си в одном проекте
Картинка для привлечения внимания
Здравствуйте, на связи nikhotmsk с очередным потоком сугубо-технических мыслей. В своей прошлой статье я обещал не использовать жаргонный язык и улучшить читаемость статей. Так вот, сообщаю, что из этого ничего не получилось. Поэтому если вы ничего не поймете, то это значит, что у вас не хватило знаний, как говорил персонаж из книги — «Чтобы что-то узнать, надо уже что-то знать». Но расстраиваться не нужно.
Глядя на главную картинку вы уже почувствовали неладное. Да, я программирую для старинного компьютера ZX Spectrum. Того самого, который построен на чипе Zilog Z80, и у которого графическая память, пожалуй, самая запутанная среди всех ретро-машин. Но наша статья не об этом, а о том, как всё же совместить Ассемблер и Си. Методы, описанные здесь, скорее всего подойдут и к вашему проекту. Ведь теория остается неизменной.
Почитать
Чтобы разобраться в основном языке программирования, который лежит в основе всего, и на котором написано всё остальное — языке программирования Си (он же «Pure C», он же «Си без плюсов») я рекомендую книгу The C Programming Language. Это — библия программиста. Даже если вы никогда не притрагивались к Си, всё равно рекомендую ее прочитать, потому что там — основы. Книга вовсе не скучная, там сразу же даются примеры кода для размышления. Помните, что язык Си был изобретен по приколу, а это наиболее правильный способ изобрести удачную вещь. Я изучил Си, когда мне нужно было написать музыкальный редактор для универа. Меня очень вдохновила другая книга — руководство по компилятору GCC, вступление к которому написал Ричард Столлман, тот самый. Когда человек пишет что-то осмысленное, это всегда здорово, так ведь. Наличие хорошей документации — это положительный момент.
С ассемблером чуть сложнее. Z80 и его побратим Intel 386 — это архитектуры процессора, которые НЕ благосклонны к новичкам. Когда я начинал его изучать, я ничего не понял. Мне повезло, что я встретил более дружелюбный камень — Atmel AVR (тот самый, который используется в Arduino). И прочитал замечательное руководство к нему от Герхарда Шмидта — профессора информатики из Германии и автора сайта avr-asm-tutorial.net. После AVR сразу становятся видны грехи других архитектур, как в плане языка, так и в плане документации к ним.
Следующая книга — это «Реверс-инжениринг для начинающих» от Дениса Юрьевича. Открытая для чтения, книга со множеством примеров, и покрывающая сразу несколько архитектур одновременно.
Старичок Zilog Z80 — это восьмибитный процессор, архитектуры Фон-Неймана, который встречается в бытовой технике и некоторых ретро-компьютерах. Сам по себе совершенно беспомощный чип, которому необходима обвязка в виде памяти, отдельной видео-подсистемы, звукогенератора и прочего. Поэтому лучше сразу брать документацию к конкретному ретро-компьютеру и смотреть там, что к чему. Для компьютера ZX Spectrum существует множество документации на русском языке от издательства ИНФОРКОМ. Там же рассказывается, как научить компьютер грузиться с аудио-кассеты, и как начать постепенно создавать программы на Асме.
Кстати, основным языком программирования тогда был интерпретируемый язык BASIC. Все ретро-компьютеры так или иначе его поддерживали. И это был реально крутой язык, который по настоящему раскрыл возможности ретро-компов. Чтобы было понятно, в те времена в Бейсике присутствовали и активно использовались ключевые слова PEEK и POKE. Хакеры их обожали. Что именно делают эти команды, вы можете узнать сами, со временем. Ну и естественно, из бейсика можно было вызывать машинный код с помощью ключевого слова USR.
Примерно в те времена появился язык программирования высокого уровня Си, который не имел ничего общего с домашними машинами. Потому что был предназначен для использования в коммерческой версии операционной системы Unix, для компьютера PDP-11. Портирование языка на другие компьютеры — непростая задача, вскоре появились коммерческие компиляторы, позволявшие «программировать на Си» в ZX Spectrum.
Но мы пойдем другим путем. Мы научим современный компьютер генерировать программы для ретро-компьютера, с последующим их запуском в эмуляторе.
На чем запускать программы для Спектрума?
Если у вас есть реальный железный Спектрум, можно попробовать загрузить его с «кассеты». Вместо кассеты используется смартфон с аудио-джеком и шнуром. Нужно просто проиграть аудиофайл с помощью специальной программы, а на спектруме активировать загрузку с кассеты. Умельцы делают платы расширения для спектрума, чтобы эмулировать дисковод TR-DOS. Но это уже другая история. Я ничего не имею против файловой системы, просто не во всех спектрумах она есть. Всё таки загрузка с кассеты — более универсальный формат.
А теперь я хочу похвалить эмуляторы. Эмулятор — это программа, которая притворяется ретро-компьютером и позволяет запускать ретро-игры. Будущее за эмуляторами. Благодаря ним я могу запустить что-то на любой технике, на которой захочу, даже на планшете. Можно запускать неограниченное количество эмуляторов одновременно, ставить программы на паузу, отлаживать, делать слепки памяти. Разве это не здорово?
На моем Debian GNU/Linux стоят несколько эмуляторов — это FUSE (Free Spectrum Emulator), FBZX (Frame Buffer Spectrum Emulator). Они — самые экономичные в плане ресурсов.
В проекте Debian особое отношение к прошивкам. Оригинальные ROMы для спектрума придется скачать отдельно и положить в нужное место. Попробуйте найти их сами, я не буду давать ссылку.
Самый навороченный — это Unreal Spectrum Emulator. Но он довольно неповоротливый. Даже на лучшей машине, которая у меня есть, он будет немного притормаживать.
А еще в Спектрум можно играть прямо из браузера. Почти все ретро-машины поселились в интернете, можно без труда найти коллекции игр для Cпектрума, и там будет кнопка «Играть».
Игры для Спектрума можно найти тут и тут.
Выбираем компилятор
Я не первый пишу статью на эту тему. Есть еще один автор, он решил выбрать z88dk и написал подробную статью про то, как подключить отладчик gdb. Для меня это сложный подход, мы туда не пойдем.
Компилятор хороший, есть много встроенных библиотек, куча примеров, которые даже запускаются, компилятор понимает ZX Spectrum и умеет к нему адаптироваться. Здорово. Претензии к Z88dk у меня следующие: документация немного сырая, и еще там не работает одна важная функция — экспорт листинга. То есть, просмотреть промежуточный код на ассемблере, который генерирует компилятор — не получится. Может быть, к тому времени как вы будете читать статью, его починят, и документацию напишут как надо.
@desertkun пишет, что компилятора под Спектрум всего два — bin-utils-z80 и z88dk. А вот и нет! Есть еще SDCC — Small Devices C Compiler.
Но ведь он не понимает, что такое Спектрум, скажете вы. Это не важно, мы его научим. Главное чтобы компилировал, и чтобы документация была хорошая, и она у нас есть.
Пишем код
Так выглядит работающий вариант
Прошу прощения за длинное вступление. Сейчас будет код.
const char bm_mouse_data [] = {16, 7,
0b00001100, 0b00000000,
0b00010010, 0b11100001,
0b00110011, 0b00010010,
0b01001100, 0b00001010,
0b01010000, 0b00001001,
0b10000000, 0b00001001,
0b01111111, 0b11110110};
int main (void) {
while (1) {
dump_bitmap(bm_mouse_data);
}
}
Что у нас тут происходит. Вывод битмапа на экран. Битмап я получил от Дмитрия Лазарева @zenShaman, и он предназначался для компьютерной игры, которую мы вместе пишем.
Массив объявлен как const потому что иначе он не попадает в память. Я так и не понял, почему. Причем тут CRT0 код, расскажу позже.
void dump_bitmap (char *bits) {
unsigned char i, ii;
char *p = (char*) 0x4000;
char *d = bits + 2;
unsigned char width = bits[0];
for (i = (unsigned char) bits[1] ; i > (unsigned char) 0; i--) {
for (ii = width; ii > (unsigned char) 0 ; ii-=8) {
*p = *d; /* draw on screen */
p++;
d++;
}
p -= (width / 8);
p = get_next_line_wrapper(p);
}
}
Функция dump_bitmap
выводит наш битмап на экран Спектрума. Эта функция предназначена для отладки, просто чтобы убедиться что битмап работает.
char *get_next_line_wrapper (char *pointer) __naked {
pointer;
__asm
ex de,hl
call get_next_line
ret
get_next_line::
; code from ChibiAkumas.com z80 tutorial
; DE - video memory address
; returns DE - new video memory address
inc d
ld a, d
and #0b00000111 ; check bits Y5 Y4 Y3 overflow
ret nz
ld a, e
add a, #0b00100000
ld e, a
ret c ; check bits Y2 Y1 Y0 overflow
ld a, d
sub #0b00001000 ; fix overflow bit Y6
ld d, a
ret
__endasm;
}
А вот тут уже интересно. Вот первая функция с ассемблерной вставкой. Модификатор __naked
--- это подсказка для компилятора, чтобы он не создавал пролог и эпилог функции. Теперь мы сами отвечаем за сохранение регистров в стек и за команду ret
в конце.
Константы должны быть обозначены символом #
, чтобы ассемблер понял, что это константы.
Двойное двоеточие --- это знак для ассемблера что данная метка — глобальная. Откуда я это узнал? Из дополнительного файла документации по ассемблерам. Там есть всё что может понадобиться, поэтому файл получился довольно длинным, но тем не менее интересным.
А как получить доступ к аргументу функции из ассемблера?
Никак. Ассемблер не видит аргументов функции. Он видит только регистры и память. В которых эти аргументы и находятся. В каких именно? Это зависит от calling convention --- соглашения о передаче аргументов в вызываемую функцию.
Берем мануал, смотрим на эту картинку. Соглашение называется sdcccall(1)
--- расшифровывается как SDCC Calling Convention version 1.
sdccman.pdf — страница 74
Там несколько вариантов для разных процессоров. Этот --- для Z80.
Мне такой вариант очень нравится. Первые несколько аргументов отправляются в регистры, а потом остальное уже будет лежать в стеке. У нас аргумент всего один, его размер — два байта, значит регистр HL для него подходит.
Там же написано, что возврат значения в регистре DL. При условии, что возвращаемое значение — два байта. Всё просто.
Значения этих регистров можно менять сколько угодно, возвращать их назад как было вовсе не обязательно. Ведь это локальные переменные, их можно менять. С другими регистрами так не пойдет, их всё-таки надо будет сохранить в стек, и потом вернуть назад, так как было.
А как получить доступ к глобальной переменной?
Есть несколько способов. По идее, для любой глобальной переменной, объявленной в Си
volatile char counter;
Должна быть соответствующая метка в ассемблере, которая содержит адрес в памяти, где эта переменная находится:
ld a, (_counter)
inc a
ld (_counter), a
Есть другой способ. Можно выделить память для переменной в ассемблере. Желательно в секции DSEG, или где у вас там данные хранятся обычно. Двойное двоеточие, чтобы была глобальная метка.
_counter:: .db 0x01
А в Си просто объявить ее как extern
. То есть, не выделять память для нее, а взять уже готовую.
extern volatile counter
У меня вроде работает.
Если ничего не помогает, то просто возьмите адрес переменной и передайте ее как аргумент функции. Поначалу я так тоже делал, пока не разобрался с метками.
Ключевое слово volatile
--- отключает оптимизации. Это для того чтобы прерывания могли корректно работать с данной переменной, плюс мне так спокойнее.
Упаковываем в .tap файл
Код написан, пора его компилировать.
sdcc -mz80 --no-std-crt0 --code-loc 0x800A --data-loc 0xA000 habr_demo.c -o main.ihx
Опция --no-std-crt0
интересная, с ее помощью я избегаю подключения стандартного «С Runtime» кода. Видите ли, компилятор пытается добавить заголовок, чтобы программа приобрела законченную форму прошивки. Предпологается что компьютер совсем пустой, и наша программа будет там в качестве операционной системы. Поэтому адрес начала кода становится 0x0000
, стек устанавливается в конец памяти, а потом вызывается функция _main
.
Но у нас другие планы. Мы хотим сделать загрузку с кассеты по адресу 0x8000
. Поэтому мы отключили crt0.
Выходной файл будет в формате Intel Hex. Это такой формат, удобный для прошивок. Байты в нем упакованы в специальные текстовые строки со встроенной проверкой корректности, чтобы можно было ввести вручную в программатор. Нам это не нужно, поэтому мы превратим его сначала в бинарник:
objcopy -I ihex -O binary main.ihx habr_demo.bin
А потом упакуем его в .TAP файл
wine zx_spectrum_utils/bin2tap.exe -b habr_demo.bin
Запускаем получившийся .tap файл в эмуляторе спектрума и видим, что ничего не получается. Смотрим отладчиком, и видим, что компилятор расположил функции так как ему хочется, и вместо main у нас начинает выполняться другая функция.
Как же сделать так, чтобы функция main попала в начало файла по адресу 0x8000
? Есть парочка вариантов. Читаем следующий раздел про crt0 код. Но главную задачу мы сделали. Код попал в память компьютера, остальное можно настроить.
Формат TAP — это образ кассеты, в нем содержится полная информация о том, какой сигнал записывается на кассету, включая начальный сигнал выравнивания уровней. Файлы TAP можно конкатенировать, что интересно --- просто присоединять содержимое файла в конец другого файла, чтобы получить сборный файл, и он будет работать точно так же. Если в компьютерной игре есть какая-то защита от копирования кассет, формат TAP передает все нюансы, чтобы программа думала, что кассета настоящая.
TAP умеет грузить секции с кодом в нужный регион памяти Спектрума. А также грузить BASIC программы. Программа на BASIC необходима, потому что у BASIC есть важная функция --- автозапуск. Вы же хотите, чтобы после загрузки с кассеты приложение на ассемблере запустилось?
Wine --- это способ запуска Windows программ на Linux машинах. Вам он может быть не нужен, зависит от ситуации. Вы заметили, что всё это происходит на Linux? По идее, можно адаптировать рецепт для Windows и добиться тех же результатов.
Делаем свой C Runtime код.
То что было наверху — это немного не полноценный вариант. Ведь там нет crt0 кода. Нет того самого, что будет подготавливать память, ставить стек на нужное место, и запускать нашу функцию main. Для этого и нужен crt0 код. Его придется написать самому.
Можно ли перехитрить систему и обойтись без него? Да. Методом экспериментального тыка было установлено, что по адресу 0×8000 попадает первая функция, которая была объявлена. Есть нюансы, но в целом это так. Поэтому:
void header_crt0 (void)
int main (void)
void header_crt0 (void) {
main();
}
Просто добавляем этот код наверх и не паримся. Стек сами потом поставим куда надо.
Но это плохой вариант. Плохой плохой. Дело в том, что компилятор хочет чего-то добавить. Он хочет, чтобы перед программой мы вызвали специальный код, расположенный в секции INITIALIZER. Там может появиться что-то особенное, предназначенное для особых переменных.
Также очень важно заполнить нулями ту секцию, которая содержит неинициализированные переменные. Может быть для вашего кода это не проблема, но для библиотек это уже может быть необходимо.
Поэтому переходим на сайт уважаемого @Shaos и делаем так, как там указано.
Команды для сборки получаются следующие:
sdasz80 -o crt0.rel crt0_by_shaos.s
sdcc -mz80 --reserve-regs-iy --code-loc 0x800A --data-loc 0xa000 --no-std-crt0 crt0.rel habr_demo.c -o main.ihx
objcopy -I ihex -O binary main.ihx habr_demo.bin
wine zx_spectrum_utils/bin2tap.exe -b habr_demo.bin
Вы читаете эту статью не просто так, а значит у вас могут возникнуть проблемы с этим рецептом. Поэтому я всегда на связи. Чем смогу, помогу. Да пребудет с вами Сила.
____
_.' : `._
.-.'`. ; .'`.-.
__ / : ___\ ; /___ ; \ __
,'_ ""--.:__;".-.";: :".-.":__;.--"" _`,
:' `.t""--.. '<@.`;_ ',@>` ..--""j.' `;
`:-.._J '-.-'L__ `-- ' L_..-;'
"-.__ ; .-" "-. : __.-"
L ' /.------.\ ' J
"-. "--" .-"
__.l"-:_JL_;-";.__
.-j/'.; ;"""" / .'\"-.
.' /:`. "-.: .-" .'; `.
.-" / ; "-. "-..-" .-" : "-.
.+"-. : : "-.__.-" ;-._ \
; \ `.; ; : : "+. ;
: ; ; ; : ; : \:
: `."-; ; ; : ; ,/;
; -: ; : ; : .-"' :
:\ \ : ; : \.-" :
;`. \ ; : ;.'_..-- / ;
: "-. "-: ; :/." .' :
\ .-`.\ /t-"" ":-+. :
`. .-" `l __/ /`. : ; ; \ ;
\ .-" .-"-.-" .' .'j \ / ;/
\ / .-" /. .'.' ;_:' ;
:-""-.`./-.' / `.___.'
\ `t ._ / bug :F_P:
"-.t-._:'
Фестиваль компьютерного искусства
Не в силах себя удержать, докладываю, что фестиваль компьютерного искусства Undefined, к которому я имею прямое отношение, будет проходить, как ему положено, каждые пол-года в Ленинградской области. Вход в зал свободный. Предусмотрены конкурсы по программированию и доклады. С нетерпением жду ваших самодельных электронных приспособлений на выставку. Ближайшая дата — 1 марта 2025 года.